diff --git a/package.json b/package.json index 37e6e09..34d6449 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@wharfkit/wallet-plugin-anchor", "description": "An Anchor plugin for use with @wharfkit/session.", - "version": "1.7.3", + "version": "1.8.0-rc1", "homepage": "https://github.com/wharfkit/wallet-plugin-anchor", "license": "BSD-3-Clause", "main": "lib/wallet-plugin-anchor.js", @@ -18,7 +18,7 @@ "dependencies": { "@greymass/buoy": "^1.0.3", "@wharfkit/antelope": "^1.0.5", - "@wharfkit/protocol-esr": "^1.6.1", + "@wharfkit/protocol-esr": "^1.7.0-rc1", "isomorphic-ws": "^5.0.0", "ws": "^8.13.0" }, diff --git a/src/transports/native.ts b/src/transports/native.ts index ce24a0c..f717b2b 100644 --- a/src/transports/native.ts +++ b/src/transports/native.ts @@ -16,16 +16,21 @@ import { WalletPluginSignResponse, } from '@wharfkit/session' import { + clearTransactionHandoff, extractSignaturesFromCallback, generateReturnUrl, isAppleHandheld, isCallback, isKnownMobile, + isSamePageReturn, LinkInfo, sealMessage, setTransactionCallback, + storeTransactionHandoff, + TransactionHandoff, verifyLoginCallbackResponse, waitForCallback, + waitForPageReturn, } from '@wharfkit/protocol-esr' import {AnchorRequestCancelledError} from './errors' @@ -212,14 +217,6 @@ export class NativeTransport { sameDeviceRequest.setInfoKey('return_path', returnUrl) } - if (data.sameDevice) { - if (data.launchUrl) { - window.location.href = data.launchUrl - } else if (isAppleHandheld()) { - window.location.href = 'anchor://link' - } - } - const signManually = () => { context.ui?.prompt({ title: t('transact.sign_manually.title', {default: 'Sign manually'}), @@ -281,27 +278,81 @@ export class NativeTransport { promptPromise.catch(() => clearTimeout(timer)) - const callbackPromise = waitForCallback(callback, this.options.buoyWs, t) + const callbackController = new AbortController() + const deferCallbackUntilReturn = data.sameDevice && returnUrl && isSamePageReturn(returnUrl) + const transactionHandoff: TransactionHandoff | null = + deferCallbackUntilReturn && returnUrl + ? { + version: 1, + returnUrl, + callback, + transactionId: String(resolved.transaction.id), + chainId: String(context.chain.id), + actor: String(context.permissionLevel.actor), + permission: String(context.permissionLevel.permission), + expiresAt: expiration.toISOString(), + } + : null + + // iOS Safari can open the return_path in a fresh tab; persist enough for it to finish the ceremony. + if (transactionHandoff) { + storeTransactionHandoff(transactionHandoff) + } - if (data.channelUrl) { - const service = new URL(data.channelUrl).origin - const channel = new URL(data.channelUrl).pathname.substring(1) - const sealedMessage = await sealMessage( - (data.sameDevice ? sameDeviceRequest : modifiedRequest).encode(true, false, 'esr:'), - PrivateKey.from(data.privateKey), - PublicKey.from(data.signerKey) - ) + // A WebSocket left open while Safari suspends this page can swallow the signature; connect after return. + const callbackPromise = deferCallbackUntilReturn + ? waitForPageReturn(returnUrl!, callbackController.signal).then(() => + waitForCallback(callback, this.options.buoyWs, t) + ) + : waitForCallback(callback, this.options.buoyWs, t) - send(Serializer.encode({object: sealedMessage}).array, {service, channel}) - } else { - // If no channel is defined, fallback to the same device request and trigger immediately - window.location.href = sameDeviceRequest.encode() + try { + if (data.channelUrl) { + const service = new URL(data.channelUrl).origin + const channel = new URL(data.channelUrl).pathname.substring(1) + const sealedMessage = await sealMessage( + (data.sameDevice ? sameDeviceRequest : modifiedRequest).encode( + true, + false, + 'esr:' + ), + PrivateKey.from(data.privateKey), + PublicKey.from(data.signerKey) + ) + const payload = Serializer.encode({object: sealedMessage}).array + + if (data.sameDevice) { + // Safari suspends this page the instant Anchor opens; the request must reach buoy first. + await send(payload, {service, channel}) + if (data.launchUrl) { + window.location.href = data.launchUrl + } else if (isAppleHandheld()) { + window.location.href = 'anchor://link' + } + } else { + send(payload, {service, channel}) + } + } else { + // If no channel is defined, fallback to the same device request and trigger immediately + window.location.href = sameDeviceRequest.encode() + } + } catch (error) { + clearTimeout(timer) + // The abandoned page-return wait rejects on abort; swallow it. + callbackPromise.catch(() => undefined) + callbackController.abort() + promptPromise.cancel() + throw error } const callbackResponse = await Promise.race([callbackPromise, promptPromise]).finally( () => { clearTimeout(timer) + callbackController.abort() promptPromise.cancel() + if (transactionHandoff) { + clearTransactionHandoff(transactionHandoff) + } } ) diff --git a/test/tests/handoff.ts b/test/tests/handoff.ts new file mode 100644 index 0000000..badedd0 --- /dev/null +++ b/test/tests/handoff.ts @@ -0,0 +1,211 @@ +import {assert} from 'chai' +import {ChainDefinition} from '@wharfkit/session' +import {ResolvedSigningRequest} from '@wharfkit/signing-request' +import {APIClient, PermissionLevel, PrivateKey} from '@wharfkit/antelope' +import * as buoy from '@greymass/buoy' +import * as protocol from '@wharfkit/protocol-esr' +import sinon from 'sinon' +import zlib from 'pako' + +import {NativeTransport} from '$lib/transports/native' +import {mockCallbackPayload} from '$test/utils/mock-esr' +import {makeMockUI} from '$test/utils/mock-ui' + +const jungle4 = ChainDefinition.from({ + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', +}) + +const sessionKey = PrivateKey.generate('K1') +const walletKey = PrivateKey.generate('K1') + +const HANDOFF_KEY = 'wharfkit:anchor-transaction-handoff' +const PAGE_URL = 'http://localhost/page' +const RETURN_URL = `${PAGE_URL}#RETURN01` +const TXID = 'f0e5f6f0a4d5c9c8e7b6a5948382716059483726150493827160594837261504' + +function makeSameDeviceData() { + return { + sameDevice: true, + launchUrl: 'anchor://launch', + channelUrl: 'https://cb.anchor.link/channel-test', + channelName: 'phone', + privateKey: String(sessionKey), + signerKey: String(walletKey.toPublic()), + } +} + +function makeTransport(data: Record) { + return new NativeTransport({id: 'anchor', data, buoyUrl: 'https://cb.anchor.link'}) +} + +function makeResolved(): ResolvedSigningRequest { + const expiration = new Date(Date.now() + 60 * 60 * 1000) + return { + transaction: { + expiration: {toDate: () => expiration}, + id: TXID, + }, + } as unknown as ResolvedSigningRequest +} + +function makeTransactContext(ui: any) { + return { + chain: jungle4, + ui, + fetch: global.fetch, + hooks: {}, + appName: 'unittest', + accountName: 'wharfkit1131', + permissionName: 'test', + permissionLevel: PermissionLevel.from('wharfkit1131@test'), + walletPlugins: [], + arbitrary: {}, + uiRequirements: {}, + addHook: () => {}, + getClient: () => new APIClient({url: jungle4.url}), + createRequest: async () => ({ + setInfoKey: () => undefined, + setCallback: () => undefined, + clone: () => ({ + setInfoKey: () => undefined, + setCallback: () => undefined, + encode: () => 'esr-same-device', + }), + encode: () => 'esr-encoded-request', + }), + esrOptions: {zlib}, + } as any +} + +function settle(ms = 50) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function simulatePageReturn() { + window.dispatchEvent(new (window as any).Event('pagehide')) + window.dispatchEvent(new (window as any).Event('pageshow')) +} + +suite('native signing handoff', function () { + this.timeout(10 * 1000) + + setup(function () { + window.localStorage.removeItem(HANDOFF_KEY) + window.location.href = PAGE_URL + }) + + teardown(function () { + sinon.restore() + window.localStorage.removeItem(HANDOFF_KEY) + }) + + test('same-device delivery reaches buoy before the app is launched', async function () { + let resolveSend: () => void = () => undefined + const sendStub = sinon.stub(buoy, 'send').returns( + new Promise((resolve) => { + resolveSend = () => resolve(undefined as any) + }) + ) + const transport = makeTransport(makeSameDeviceData()) + transport.sign(makeResolved(), makeTransactContext(makeMockUI())).catch(() => undefined) + await settle() + + assert.equal(sendStub.callCount, 1, 'the sealed request was sent') + assert.equal(window.location.href, PAGE_URL, 'no launch until delivery settles') + + resolveSend() + await settle(10) + assert.equal(window.location.href, 'anchor://launch', 'launched after delivery') + }) + + test('a same-page return defers the callback connection and stores a handoff', async function () { + sinon.stub(buoy, 'send').resolves(undefined as any) + sinon.stub(protocol, 'generateReturnUrl').returns(RETURN_URL) + let resolveCallback: (payload: any) => void = () => undefined + const callbackStub = sinon.stub(protocol, 'waitForCallback').returns( + new Promise((resolve) => { + resolveCallback = resolve + }) + ) + + const transport = makeTransport(makeSameDeviceData()) + const signing = transport + .sign(makeResolved(), makeTransactContext(makeMockUI())) + .catch(() => undefined) + await settle() + + assert.equal(callbackStub.callCount, 0, 'no websocket while the page is handed off') + const stored = JSON.parse(window.localStorage.getItem(HANDOFF_KEY)!) + assert.equal(stored.version, 1) + assert.equal(stored.returnUrl, RETURN_URL) + assert.equal(stored.transactionId, TXID) + assert.equal(stored.chainId, String(jungle4.id)) + assert.equal(stored.actor, 'wharfkit1131') + assert.equal(stored.permission, 'test') + assert.isString(stored.expiresAt) + assert.equal(stored.callback.service, 'https://cb.anchor.link') + assert.isString(stored.callback.channel) + + simulatePageReturn() + await settle(10) + assert.equal(callbackStub.callCount, 1, 'connected once the page returned') + + resolveCallback(mockCallbackPayload) + await signing + assert.isNull(window.localStorage.getItem(HANDOFF_KEY), 'cleared after completion') + }) + + test('a cross-page return keeps the immediate callback connection', async function () { + sinon.stub(buoy, 'send').resolves(undefined as any) + sinon.stub(protocol, 'generateReturnUrl').returns('googlechrome://') + const callbackStub = sinon.stub(protocol, 'waitForCallback').returns(new Promise(() => {})) + + const transport = makeTransport(makeSameDeviceData()) + transport.sign(makeResolved(), makeTransactContext(makeMockUI())).catch(() => undefined) + await settle() + + assert.equal(callbackStub.callCount, 1, 'connects right away') + assert.isNull(window.localStorage.getItem(HANDOFF_KEY), 'no handoff record') + }) + + test('a cross-device session keeps the immediate callback connection', async function () { + sinon.stub(buoy, 'send').resolves(undefined as any) + sinon.stub(protocol, 'generateReturnUrl').returns(RETURN_URL) + const callbackStub = sinon.stub(protocol, 'waitForCallback').returns(new Promise(() => {})) + + const transport = makeTransport({...makeSameDeviceData(), sameDevice: false}) + transport.sign(makeResolved(), makeTransactContext(makeMockUI())).catch(() => undefined) + await settle() + + assert.equal(callbackStub.callCount, 1, 'connects right away') + assert.isNull(window.localStorage.getItem(HANDOFF_KEY), 'no handoff record') + assert.equal(window.location.href, PAGE_URL, 'no launch attempt') + }) + + test('a delivery failure cancels the prompt and rethrows', async function () { + sinon.stub(buoy, 'send').rejects(new Error('buoy unavailable')) + const ui = makeMockUI() + let cancelCount = 0 + const originalPrompt = ui.prompt.bind(ui) + ui.prompt = ((args: any) => { + const pending = originalPrompt(args) + const originalCancel = pending.cancel.bind(pending) + pending.cancel = ((reason?: string) => { + cancelCount += 1 + return originalCancel(reason) + }) as typeof pending.cancel + return pending + }) as typeof ui.prompt + + const transport = makeTransport(makeSameDeviceData()) + let message = '' + await transport.sign(makeResolved(), makeTransactContext(ui)).catch((error) => { + message = error.message + }) + + assert.equal(message, 'buoy unavailable', 'the delivery error surfaces') + assert.equal(cancelCount, 1, 'the prompt was cancelled') + assert.equal(window.location.href, PAGE_URL, 'the app was never launched') + }) +}) diff --git a/yarn.lock b/yarn.lock index 1ab5213..c90729a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -708,15 +708,16 @@ node-fetch "^2.6.1" tslib "^2.1.0" -"@wharfkit/protocol-esr@^1.6.1": - version "1.6.1" - resolved "https://registry.npmjs.org/@wharfkit/protocol-esr/-/protocol-esr-1.6.1.tgz#84bc8f6d51e672e0b6cf8e8baf3dd4dae8d736ed" - integrity sha512-m6B28f56Jh+CEB/MI6VkdR+3sMihsdbQULCM5wg17DsorNBXbpwVGsAzJonmLrymhbp8sUDUkXlq0yWqoX7tuQ== +"@wharfkit/protocol-esr@^1.7.0-rc1": + version "1.7.0-rc1" + resolved "https://registry.npmjs.org/@wharfkit/protocol-esr/-/protocol-esr-1.7.0-rc1.tgz#c02c0b7488ff866bd6b2471fa8b4449d0fb5ee21" + integrity sha512-PtD0JDBZr9GIhuLBdUKA+fFy1U544MOAfHtskzdtd+0QWS7imCGCe45Bfr66AXpQ9IQ/FUCwg8HFHRcwBNUZxg== dependencies: "@greymass/buoy" "^1.0.3" "@wharfkit/antelope" "^1.1.0" "@wharfkit/sealed-messages" "^1.2.0" isomorphic-ws "^5.0.0" + pako "^2.1.0" ws "^8.13.0" "@wharfkit/resources@^1.1.0":