diff --git a/packages/haystack/README.md b/packages/haystack/README.md index f7df137..1e93269 100644 --- a/packages/haystack/README.md +++ b/packages/haystack/README.md @@ -186,6 +186,27 @@ const summary = swap.getSummary() Without `slippage`, a launch quote overstates the output by about the slippage; `newSwap()` logs a warning when the two don't match. Fixed-output launch buys and launch-token-to-launch-token swaps aren't supported yet and return a 400. +### First Stage Taxed Tokens + +First Stage tokens (MOOJ, DEAL, DUMBLY, STACKED and others) are frozen in registered holders' wallets and charge a tax to move. The router handles them itself: when a swap's input or output is one of these tokens and the holding is frozen, the swap group includes First Stage's own calls (unfreeze, the tax payment, refreeze), and the quote accounts for the tax. Pass `address`, so the router can check the holding: + +```typescript +const quote = await router.newQuote({ + fromASAID: 2154668640, // MOOJ + toASAID: 0, // ALGO + amount: 1_000_000_000, + address: activeAddress, +}) + +if (quote.firstStage) { + // quote.quote is net of the tax + // quote.firstStage.fromTax: MOOJ sell tax, included in `amount` + // quote.firstStage.toTax: the output's buy tax (0 here) +} +``` + +Without `address`, the swap is built without First Stage's calls, which fails for a frozen holding. Don't use a First Stage middleware (such as `@firststage/deflex-middleware`) with the router: its calls and the router's would be added twice, and the group fails. This applies to ORDER_ROUTER v4 swaps, and is on for API keys by default; contact TxnLab if your key's integration still wraps these swaps itself. + ### Transaction Signing The SDK supports both standard `algosdk.TransactionSigner` and ARC-1 compliant signer functions. @@ -298,25 +319,27 @@ Middleware can: - Add transactions before the swap (e.g., unfreeze account, setup calls) - Add transactions after the swap (e.g., tax payments, cleanup calls) +First Stage tokens no longer need one: the router wraps them itself (see [First Stage Taxed Tokens](#first-stage-taxed-tokens)). + ```typescript import { RouterClient } from '@txnlab/haystack-router' -import { FirstStageMiddleware } from '@firststage/deflex-middleware' // Example external package +import { MyAssetMiddleware } from 'my-asset-middleware' // Example external package // Initialize middleware -const firstStage = new FirstStageMiddleware({ +const myAsset = new MyAssetMiddleware({ contractAppId: 123456, }) // Pass middleware to RouterClient const router = new RouterClient({ apiKey: '1b72df7e-1131-4449-8ce1-29b79dd3f51e', // Free tier (60 requests/min) - middleware: [firstStage], // Middleware is applied automatically + middleware: [myAsset], // Middleware is applied automatically }) // Use normally - middleware handles everything const quote = await router.newQuote({ fromASAID: 0, // ALGO - toASAID: 789012, // Custom asset (e.g., MOOJ, DEAL) + toASAID: 789012, // Custom asset amount: 1_000_000, address: userAddress, }) diff --git a/packages/haystack/src/client.ts b/packages/haystack/src/client.ts index 6648faf..658a31f 100644 --- a/packages/haystack/src/client.ts +++ b/packages/haystack/src/client.ts @@ -14,6 +14,7 @@ import { DEPRECATED_PROTOCOLS, MAX_FEE_BPS, } from './constants' +import { toSwapFirstStageQuote } from './firststage' import { toSwapLaunchQuote } from './launch' import { Logger } from './logger' import { request } from './utils' @@ -206,6 +207,12 @@ export class RouterClient { url.searchParams.append('optIn', String(includeOptIn)) } + // Lets the router quote a First Stage taxed token's swap from the + // account's own holding (wrapped only while it is frozen) + if (address) { + url.searchParams.append('address', this.validateAddress(address)) + } + if (slippage !== undefined) { url.searchParams.append('slippage', String(slippage)) } @@ -376,7 +383,7 @@ export class RouterClient { } const response = await this.fetchQuote(adjustedParams) - const { launch, ...rest } = response + const { launch, firstStage, ...rest } = response return { ...rest, @@ -385,6 +392,7 @@ export class RouterClient { address: params.address ?? undefined, slippage: adjustedParams.slippage, launch: launch && toSwapLaunchQuote(launch), + firstStage: firstStage && toSwapFirstStageQuote(firstStage), createdAt: Date.now(), } } diff --git a/packages/haystack/src/composer.ts b/packages/haystack/src/composer.ts index d97a647..130dcdc 100644 --- a/packages/haystack/src/composer.ts +++ b/packages/haystack/src/composer.ts @@ -18,6 +18,7 @@ import { type TransactionWithSigner, } from 'algosdk' import { DEFAULT_CONFIRMATION_ROUNDS } from './constants' +import { toSwapFirstStageQuote } from './firststage' import { toSwapLaunchQuote } from './launch' import { Logger } from './logger' import type { SwapMiddleware, SwapContext, QuoteContext } from './middleware' @@ -797,15 +798,15 @@ export class SwapComposer { const curveIndex = this.initLaunchSummary(decoded) - // The last transaction in swapTxns is the app call that will contain - // the inner transaction sending the output asset to the user. On a launch - // buy the output comes from the curve buy call instead, wherever it is. + // The router's last app call (v4's route, v3's finalize) holds the inner + // transaction sending the output asset to the user. On a launch buy the + // output comes from the curve buy call instead, wherever it is. // We'll get its ID after buildGroup() is called // Store the relative index so we can get the ID later const outputIndex = this.summaryData?.launch?.side === 'buy' && curveIndex !== undefined ? curveIndex - : swapTxns.length - 1 + : this.findOutputTransactionIndex(decoded) const outputTxnRelativeIndex = outputIndex >= 0 ? appOptIns.length + outputIndex : undefined @@ -860,6 +861,23 @@ export class SwapComposer { return undefined } + /** + * Finds the router's last app call among the API's transactions: v4's + * route call or v3's finalize, which pays the output. The router's calls + * are the pre-signed ones. A First Stage swap ends with the user's own tax + * payment and First Stage calls after it, so the last transaction isn't + * always the router's. Falls back to the last transaction. + */ + private findOutputTransactionIndex(txns: DecodedSwapTransaction[]): number { + for (let i = txns.length - 1; i >= 0; i--) { + const t = txns[i] + if (t?.signature !== false && t?.txn.type === TransactionType.appl) { + return i + } + } + return txns.length - 1 + } + /** * For a launch quote, finds the user-signed curve buy or sell call and * records the curve leg in summaryData. On a sell, the input amount becomes @@ -1033,6 +1051,9 @@ export class SwapComposer { quote: this.quote.quote === '' ? 0n : BigInt(this.quote.quote), amount: 0n, // Not available in FetchQuoteResponse launch: this.quote.launch && toSwapLaunchQuote(this.quote.launch), + firstStage: + this.quote.firstStage && + toSwapFirstStageQuote(this.quote.firstStage), createdAt: Date.now(), } diff --git a/packages/haystack/src/firststage.ts b/packages/haystack/src/firststage.ts new file mode 100644 index 0000000..e7132e8 --- /dev/null +++ b/packages/haystack/src/firststage.ts @@ -0,0 +1,19 @@ +/** + * Helpers for First Stage taxed token quotes + * @internal + */ + +import type { FirstStageQuote, SwapFirstStageQuote } from './types' + +/** + * Normalize a quote's raw First Stage object, coercing amounts to bigint + */ +export function toSwapFirstStageQuote( + firstStage: FirstStageQuote, +): SwapFirstStageQuote { + return { + fromTax: BigInt(firstStage.fromTax), + toTax: BigInt(firstStage.toTax), + wrapped: [...(firstStage.wrapped ?? [])], + } +} diff --git a/packages/haystack/src/types.ts b/packages/haystack/src/types.ts index 9f8e376..b515545 100644 --- a/packages/haystack/src/types.ts +++ b/packages/haystack/src/types.ts @@ -95,7 +95,13 @@ export interface FetchQuoteParams { /** Whether to include asset opt-in transaction (overrides config.autoOptIn if set) */ readonly optIn?: boolean - /** Address of the account that will perform the swap (required if autoOptIn is enabled) */ + /** + * Address of the account that will perform the swap (required if autoOptIn is enabled) + * + * Sent with the quote request: a swap of a First Stage taxed token (MOOJ and + * others) is quoted with its tax, and wrapped in First Stage's calls, only + * when this account's holding is frozen. Without it, the swap isn't wrapped. + */ readonly address?: string | null /** @@ -246,6 +252,30 @@ export type SwapLaunchQuote = Omit< readonly graduates: boolean } +/** + * First Stage taxes of a quote that swaps a First Stage taxed token (MOOJ and + * others) out of or into a frozen holding. The router adds First Stage's own + * calls to the swap group; don't also use a First Stage middleware. + * + * Amounts are in base units of that side's asset. + */ +export interface FirstStageQuote { + /** Sell tax on the input asset (0 when the input isn't wrapped or isn't taxed) */ + readonly fromTax: number + /** Buy tax on the output asset (0 when the output isn't wrapped or isn't taxed) */ + readonly toTax: number + /** The sides the swap wraps in First Stage's calls when their holding is frozen */ + readonly wrapped: ('from' | 'to')[] +} + +/** + * Normalized First Stage taxes on a SwapQuote, with amounts coerced to bigint + */ +export type SwapFirstStageQuote = Omit & { + readonly fromTax: bigint + readonly toTax: bigint +} + /** * Quote response from the Haystack Router API */ @@ -289,6 +319,12 @@ export interface FetchQuoteResponse { * For a buy, `quote` is launch tokens out; for a sell, it is the output asset. */ readonly launch?: LaunchQuote + /** + * First Stage taxes, present only when the swap wraps a First Stage taxed + * token's frozen holding. `quote` is already net of them: what the output + * holding keeps (fixed-input), or all the input holding sends (fixed-output). + */ + readonly firstStage?: FirstStageQuote } /** @@ -296,7 +332,10 @@ export interface FetchQuoteResponse { * * Extends the raw API response with additional metadata and type normalization. */ -export type SwapQuote = Omit & { +export type SwapQuote = Omit< + FetchQuoteResponse, + 'quote' | 'launch' | 'firstStage' +> & { /** * The quoted output amount or input amount (coerced to bigint) * @@ -317,6 +356,9 @@ export type SwapQuote = Omit & { /** Bonding-curve details, present only when one side is a pre-bonding HayLaunch token */ readonly launch?: SwapLaunchQuote + /** First Stage taxes, present only when the swap wraps a First Stage taxed token's frozen holding */ + readonly firstStage?: SwapFirstStageQuote + /** Timestamp when the quote was created (in milliseconds) */ readonly createdAt: number } diff --git a/packages/haystack/tests/firststage.test.ts b/packages/haystack/tests/firststage.test.ts new file mode 100644 index 0000000..23a39c2 --- /dev/null +++ b/packages/haystack/tests/firststage.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { RouterClient } from '../src/client' +import type { FetchQuoteResponse } from '../src/types' + +vi.mock('../src/utils', () => ({ + request: vi.fn(), + HTTPError: class HTTPError extends Error {}, +})) + +describe('First Stage taxed token quotes', () => { + let client: RouterClient + let mockRequest: ReturnType + + const validAddress = + '5BPCE3UNCPAIONAOMY4CVUXNU27SOCXYE4QSXEQFYXV6ORFQIKVTOR6ZTM' + + const sellQuote: Partial = { + fromASAID: 2154668640, + toASAID: 0, + quote: 5_000_000, + type: 'fixed-input', + route: [], + txnPayload: { iv: 'test-iv', data: 'test-data' }, + requiredAppOptIns: [], + firstStage: { fromTax: 90_000, toTax: 0, wrapped: ['from'] }, + } + + beforeEach(async () => { + client = new RouterClient({ apiKey: 'test-api-key' }) + const requestModule = vi.mocked(await import('../src/utils'), { + partial: true, + }) + mockRequest = requestModule.request as ReturnType + mockRequest.mockReset() + }) + + it('sends the address with the quote request', async () => { + mockRequest.mockResolvedValue(sellQuote) + + await client.fetchQuote({ + fromASAID: 2154668640, + toASAID: 0, + amount: 1_000_000, + address: validAddress, + }) + + const url = new URL(mockRequest.mock.calls?.[0]?.[0] as string) + expect(url.searchParams.get('address')).toBe(validAddress) + }) + + it('sends no address when none is given', async () => { + mockRequest.mockResolvedValue(sellQuote) + + await client.fetchQuote({ fromASAID: 0, toASAID: 31566704, amount: 1 }) + + const url = new URL(mockRequest.mock.calls?.[0]?.[0] as string) + expect(url.searchParams.has('address')).toBe(false) + }) + + it('rejects an invalid address before calling the API', async () => { + await expect( + client.fetchQuote({ + fromASAID: 0, + toASAID: 31566704, + amount: 1, + address: 'invalid-address', + }), + ).rejects.toThrow('Invalid Algorand address') + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('normalizes the First Stage taxes in newQuote', async () => { + mockRequest.mockResolvedValue(sellQuote) + + const quote = await client.newQuote({ + fromASAID: 2154668640, + toASAID: 0, + amount: 1_000_000, + address: validAddress, + }) + + expect(quote.quote).toBe(5_000_000n) + expect(quote.firstStage).toEqual({ + fromTax: 90_000n, + toTax: 0n, + wrapped: ['from'], + }) + }) + + it('leaves firstStage undefined for a regular quote', async () => { + mockRequest.mockResolvedValue({ ...sellQuote, firstStage: undefined }) + + const quote = await client.newQuote({ + fromASAID: 0, + toASAID: 31566704, + amount: 1_000_000, + }) + + expect(quote.firstStage).toBeUndefined() + }) +}) diff --git a/packages/haystack/tests/launch.test.ts b/packages/haystack/tests/launch.test.ts index cbbc7a8..2cbd46f 100644 --- a/packages/haystack/tests/launch.test.ts +++ b/packages/haystack/tests/launch.test.ts @@ -240,12 +240,14 @@ describe('SwapComposer launch token swaps', () => { assetIndex: 3454502531, suggestedParams, }) - pendingInfo[txIds[12]!] = { innerTxns: [inner(output)] } + // The router's finalize (its last app call) pays the output, not the + // user's transactions after it + pendingInfo[txIds[8]!] = { innerTxns: [inner(output)] } await composer.execute() const summary = composer.getSummary() - expect(summary?.outputTxnId).toBe(txIds[12]) + expect(summary?.outputTxnId).toBe(txIds[8]) expect(summary?.outputAmount).toBe(42n) expect(summary?.launch).toBeUndefined() }) diff --git a/packages/haystack/tests/router-groups.test.ts b/packages/haystack/tests/router-groups.test.ts index f380d59..cef3679 100644 --- a/packages/haystack/tests/router-groups.test.ts +++ b/packages/haystack/tests/router-groups.test.ts @@ -347,6 +347,47 @@ describe('SwapComposer on ORDER_ROUTER v3 and v4 groups', () => { ) }) + // A First Stage sell: the user's top call before the route, and the tax + // payment and bottom call after it + it('is the Swap event delivery on v4 when First Stage calls follow the route', async () => { + const FIRST_STAGE = 3158291365n + const MOOJ = 2154668640n + const userCall = (note: string) => + toSwapTxn( + algosdk.makeApplicationNoOpTxnFromObject({ + sender: user, + appIndex: FIRST_STAGE, + suggestedParams, + note: new TextEncoder().encode(note), + }), + ) + const composer = createComposer(quote(MOOJ, 0n), [ + userCall('top'), + ...v4Group(MOOJ), + userAxfer(FIRST_STAGE, 90_000, MOOJ), + userCall('bottom'), + ]) + const { txns, txIds } = await buildAndStubExecute(composer) + pendingInfo[txIds[2]!] = routeInfo( + txns[2]!.txn, + [ + swapEventLog({ + assetIn: MOOJ, + assetOut: 0n, + delivered: 3_000_000n, + beneficiary: user, + }), + ], + [innerPay(user, 3_000_000)], + ) + + await composer.execute() + + expect(composer.getInputTransactionId()).toBe(txns[1]?.txn.txID()) + expect(composer.getSummary()?.outputAmount).toBe(3_000_000n) + expect(composer.getSummary()?.inputAmount).toBe(1_000_000n) + }) + 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)