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

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