Skip to content

Instantly share code, notes, and snippets.

@drichar
Created June 25, 2025 18:01
Show Gist options
  • Save drichar/3cedef3196eaa587eea0a8e0b81e5a90 to your computer and use it in GitHub Desktop.
Save drichar/3cedef3196eaa587eea0a8e0b81e5a90 to your computer and use it in GitHub Desktop.
React hook for executing swaps using Deflex order router. Handles both composable and non-composable protocols with automatic opt-ins/opt-outs, atomic transaction grouping, Web3Auth signing, and abort functionality.
import { AccountInformation } from '@algorandfoundation/algokit-utils/types/account'
import { useMutation } from '@tanstack/react-query'
import algosdk from 'algosdk'
import { ALGORAND_ASSET_ID } from '@/constants/assets'
import { useAccountInfo } from '@/hooks/useAccountInfo'
import { algorand } from '@/lib/algorand'
import { useAuth } from '@/lib/auth'
import {
DeflexQuote,
DeflexTransaction,
DeflexTransactionSignature,
fetchDeflexSwapTransactions,
} from '@/lib/deflex'
import { createWeb3AuthSigner } from '@/lib/web3auth'
import { addBreadcrumb } from '@/lib/sentry/config'
import { AssetAmount } from '@/utils/asset-amount'
import { getErrorMessage } from '@/utils/error'
// Custom error class for swap abortion
export class SwapAbortedError extends Error {
constructor(message = 'Swap aborted') {
super(message)
this.name = 'SwapAbortedError'
}
}
interface SwapParams {
slippage?: number
onStatus?: (status: string) => void
signal?: AbortSignal
deflexQuote: DeflexQuote
}
// Common data structure for processed transactions
interface ProcessedTransaction {
txn: algosdk.Transaction
needsUserSignature: boolean
deflexSignature?: DeflexTransactionSignature
originalGroupId?: Uint8Array
}
// Common processing results shared between flows
interface CommonProcessingResult {
deflexQuote: DeflexQuote
appOptInTxns: algosdk.Transaction[]
assetOptInTxn: algosdk.Transaction | null
deflexTxnItems: ProcessedTransaction[]
assetOptOutTxn: algosdk.Transaction | null
}
/**
* Hook for performing asset swaps using the Deflex order-routing API
*
* Clean, modular implementation that handles both composable and non-composable protocols
* with maximum code reuse and clear separation of concerns.
*/
export function useSwap() {
const { algorandAddress } = useAuth()
const { data: accountInfo } = useAccountInfo()
return useMutation({
mutationFn: async ({
deflexQuote,
slippage = 5,
onStatus,
signal,
}: SwapParams) => {
addBreadcrumb('Swap initiated', 'swap', 'info', {
assetInId: deflexQuote.fromASAID,
assetOutId: deflexQuote.toASAID,
amount: deflexQuote.amountIn?.toString(),
slippage,
userAddress: algorandAddress,
})
if (!algorandAddress) {
throw new Error('Wallet not connected')
}
if (!accountInfo) {
throw new Error('Account information not found')
}
if (signal?.aborted) {
throw new SwapAbortedError()
}
try {
// Step 1: Common processing (same for both flows)
const commonData = await processCommonSteps({
slippage,
algorandAddress,
accountInfo,
signal,
deflexQuote,
})
// Step 2: Detect protocol composability
const isNonComposable = detectNonComposableProtocol(
commonData.deflexQuote,
)
console.log(
`Executing ${isNonComposable ? 'non-composable' : 'composable'} swap flow`,
)
// Step 3: Execute appropriate flow
if (isNonComposable) {
return await executeNonComposableFlow({
...commonData,
onStatus,
signal,
})
} else {
return await executeComposableFlow({
...commonData,
onStatus,
signal,
})
}
} catch (error) {
console.error('Swap failed:', error)
addBreadcrumb('Swap operation failed', 'swap', 'error', {
error: getErrorMessage(error),
})
throw error
}
},
})
}
/**
* Process all common steps shared between composable and non-composable flows
*/
async function processCommonSteps({
slippage,
algorandAddress,
accountInfo,
signal,
deflexQuote,
}: {
slippage: number
algorandAddress: string
accountInfo: AccountInformation
signal?: AbortSignal
deflexQuote: DeflexQuote
}): Promise<CommonProcessingResult> {
if (!deflexQuote.txnPayload) {
throw new Error(
'Unable to fetch swap transactions - invalid transaction payload',
)
}
if (signal?.aborted) throw new SwapAbortedError()
// Analyze opt-in requirements
const { appOptInTxns, assetOptInTxn } = await analyzeOptInRequirements(
deflexQuote,
accountInfo,
algorandAddress,
)
// Fetch Deflex transactions
const deflexTxnGroup = await fetchDeflexSwapTransactions({
address: algorandAddress,
txnPayloadJSON: deflexQuote.txnPayload,
slippage,
})
// Process Deflex transactions
const deflexTxnItems = await processDeflexTransactions(deflexTxnGroup.txns)
// Check for asset opt-out requirement
const assetOptOutTxn = await checkAssetOptOut(
BigInt(deflexQuote.fromASAID),
BigInt(deflexQuote.amountIn),
accountInfo,
algorandAddress,
)
return {
deflexQuote,
appOptInTxns,
assetOptInTxn,
deflexTxnItems,
assetOptOutTxn,
}
}
/**
* Detect if the route contains non-composable protocols (Tinyman v1)
*/
function detectNonComposableProtocol(deflexQuote: DeflexQuote): boolean {
return Object.keys(deflexQuote.flattenedRoute).some(
(protocol) => protocol.toLowerCase() === 'tinyman',
)
}
/**
* Execute composable flow: combine all transactions into single atomic group
*/
async function executeComposableFlow({
deflexQuote,
appOptInTxns,
assetOptInTxn,
deflexTxnItems,
assetOptOutTxn,
onStatus,
signal,
}: CommonProcessingResult & {
onStatus?: (status: string) => void
signal?: AbortSignal
}) {
// Compose single transaction group
const allTransactions = composeTransactionGroup({
appOptInTxns,
assetOptInTxn,
deflexTxnItems,
assetOptOutTxn,
})
console.log(
`Submitting ${allTransactions.length} transactions as atomic group`,
)
// Assign new group ID to all transactions
assignNewGroupId(allTransactions)
if (signal?.aborted) throw new SwapAbortedError()
// Sign and prepare transactions
onStatus?.('Signing transactions...')
const signedTransactions = await signAndPrepareTransactions(allTransactions)
if (signal?.aborted) throw new SwapAbortedError()
// Submit single atomic group
onStatus?.('Sending transactions...')
const response = await submitTransactionGroup(
signedTransactions,
'composable swap',
)
const txId = response.txid
// Wait for confirmation
onStatus?.('Waiting for confirmation...')
const confirmation = await algosdk.waitForConfirmation(
algorand.client.algod,
txId,
4,
)
addBreadcrumb('Swap (composable) completed successfully', 'swap', 'info', {
txId,
round: Number(confirmation.confirmedRound),
})
return {
quote: deflexQuote,
txnId: txId,
round: Number(confirmation.confirmedRound),
}
}
/**
* Execute non-composable flow: submit separate groups sequentially
*/
async function executeNonComposableFlow({
deflexQuote,
appOptInTxns,
assetOptInTxn,
deflexTxnItems,
assetOptOutTxn,
onStatus,
signal,
}: CommonProcessingResult & {
onStatus?: (status: string) => void
signal?: AbortSignal
}) {
let mainTxId: string
// Step 1: Submit opt-ins if needed
if (appOptInTxns.length > 0 || assetOptInTxn) {
onStatus?.('Performing opt-ins...')
const optInTransactions: ProcessedTransaction[] = [
...appOptInTxns.map((txn) => ({ txn, needsUserSignature: true })),
...(assetOptInTxn
? [{ txn: assetOptInTxn, needsUserSignature: true }]
: []),
]
assignNewGroupId(optInTransactions)
const signedOptIns = await signAndPrepareTransactions(optInTransactions)
await submitTransactionGroup(signedOptIns, 'opt-ins')
}
if (signal?.aborted) throw new SwapAbortedError()
// Step 2: Submit Deflex transactions with preserved group IDs
onStatus?.('Sending swap transactions...')
// Restore original group IDs for Deflex transactions
preserveOriginalGroupIds(deflexTxnItems)
const signedSwapTxns = await signAndPrepareTransactions(deflexTxnItems)
const swapResponse = await submitTransactionGroup(
signedSwapTxns,
'Deflex swap',
)
mainTxId = swapResponse.txid
// Step 3: Submit opt-out if needed
if (assetOptOutTxn) {
onStatus?.('Performing asset opt-out...')
const optOutTransactions: ProcessedTransaction[] = [
{ txn: assetOptOutTxn, needsUserSignature: true },
]
assignNewGroupId(optOutTransactions)
const signedOptOut = await signAndPrepareTransactions(optOutTransactions)
await submitTransactionGroup(signedOptOut, 'opt-out')
}
// Wait for confirmation of main swap
onStatus?.('Waiting for confirmation...')
const confirmation = await algosdk.waitForConfirmation(
algorand.client.algod,
mainTxId,
4,
)
addBreadcrumb(
'Swap (non-composable) completed successfully',
'swap',
'info',
{
txId: mainTxId,
round: Number(confirmation.confirmedRound),
},
)
return {
quote: deflexQuote,
txnId: mainTxId,
round: Number(confirmation.confirmedRound),
}
}
/**
* Compose all transactions into a single group (for composable flow)
*/
function composeTransactionGroup({
appOptInTxns,
assetOptInTxn,
deflexTxnItems,
assetOptOutTxn,
}: {
appOptInTxns: algosdk.Transaction[]
assetOptInTxn: algosdk.Transaction | null
deflexTxnItems: ProcessedTransaction[]
assetOptOutTxn: algosdk.Transaction | null
}): ProcessedTransaction[] {
const allTransactions: ProcessedTransaction[] = []
// Add app opt-ins
appOptInTxns.forEach((txn) => {
allTransactions.push({ txn, needsUserSignature: true })
})
// Add asset opt-in
if (assetOptInTxn) {
allTransactions.push({ txn: assetOptInTxn, needsUserSignature: true })
}
// Add Deflex transactions
deflexTxnItems.forEach((item) => {
allTransactions.push(item)
})
// Add asset opt-out
if (assetOptOutTxn) {
allTransactions.push({ txn: assetOptOutTxn, needsUserSignature: true })
}
return allTransactions
}
/**
* Assign new group ID to all transactions (for composable flow)
*/
function assignNewGroupId(transactions: ProcessedTransaction[]) {
const txns = transactions.map((item) => item.txn)
const groupId = algosdk.computeGroupID(txns)
transactions.forEach((item) => {
item.txn.group = groupId
})
}
/**
* Restore original group IDs (for non-composable flow)
*/
function preserveOriginalGroupIds(transactions: ProcessedTransaction[]) {
transactions.forEach((item) => {
if (item.originalGroupId) {
item.txn.group = item.originalGroupId
}
})
}
/**
* Sign and prepare transactions for submission (works for both flows)
*/
async function signAndPrepareTransactions(
transactions: ProcessedTransaction[],
): Promise<Uint8Array[]> {
const finalSignedTxns: Uint8Array[] = []
// Separate user transactions from pre-signed transactions
const userTransactions: algosdk.Transaction[] = []
const userTransactionIndexes: number[] = []
// Process transactions and collect user transactions that need signing
for (let i = 0; i < transactions.length; i++) {
const item = transactions[i]
if (item.needsUserSignature) {
userTransactions.push(item.txn)
userTransactionIndexes.push(userTransactions.length - 1)
} else if (item.deflexSignature) {
// Re-sign this transaction with the provided signature
const signedTxnBytes = await reSignTransaction(
item.txn,
item.deflexSignature,
)
finalSignedTxns.push(signedTxnBytes)
}
}
// Sign user transactions with Web3Auth if any exist
let userSignedTxns: Uint8Array[] = []
if (userTransactions.length > 0) {
const web3AuthSigner = createWeb3AuthSigner()
userSignedTxns = await web3AuthSigner(
userTransactions,
userTransactionIndexes,
)
}
// Combine user-signed and pre-signed transactions in correct order
let userSignedIndex = 0
let preSignedIndex = 0
const result: Uint8Array[] = []
for (const item of transactions) {
if (item.needsUserSignature) {
result.push(userSignedTxns[userSignedIndex])
userSignedIndex++
} else {
result.push(finalSignedTxns[preSignedIndex])
preSignedIndex++
}
}
return result
}
/**
* Submit transaction group to network (works for both flows)
*/
async function submitTransactionGroup(
signedTxns: Uint8Array[],
description: string,
) {
const response = await algorand.client.algod
.sendRawTransaction(signedTxns)
.do()
console.log(`${description} submitted with txId: ${response.txid}`)
return response
}
// ================================
// SHARED UTILITY FUNCTIONS
// ================================
/**
* Analyze what opt-ins are required for the swap
*/
async function analyzeOptInRequirements(
deflexQuote: DeflexQuote,
accountInfo: AccountInformation,
algorandAddress: string,
) {
// Check app opt-ins
const requiredAppOptIns = deflexQuote.requiredAppOptIns || []
const userApps =
accountInfo?.appsLocalState?.map((app: { id: bigint }) => Number(app.id)) ||
[]
const appsToOptIn = requiredAppOptIns.filter(
(appId: number) => !userApps.includes(appId),
)
// Check asset opt-in
const assetOutId = BigInt(deflexQuote.toASAID)
const needsAssetOptIn =
assetOutId !== ALGORAND_ASSET_ID &&
accountInfo?.assets?.find(
(asset: { assetId: bigint }) => asset.assetId === assetOutId,
) === undefined
// Create opt-in transactions if needed
const appOptInTxns: algosdk.Transaction[] = []
if (appsToOptIn.length > 0) {
for (const appId of appsToOptIn) {
const optInTxn = algosdk.makeApplicationOptInTxnFromObject({
sender: algorandAddress,
suggestedParams: await algorand.getSuggestedParams(),
appIndex: appId,
})
appOptInTxns.push(optInTxn)
}
}
let assetOptInTxn: algosdk.Transaction | null = null
if (needsAssetOptIn) {
assetOptInTxn = await algorand.createTransaction.assetOptIn({
sender: algorandAddress,
assetId: BigInt(assetOutId),
})
}
return { appOptInTxns, assetOptInTxn }
}
/**
* Process Deflex transactions, preserving original group IDs
*/
async function processDeflexTransactions(
deflexTxns: DeflexTransaction[],
): Promise<ProcessedTransaction[]> {
const deflexTxnItems: ProcessedTransaction[] = []
for (let i = 0; i < deflexTxns.length; i++) {
const deflexTxn = deflexTxns[i]
try {
// Decode transaction from base64 data
const txnBytes = Buffer.from(deflexTxn.data, 'base64')
const transaction = algosdk.decodeUnsignedTransaction(txnBytes)
// Store original group ID for non-composable flows
const originalGroupId = transaction.group
// Remove group ID (will be reassigned or restored later)
delete transaction.group
if (deflexTxn.signature !== false) {
// Pre-signed transaction
deflexTxnItems.push({
txn: transaction,
needsUserSignature: false,
deflexSignature: deflexTxn.signature as DeflexTransactionSignature,
originalGroupId,
})
} else {
// User transaction
deflexTxnItems.push({
txn: transaction,
needsUserSignature: true,
originalGroupId,
})
}
} catch (error) {
console.error(`Error processing Deflex transaction ${i}:`, error)
throw error
}
}
return deflexTxnItems
}
/**
* Check if asset opt-out is needed (for full balance swaps)
*/
async function checkAssetOptOut(
assetInId: bigint,
amount: bigint,
accountInfo: AccountInformation,
algorandAddress: string,
): Promise<algosdk.Transaction | null> {
if (assetInId === ALGORAND_ASSET_ID || !accountInfo?.assets) {
return null
}
const accountAssetInfo = accountInfo.assets.find(
(asset: { assetId: bigint }) => asset.assetId === BigInt(assetInId),
)
if (!accountAssetInfo) {
return null
}
const assetInfo = await algorand.asset.getById(BigInt(assetInId))
const assetInBalance = AssetAmount.MicroUnits(
{ id: assetInId, ...assetInfo },
accountAssetInfo.amount,
)
// Check if swapping entire balance
if (amount === assetInBalance.microUnits) {
return await algorand.createTransaction.assetTransfer({
sender: algorandAddress,
assetId: BigInt(assetInId),
amount: BigInt(0),
receiver: algorandAddress,
closeAssetTo: assetInfo.creator,
validityWindow: 1000,
})
}
return null
}
/**
* Re-sign a transaction using the provided Deflex signature
*/
async function reSignTransaction(
transaction: algosdk.Transaction,
signature: DeflexTransactionSignature,
): Promise<Uint8Array> {
try {
if (signature.type === 'logic_signature') {
// Decode the signature value to extract the logic signature
const decoded = algosdk.msgpackRawDecode(signature.value) as any
if (!decoded.lsig) {
throw new Error('Logic signature structure missing lsig field')
}
const lsig = decoded.lsig
const logicSigAccount = new algosdk.LogicSigAccount(lsig.l, lsig.arg)
const signedTxn = algosdk.signLogicSigTransactionObject(
transaction,
logicSigAccount,
)
return signedTxn.blob
} else if (signature.type === 'secret_key') {
const signResult = algosdk.signTransaction(transaction, signature.value)
return signResult.blob
} else {
throw new Error(`Unsupported signature type: ${signature.type}`)
}
} catch (error) {
console.error(`Error re-signing transaction:`, error)
throw error
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment