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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
93 changes: 72 additions & 21 deletions src/transports/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'}),
Expand Down Expand Up @@ -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)
}
}
)

Expand Down
211 changes: 211 additions & 0 deletions test/tests/handoff.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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')
})
})
9 changes: 5 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Loading