From 04f18f60cee33d6dcdd984faaba57ea57244f90d Mon Sep 17 00:00:00 2001 From: playmaker410 Date: Thu, 27 Aug 2026 07:40:06 +0100 Subject: [PATCH] fix(lobstr): isolate concurrent signing callbacks --- src/__tests__/lobstr.test.ts | 175 +++++++++++++++++++++++++++++------ src/services/lobstr.ts | 165 ++++++++++++++++++++------------- 2 files changed, 250 insertions(+), 90 deletions(-) diff --git a/src/__tests__/lobstr.test.ts b/src/__tests__/lobstr.test.ts index 760c552..8ef5cf6 100644 --- a/src/__tests__/lobstr.test.ts +++ b/src/__tests__/lobstr.test.ts @@ -7,7 +7,7 @@ * - Callback URL parsing * - `LobstrNotInstalledError` when Linking.canOpenURL returns false * - `openLobstrForSigning` resolves when resolveLobstrCallback is called - * - `cancelLobstrCallback` rejects the pending promise + * - Concurrent signing callbacks, cancellation, and timeouts stay isolated */ // Mock react-native's Linking module before any imports. @@ -46,6 +46,32 @@ import { const mockCanOpenURL = Linking.canOpenURL as jest.Mock; const mockOpenURL = Linking.openURL as jest.Mock; +async function flushLobstrOpen(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function getOpenedCallbackUrl(callIndex = 0): string { + const sep7Uri = mockOpenURL.mock.calls[callIndex]?.[0] as string | undefined; + if (!sep7Uri) { + throw new Error(`Missing Lobstr openURL call at index ${callIndex}`); + } + const query = sep7Uri.slice(sep7Uri.indexOf('?') + 1); + const callback = new URLSearchParams(query).get('callback'); + if (!callback?.startsWith('url:')) { + throw new Error('Missing SEP-7 URL callback'); + } + return callback.slice('url:'.length); +} + +function getCallbackId(callIndex = 0): string { + const id = new URL(getOpenedCallbackUrl(callIndex)).searchParams.get('id'); + if (!id) { + throw new Error('Missing Lobstr callback correlation ID'); + } + return id; +} + beforeEach(() => { jest.clearAllMocks(); // Default: Lobstr is installed. @@ -77,33 +103,35 @@ describe('LOBSTR_CALLBACK_URI', () => { describe('buildSep7TxUri', () => { const XDR = 'AAAAAQAAAA=='; const PUBLIC_KEY = 'GBILLBOARDPUBLICKEY'; + const CORRELATION_ID = 'signing-call-1'; it('starts with the web+stellar:tx prefix', () => { - const uri = buildSep7TxUri(XDR, PUBLIC_KEY); + const uri = buildSep7TxUri(XDR, PUBLIC_KEY, CORRELATION_ID); expect(uri).toMatch(/^web\+stellar:tx\?/); }); it('includes the encoded xdr parameter', () => { - const uri = buildSep7TxUri(XDR, PUBLIC_KEY); + const uri = buildSep7TxUri(XDR, PUBLIC_KEY, CORRELATION_ID); expect(uri).toContain('xdr='); // The raw XDR value must appear URL-encoded in the URI. expect(decodeURIComponent(uri)).toContain(XDR); }); it('includes the pubkey parameter', () => { - const uri = buildSep7TxUri(XDR, PUBLIC_KEY); + const uri = buildSep7TxUri(XDR, PUBLIC_KEY, CORRELATION_ID); expect(uri).toContain(`pubkey=${PUBLIC_KEY}`); }); - it('includes a callback parameter pointing at the ecotask scheme', () => { - const uri = buildSep7TxUri(XDR, PUBLIC_KEY); - // callback value is URL-encoded; decode to inspect - const decoded = decodeURIComponent(uri); - expect(decoded).toContain('ecotask://'); + it('includes the correlation ID in the encoded callback URL', () => { + const uri = buildSep7TxUri(XDR, PUBLIC_KEY, CORRELATION_ID); + const query = uri.slice(uri.indexOf('?') + 1); + const callback = new URLSearchParams(query).get('callback'); + + expect(callback).toBe(`url:${LOBSTR_CALLBACK_URI}?id=${CORRELATION_ID}`); }); it('includes the testnet network passphrase', () => { - const uri = buildSep7TxUri(XDR, PUBLIC_KEY); + const uri = buildSep7TxUri(XDR, PUBLIC_KEY, CORRELATION_ID); // URLSearchParams encodes spaces as '+'; decode both forms. const decoded = decodeURIComponent(uri).replace(/\+/g, ' '); expect(decoded).toContain('Test SDF Network'); @@ -119,7 +147,7 @@ describe('buildSep7TxUri', () => { })); const { buildSep7TxUri: buildMainnet } = jest.requireActual('../services/lobstr'); - const uri = buildMainnet(XDR, PUBLIC_KEY); + const uri = buildMainnet(XDR, PUBLIC_KEY, CORRELATION_ID); const decoded = decodeURIComponent(uri).replace(/\+/g, ' '); expect(decoded).toContain('Public Global Stellar Network ; September 2015'); expect(decoded).not.toContain('Test SDF Network'); @@ -171,10 +199,13 @@ describe('buildSep7PayUri', () => { // --------------------------------------------------------------------------- describe('parseLobstrCallbackUrl', () => { - it('extracts the signed XDR from a valid callback URL', () => { + it('extracts the signed XDR and ID from a valid callback URL', () => { const signedXDR = 'SIGNEDXDR=='; - const url = `ecotask://lobstr/callback?xdr=${encodeURIComponent(signedXDR)}`; - expect(parseLobstrCallbackUrl(url)).toBe(signedXDR); + const id = 'signing-call-1'; + const url = `ecotask://lobstr/callback?xdr=${encodeURIComponent( + signedXDR, + )}&id=${id}`; + expect(parseLobstrCallbackUrl(url)).toEqual({ xdr: signedXDR, id }); }); it('throws when the URL has no query string', () => { @@ -185,14 +216,23 @@ describe('parseLobstrCallbackUrl', () => { it('throws when the xdr parameter is absent', () => { expect(() => - parseLobstrCallbackUrl('ecotask://lobstr/callback?other=value'), + parseLobstrCallbackUrl('ecotask://lobstr/callback?id=signing-call-1'), ).toThrow('missing the signed XDR'); }); + it('throws when the correlation ID is absent', () => { + expect(() => + parseLobstrCallbackUrl('ecotask://lobstr/callback?xdr=SIGNED_XDR'), + ).toThrow('missing the correlation ID'); + }); + it('handles XDR values containing "+" characters', () => { const signedXDR = 'ABC+DEF=='; - const url = `ecotask://lobstr/callback?xdr=${encodeURIComponent(signedXDR)}`; - expect(parseLobstrCallbackUrl(url)).toBe(signedXDR); + const id = 'signing-call-2'; + const url = `ecotask://lobstr/callback?xdr=${encodeURIComponent( + signedXDR, + )}&id=${id}`; + expect(parseLobstrCallbackUrl(url)).toEqual({ xdr: signedXDR, id }); }); }); @@ -243,11 +283,11 @@ describe('openLobstrForSigning — callback flow', () => { it('opens a web+stellar:tx URI', async () => { const signedXDR = 'SIGNED_XDR_VALUE=='; const promise = openLobstrForSigning('ORIGINAL_XDR==', 'GPUBLICKEY'); + await flushLobstrOpen(); + const id = getCallbackId(); - // _pendingResolve is set synchronously in the Promise constructor, so - // resolveLobstrCallback can be called immediately without any flush. resolveLobstrCallback( - `ecotask://lobstr/callback?xdr=${encodeURIComponent(signedXDR)}`, + `ecotask://lobstr/callback?xdr=${encodeURIComponent(signedXDR)}&id=${id}`, ); await expect(promise).resolves.toBe(signedXDR); @@ -257,10 +297,40 @@ describe('openLobstrForSigning — callback flow', () => { it('rejects when the callback URL is malformed', async () => { const promise = openLobstrForSigning('XDR==', 'GPUBLICKEY'); + await flushLobstrOpen(); + const id = getCallbackId(); - resolveLobstrCallback('ecotask://lobstr/callback'); // no xdr param + resolveLobstrCallback(`ecotask://lobstr/callback?id=${id}`); - await expect(promise).rejects.toThrow('missing query parameters'); + await expect(promise).rejects.toThrow('missing the signed XDR'); + }); + + it('routes two concurrent callbacks to their matching promises', async () => { + const firstPromise = openLobstrForSigning('FIRST_XDR==', 'GFIRST'); + const secondPromise = openLobstrForSigning('SECOND_XDR==', 'GSECOND'); + await flushLobstrOpen(); + + const firstId = getCallbackId(0); + const secondId = getCallbackId(1); + expect(firstId).not.toBe(secondId); + + const firstSettled = jest.fn(); + firstPromise.then(firstSettled, firstSettled); + + resolveLobstrCallback( + `ecotask://lobstr/callback?xdr=${encodeURIComponent( + 'SIGNED_SECOND_XDR==', + )}&id=${secondId}`, + ); + await expect(secondPromise).resolves.toBe('SIGNED_SECOND_XDR=='); + expect(firstSettled).not.toHaveBeenCalled(); + + resolveLobstrCallback( + `ecotask://lobstr/callback?xdr=${encodeURIComponent( + 'SIGNED_FIRST_XDR==', + )}&id=${firstId}`, + ); + await expect(firstPromise).resolves.toBe('SIGNED_FIRST_XDR=='); }); }); @@ -269,12 +339,33 @@ describe('openLobstrForSigning — callback flow', () => { // --------------------------------------------------------------------------- describe('cancelLobstrCallback', () => { - it('rejects the pending signing promise', async () => { - const promise = openLobstrForSigning('XDR==', 'GPUBLICKEY'); + it('rejects all pending signing promises when called without an ID', async () => { + const firstPromise = openLobstrForSigning('FIRST_XDR==', 'GFIRST'); + const secondPromise = openLobstrForSigning('SECOND_XDR==', 'GSECOND'); - // _pendingReject is set synchronously, no flush needed. cancelLobstrCallback(); - await expect(promise).rejects.toThrow('cancelled'); + await expect(firstPromise).rejects.toThrow('cancelled'); + await expect(secondPromise).rejects.toThrow('cancelled'); + }); + + it('rejects only the pending call matching the given ID', async () => { + const firstPromise = openLobstrForSigning('FIRST_XDR==', 'GFIRST'); + const secondPromise = openLobstrForSigning('SECOND_XDR==', 'GSECOND'); + await flushLobstrOpen(); + const firstId = getCallbackId(0); + const secondId = getCallbackId(1); + const secondSettled = jest.fn(); + secondPromise.then(secondSettled, secondSettled); + + cancelLobstrCallback(firstId); + + await expect(firstPromise).rejects.toThrow('cancelled'); + expect(secondSettled).not.toHaveBeenCalled(); + + resolveLobstrCallback( + `ecotask://lobstr/callback?xdr=SIGNED_SECOND_XDR&id=${secondId}`, + ); + await expect(secondPromise).resolves.toBe('SIGNED_SECOND_XDR'); }); it('is a no-op when there is no pending promise', () => { @@ -317,13 +408,20 @@ describe('openLobstrForSigning — timeout', () => { await Promise.resolve(); expect(settled).not.toHaveBeenCalled(); + + cancelLobstrCallback(); + await expect(promise).rejects.toThrow('cancelled'); }); it('clears the timeout when the callback resolves successfully', async () => { const promise = openLobstrForSigning('ORIGINAL_XDR==', 'GPUBLICKEY'); + await flushLobstrOpen(); + const id = getCallbackId(); resolveLobstrCallback( - `ecotask://lobstr/callback?xdr=${encodeURIComponent('SIGNED_XDR==')}`, + `ecotask://lobstr/callback?xdr=${encodeURIComponent( + 'SIGNED_XDR==', + )}&id=${id}`, ); await expect(promise).resolves.toBe('SIGNED_XDR=='); @@ -332,6 +430,29 @@ describe('openLobstrForSigning — timeout', () => { await Promise.resolve(); }); + it('times out and cleans up only the matching pending call', async () => { + const firstPromise = openLobstrForSigning('FIRST_XDR==', 'GFIRST'); + await flushLobstrOpen(); + + jest.advanceTimersByTime(LOBSTR_SIGNING_TIMEOUT_MS / 2); + + const secondPromise = openLobstrForSigning('SECOND_XDR==', 'GSECOND'); + await flushLobstrOpen(); + const secondId = getCallbackId(1); + const secondSettled = jest.fn(); + secondPromise.then(secondSettled, secondSettled); + + jest.advanceTimersByTime(LOBSTR_SIGNING_TIMEOUT_MS / 2); + + await expect(firstPromise).rejects.toThrow('Lobstr signing timed out'); + expect(secondSettled).not.toHaveBeenCalled(); + + resolveLobstrCallback( + `ecotask://lobstr/callback?xdr=SIGNED_SECOND_XDR&id=${secondId}`, + ); + await expect(secondPromise).resolves.toBe('SIGNED_SECOND_XDR'); + }); + it('clears the timeout when cancelLobstrCallback is called', async () => { const promise = openLobstrForSigning('XDR==', 'GPUBLICKEY'); diff --git a/src/services/lobstr.ts b/src/services/lobstr.ts index 8d3b18e..50566fb 100644 --- a/src/services/lobstr.ts +++ b/src/services/lobstr.ts @@ -6,7 +6,8 @@ * Auth flow (tx URI type): * 1. Build a `web+stellar:tx?xdr=&callback=&pubkey=` * URI and open it — Lobstr takes the user through a signing UI. - * 2. Lobstr redirects to `ecotask://lobstr/callback?xdr=` + * 2. Lobstr redirects to + * `ecotask://lobstr/callback?xdr=&id=` * (when the `callback` param is a `url:ecotask://…` value). * 3. RootNavigator receives the deep link; the pending promise is resolved * with the signed XDR so the caller can continue. @@ -42,24 +43,43 @@ export const LOBSTR_SIGNING_TIMEOUT_MS = 5 * 60 * 1000; type CallbackResolve = (signedXDR: string) => void; type CallbackReject = (reason: Error) => void; -let _pendingResolve: CallbackResolve | null = null; -let _pendingReject: CallbackReject | null = null; -let _pendingTimeout: ReturnType | null = null; +interface PendingCall { + resolve: CallbackResolve; + reject: CallbackReject; + timer: ReturnType; +} + +const pendingCalls = new Map(); +let nextCorrelationId = 0; /** - * Clear the pending resolve/reject slots and any in-flight timeout. - * - * Must be called whenever the pending promise is settled (success, error, - * cancellation, or timeout) so a stray timer never rejects a promise that - * has already been consumed. + * Generate an ID that is unique for every signing call in this module + * instance. The sequence suffix also keeps calls unique when they start in + * the same millisecond. */ -function clearPendingLobstr(): void { - _pendingResolve = null; - _pendingReject = null; - if (_pendingTimeout !== null) { - clearTimeout(_pendingTimeout); - _pendingTimeout = null; +function createCorrelationId(): string { + nextCorrelationId += 1; + return `${Date.now().toString(36)}-${nextCorrelationId.toString(36)}`; +} + +/** Remove one pending call and clear only its timeout. */ +function takePendingCall(id: string): PendingCall | undefined { + const pending = pendingCalls.get(id); + if (pending) { + pendingCalls.delete(id); + clearTimeout(pending.timer); } + return pending; +} + +function getCallbackParams(url: string): URLSearchParams { + // URLSearchParams requires a query string; extract it manually to avoid + // cross-platform URL parsing quirks in React Native's JS engine. + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) { + throw new Error('Lobstr callback URL is missing query parameters'); + } + return new URLSearchParams(url.slice(queryIndex + 1)); } /** @@ -68,27 +88,44 @@ function clearPendingLobstr(): void { * `openLobstrForSigning`. */ export function resolveLobstrCallback(url: string): void { - if (!_pendingResolve || !_pendingReject) { + let id: string | null = null; + try { + id = getCallbackParams(url).get('id'); + } catch { + // Without an ID, the callback cannot safely be associated with a call. + } + + if (!id || !pendingCalls.has(id)) { return; } + try { - const signedXDR = parseLobstrCallbackUrl(url); - _pendingResolve(signedXDR); + const callback = parseLobstrCallbackUrl(url); + takePendingCall(callback.id)?.resolve(callback.xdr); } catch (err) { - _pendingReject(err instanceof Error ? err : new Error(String(err))); - } finally { - clearPendingLobstr(); + takePendingCall(id)?.reject( + err instanceof Error ? err : new Error(String(err)), + ); } } /** - * Cancel any pending Lobstr signing promise (e.g., user navigated away). + * Cancel one pending Lobstr signing promise, or all calls when no ID is given. */ -export function cancelLobstrCallback(): void { - if (_pendingReject) { - _pendingReject(new Error('Lobstr signing was cancelled')); +export function cancelLobstrCallback(id?: string): void { + const cancellationError = new Error('Lobstr signing was cancelled'); + + if (id !== undefined) { + takePendingCall(id)?.reject(cancellationError); + return; } - clearPendingLobstr(); + + const calls = Array.from(pendingCalls.values()); + pendingCalls.clear(); + calls.forEach(call => { + clearTimeout(call.timer); + call.reject(cancellationError); + }); } // --------------------------------------------------------------------------- @@ -98,16 +135,23 @@ export function cancelLobstrCallback(): void { /** * Build a SEP-7 `tx` URI for transaction signing. * - * @param xdr Base64-encoded unsigned transaction XDR. - * @param publicKey Sender public key (populates `pubkey` field). - * @returns A `web+stellar:tx?…` URI string. + * @param xdr Base64-encoded unsigned transaction XDR. + * @param publicKey Sender public key (populates `pubkey` field). + * @param correlationId ID used to route the signed-XDR callback. + * @returns A `web+stellar:tx?…` URI string. */ -export function buildSep7TxUri(xdr: string, publicKey: string): string { +export function buildSep7TxUri( + xdr: string, + publicKey: string, + correlationId: string, +): string { + const callbackParams = new URLSearchParams({ id: correlationId }); + const callbackUrl = `${LOBSTR_CALLBACK_URI}?${callbackParams.toString()}`; const params = new URLSearchParams({ xdr, pubkey: publicKey, // `url:` prefix tells Lobstr the callback is a URL deep link. - callback: `url:${LOBSTR_CALLBACK_URI}`, + callback: `url:${callbackUrl}`, network_passphrase: STELLAR_NETWORK_PASSPHRASE, }); return `web+stellar:tx?${params.toString()}`; @@ -145,27 +189,28 @@ export function buildSep7PayUri( // --------------------------------------------------------------------------- /** - * Parse a Lobstr deep-link callback URL and extract the signed XDR. + * Parse a Lobstr deep-link callback URL and extract its signed XDR and + * correlation ID. * * Expected format: - * `ecotask://lobstr/callback?xdr=` + * `ecotask://lobstr/callback?xdr=&id=` * - * @throws Error when the URL is malformed or the `xdr` param is absent. + * @throws Error when the URL is malformed or a required param is absent. */ -export function parseLobstrCallbackUrl(url: string): string { - // URLSearchParams requires a query string; extract it manually to avoid - // cross-platform URL parsing quirks in React Native's JS engine. - const queryIndex = url.indexOf('?'); - if (queryIndex === -1) { - throw new Error('Lobstr callback URL is missing query parameters'); - } - const query = url.slice(queryIndex + 1); - const params = new URLSearchParams(query); +export function parseLobstrCallbackUrl(url: string): { + xdr: string; + id: string; +} { + const params = getCallbackParams(url); const xdr = params.get('xdr'); if (!xdr) { throw new Error('Lobstr callback URL is missing the signed XDR'); } - return xdr; + const id = params.get('id'); + if (!id) { + throw new Error('Lobstr callback URL is missing the correlation ID'); + } + return { xdr, id }; } // --------------------------------------------------------------------------- @@ -198,25 +243,20 @@ export function openLobstrForSigning( xdr: string, publicKey: string, ): Promise { - // Cancel any previous pending callback before registering a new one. - cancelLobstrCallback(); + const correlationId = createCorrelationId(); - // Register the resolve/reject slots synchronously so that any call to + // Register the pending call synchronously so that any call to // resolveLobstrCallback() or cancelLobstrCallback() — even one microtask - // after this function is called — will find them populated. + // after this function is called — will find it populated. return new Promise((resolve, reject) => { - _pendingResolve = resolve; - _pendingReject = reject; - // Reject if the user never signs (e.g. they dismiss Lobstr). Without // this the promise hangs forever and the caller is stuck loading. - _pendingTimeout = setTimeout(() => { - if (_pendingReject) { - const timedOut = _pendingReject; - clearPendingLobstr(); - timedOut(new Error('Lobstr signing timed out')); - } + const timer = setTimeout(() => { + takePendingCall(correlationId)?.reject( + new Error('Lobstr signing timed out'), + ); }, LOBSTR_SIGNING_TIMEOUT_MS); + pendingCalls.set(correlationId, { resolve, reject, timer }); // Kick off async work; on any failure, reject through the registered slot. isLobstrInstalled() @@ -224,14 +264,13 @@ export function openLobstrForSigning( if (!installed) { throw new LobstrNotInstalledError(); } - return Linking.openURL(buildSep7TxUri(xdr, publicKey)); + return Linking.openURL(buildSep7TxUri(xdr, publicKey, correlationId)); }) .catch(err => { - // Only reject if the slot hasn't been consumed by a callback already. - if (_pendingReject) { - const failed = _pendingReject; - clearPendingLobstr(); - failed( + // Only reject if this call hasn't been consumed by a callback already. + const failed = takePendingCall(correlationId); + if (failed) { + failed.reject( err instanceof LobstrNotInstalledError ? err : new Error(