Skip to content
Merged
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
31 changes: 27 additions & 4 deletions packages/haystack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
})
Expand Down
10 changes: 9 additions & 1 deletion packages/haystack/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -376,7 +383,7 @@ export class RouterClient {
}

const response = await this.fetchQuote(adjustedParams)
const { launch, ...rest } = response
const { launch, firstStage, ...rest } = response

return {
...rest,
Expand All @@ -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(),
}
}
Expand Down
29 changes: 25 additions & 4 deletions packages/haystack/src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
}

Expand Down
19 changes: 19 additions & 0 deletions packages/haystack/src/firststage.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [])],
}
}
46 changes: 44 additions & 2 deletions packages/haystack/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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<FirstStageQuote, 'fromTax' | 'toTax'> & {
readonly fromTax: bigint
readonly toTax: bigint
}

/**
* Quote response from the Haystack Router API
*/
Expand Down Expand Up @@ -289,14 +319,23 @@ 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
}

/**
* Enhanced quote result returned by RouterClient.newQuote()
*
* Extends the raw API response with additional metadata and type normalization.
*/
export type SwapQuote = Omit<FetchQuoteResponse, 'quote' | 'launch'> & {
export type SwapQuote = Omit<
FetchQuoteResponse,
'quote' | 'launch' | 'firstStage'
> & {
/**
* The quoted output amount or input amount (coerced to bigint)
*
Expand All @@ -317,6 +356,9 @@ export type SwapQuote = Omit<FetchQuoteResponse, 'quote' | 'launch'> & {
/** 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
}
Expand Down
101 changes: 101 additions & 0 deletions packages/haystack/tests/firststage.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>

const validAddress =
'5BPCE3UNCPAIONAOMY4CVUXNU27SOCXYE4QSXEQFYXV6ORFQIKVTOR6ZTM'

const sellQuote: Partial<FetchQuoteResponse> = {
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<typeof vi.fn>
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()
})
})
6 changes: 4 additions & 2 deletions packages/haystack/tests/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down
Loading
Loading