Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
7 changes: 7 additions & 0 deletions .changeset/fresh-redirects-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve legacy Latin-1 payloads that are valid UTF-8

When a redirect response was produced by the previous Latin-1 btoa(JSON.stringify(...)) path, this line now treats it as UTF-8 whenever the raw bytes are well-formed UTF-8. For example, a legacy payload containing the literal text é decodes to é, so getRedirectResponse() silently returns different data instead of honoring the legacy fallback. This affects existing wallet redirects with those byte patterns; add an explicit encoding/version signal or otherwise disambiguate before preferring UTF-8.

Useful? React with 👍 / 👎.

} 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)
})
})