From 9208030b6012035e5de02b3590f4383dfaffe81b Mon Sep 17 00:00:00 2001 From: aaroncox Date: Thu, 20 Aug 2026 13:28:34 -0700 Subject: [PATCH 1/2] Adding transaction handoff helpers --- package.json | 2 +- src/handoff.ts | 215 +++++++++++++++++++++++ src/index.ts | 1 + test/tests/handoff.ts | 398 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 src/handoff.ts create mode 100644 test/tests/handoff.ts diff --git a/package.json b/package.json index dd13e99..06c8c07 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@wharfkit/antelope": "^1.1.0", "@wharfkit/sealed-messages": "^1.2.0", "isomorphic-ws": "^5.0.0", + "pako": "^2.1.0", "ws": "^8.13.0" }, "peerDependencies": { @@ -53,7 +54,6 @@ "mocha": "^10.0.0", "node-fetch": "^2.6.1", "nyc": "^15.1.0", - "pako": "^2.1.0", "prettier": "^2.2.1", "rollup": "^2.70.2", "rollup-plugin-dts": "^4.2.1", diff --git a/src/handoff.ts b/src/handoff.ts new file mode 100644 index 0000000..a14dc3c --- /dev/null +++ b/src/handoff.ts @@ -0,0 +1,215 @@ +import {receive, ReceiveContext} from '@greymass/buoy' +import { + AbiProvider, + CallbackPayload, + Cancelable, + ResolvedSigningRequest, + SignedTransaction, +} from '@wharfkit/session' +import zlib from 'pako' + +import {extractSignaturesFromCallback, isCallback} from './esr' + +const TRANSACTION_HANDOFF_KEY = 'wharfkit:anchor-transaction-handoff' + +interface CallbackChannel { + channel: string + service: string +} + +export interface TransactionHandoff { + version: 1 + returnUrl: string + callback: CallbackChannel + transactionId: string + chainId: string + actor: string + permission: string + expiresAt: string +} + +function isCallbackChannel(value: unknown): value is CallbackChannel { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return typeof candidate.channel === 'string' && typeof candidate.service === 'string' +} + +function isTransactionHandoff(value: unknown): value is TransactionHandoff { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + candidate.version === 1 && + typeof candidate.returnUrl === 'string' && + isCallbackChannel(candidate.callback) && + typeof candidate.transactionId === 'string' && + typeof candidate.chainId === 'string' && + typeof candidate.actor === 'string' && + typeof candidate.permission === 'string' && + typeof candidate.expiresAt === 'string' + ) +} + +/** Check whether returnUrl differs from currentUrl only by a non-empty fragment. */ +export function isSamePageReturn(returnUrl: string, currentUrl = window.location.href): boolean { + const current = new URL(currentUrl) + const target = new URL(returnUrl, current) + const targetHash = target.hash + current.hash = '' + target.hash = '' + return targetHash.length > 1 && current.href === target.href +} + +/** Resolve once the page returns from an app handoff: the return hash arrives or the page is re-shown. */ +export function waitForPageReturn( + returnUrl: string, + signal?: AbortSignal, + pageWindow: Window = window, + pageDocument: Document = document +): Promise { + const expectedHash = new URL(returnUrl, pageWindow.location.href).hash + let departed = pageDocument.visibilityState === 'hidden' + return new Promise((resolve, reject) => { + const cleanup = () => { + pageWindow.removeEventListener('hashchange', onHashChange) + pageWindow.removeEventListener('pagehide', onPageHide) + pageWindow.removeEventListener('pageshow', onPageShow) + pageDocument.removeEventListener('visibilitychange', onVisibilityChange) + signal?.removeEventListener('abort', onAbort) + } + const finish = () => { + cleanup() + resolve() + } + const onHashChange = () => { + if (expectedHash && pageWindow.location.hash === expectedHash) finish() + } + const onPageHide = () => { + departed = true + } + const onPageShow = () => { + if (departed) finish() + } + const onVisibilityChange = () => { + if (pageDocument.visibilityState === 'hidden') { + departed = true + } else if (departed) { + finish() + } + } + const onAbort = () => { + cleanup() + reject(new Error('Transaction callback wait cancelled')) + } + + pageWindow.addEventListener('hashchange', onHashChange) + pageWindow.addEventListener('pagehide', onPageHide) + pageWindow.addEventListener('pageshow', onPageShow) + pageDocument.addEventListener('visibilitychange', onVisibilityChange) + signal?.addEventListener('abort', onAbort, {once: true}) + if (signal?.aborted) onAbort() + }) +} + +export function storeTransactionHandoff( + handoff: TransactionHandoff, + storage: Storage = window.localStorage +): void { + storage.setItem(TRANSACTION_HANDOFF_KEY, JSON.stringify(handoff)) +} + +export function findReturnedTransactionHandoff( + currentUrl = window.location.href, + now = Date.now(), + storage: Storage = window.localStorage +): TransactionHandoff | null { + const encoded = storage.getItem(TRANSACTION_HANDOFF_KEY) + if (!encoded) return null + let handoff: unknown + try { + handoff = JSON.parse(encoded) + } catch { + storage.removeItem(TRANSACTION_HANDOFF_KEY) + return null + } + if (!isTransactionHandoff(handoff)) { + storage.removeItem(TRANSACTION_HANDOFF_KEY) + return null + } + const expiresAt = Date.parse(handoff.expiresAt) + if (!Number.isFinite(expiresAt) || expiresAt <= now) { + storage.removeItem(TRANSACTION_HANDOFF_KEY) + return null + } + try { + return new URL(currentUrl).href === new URL(handoff.returnUrl).href ? handoff : null + } catch { + storage.removeItem(TRANSACTION_HANDOFF_KEY) + return null + } +} + +export function clearTransactionHandoff( + handoff: TransactionHandoff, + storage: Storage = window.localStorage +): void { + const current = findReturnedTransactionHandoff(handoff.returnUrl, 0, storage) + if (current?.returnUrl === handoff.returnUrl) { + storage.removeItem(TRANSACTION_HANDOFF_KEY) + } +} + +/** Receive the buffered signed transaction for a stored handoff, or null when none matches the current URL. */ +export function receiveReturnedTransaction(options: { + currentUrl?: string + now?: number + storage?: Storage + WebSocket?: typeof WebSocket + abiProvider: AbiProvider +}): Cancelable | null { + const storage = options.storage ?? window.localStorage + const now = options.now ?? Date.now() + const handoff = findReturnedTransactionHandoff(options.currentUrl, now, storage) + if (!handoff) return null + const ctx: ReceiveContext = {} + const pending = receive( + { + ...handoff.callback, + WebSocket: options.WebSocket ?? WebSocket, + timeout: Math.max(1, Date.parse(handoff.expiresAt) - now), + }, + ctx + ) + const transaction = pending + .then(async (response) => { + if (typeof response !== 'string') { + throw new Error('Anchor did not return a signed transaction') + } + const payload: CallbackPayload = JSON.parse(response) + const signatures = extractSignaturesFromCallback(payload) + if (!isCallback(payload) || signatures.length === 0) { + throw new Error('Anchor did not return a signed transaction') + } + const resolved = await ResolvedSigningRequest.fromPayload(payload, { + zlib, + abiProvider: options.abiProvider, + }) + if ( + String(resolved.transaction.id) !== handoff.transactionId || + String(resolved.chainId) !== handoff.chainId || + String(resolved.signer.actor) !== handoff.actor || + String(resolved.signer.permission) !== handoff.permission + ) { + throw new Error('Anchor returned a different transaction') + } + return SignedTransaction.from({ + ...resolved.transaction, + signatures, + }) + }) + .finally(() => clearTransactionHandoff(handoff, storage)) as Cancelable + transaction.cancel = () => { + ctx.cancel?.() + return transaction + } + return transaction +} diff --git a/src/index.ts b/src/index.ts index c025c18..3256907 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,5 @@ export * from './anchor-types' export * from './buoy-types' export * from './callback' export * from './esr' +export * from './handoff' export * from './utils' diff --git a/test/tests/handoff.ts b/test/tests/handoff.ts new file mode 100644 index 0000000..07259d0 --- /dev/null +++ b/test/tests/handoff.ts @@ -0,0 +1,398 @@ +import {expect} from 'chai' +import sinon from 'sinon' +import * as buoy from '@greymass/buoy' +import { + Checksum256, + PermissionLevel, + PrivateKey, + ResolvedSigningRequest, + Transaction, +} from '@wharfkit/session' + +import { + clearTransactionHandoff, + findReturnedTransactionHandoff, + isSamePageReturn, + receiveReturnedTransaction, + storeTransactionHandoff, + TransactionHandoff, + waitForPageReturn, +} from 'src/handoff' + +const HANDOFF_KEY = 'wharfkit:anchor-transaction-handoff' + +class MockLocalStorage implements Storage { + data: Record = {} + get length(): number { + return Object.keys(this.data).length + } + clear(): void { + this.data = {} + } + getItem(key: string): string | null { + return key in this.data ? this.data[key] : null + } + key(index: number): string | null { + return Object.keys(this.data)[index] ?? null + } + removeItem(key: string): void { + delete this.data[key] + } + setItem(key: string, value: string): void { + this.data[key] = value + } +} + +function makePageWindow(href: string) { + const pageWindow = new EventTarget() as EventTarget & {location: {href: string; hash: string}} + pageWindow.location = {href, hash: ''} + return pageWindow +} + +function makePageDocument(visibilityState: DocumentVisibilityState = 'visible') { + const pageDocument = new EventTarget() as EventTarget & { + visibilityState: DocumentVisibilityState + } + pageDocument.visibilityState = visibilityState + return pageDocument +} + +class MockWebSocket {} + +const chainId = Checksum256.from('00'.repeat(32)) +const transaction = Transaction.from({ + expiration: '2026-08-15T08:00:00', + ref_block_num: 1, + ref_block_prefix: 2, + max_net_usage_words: 0, + max_cpu_usage_ms: 0, + delay_sec: 0, + context_free_actions: [], + actions: [], + transaction_extensions: [], +}) + +function makeHandoff(overrides: Partial = {}): TransactionHandoff { + return { + version: 1, + returnUrl: 'https://example.com/authorize/anchor#RETURN12', + callback: {service: 'https://cb.anchor.link', channel: 'callback-channel'}, + transactionId: String(transaction.id), + chainId: String(chainId), + actor: 'example', + permission: 'active', + expiresAt: '2026-08-15T08:00:00.000Z', + ...overrides, + } +} + +suite('handoff', () => { + let storage: MockLocalStorage + + setup(() => { + sinon.restore() + storage = new MockLocalStorage() + }) + + teardown(() => { + sinon.restore() + }) + + suite('isSamePageReturn', () => { + test('recognizes a return url that only adds a fragment', () => { + expect( + isSamePageReturn( + 'https://example.com/authorize/anchor#RETURN12', + 'https://example.com/authorize/anchor' + ) + ).to.be.true + }) + + test('rejects a return url for a different page', () => { + expect( + isSamePageReturn( + 'https://example.com/authorize/anchor#RETURN12', + 'https://example.com/authorize/anchor?attempt=2' + ) + ).to.be.false + }) + + test('rejects a return url without a fragment', () => { + expect( + isSamePageReturn( + 'https://example.com/authorize/anchor', + 'https://example.com/authorize/anchor' + ) + ).to.be.false + }) + }) + + suite('waitForPageReturn', () => { + test('resolves when the expected hash arrives', async () => { + const pageWindow = makePageWindow('https://example.com/authorize/anchor') + const pageDocument = makePageDocument() + const pending = waitForPageReturn( + `${pageWindow.location.href}#RETURN12`, + undefined, + pageWindow as unknown as Window, + pageDocument as unknown as Document + ) + let completed = false + void pending.then(() => { + completed = true + }) + + await Promise.resolve() + expect(completed).to.be.false + + pageWindow.location.hash = '#RETURN12' + pageWindow.dispatchEvent(new Event('hashchange')) + await pending + + expect(completed).to.be.true + }) + + test('resolves after a hide and show round-trip', async () => { + const pageWindow = makePageWindow('https://example.com/authorize/anchor') + const pageDocument = makePageDocument() + const pending = waitForPageReturn( + `${pageWindow.location.href}#RETURN12`, + undefined, + pageWindow as unknown as Window, + pageDocument as unknown as Document + ) + + pageDocument.visibilityState = 'hidden' + pageDocument.dispatchEvent(new Event('visibilitychange')) + pageDocument.visibilityState = 'visible' + pageDocument.dispatchEvent(new Event('visibilitychange')) + + await pending + }) + + test('resolves on pageshow after pagehide', async () => { + const pageWindow = makePageWindow('https://example.com/authorize/anchor') + const pageDocument = makePageDocument() + const pending = waitForPageReturn( + `${pageWindow.location.href}#RETURN12`, + undefined, + pageWindow as unknown as Window, + pageDocument as unknown as Document + ) + + pageWindow.dispatchEvent(new Event('pagehide')) + pageWindow.dispatchEvent(new Event('pageshow')) + + await pending + }) + + test('rejects when aborted', async () => { + const pageWindow = makePageWindow('https://example.com/authorize/anchor') + const pageDocument = makePageDocument() + const controller = new AbortController() + const pending = waitForPageReturn( + `${pageWindow.location.href}#RETURN12`, + controller.signal, + pageWindow as unknown as Window, + pageDocument as unknown as Document + ) + + controller.abort() + + let error: Error | undefined + await pending.catch((err) => { + error = err + }) + expect(error?.message).to.equal('Transaction callback wait cancelled') + }) + }) + + suite('transaction handoff storage', () => { + test('finds only the exact unexpired return url', () => { + const handoff = makeHandoff() + storeTransactionHandoff(handoff, storage) + + expect( + findReturnedTransactionHandoff( + handoff.returnUrl, + Date.parse('2026-08-15T07:59:00Z'), + storage + ) + ).to.deep.equal(handoff) + expect( + findReturnedTransactionHandoff( + 'https://example.com/authorize/anchor', + Date.parse('2026-08-15T07:59:00Z'), + storage + ) + ).to.be.null + }) + + test('removes an expired handoff', () => { + const handoff = makeHandoff() + storeTransactionHandoff(handoff, storage) + + expect( + findReturnedTransactionHandoff( + handoff.returnUrl, + Date.parse('2026-08-15T08:01:00Z'), + storage + ) + ).to.be.null + expect(storage.length).to.equal(0) + }) + + test('removes a malformed record', () => { + storage.setItem(HANDOFF_KEY, 'not json') + expect(findReturnedTransactionHandoff('https://example.com/', 0, storage)).to.be.null + expect(storage.length).to.equal(0) + + storage.setItem(HANDOFF_KEY, JSON.stringify({version: 2})) + expect(findReturnedTransactionHandoff('https://example.com/', 0, storage)).to.be.null + expect(storage.length).to.equal(0) + }) + + test('does not let an old page clear a newer handoff', () => { + const oldHandoff = makeHandoff() + const newHandoff = makeHandoff({ + returnUrl: 'https://example.com/authorize/anchor#NEWFLOW1', + }) + storeTransactionHandoff(newHandoff, storage) + + clearTransactionHandoff(oldHandoff, storage) + + expect( + findReturnedTransactionHandoff( + newHandoff.returnUrl, + Date.parse('2026-08-15T07:59:00Z'), + storage + ) + ).to.deep.equal(newHandoff) + }) + + test('clears its own handoff', () => { + const handoff = makeHandoff() + storeTransactionHandoff(handoff, storage) + + clearTransactionHandoff(handoff, storage) + + expect(storage.length).to.equal(0) + }) + }) + + suite('receiveReturnedTransaction', () => { + test('returns null without a matching handoff', () => { + const result = receiveReturnedTransaction({ + currentUrl: 'https://example.com/authorize/anchor', + now: Date.parse('2026-08-15T07:59:00Z'), + storage, + abiProvider: {getAbi: sinon.fake()}, + }) + expect(result).to.be.null + }) + + test('reconstructs the signed transaction from the callback', async () => { + const handoff = makeHandoff() + const abiProvider = {getAbi: sinon.fake()} + const signature = PrivateKey.generate('K1').signDigest( + Checksum256.hash(new TextEncoder().encode('returned')) + ) + const payload = { + tx: String(transaction.id), + req: 'esr:mock', + sig: String(signature), + sa: handoff.actor, + sp: handoff.permission, + cid: handoff.chainId, + rbn: '1', + rid: '2', + ex: '2026-08-15T08:00:00', + } + storeTransactionHandoff(handoff, storage) + const receiveStub = sinon.stub(buoy, 'receive').resolves(JSON.stringify(payload)) + const fromPayload = sinon.stub(ResolvedSigningRequest, 'fromPayload').resolves({ + transaction, + chainId, + signer: PermissionLevel.from(`${handoff.actor}@${handoff.permission}`), + } as unknown as ResolvedSigningRequest) + + const pending = receiveReturnedTransaction({ + currentUrl: handoff.returnUrl, + now: Date.parse('2026-08-15T07:59:00Z'), + storage, + WebSocket: MockWebSocket as unknown as typeof WebSocket, + abiProvider, + }) + if (!pending) throw new Error('Expected a returned transaction') + const signed = await pending + + expect(signed.signatures.map(String)).to.deep.equal([String(signature)]) + expect(String(signed.id)).to.equal(String(transaction.id)) + expect(fromPayload.calledWithMatch(payload, {abiProvider})).to.be.true + expect(receiveStub.calledWithMatch(handoff.callback)).to.be.true + expect(receiveStub.firstCall.args[0].timeout).to.equal(60 * 1000) + expect(storage.length).to.equal(0) + }) + + test('cancels the buoy receive', async () => { + const handoff = makeHandoff() + storeTransactionHandoff(handoff, storage) + sinon.stub(buoy, 'receive').callsFake( + (_options, ctx) => + new Promise((_resolve, reject) => { + if (ctx) ctx.cancel = () => reject(new Error('Cancelled')) + }) + ) + + const pending = receiveReturnedTransaction({ + currentUrl: handoff.returnUrl, + now: Date.parse('2026-08-15T07:59:00Z'), + storage, + WebSocket: MockWebSocket as unknown as typeof WebSocket, + abiProvider: {getAbi: sinon.fake()}, + }) + if (!pending) throw new Error('Expected a returned transaction') + + pending.cancel() + + let error: Error | undefined + await pending.catch((err) => { + error = err + }) + expect(error?.message).to.equal('Cancelled') + expect(storage.length).to.equal(0) + }) + + test('rejects a callback for a different transaction', async () => { + const handoff = makeHandoff({transactionId: '11'.repeat(32)}) + const signature = PrivateKey.generate('K1').signDigest( + Checksum256.hash(new TextEncoder().encode('returned')) + ) + storeTransactionHandoff(handoff, storage) + sinon.stub(buoy, 'receive').resolves( + JSON.stringify({tx: String(transaction.id), sig: String(signature)}) + ) + sinon.stub(ResolvedSigningRequest, 'fromPayload').resolves({ + transaction, + chainId, + signer: PermissionLevel.from(`${handoff.actor}@${handoff.permission}`), + } as unknown as ResolvedSigningRequest) + + const pending = receiveReturnedTransaction({ + currentUrl: handoff.returnUrl, + now: Date.parse('2026-08-15T07:59:00Z'), + storage, + WebSocket: MockWebSocket as unknown as typeof WebSocket, + abiProvider: {getAbi: sinon.fake()}, + }) + if (!pending) throw new Error('Expected a returned transaction') + + let error: Error | undefined + await pending.catch((err) => { + error = err + }) + expect(error?.message).to.equal('Anchor returned a different transaction') + expect(storage.length).to.equal(0) + }) + }) +}) From 87104e8850b53173b74f16675f9f71809eeccf33 Mon Sep 17 00:00:00 2001 From: aaroncox Date: Thu, 20 Aug 2026 14:44:03 -0700 Subject: [PATCH 2/2] v1.7.0-rc1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 06c8c07..a2fd141 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@wharfkit/protocol-esr", "description": "Abstract methods useful to all ESR-based wallet plugins", - "version": "1.6.1", + "version": "1.7.0-rc1", "homepage": "https://github.com/wharfkit/protocol-esr", "license": "BSD-3-Clause", "main": "lib/protocol-esr.js",