Skip to content
Closed
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

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 👍 / 👎.

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