This guide explains how to create middleware for the Haystack Router SDK to support assets with special transfer requirements.
- Overview
- When to Use Middleware
- Middleware Interface
- Creating Middleware
- Publishing Middleware
- Best Practices
- Example: First Stage Assets
Some Algorand assets require additional transactions to be included in swap transaction groups. Examples include:
- Assets with transfer taxes or fees
- Assets with freeze/unfreeze requirements
- Assets that interact with smart contracts during transfers
- Assets with custom tokenomics logic
Rather than modifying the Haystack Router SDK for each special asset type, middleware allows you to create standalone packages that integrate seamlessly with the SDK.
Create middleware when an asset requires:
- Quote Adjustments: Modify parameters like
maxGroupSizeoramountbefore fetching quotes - Pre-Swap Transactions: Add transactions before the swap (e.g., unfreeze account, setup calls)
- Post-Swap Transactions: Add transactions after the swap (e.g., tax payments, cleanup calls)
interface SwapMiddleware {
// Required
readonly name: string
readonly version: string
shouldApply(params: { fromASAID: bigint; toASAID: bigint }): Promise<boolean>
// Optional hooks
adjustQuoteParams?(params: FetchQuoteParams): Promise<FetchQuoteParams>
beforeSwap?(context: SwapContext): Promise<TransactionWithSigner[]>
afterSwap?(context: SwapContext): Promise<TransactionWithSigner[]>
}Unique identifier for your middleware (e.g., 'FirstStage', 'CustomAsset')
Semantic version of your middleware (e.g., '1.0.0')
Determines if middleware should be applied to a given swap. Called during both quote and swap phases.
Parameters:
params.fromASAID: bigint- Input asset IDparams.toASAID: bigint- Output asset ID
Returns: true if either asset requires special handling
Example:
async shouldApply(params) {
// Check if either asset is registered in your smart contract
const [fromRegistered, toRegistered] = await Promise.all([
this.isRegistered(params.fromASAID),
this.isRegistered(params.toASAID),
])
return fromRegistered || toRegistered
}Modify quote request parameters before fetching the quote.
⚠️ IMPORTANT: If your middleware adds transactions viabeforeSwap()orafterSwap(), you MUST implement this method and reducemaxGroupSizeaccordingly. The Haystack Router API may return routes that use all 16 available transaction slots, causing the swap to fail if you don't reserve space for the extra transactions.
Common adjustments:
- Reduce
maxGroupSizeto account for additional transactions (REQUIRED if adding txns) - Adjust
amountto account for fees/taxes - Modify
disabledProtocolsif certain DEXs are incompatible
Example:
async adjustQuoteParams(params) {
const [fromTaxed, toTaxed] = await Promise.all([
this.hasTax(params.fromASAID),
this.hasTax(params.toASAID),
])
// 3 extra transactions per taxed asset
let maxGroupSize = params.maxGroupSize ?? 16
if (fromTaxed) maxGroupSize -= 3
if (toTaxed) maxGroupSize -= 3
// Adjust amount for input tax (e.g., 9% tax)
let amount = params.amount
if (fromTaxed) {
const taxRate = await this.getTaxRate(params.fromASAID)
amount = this.calculateWithTax(amount, taxRate)
}
return { ...params, maxGroupSize, amount }
}Add transactions before the swap transactions.
Use cases:
- Unfreeze accounts
- Initialize smart contract state
- Setup transactions
Example:
async beforeSwap(context) {
const txns: TransactionWithSigner[] = []
if (await this.needsUnfreeze(context.fromASAID)) {
const unfreezeCall = makeApplicationNoOpTxnFromObject({
sender: context.address,
appIndex: this.appId,
appArgs: [new Uint8Array(Buffer.from('unfreeze'))],
suggestedParams: context.suggestedParams,
})
txns.push({
txn: unfreezeCall,
signer: context.signer, // User's signer from context
})
}
return txns
}Add transactions after the swap transactions.
Use cases:
- Tax/fee payments
- Refreeze accounts
- Cleanup transactions
Example:
async afterSwap(context) {
const txns: TransactionWithSigner[] = []
if (await this.hasTax(context.fromASAID)) {
const taxAmount = await this.calculateTax(context)
// Tax payment transaction
const taxPayment = makeAssetTransferTxnWithSuggestedParamsFromObject({
sender: context.address,
receiver: this.taxReceiver,
amount: taxAmount,
assetIndex: context.fromASAID,
suggestedParams: context.suggestedParams,
})
// Refreeze call
const refreezeCall = makeApplicationNoOpTxnFromObject({
sender: context.address,
appIndex: this.appId,
appArgs: [new Uint8Array(Buffer.from('refreeze'))],
suggestedParams: context.suggestedParams,
})
txns.push(
{ txn: taxPayment, signer: context.signer },
{ txn: refreezeCall, signer: context.signer },
)
}
return txns
}The context object provided to beforeSwap and afterSwap hooks:
interface SwapContext {
readonly quote: SwapQuote // The quote result
readonly address: string // User's address
readonly algodClient: Algodv2 // Algod client for queries
readonly suggestedParams: SuggestedParams // Transaction parameters
readonly fromASAID: bigint // Input asset ID
readonly toASAID: bigint // Output asset ID
readonly signer: TransactionSigner // User's transaction signer
}Create a new npm package:
npm init -y
npm install --save-peer @txnlab/haystack-router algosdk
npm install --save-dev typescript @types/nodeCreate your middleware class:
// src/index.ts
import type {
SwapMiddleware,
SwapContext,
FetchQuoteParams,
} from '@txnlab/haystack-router'
import type { TransactionWithSigner } from 'algosdk'
export interface CustomAssetConfig {
contractAppId: number | bigint
}
export class CustomAssetMiddleware implements SwapMiddleware {
readonly name = 'CustomAsset'
readonly version = '1.0.0'
private contractAppId: number | bigint
constructor(config: CustomAssetConfig) {
this.contractAppId = config.contractAppId
}
async shouldApply(params: {
fromASAID: bigint
toASAID: bigint
}): Promise<boolean> {
// Check if either asset is registered
const [fromRegistered, toRegistered] = await Promise.all([
this.isAssetRegistered(params.fromASAID),
this.isAssetRegistered(params.toASAID),
])
return fromRegistered || toRegistered
}
async adjustQuoteParams(
params: FetchQuoteParams
): Promise<FetchQuoteParams> {
// Implement quote adjustments
return params
}
async beforeSwap(
context: SwapContext
): Promise<TransactionWithSigner[]> {
// Implement before-swap transactions
return []
}
async afterSwap(
context: SwapContext
): Promise<TransactionWithSigner[]> {
// Implement after-swap transactions
return []
}
private async isAssetRegistered(assetId: bigint): Promise<boolean> {
// Query smart contract to check registration
// Cache results for performance
return false
}
}For performance, cache smart contract queries:
export class CustomAssetMiddleware implements SwapMiddleware {
private assetCache = new Map<bigint, boolean>()
private taxRateCache = new Map<bigint, number>()
private async isAssetRegistered(assetId: bigint): Promise<boolean> {
if (this.assetCache.has(assetId)) {
return this.assetCache.get(assetId)!
}
// Query smart contract
const isRegistered = await this.queryContract(assetId)
this.assetCache.set(assetId, isRegistered)
return isRegistered
}
}async beforeSwap(context: SwapContext): Promise<TransactionWithSigner[]> {
try {
// Your logic here
return txns
} catch (error) {
console.error(`${this.name} middleware error:`, error)
// Return empty array to avoid breaking the swap
return []
}
}{
"name": "@your-org/haystack-router-middleware",
"version": "1.0.0",
"description": "Haystack Router middleware for CustomAsset",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"keywords": ["haystack-router", "algorand", "dex", "middleware"],
"peerDependencies": {
"@txnlab/haystack-router": "^1.2.0",
"algosdk": "^3.0.0"
}
}Include clear usage instructions:
# CustomAsset Haystack Router Middleware
Middleware for swapping CustomAsset tokens via Haystack Router.
## Installation
```bash
npm install @your-org/haystack-router-middleware @txnlab/haystack-router algosdk
```
## Usage
```typescript
import { RouterClient } from '@txnlab/haystack-router'
import { CustomAssetMiddleware } from '@your-org/haystack-router-middleware'
const middleware = new CustomAssetMiddleware({
contractAppId: 123456,
})
const router = new RouterClient({
apiKey: '1b72df7e-1131-4449-8ce1-29b79dd3f51e', // Free tier (60 requests/min)
middleware: [middleware],
})
// Use normally
const quote = await router.newQuote({ ... })
const swap = await router.newSwap({ ... })
await swap.execute()
```This is the most critical requirement for middleware developers.
If your middleware adds ANY transactions via beforeSwap() or afterSwap(), you MUST implement adjustQuoteParams() and reduce maxGroupSize by the exact number of transactions you add.
Why this is critical:
- Algorand has a hard limit of 16 transactions per atomic group
- Haystack Router API will optimize routes to use as many transactions as the
maxGroupSizeallows - If Haystack Router returns a 16-transaction route and you try to add more, the transaction will fail
Example:
async adjustQuoteParams(params: FetchQuoteParams): Promise<FetchQuoteParams> {
// If you add 3 transactions per asset (unfreeze, tax, refreeze)
let maxGroupSize = params.maxGroupSize ?? 16
const fromNeedsExtra = await this.needsExtraTransactions(params.fromASAID)
const toNeedsExtra = await this.needsExtraTransactions(params.toASAID)
if (fromNeedsExtra) maxGroupSize -= 3
if (toNeedsExtra) maxGroupSize -= 3
return { ...params, maxGroupSize }
}The SDK's autoOptIn: true config automatically adds opt-in transactions when needed. If your middleware adds its own opt-in (e.g., for ordering requirements), set optIn: false in adjustQuoteParams():
async adjustQuoteParams(params: FetchQuoteParams): Promise<FetchQuoteParams> {
const needsOptIn = await this.needsAssetOptIn(params.toASAID)
return {
...params,
maxGroupSize: needsOptIn ? (params.maxGroupSize ?? 16) - 1 : params.maxGroupSize,
optIn: false, // Disable SDK's opt-in
}
}- Minimize transactions: Only add when absolutely necessary
- Cache queries: Avoid redundant smart contract calls
- Handle errors: Return empty arrays to avoid breaking swaps
- Test thoroughly: Test with both, one, or neither asset requiring middleware
- Document assets: List supported asset IDs and versions clearly
Here's a complete example for assets using the First Stage contract:
import type {
SwapMiddleware,
SwapContext,
FetchQuoteParams,
} from '@txnlab/haystack-router'
import {
makeApplicationNoOpTxnFromObject,
makeAssetTransferTxnWithSuggestedParamsFromObject,
type Algodv2,
type TransactionWithSigner,
} from 'algosdk'
interface AssetTaxInfo {
taxRate: number // Basis points (e.g., 900 = 9%)
taxReceiver: string
}
export class FirstStageMiddleware implements SwapMiddleware {
readonly name = 'FirstStage'
readonly version = '1.0.0'
private contractAppId: number | bigint
private assetInfoCache = new Map<bigint, AssetTaxInfo | null>()
constructor(config: { contractAppId: number | bigint }) {
this.contractAppId = config.contractAppId
}
async shouldApply(params: {
fromASAID: bigint
toASAID: bigint
}): Promise<boolean> {
const [fromInfo, toInfo] = await Promise.all([
this.getAssetTaxInfo(params.fromASAID),
this.getAssetTaxInfo(params.toASAID),
])
return fromInfo !== null || toInfo !== null
}
async adjustQuoteParams(
params: FetchQuoteParams
): Promise<FetchQuoteParams> {
const [fromInfo, toInfo] = await Promise.all([
this.getAssetTaxInfo(BigInt(params.fromASAID)),
this.getAssetTaxInfo(BigInt(params.toASAID)),
])
// Reduce maxGroupSize: 3 transactions per taxed asset
let maxGroupSize = params.maxGroupSize ?? 16
if (fromInfo) maxGroupSize -= 3
if (toInfo) maxGroupSize -= 3
// Adjust amount for input tax
let amount = params.amount
if (fromInfo && params.type === 'fixed-input') {
amount = this.applyTax(amount, fromInfo.taxRate)
}
return { ...params, maxGroupSize, amount }
}
async beforeSwap(
context: SwapContext
): Promise<TransactionWithSigner[]> {
const txns: TransactionWithSigner[] = []
// Unfreeze for input asset
const fromInfo = await this.getAssetTaxInfo(context.fromASAID)
if (fromInfo) {
txns.push(this.createUnfreezeTxn(context))
}
// Unfreeze for output asset
const toInfo = await this.getAssetTaxInfo(context.toASAID)
if (toInfo) {
txns.push(this.createUnfreezeTxn(context))
}
return txns
}
async afterSwap(
context: SwapContext
): Promise<TransactionWithSigner[]> {
const txns: TransactionWithSigner[] = []
const fromInfo = await this.getAssetTaxInfo(context.fromASAID)
if (fromInfo) {
const taxAmount = this.calculateTaxAmount(context, fromInfo)
txns.push(
this.createTaxPaymentTxn(context, fromInfo, taxAmount),
this.createRefreezeTxn(context),
)
}
const toInfo = await this.getAssetTaxInfo(context.toASAID)
if (toInfo) {
const taxAmount = this.calculateTaxAmount(context, toInfo)
txns.push(
this.createTaxPaymentTxn(context, toInfo, taxAmount),
this.createRefreezeTxn(context),
)
}
return txns
}
private async getAssetTaxInfo(
assetId: bigint
): Promise<AssetTaxInfo | null> {
if (this.assetInfoCache.has(assetId)) {
return this.assetInfoCache.get(assetId)!
}
try {
// Query First Stage contract for asset info
// This is simplified - actual implementation would query box storage
const info: AssetTaxInfo | null = null // Replace with actual query
this.assetInfoCache.set(assetId, info)
return info
} catch {
this.assetInfoCache.set(assetId, null)
return null
}
}
private applyTax(amount: bigint | number, taxRateBps: number): bigint {
const amt = BigInt(amount)
const tax = (amt * BigInt(taxRateBps)) / 10000n
return amt + tax
}
private calculateTaxAmount(
context: SwapContext,
info: AssetTaxInfo
): bigint {
// Simplified - actual implementation would calculate based on quote
return (BigInt(context.quote.amount) * BigInt(info.taxRate)) / 10000n
}
private createUnfreezeTxn(
context: SwapContext
): TransactionWithSigner {
const txn = makeApplicationNoOpTxnFromObject({
sender: context.address,
appIndex: this.contractAppId,
appArgs: [new Uint8Array(Buffer.from('unfreeze'))],
suggestedParams: context.suggestedParams,
})
return { txn, signer: context.signer }
}
private createTaxPaymentTxn(
context: SwapContext,
info: AssetTaxInfo,
amount: bigint
): TransactionWithSigner {
const txn = makeAssetTransferTxnWithSuggestedParamsFromObject({
sender: context.address,
receiver: info.taxReceiver,
amount,
assetIndex: context.fromASAID,
suggestedParams: context.suggestedParams,
})
return { txn, signer: context.signer }
}
private createRefreezeTxn(
context: SwapContext
): TransactionWithSigner {
const txn = makeApplicationNoOpTxnFromObject({
sender: context.address,
appIndex: this.contractAppId,
appArgs: [new Uint8Array(Buffer.from('refreeze'))],
suggestedParams: context.suggestedParams,
})
return { txn, signer: context.signer }
}
}