Created
June 25, 2025 18:01
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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