From fb41bf5ea76f22e13b1189e362c8be3b08900005 Mon Sep 17 00:00:00 2001 From: Patrick Bennett <170563+pbennett@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:05:29 -0400 Subject: [PATCH] feat(composer): support ORDER_ROUTER v4 groups - The swap's input is the user's asset transfer to the router, else their payment to it. On v4 the user can pay the router's opt-ins before an ASA input; v3 groups resolve as before. - getSummary()'s output on v4 is the delivered field of the route call's ARC-28 Swap event (selector 6d221c27), when the event names the user as beneficiary and the quote's output asset. v3 groups log no event and still use an inner transfer of the output to the user, now the first one rather than the last. --- .claude/CLAUDE.md | 4 +- packages/haystack/src/composer.ts | 143 ++++-- packages/haystack/src/types.ts | 9 +- packages/haystack/tests/router-groups.test.ts | 431 ++++++++++++++++++ 4 files changed, 542 insertions(+), 45 deletions(-) create mode 100644 packages/haystack/tests/router-groups.test.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9fc5bd2..5055852 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -38,11 +38,11 @@ The public API is whatever `src/index.ts` re-exports: client, composer, constant 1. `RouterClient.newQuote()` runs each middleware's `shouldApply`, then `adjustQuoteParams`, then calls `fetchQuote()` (GET `/fetchQuote`) and normalizes the result to a `SwapQuote` with bigint fields. Calling `fetchQuote()` directly **skips** `adjustQuoteParams`. 2. `RouterClient.newSwap()` POSTs the quote's encrypted `txnPayload` to `/fetchExecuteSwapTxns` and returns a `SwapComposer` built on algosdk's `AtomicTransactionComposer`. 3. `SwapComposer.addSwapTransactions()` builds the group in this order: user-added txns, then middleware `beforeSwap`, then app opt-ins for `quote.requiredAppOptIns` the account lacks, then API swap txns, then middleware `afterSwap`. It checks the 16-txn group limit. `sign()`/`submit()`/`execute()` call it automatically if it hasn't run yet. -4. `execute()` records the input and output txn IDs, reads the actual output amount from the last swap app call's inner txns (exposed by `getSummary()`), and on failure logs context through `debug.ts`. +4. `execute()` records the input and output txn IDs, reads the actual output amount for `getSummary()`, and on failure logs context through `debug.ts`. The output is read from the last swap app call. On ORDER_ROUTER v4 that's the route call, and the output is the `delivered` field of its ARC-28 `Swap` event (`readSwapEvent`, selector `6d221c27`; a contract change to the event must be mirrored there). v3 logs no event, so the output is its first inner transfer of the output asset to the user. ### Signing -- API swap txns with `signature: false` get the configured signer. The swap's input is the one that pays or transfers to the router app's address, found from the router's pre-signed app calls (falling back to the last user-signed txn). Only that txn gets the optional `note`, and its group index is tracked for `getInputTransactionId()`. Launch swaps have several user-signed txns, and their two `gas()` calls differ only by note. +- API swap txns with `signature: false` get the configured signer. The swap's input is the first user-signed asset transfer to the router app's address, else the first user-signed payment to it (falling back to the last user-signed txn). The router's address comes from its pre-signed app calls. The user can also pay the router for its opt-ins and fees: after the input on ORDER_ROUTER v3, before an ASA input on v4. The rule holds because an ASA input is the only asset transfer the user makes to the router, and no payment to the router precedes an ALGO input. Only that txn gets the optional `note`, and its group index is tracked for `getInputTransactionId()`. Launch swaps have several user-signed txns, and their two `gas()` calls differ only by note. - Launch-token swaps (`quote.launch`): the user-signed HayLaunch curve call is found by ABI selector (`launchCurveSide()` in `composer.ts`). A buy's output is the curve call's ARC-4 uint64 return, not an inner transfer; a sell's input is the `tokensToSell` app arg. - Every other API txn carries a logic-sig or secret-key `signature` and is re-signed locally by `createSwapSigner()`. The API's group ID is stripped and reassigned by the ATC. - The user signer can be an `algosdk.TransactionSigner` or an ARC-1 `SignerFunction`. `defaultSigner` filters out the `null`s that ARC-1 wallets return. diff --git a/packages/haystack/src/composer.ts b/packages/haystack/src/composer.ts index 90a14ac..d97a647 100644 --- a/packages/haystack/src/composer.ts +++ b/packages/haystack/src/composer.ts @@ -2,6 +2,7 @@ import { AtomicTransactionComposer, decodeUint64, decodeUnsignedTransaction, + encodeAddress, getApplicationAddress, isValidAddress, LogicSigAccount, @@ -39,6 +40,19 @@ const LAUNCH_SELL_SELECTOR = '028a0647' /** Prefix of the log that carries an ARC-4 method's return value */ const ARC4_RETURN_PREFIX = '151f7c75' +/** + * Selector of ORDER_ROUTER v4's ARC-28 Swap event: the first 4 bytes of the + * SHA-512/256 of + * Swap(uint64,uint64,uint8,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,address,address,address) + * (assetIn, assetOut, type, amount, amountIn, outDelta, delivered, + * refundSwapped, refunded, algoReturned, treasuryFee, referrerFee, sender, + * beneficiary, referrer) + */ +const SWAP_EVENT_SELECTOR = '6d221c27' + +/** Byte length of a Swap event log: the selector, 11 uint64s, a uint8 and 3 addresses */ +const SWAP_EVENT_LENGTH = 4 + 11 * 8 + 1 + 3 * 32 + /** A swap transaction from the API, decoded */ interface DecodedSwapTransaction { txn: Transaction @@ -75,6 +89,26 @@ function readUint64Return(logs: Uint8Array[] | undefined): bigint | undefined { return decodeUint64(log.subarray(4), 'bigint') } +/** + * Reads the output delivery from the Swap event an ORDER_ROUTER v4 route call + * logs at settlement. v3 logs none, so this returns undefined for it. + */ +function readSwapEvent( + logs: Uint8Array[] | undefined, +): { assetOut: bigint; delivered: bigint; beneficiary: string } | undefined { + const log = logs?.find( + (l) => + l.length === SWAP_EVENT_LENGTH && + Buffer.from(l.subarray(0, 4)).toString('hex') === SWAP_EVENT_SELECTOR, + ) + if (!log) return undefined + return { + assetOut: decodeUint64(log.subarray(12, 20), 'bigint'), + delivered: decodeUint64(log.subarray(45, 53), 'bigint'), + beneficiary: encodeAddress(log.subarray(125, 157)), + } +} + /** * A transaction signer function that supports both standard algosdk.TransactionSigner * and ARC-1 compliant signers that may return null for unsigned transactions. @@ -786,10 +820,17 @@ export class SwapComposer { } /** - * Finds the swap's input transaction: the user's payment or asset transfer - * to the router app, whose address comes from the router's pre-signed app - * calls. Falls back to the last user-signed transaction for groups without - * a router app call. + * Finds the swap's input transaction among the API's transactions: the + * first user-signed asset transfer to the router app, else the first + * user-signed payment to it. The router's address comes from its pre-signed + * app calls. Falls back to the last user-signed transaction for groups + * without a router app call. + * + * The user may also pay the router ALGO for its fees and opt-ins, after the + * input on ORDER_ROUTER v3 and before an ASA input on v4. The rule relies + * on two invariants of the groups the router API builds: an ASA input is + * the only asset transfer the user makes to the router, and no payment to + * the router precedes an ALGO input. */ private findInputTransactionIndex( txns: DecodedSwapTransaction[], @@ -801,13 +842,16 @@ export class SwapComposer { if (routerAppId !== undefined) { const routerAddress = getApplicationAddress(routerAppId).toString() - const index = txns.findIndex( - ({ txn, signature }) => - signature === false && - (txn.payment?.receiver ?? txn.assetTransfer?.receiver)?.toString() === - routerAddress, - ) - if (index !== -1) return index + const isUserTxnToRouter = ({ txn, signature }: DecodedSwapTransaction) => + signature === false && + (txn.payment?.receiver ?? txn.assetTransfer?.receiver)?.toString() === + routerAddress + for (const type of [TransactionType.axfer, TransactionType.pay]) { + const index = txns.findIndex( + (t) => t.txn.type === type && isUserTxnToRouter(t), + ) + if (index !== -1) return index + } } for (let i = txns.length - 1; i >= 0; i--) { @@ -1030,12 +1074,14 @@ export class SwapComposer { } /** - * Extract the actual output amount from the confirmed output transaction's inner transactions + * Extract the actual output amount from the confirmed output transaction * - * Analyzes only the output transaction (last app call in the swap) to find the - * inner transaction that transfers the output asset to the user. On a launch - * buy the output is a balance on the HayLaunch app, so it is read from the - * curve buy call's return value instead. + * Analyzes only the output transaction (last app call in the swap). On + * ORDER_ROUTER v4 that is the route call, whose Swap event gives the amount + * delivered. v3 logs no event, so the output is its first inner transaction + * that transfers the output asset to the user (it makes only one). On a + * launch buy the output is a balance on the HayLaunch app, so it is read + * from the curve buy call's return value instead. */ private async extractActualOutputAmount(): Promise { if (!this.summaryData?.outputTxnId) { @@ -1076,31 +1122,48 @@ export class SwapComposer { return } - for (const innerTxn of pendingInfo.innerTxns ?? []) { - const txn = innerTxn.txn.txn - const payment = txn.payment - const assetTransfer = txn.assetTransfer - - // Get receiver address based on transaction type - const receiver = payment?.receiver ?? assetTransfer?.receiver - if (!receiver) continue - - // Check if this transfer is to the user - if (receiver.toString() !== userAddress) continue - - // Get sender address for outputSender - const senderAddress = txn.sender.toString() - - if (outputAssetId === 0n && payment?.amount != null) { - // ALGO output to user - this.summaryData.outputAmount = payment.amount - this.summaryData.outputSender = senderAddress - } else if (outputAssetId !== 0n && assetTransfer) { - // ASA output - verify it's the right asset - const assetId = assetTransfer.assetIndex - if (assetId === outputAssetId && assetTransfer.amount != null) { - this.summaryData.outputAmount = assetTransfer.amount + const swapEvent = readSwapEvent(pendingInfo.logs) + const routeCall = swapEvent && pendingInfo.txn.txn.applicationCall + if (swapEvent && routeCall) { + // v4: every payout goes to the event's beneficiary + if ( + swapEvent.assetOut === outputAssetId && + swapEvent.beneficiary === userAddress + ) { + this.summaryData.outputAmount = swapEvent.delivered + this.summaryData.outputSender = getApplicationAddress( + routeCall.appIndex, + ).toString() + } + } else { + for (const innerTxn of pendingInfo.innerTxns ?? []) { + const txn = innerTxn.txn.txn + const payment = txn.payment + const assetTransfer = txn.assetTransfer + + // Get receiver address based on transaction type + const receiver = payment?.receiver ?? assetTransfer?.receiver + if (!receiver) continue + + // Check if this transfer is to the user + if (receiver.toString() !== userAddress) continue + + // Get sender address for outputSender + const senderAddress = txn.sender.toString() + + if (outputAssetId === 0n && payment?.amount != null) { + // ALGO output to user + this.summaryData.outputAmount = payment.amount this.summaryData.outputSender = senderAddress + break + } else if (outputAssetId !== 0n && assetTransfer) { + // ASA output - verify it's the right asset + const assetId = assetTransfer.assetIndex + if (assetId === outputAssetId && assetTransfer.amount != null) { + this.summaryData.outputAmount = assetTransfer.amount + this.summaryData.outputSender = senderAddress + break + } } } } diff --git a/packages/haystack/src/types.ts b/packages/haystack/src/types.ts index d3685b4..f42e272 100644 --- a/packages/haystack/src/types.ts +++ b/packages/haystack/src/types.ts @@ -416,12 +416,15 @@ export interface SwapSummary { outputAssetId: bigint /** * Exact input amount in base units, from the user-signed transaction. - * For a launch sell, the launch tokens sold. + * For a launch sell, the launch tokens sold. On ORDER_ROUTER v4 an ALGO + * input also carries the ALGO that funds the router's opt-ins and pool-call + * fees (v3, and an ASA input on v4, fund them in a separate payment). */ inputAmount: bigint /** - * Exact output amount received in base units, from the inner transaction. - * For a launch buy, the launch tokens credited by the curve. + * Exact output amount received in base units: on ORDER_ROUTER v4 the + * `delivered` field of the route call's Swap event, on v3 the inner + * transaction. For a launch buy, the launch tokens credited by the curve. */ outputAmount: bigint /** Quote type */ diff --git a/packages/haystack/tests/router-groups.test.ts b/packages/haystack/tests/router-groups.test.ts new file mode 100644 index 0000000..f380d59 --- /dev/null +++ b/packages/haystack/tests/router-groups.test.ts @@ -0,0 +1,431 @@ +import { createHash } from 'node:crypto' +import algosdk from 'algosdk' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { SwapComposer } from '../src/composer' +import type { FetchQuoteResponse, SwapTransaction } from '../src/types' + +/** + * Swap groups shaped as the router API builds them for ORDER_ROUTER v3 and v4. + * + * v3: [User_opt_into_assets] [input] [funding] [User_swap...] [User_swap_finalize] + * v4: [refs carriers] [opt_in payment, opt_in] [funding] [input] [route] + * + * Either may start with the user's opt-in to the output (an asset transfer to + * themselves) and a referrer escrow's opt-in (a payment to the escrow and the + * user's call to the referrer treasury). On v4 the user's payments funding the + * router's opt-ins and fees come before an ASA input, and the route call logs + * a Swap event with the amount delivered. + */ + +const V3_APP = 3172554435n +const V4_APP = 3757715482n +const USDC = 31566704n + +const suggestedParams = { + fee: 1000, + firstValid: 1000, + lastValid: 2000, + minFee: 1000, + genesisID: 'mainnet-v1.0', + genesisHash: Buffer.from( + 'wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=', + 'base64', + ), +} + +const user = algosdk.generateAccount().addr.toString() +const hotWallet = algosdk.generateAccount().addr.toString() +const treasury = algosdk.generateAccount().addr.toString() + +const ROUTER_SIGNATURE: SwapTransaction['signature'] = { + type: 'secret_key', + value: 'unused', +} + +const toSwapTxn = ( + txn: algosdk.Transaction, + signature: SwapTransaction['signature'] = false, +): SwapTransaction => ({ + data: Buffer.from(algosdk.encodeUnsignedTransaction(txn)).toString('base64'), + group: '', + logicSigBlob: false, + signature, +}) + +/** A pre-signed router app call, told apart from the others by its note */ +const routerCall = (appIndex: bigint, note: string) => + toSwapTxn( + algosdk.makeApplicationNoOpTxnFromObject({ + sender: hotWallet, + appIndex, + suggestedParams, + note: new TextEncoder().encode(note), + }), + ROUTER_SIGNATURE, + ) + +const userPay = (appIndex: bigint, amount: number, note: string) => + toSwapTxn( + algosdk.makePaymentTxnWithSuggestedParamsFromObject({ + sender: user, + receiver: algosdk.getApplicationAddress(appIndex), + amount, + suggestedParams, + note: new TextEncoder().encode(note), + }), + ) + +const userAxfer = (appIndex: bigint, amount: number, assetIndex: bigint) => + toSwapTxn( + algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({ + sender: user, + receiver: algosdk.getApplicationAddress(appIndex), + amount, + assetIndex, + suggestedParams, + }), + ) + +/** An inner payment or asset transfer as a pending transaction response has it */ +const innerPay = (receiver: string, amount: number) => ({ + txn: { + txn: algosdk.makePaymentTxnWithSuggestedParamsFromObject({ + sender: algosdk.getApplicationAddress(V4_APP), + receiver, + amount, + suggestedParams, + }), + }, +}) +const innerAxfer = (receiver: string, amount: number, assetIndex: bigint) => ({ + txn: { + txn: algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({ + sender: algosdk.getApplicationAddress(V4_APP), + receiver, + amount, + assetIndex, + suggestedParams, + }), + }, +}) + +const quote = (fromASAID: bigint, toASAID: bigint) => + ({ + fromASAID: Number(fromASAID), + toASAID: Number(toASAID), + quote: '1000000', + route: [], + txnPayload: { iv: 'test-iv', data: 'test-data' }, + requiredAppOptIns: [], + }) as unknown as FetchQuoteResponse + +describe('SwapComposer on ORDER_ROUTER v3 and v4 groups', () => { + let mockAlgodClient: algosdk.Algodv2 + let pendingInfo: Record + + beforeEach(() => { + pendingInfo = {} + mockAlgodClient = { + accountInformation: vi.fn().mockReturnValue({ + do: vi.fn().mockResolvedValue({ appsLocalState: [], assets: [] }), + }), + pendingTransactionInformation: vi.fn((txId: string) => ({ + do: vi.fn().mockResolvedValue(pendingInfo[txId] ?? {}), + })), + getTransactionParams: vi.fn().mockReturnValue({ + do: vi.fn().mockResolvedValue(suggestedParams), + }), + } as any + }) + + const createComposer = ( + q: FetchQuoteResponse, + swapTxns: SwapTransaction[], + note?: Uint8Array, + ) => + new SwapComposer({ + quote: q, + swapTxns, + algodClient: mockAlgodClient, + address: user, + signer: async (txns: algosdk.Transaction[]) => + txns.map(() => new Uint8Array(0)), + note, + }) + + /** Build the group and stub the ATC so execute() "confirms" it */ + const buildAndStubExecute = async (composer: SwapComposer) => { + await composer.addSwapTransactions() + const txns = composer.buildGroup() + const txIds = txns.map(({ txn }) => txn.txID()) + vi.spyOn((composer as any).atc, 'execute').mockResolvedValue({ + confirmedRound: 1n, + txIDs: txIds, + methodResults: [], + }) + return { txns, txIds } + } + + describe('input transaction', () => { + // carrier, opt_in payment, opt_in, funding, input, route + const v4AsaInput = () => [ + routerCall(V4_APP, 'carrier'), + userPay(V4_APP, 200_000, 'opt-in'), + routerCall(V4_APP, 'opt_in'), + userPay(V4_APP, 102_000, 'funding'), + userAxfer(V4_APP, 5_000_000, USDC), + routerCall(V4_APP, 'route'), + ] + + it('is the ASA input on v4, not the opt-in or funding payment before it', async () => { + const note = new TextEncoder().encode('tracking-id') + const composer = createComposer(quote(USDC, 0n), v4AsaInput(), note) + const { txns, txIds } = await buildAndStubExecute(composer) + + expect(composer.getInputTransactionId()).toBe(txns[4]?.txn.txID()) + expect(txns[4]?.txn.note).toEqual(note) + expect(new TextDecoder().decode(txns[1]?.txn.note)).toBe('opt-in') + expect(new TextDecoder().decode(txns[3]?.txn.note)).toBe('funding') + + pendingInfo[txIds[5]!] = { innerTxns: [innerPay(user, 3_000_000)] } + await composer.execute() + expect(composer.getSummary()?.inputAmount).toBe(5_000_000n) + }) + + it('is the ASA input on v4 behind the output and escrow opt-ins', async () => { + const composer = createComposer(quote(USDC, 0n), [ + toSwapTxn( + algosdk.makeAssetTransferTxnWithSuggestedParamsFromObject({ + sender: user, + receiver: user, + amount: 0, + assetIndex: USDC, + suggestedParams, + }), + ), + toSwapTxn( + algosdk.makePaymentTxnWithSuggestedParamsFromObject({ + sender: user, + receiver: treasury, + amount: 100_000, + suggestedParams, + }), + ), + toSwapTxn( + algosdk.makeApplicationNoOpTxnFromObject({ + sender: user, + appIndex: 3041355560n, + suggestedParams, + }), + ), + ...v4AsaInput(), + ]) + const { txns } = await buildAndStubExecute(composer) + + expect(composer.getInputTransactionId()).toBe(txns[7]?.txn.txID()) + }) + + // The input carries the route's funding: 1,000,000 to swap plus 2,000 + it('is the ALGO input on v4, which no payment to the router precedes', async () => { + const composer = createComposer(quote(0n, USDC), [ + routerCall(V4_APP, 'carrier'), + userPay(V4_APP, 1_002_000, 'input'), + routerCall(V4_APP, 'route'), + ]) + const { txns, txIds } = await buildAndStubExecute(composer) + + expect(composer.getInputTransactionId()).toBe(txns[1]?.txn.txID()) + pendingInfo[txIds[2]!] = { innerTxns: [innerAxfer(user, 990, USDC)] } + await composer.execute() + expect(composer.getSummary()?.inputAmount).toBe(1_002_000n) + }) + + it('is the ALGO input on v3, not the funding payment after it', async () => { + const composer = createComposer(quote(0n, USDC), [ + routerCall(V3_APP, 'User_opt_into_assets'), + userPay(V3_APP, 1_000_000, 'input'), + userPay(V3_APP, 2_000, 'funding'), + routerCall(V3_APP, 'User_swap'), + routerCall(V3_APP, 'User_swap_finalize'), + ]) + const { txns, txIds } = await buildAndStubExecute(composer) + + expect(composer.getInputTransactionId()).toBe(txns[1]?.txn.txID()) + pendingInfo[txIds[4]!] = { innerTxns: [innerAxfer(user, 990, USDC)] } + await composer.execute() + expect(composer.getSummary()?.inputAmount).toBe(1_000_000n) + }) + + it('is the ASA input on v3', async () => { + const composer = createComposer(quote(USDC, 0n), [ + routerCall(V3_APP, 'User_opt_into_assets'), + userAxfer(V3_APP, 5_000_000, USDC), + userPay(V3_APP, 2_000, 'funding'), + routerCall(V3_APP, 'User_swap'), + routerCall(V3_APP, 'User_swap_finalize'), + ]) + const { txns } = await buildAndStubExecute(composer) + + expect(composer.getInputTransactionId()).toBe(txns[1]?.txn.txID()) + }) + }) + + describe('output amount', () => { + const v4Group = (from: bigint) => [ + from === 0n + ? userPay(V4_APP, 1_000_000, 'input') + : userAxfer(V4_APP, 1_000_000, from), + routerCall(V4_APP, 'route'), + ] + + /** + * The route call's Swap event, with the selector derived from the + * contract's event signature (so a wrong selector in the SDK fails here) + */ + const swapEventLog = (e: { + assetIn: bigint + assetOut: bigint + delivered: bigint + beneficiary: string + }) => { + const selector = createHash('sha512-256') + .update( + 'Swap(uint64,uint64,uint8,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,uint64,address,address,address)', + ) + .digest() + .subarray(0, 4) + // amount, amountIn, outDelta, then delivered; the fields after it are + // refundSwapped, refunded, algoReturned, treasuryFee, referrerFee + const uint64s = (...vs: bigint[]) => + vs.map((v) => algosdk.encodeUint64(v)) + return Buffer.concat([ + selector, + ...uint64s(e.assetIn, e.assetOut), + Uint8Array.of(1), + ...uint64s(1n, 2n, 3n, e.delivered, 5n, 6n, 7n, 8n, 9n), + algosdk.decodeAddress(user).publicKey, + algosdk.decodeAddress(e.beneficiary).publicKey, + algosdk.decodeAddress(treasury).publicKey, + ]) + } + + /** The pending info of a confirmed v4 route call */ + const routeInfo = ( + route: algosdk.Transaction, + logs: Uint8Array[], + innerTxns: unknown[], + ) => ({ txn: { txn: route }, logs, innerTxns }) + + // fixed-output: fee, delivery, the refund sub-swap's deposit, the input + // it refunds, the ALGO the route didn't use + it('is the Swap event delivery on v4, not the inner transfers', async () => { + const composer = createComposer(quote(USDC, 0n), v4Group(USDC)) + const { txns, txIds } = await buildAndStubExecute(composer) + const event = swapEventLog({ + assetIn: USDC, + assetOut: 0n, + delivered: 3_000_000n, + beneficiary: user, + }) + expect(event.length).toBe(189) + pendingInfo[txIds[1]!] = routeInfo( + txns[1]!.txn, + [new TextEncoder().encode('skipped: nothing to swap'), event], + // the user's first ALGO from the route isn't the delivery + [ + innerPay(user, 4_000), + innerPay(user, 3_000_000), + innerAxfer(user, 12_345, USDC), + ], + ) + + await composer.execute() + + expect(composer.getSummary()?.outputAmount).toBe(3_000_000n) + expect(composer.getSummary()?.outputSender).toBe( + algosdk.getApplicationAddress(V4_APP).toString(), + ) + }) + + it('is unknown on v4 when the Swap event pays another beneficiary', async () => { + const composer = createComposer(quote(0n, USDC), v4Group(0n)) + const { txns, txIds } = await buildAndStubExecute(composer) + pendingInfo[txIds[1]!] = routeInfo( + txns[1]!.txn, + [ + swapEventLog({ + assetIn: 0n, + assetOut: USDC, + delivered: 990n, + beneficiary: treasury, + }), + ], + [innerAxfer(user, 990, USDC)], + ) + + await composer.execute() + + expect(composer.getSummary()).toBeUndefined() + }) + + it('is unknown on v4 when the Swap event delivers another asset', async () => { + const composer = createComposer(quote(0n, USDC), v4Group(0n)) + const { txns, txIds } = await buildAndStubExecute(composer) + pendingInfo[txIds[1]!] = routeInfo( + txns[1]!.txn, + [ + swapEventLog({ + assetIn: 0n, + assetOut: 0n, + delivered: 990n, + beneficiary: user, + }), + ], + [innerAxfer(user, 990, USDC)], + ) + + await composer.execute() + + expect(composer.getSummary()).toBeUndefined() + }) + + it('is the first inner transfer of the output to the user on v3', async () => { + const composer = createComposer(quote(0n, USDC), [ + userPay(V3_APP, 1_000_000, 'input'), + routerCall(V3_APP, 'User_swap'), + routerCall(V3_APP, 'User_swap_finalize'), + ]) + const { txIds } = await buildAndStubExecute(composer) + pendingInfo[txIds[2]!] = { + logs: [], + innerTxns: [ + innerAxfer(treasury, 8, USDC), + innerPay(user, 50_000), + innerAxfer(user, 990, USDC), + innerAxfer(user, 5, USDC), + ], + } + + await composer.execute() + + expect(composer.getSummary()?.outputAmount).toBe(990n) + }) + + it('is the first inner payment to the user on v3 when the output is ALGO', async () => { + const composer = createComposer(quote(USDC, 0n), [ + userAxfer(V3_APP, 1_000_000, USDC), + routerCall(V3_APP, 'User_swap'), + routerCall(V3_APP, 'User_swap_finalize'), + ]) + const { txIds } = await buildAndStubExecute(composer) + pendingInfo[txIds[2]!] = { + logs: [], + innerTxns: [innerPay(user, 50_000), innerPay(user, 7_000)], + } + + await composer.execute() + + expect(composer.getSummary()?.outputAmount).toBe(50_000n) + }) + }) +})