Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-redirects-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@0xsequence/dapp-client': patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add wallet-wdk to the changeset

This commit changes packages/wallet/wdk/src/sequence/cron.ts in the public @0xsequence/wallet-wdk package, but the changeset only lists @0xsequence/dapp-client. With the repo's Changesets release flow, only packages named in the frontmatter are versioned/changelogged, so the cron persistence fix will not be published to consumers unless @0xsequence/wallet-wdk is included here as a patch release.

Useful? React with 👍 / 👎.

---

Fix redirect transport payload encoding so Unicode characters are handled correctly in redirect requests and responses.
30 changes: 26 additions & 4 deletions packages/wallet/dapp-client/src/DappTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,26 @@ import {

const isBrowserEnvironment = typeof window !== 'undefined' && typeof document !== 'undefined'

const bytesToBinaryString = (bytes: Uint8Array) => {
let binary = ''
const chunkSize = 0x8000
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
}
return binary
}

const binaryStringToBytes = (value: string) => {
const bytes = new Uint8Array(value.length)
for (let i = 0; i < value.length; i += 1) {
bytes[i] = value.charCodeAt(i)
}
return bytes
}

const base64Encode = (value: string) => {
if (typeof btoa !== 'undefined') {
return btoa(value)
if (typeof btoa !== 'undefined' && typeof TextEncoder !== 'undefined') {
return btoa(bytesToBinaryString(new TextEncoder().encode(value)))
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(value, 'utf-8').toString('base64')
Expand All @@ -25,8 +42,13 @@ const base64Encode = (value: string) => {
}

const base64Decode = (value: string) => {
if (typeof atob !== 'undefined') {
return atob(value)
if (typeof atob !== 'undefined' && typeof TextDecoder !== 'undefined') {
const decoded = atob(value)
try {
return new TextDecoder('utf-8', { fatal: true }).decode(binaryStringToBytes(decoded))
} catch {
return decoded
}
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(value, 'base64').toString('utf-8')
Expand Down
89 changes: 89 additions & 0 deletions packages/wallet/dapp-client/test/DappTransport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'

import { DappTransport } from '../src/DappTransport.js'
import { TransportMode } from '../src/types/index.js'

const encodeBase64Utf8 = (value: string) => {
let binary = ''
for (const byte of new TextEncoder().encode(value)) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}

const decodeBase64Utf8 = (value: string) => {
const binary = atob(value)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
return new TextDecoder().decode(bytes)
}

const createSessionStorage = () => {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
removeItem: (key: string) => {
values.delete(key)
},
}
}

describe('DappTransport redirect URLs', () => {
it('encodes unicode payloads as UTF-8 base64', async () => {
const transport = new DappTransport('https://wallet.example', TransportMode.REDIRECT, {}, createSessionStorage())
const payload = { message: 'Sign in to Sequence 🌍' }

const redirectUrl = await transport.getRequestRedirectUrl('signMessage', payload, 'https://dapp.example/callback')
const encodedPayload = new URL(redirectUrl).searchParams.get('payload')

if (!encodedPayload) {
throw new Error('Expected redirect URL to include a payload')
}
expect(JSON.parse(decodeBase64Utf8(encodedPayload))).toEqual(payload)
})

it('decodes unicode redirect response payloads', async () => {
const storage = createSessionStorage()
const transport = new DappTransport('https://wallet.example', TransportMode.REDIRECT, {}, storage)
const requestUrl = await transport.getRequestRedirectUrl('signMessage', {}, 'https://dapp.example/callback')
const id = new URL(requestUrl).searchParams.get('id')
const payload = { message: 'Signed by Sequence 🌍' }
const responseUrl = new URL('https://dapp.example/callback')

if (!id) {
throw new Error('Expected redirect URL to include an id')
}
responseUrl.searchParams.set('id', id)
responseUrl.searchParams.set('payload', encodeBase64Utf8(JSON.stringify(payload)))

await expect(transport.getRedirectResponse(false, responseUrl.toString())).resolves.toEqual({
action: 'signMessage',
payload,
})
})

it('decodes legacy Latin-1 redirect response payloads', async () => {
const storage = createSessionStorage()
const transport = new DappTransport('https://wallet.example', TransportMode.REDIRECT, {}, storage)
const requestUrl = await transport.getRequestRedirectUrl('signMessage', {}, 'https://dapp.example/callback')
const id = new URL(requestUrl).searchParams.get('id')
const payload = { message: 'Signed by Sequence Café' }
const responseUrl = new URL('https://dapp.example/callback')

if (!id) {
throw new Error('Expected redirect URL to include an id')
}
responseUrl.searchParams.set('id', id)
responseUrl.searchParams.set('payload', btoa(JSON.stringify(payload)))

await expect(transport.getRedirectResponse(false, responseUrl.toString())).resolves.toEqual({
action: 'signMessage',
payload,
})
})
})
1 change: 1 addition & 0 deletions packages/wallet/wdk/src/sequence/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export class Cron {
}

const lastRun = storage.get(id)?.lastRun ?? job.lastRun
job.lastRun = lastRun
const timeSinceLastRun = now - lastRun

if (timeSinceLastRun >= job.interval) {
Expand Down
89 changes: 89 additions & 0 deletions packages/wallet/wdk/test/cron.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import { Cron } from '../src/sequence/cron.js'

describe('Cron persistence', () => {
it('correctly persists and does not overwrite lastRun with 0', async () => {
// 1. Setup mock storage with an existing run timestamp
// Say the job ran 5 minutes ago (300,000 ms ago).
const now = Date.now()
const fiveMinutesAgo = now - 5 * 60 * 1000
const jobInterval = 10 * 60 * 1000 // 10 minutes interval

const storageMap = new Map<string, string>()
storageMap.set(
'sequence-cron-jobs',
JSON.stringify([['test-job', { lastRun: fiveMinutesAgo }]])
)

const mockStorage = {
getItem: (key: string) => storageMap.get(key) ?? null,
setItem: (key: string, value: string) => {
storageMap.set(key, value)
},
} as any

const mockLogger = {
log: vi.fn(),
}

const mockShared = {
verbose: false,
env: {
storage: mockStorage,
timers: {
setTimeout: (cb: any, ms: number) => setTimeout(cb, ms),
clearTimeout: (id: any) => clearTimeout(id),
setInterval: vi.fn(), // Prevent auto polling
clearInterval: vi.fn(),
},
},
modules: {
logger: mockLogger,
},
} as any

// 2. Instantiate Cron (recreating WDK reload)
const cron = new Cron(mockShared)

// Register the job with interval 10 minutes
const handler = vi.fn().mockResolvedValue(undefined)
cron.registerJob('test-job', jobInterval, handler)

// 3. Manually trigger the first check
// This will load the storage state: lastRun = fiveMinutesAgo.
// Time elapsed is 5 minutes, which is less than the 10-minute interval.
// Therefore, handler should NOT run.
// AND, importantly, the fix should ensure we don't overwrite localStorage with 0.
await (cron as any).currentCheckJobsPromise

// Verify handler was not called
expect(handler).not.toHaveBeenCalled()

// Verify localStorage was NOT overwritten with 0!
// The storage should still contain the fiveMinutesAgo timestamp.
const persistedState = JSON.parse(storageMap.get('sequence-cron-jobs')!)
const testJobState = persistedState.find(([id]: any) => id === 'test-job')
expect(testJobState).toBeDefined()
expect(testJobState[1].lastRun).toBe(fiveMinutesAgo)

// 4. Test that the job runs when the interval HAS elapsed
// Let's modify the storage to make the last run 15 minutes ago.
const fifteenMinutesAgo = Date.now() - 15 * 60 * 1000
storageMap.set(
'sequence-cron-jobs',
JSON.stringify([['test-job', { lastRun: fifteenMinutesAgo }]])
)

// Trigger check again
await (cron as any).executeCheckJobsChain()
await (cron as any).currentCheckJobsPromise

// Verify handler WAS called this time
expect(handler).toHaveBeenCalledTimes(1)

// Verify storage was updated with the new run time (which should be close to now)
const updatedState = JSON.parse(storageMap.get('sequence-cron-jobs')!)
const updatedJobState = updatedState.find(([id]: any) => id === 'test-job')
expect(updatedJobState[1].lastRun).toBeGreaterThanOrEqual(now)
})
})