diff --git a/e2e/arbitrum/lbtcUsdt0.spec.ts b/e2e/arbitrum/lbtcUsdt0.spec.ts index d3b73d5c6..f22a198a4 100644 --- a/e2e/arbitrum/lbtcUsdt0.spec.ts +++ b/e2e/arbitrum/lbtcUsdt0.spec.ts @@ -87,6 +87,61 @@ describeArbitrumE2e("Arbitrum stablecoin e2e", () => { await generateLiquidBlock(); }); + test("claims an L-BTC to TBTC chain swap to a pasted destination without a connected wallet", async ({ + arbitrum, + recipientAddress, + page, + }) => { + test.setTimeout(fullFlowTestTimeout); + + const token = getRegtestTokenAddress("TBTC"); + expect( + await getTokenBalance( + arbitrum.publicClient, + token, + recipientAddress, + ), + ).toEqual(0n); + + const swapId = await createSwap( + page, + "L-BTC", + "TBTC", + recipientAddress, + lbtcSendAmount, + ); + + expect(await page.evaluate(() => "ethereum" in window)).toBe(false); + + const storedSwap = await getStoredSwap(page, swapId); + expect(storedSwap).not.toBeNull(); + expect(storedSwap!.dex).toBeUndefined(); + + await page + .locator("div[data-testid='pay-onchain-buttons']") + .getByText("address") + .click(); + const lockupAddress = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(lockupAddress).toBeDefined(); + + await elementsSendToAddress(lockupAddress, lbtcSendAmount); + await generateLiquidBlock(); + + await expect( + page.locator("div[data-status='transaction.claimed']"), + ).toBeVisible({ timeout: fullFlowTestTimeout }); + + expect( + await getTokenBalance( + arbitrum.publicClient, + token, + recipientAddress, + ), + ).toBeGreaterThan(0n); + }); + test("claims an L-BTC to USDT0-Arbitrum chain swap", async ({ arbitrum, recipientAddress, diff --git a/src/status/TransactionConfirmed.tsx b/src/status/TransactionConfirmed.tsx index d51fdaf78..e462f888f 100644 --- a/src/status/TransactionConfirmed.tsx +++ b/src/status/TransactionConfirmed.tsx @@ -1081,8 +1081,12 @@ export const ClaimEvm = (props: { receiveAmount: execution.minAmountOut, }; } else { - const sig = signer(); - if (sig === undefined) { + const claimSigner = getSignerForGasAbstraction( + props.gasAbstraction, + signer(), + getGasAbstractionSigner(props.assetReceive), + ); + if (claimSigner === undefined) { throw new Error("missing signer for claim"); } result = await claimAsset({ @@ -1094,7 +1098,7 @@ export const ClaimEvm = (props: { refundAddress: getAddress(props.refundAddress), timeoutBlockHeight: props.timeoutBlockHeight, destination: getAddress(props.signerAddress), - signer: () => sig, + signer: () => claimSigner, gasAbstractionSigner: getGasAbstractionSigner( props.assetReceive, ), diff --git a/tests/status/AutoClaimHops.spec.tsx b/tests/status/AutoClaimHops.spec.tsx index 922b02af9..d778b6ca3 100644 --- a/tests/status/AutoClaimHops.spec.tsx +++ b/tests/status/AutoClaimHops.spec.tsx @@ -23,6 +23,7 @@ const modifySwap = vi.fn(); const getErc20Swap = vi.fn(); const getGasAbstractionSigner = vi.fn(); const fetchDexQuote = vi.fn<(...args: unknown[]) => Promise>(); +const gasTopUpSupported = vi.fn<(asset: string) => boolean>(() => false); const sendPopulatedTransaction = vi.fn<(...args: unknown[]) => Promise>(); const claimAsset = @@ -62,7 +63,7 @@ vi.mock("../../src/hooks/useModifySwap", () => ({ vi.mock("../../src/utils/quoter", () => ({ fetchDexQuote, - gasTopUpSupported: () => false, + gasTopUpSupported: (asset: string) => gasTopUpSupported(asset), getGasTopUpNativeAmount: vi.fn(), fetchGasTokenQuote: vi.fn(), })); @@ -297,6 +298,7 @@ describe("ClaimEvm", () => { beforeEach(() => { vi.clearAllMocks(); setSigner(makeSigner(signerAddress)); + getGasAbstractionSigner.mockReturnValue(makeSigner(signerAddress)); claimAsset.mockResolvedValue({ transactionHash: "0xclaimtx", receiveAmount: 991n, @@ -317,6 +319,57 @@ describe("ClaimEvm", () => { expect(claimAsset.mock.calls[0][0].amount).toBe(777); }); + // L-BTC -> TBTC chain swap created with a pasted destination and gas + // top-up left off: the claim is sponsored by the rescue key derived + // signer, so it must not require a connected wallet + test("claims an ERC20 chain swap to a pasted destination without a connected wallet", async () => { + const destination = "0x0B99060995946051af5ac29148C2b33808b734e9"; + const gasSigner = makeSigner( + "0xa90D48e66F145851f4E047f1bc14ebe33c7a156d", + ); + setSigner(undefined); + getGasAbstractionSigner.mockReturnValue(gasSigner); + gasTopUpSupported.mockReturnValue(true); + getSwap.mockResolvedValue({ + id: "swap-1", + type: SwapType.Chain, + claimDetails: { amount: 99_723 }, + } as SomeSwap); + + render(() => ( + + )); + + await waitFor(() => expect(claimAsset).toHaveBeenCalledTimes(1)); + expect(screen.queryByText(/missing signer/)).toBeNull(); + + const args = claimAsset.mock.calls[0][0] as unknown as { + amount: number; + claimAddress: string; + destination: string; + signer: () => Signer; + }; + expect(args.signer()).toBe(gasSigner); + // Funds stay bound to the address the user pasted, so no forwarding + // transfer is appended on top of the claim + expect(args.claimAddress).toBe(destination); + expect(args.destination).toBe(destination); + expect(args.amount).toBe(99_723); + }); + test("does not submit another claim when one is already persisted", async () => { getSwap.mockResolvedValue({ id: "swap-1",