Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.
58 changes: 46 additions & 12 deletions packages/wallet/dapp-client/src/DappTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,56 @@ 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')
}
throw new Error('Base64 encoding is not supported in this environment.')
}

const base64Decode = (value: string) => {
if (typeof atob !== 'undefined') {
return atob(value)
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(value, 'base64').toString('utf-8')
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.')
}
throw new Error('Base64 decoding is not supported in this environment.')
}

enum ConnectionState {
Expand Down Expand Up @@ -198,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()
}
Expand Down Expand Up @@ -237,20 +270,21 @@ 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())
}
}

if (responseErrorB64) {
try {
return {
error: JSON.parse(base64Decode(responseErrorB64), jsonRevivers),
error: JSON.parse(base64Decode(responseErrorB64, encoding), jsonRevivers),
action: originalRequest.action,
}
} catch (e) {
Expand All @@ -264,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) {
Expand Down
112 changes: 112 additions & 0 deletions packages/wallet/dapp-client/test/DappTransport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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 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)
})

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)))
responseUrl.searchParams.set('encoding', 'utf-8')

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

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