From 5e93b196580ede3cecfcac6bbf8686d3a10c6a94 Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 20 Jun 2026 17:18:46 +0530 Subject: [PATCH 1/4] fix: handle unicode redirect payloads --- .changeset/fresh-redirects-dance.md | 5 ++ .../wallet/dapp-client/src/DappTransport.ts | 30 ++++++- .../dapp-client/test/DappTransport.test.ts | 89 +++++++++++++++++++ 3 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 .changeset/fresh-redirects-dance.md create mode 100644 packages/wallet/dapp-client/test/DappTransport.test.ts diff --git a/.changeset/fresh-redirects-dance.md b/.changeset/fresh-redirects-dance.md new file mode 100644 index 0000000000..d01d28799e --- /dev/null +++ b/.changeset/fresh-redirects-dance.md @@ -0,0 +1,5 @@ +--- +'@0xsequence/dapp-client': patch +--- + +Fix redirect transport payload encoding so Unicode characters are handled correctly in redirect requests and responses. diff --git a/packages/wallet/dapp-client/src/DappTransport.ts b/packages/wallet/dapp-client/src/DappTransport.ts index 090b1070b8..44f8492f63 100644 --- a/packages/wallet/dapp-client/src/DappTransport.ts +++ b/packages/wallet/dapp-client/src/DappTransport.ts @@ -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') @@ -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') diff --git a/packages/wallet/dapp-client/test/DappTransport.test.ts b/packages/wallet/dapp-client/test/DappTransport.test.ts new file mode 100644 index 0000000000..9bacdc1d33 --- /dev/null +++ b/packages/wallet/dapp-client/test/DappTransport.test.ts @@ -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() + 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, + }) + }) +}) From 32f7180fa3b4a96f7caf8285404b7ba7bf434ee9 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 15:43:11 +0530 Subject: [PATCH 2/4] fix(wdk): prevent resetting cron lastRun to 0 in storage --- packages/wallet/wdk/src/sequence/cron.ts | 1 + packages/wallet/wdk/test/cron.test.ts | 89 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 packages/wallet/wdk/test/cron.test.ts diff --git a/packages/wallet/wdk/src/sequence/cron.ts b/packages/wallet/wdk/src/sequence/cron.ts index f95117109a..bfd302379f 100644 --- a/packages/wallet/wdk/src/sequence/cron.ts +++ b/packages/wallet/wdk/src/sequence/cron.ts @@ -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) { diff --git a/packages/wallet/wdk/test/cron.test.ts b/packages/wallet/wdk/test/cron.test.ts new file mode 100644 index 0000000000..41d4c362d8 --- /dev/null +++ b/packages/wallet/wdk/test/cron.test.ts @@ -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() + 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) + }) +}) From eee01b0f4c8d36f7398da56e192676ef6b9cb4a5 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 15:49:40 +0530 Subject: [PATCH 3/4] chore(changeset): add @0xsequence/wallet-wdk patch and description for cron fix --- .changeset/fresh-redirects-dance.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/fresh-redirects-dance.md b/.changeset/fresh-redirects-dance.md index d01d28799e..676217c5b7 100644 --- a/.changeset/fresh-redirects-dance.md +++ b/.changeset/fresh-redirects-dance.md @@ -1,5 +1,7 @@ --- '@0xsequence/dapp-client': patch +'@0xsequence/wallet-wdk': patch --- Fix redirect transport payload encoding so Unicode characters are handled correctly in redirect requests and responses. +Fix WDK cron scheduler resetting lastRun timestamp in storage to 0, which caused background jobs to execute too frequently after app reloads. From 2e80d036cbfe261058b7a5fa9c213ca7032913ad Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 16:03:17 +0530 Subject: [PATCH 4/4] fix(dapp-client): disambiguate UTF-8 and legacy Latin-1 redirect payloads --- .../wallet/dapp-client/src/DappTransport.ts | 40 ++++++++++++------- .../dapp-client/test/DappTransport.test.ts | 25 +++++++++++- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/wallet/dapp-client/src/DappTransport.ts b/packages/wallet/dapp-client/src/DappTransport.ts index 44f8492f63..e32abc2bd8 100644 --- a/packages/wallet/dapp-client/src/DappTransport.ts +++ b/packages/wallet/dapp-client/src/DappTransport.ts @@ -41,19 +41,29 @@ const base64Encode = (value: string) => { throw new Error('Base64 encoding is not supported in this environment.') } -const base64Decode = (value: string) => { - 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 +const base64Decode = (value: string, encoding?: string) => { + if (encoding === 'utf-8') { + 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') + } + throw new Error('Base64 decoding is not supported in this environment.') + } else { + if (typeof atob !== 'undefined') { + return atob(value) + } + if (typeof Buffer !== 'undefined') { + return Buffer.from(value, 'base64').toString('latin1') + } + throw new Error('Base64 decoding is not supported in this environment.') } - if (typeof Buffer !== 'undefined') { - return Buffer.from(value, 'base64').toString('utf-8') - } - throw new Error('Base64 decoding is not supported in this environment.') } enum ConnectionState { @@ -220,6 +230,7 @@ export class DappTransport { url.searchParams.set('id', id) url.searchParams.set('redirectUrl', redirectUrl) url.searchParams.set('mode', 'redirect') + url.searchParams.set('encoding', 'utf-8') return url.toString() } @@ -259,12 +270,13 @@ export class DappTransport { const responsePayloadB64 = params.get('payload') const responseErrorB64 = params.get('error') + const encoding = params.get('encoding') || undefined if (cleanState) { await this.sequenceSessionStorage.removeItem(REDIRECT_REQUEST_KEY) if (this.isBrowser && !url && window.history) { const cleanUrl = new URL(window.location.href) - ;['id', 'payload', 'error', 'mode'].forEach((p) => cleanUrl.searchParams.delete(p)) + ;['id', 'payload', 'error', 'mode', 'encoding'].forEach((p) => cleanUrl.searchParams.delete(p)) history.replaceState({}, document.title, cleanUrl.toString()) } } @@ -272,7 +284,7 @@ export class DappTransport { if (responseErrorB64) { try { return { - error: JSON.parse(base64Decode(responseErrorB64), jsonRevivers), + error: JSON.parse(base64Decode(responseErrorB64, encoding), jsonRevivers), action: originalRequest.action, } } catch (e) { @@ -286,7 +298,7 @@ export class DappTransport { if (responsePayloadB64) { try { return { - payload: JSON.parse(base64Decode(responsePayloadB64), jsonRevivers), + payload: JSON.parse(base64Decode(responsePayloadB64, encoding), jsonRevivers), action: originalRequest.action, } } catch (e) { diff --git a/packages/wallet/dapp-client/test/DappTransport.test.ts b/packages/wallet/dapp-client/test/DappTransport.test.ts index 9bacdc1d33..597c06af36 100644 --- a/packages/wallet/dapp-client/test/DappTransport.test.ts +++ b/packages/wallet/dapp-client/test/DappTransport.test.ts @@ -39,11 +39,13 @@ describe('DappTransport redirect URLs', () => { 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') + const parsedUrl = new URL(redirectUrl) + const encodedPayload = parsedUrl.searchParams.get('payload') if (!encodedPayload) { throw new Error('Expected redirect URL to include a payload') } + expect(parsedUrl.searchParams.get('encoding')).toBe('utf-8') expect(JSON.parse(decodeBase64Utf8(encodedPayload))).toEqual(payload) }) @@ -60,6 +62,7 @@ describe('DappTransport redirect URLs', () => { } responseUrl.searchParams.set('id', id) responseUrl.searchParams.set('payload', encodeBase64Utf8(JSON.stringify(payload))) + responseUrl.searchParams.set('encoding', 'utf-8') await expect(transport.getRedirectResponse(false, responseUrl.toString())).resolves.toEqual({ action: 'signMessage', @@ -86,4 +89,24 @@ describe('DappTransport redirect URLs', () => { payload, }) }) + + it('preserves legacy Latin-1 payloads with UTF-8 byte patterns (e.g. é)', 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: 'é' } + 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, + }) + }) })