@txnlab/haystack-router 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1028 @@
1
+ import { t as Logger } from "./logger-DUVOA2Oo.mjs";
2
+ import { Algodv2, AtomicTransactionComposer, LogicSigAccount, Transaction, decodeUnsignedTransaction, isValidAddress, makeApplicationOptInTxnFromObject, makeAssetTransferTxnWithSuggestedParamsFromObject, msgpackRawDecode, signLogicSigTransactionObject, signTransaction } from "algosdk";
3
+
4
+ //#region src/constants.ts
5
+ /**
6
+ * Supported DEX protocols for swap routing
7
+ */
8
+ let Protocol = /* @__PURE__ */ function(Protocol$1) {
9
+ Protocol$1["TinymanV2"] = "TinymanV2";
10
+ Protocol$1["Algofi"] = "Algofi";
11
+ Protocol$1["Algomint"] = "Algomint";
12
+ Protocol$1["Pact"] = "Pact";
13
+ Protocol$1["Folks"] = "Folks";
14
+ Protocol$1["TAlgo"] = "TAlgo";
15
+ return Protocol$1;
16
+ }({});
17
+ /**
18
+ * Deprecated protocols that are automatically excluded from routing
19
+ *
20
+ * @internal
21
+ */
22
+ const DEPRECATED_PROTOCOLS = ["Humble", "Tinyman"];
23
+ /** Default Algod node URI for mainnet */
24
+ const DEFAULT_ALGOD_URI = "https://mainnet-api.4160.nodely.dev/";
25
+ /** Default Algod node token (empty for public nodes) */
26
+ const DEFAULT_ALGOD_TOKEN = "";
27
+ /** Default Algod node port */
28
+ const DEFAULT_ALGOD_PORT = 443;
29
+ /** Default Haystack Router API base URL */
30
+ const DEFAULT_API_BASE_URL = "https://hayrouter.txnlab.dev/api";
31
+ /** Default fee in basis points (0.10%) */
32
+ const DEFAULT_FEE_BPS = 10;
33
+ /** Maximum allowed fee in basis points (3.00%) */
34
+ const MAX_FEE_BPS = 300;
35
+ /** Default maximum transaction group size */
36
+ const DEFAULT_MAX_GROUP_SIZE = 16;
37
+ /** Default maximum routing depth (number of hops) */
38
+ const DEFAULT_MAX_DEPTH = 4;
39
+ /** Default auto opt-in setting (automatic asset/app opt-in detection) */
40
+ const DEFAULT_AUTO_OPT_IN = false;
41
+ /** Default debug level (no debug logging) */
42
+ const DEFAULT_DEBUG_LEVEL = "none";
43
+ /** Default number of rounds to wait for transaction confirmation */
44
+ const DEFAULT_CONFIRMATION_ROUNDS = 10;
45
+
46
+ //#endregion
47
+ //#region src/composer.ts
48
+ /**
49
+ * Status of the SwapComposer transaction group lifecycle
50
+ */
51
+ let SwapComposerStatus = /* @__PURE__ */ function(SwapComposerStatus$1) {
52
+ /** The atomic group is still under construction. */
53
+ SwapComposerStatus$1[SwapComposerStatus$1["BUILDING"] = 0] = "BUILDING";
54
+ /** The atomic group has been finalized, but not yet signed. */
55
+ SwapComposerStatus$1[SwapComposerStatus$1["BUILT"] = 1] = "BUILT";
56
+ /** The atomic group has been finalized and signed, but not yet submitted to the network. */
57
+ SwapComposerStatus$1[SwapComposerStatus$1["SIGNED"] = 2] = "SIGNED";
58
+ /** The atomic group has been finalized, signed, and submitted to the network. */
59
+ SwapComposerStatus$1[SwapComposerStatus$1["SUBMITTED"] = 3] = "SUBMITTED";
60
+ /** The atomic group has been finalized, signed, submitted, and successfully committed to a block. */
61
+ SwapComposerStatus$1[SwapComposerStatus$1["COMMITTED"] = 4] = "COMMITTED";
62
+ return SwapComposerStatus$1;
63
+ }({});
64
+ /**
65
+ * Composer for building and executing atomic swap transaction groups
66
+ *
67
+ * The SwapComposer allows you to build complex transaction groups by adding custom
68
+ * transactions before and after swap transactions. It handles pre-signed transactions,
69
+ * automatic app opt-ins, and provides a fluent API for transaction group construction.
70
+ *
71
+ * @example
72
+ * ```typescript
73
+ * const quote = await router.fetchQuote({ ... })
74
+ * const composer = await router.newSwap({ quote, address, slippage, signer })
75
+ *
76
+ * await composer
77
+ * .addTransaction(customTxn)
78
+ * .addSwapTransactions()
79
+ * .execute()
80
+ * ```
81
+ */
82
+ var SwapComposer = class SwapComposer {
83
+ /** The ATC used to compose the group */
84
+ atc = new AtomicTransactionComposer();
85
+ /** The maximum size of an atomic transaction group. */
86
+ static MAX_GROUP_SIZE = AtomicTransactionComposer.MAX_GROUP_SIZE;
87
+ /** Whether the swap transactions have been added to the atomic group. */
88
+ swapTransactionsAdded = false;
89
+ quote;
90
+ requiredAppOptIns;
91
+ swapTxns;
92
+ algodClient;
93
+ address;
94
+ signer;
95
+ middleware;
96
+ note;
97
+ inputTransactionIndex;
98
+ outputTransactionIndex;
99
+ /** Summary data built incrementally during swap composition */
100
+ summaryData;
101
+ /**
102
+ * Create a new SwapComposer instance
103
+ *
104
+ * Note: Most developers should use RouterClient.newSwap() instead of constructing
105
+ * this directly, as the factory method handles fetching swap transactions automatically.
106
+ *
107
+ * @param config - Configuration for the composer
108
+ * @param config.quote - The quote response from fetchQuote()
109
+ * @param config.swapTxns - The swap transactions from fetchSwapTransactions()
110
+ * @param config.algodClient - Algodv2 client instance
111
+ * @param config.address - The address of the account that will sign transactions
112
+ * @param config.signer - Transaction signer function
113
+ * @param config.middleware - Middleware to apply during swap composition
114
+ */
115
+ constructor(config) {
116
+ if (config.debugLevel) Logger.setLevel(config.debugLevel);
117
+ if (!config.quote) throw new Error("Quote is required");
118
+ if (!config.swapTxns) throw new Error("Swap transactions are required");
119
+ if (config.swapTxns.length === 0) throw new Error("Swap transactions array cannot be empty");
120
+ if (!config.algodClient) throw new Error("Algodv2 client instance is required");
121
+ if (!config.signer) throw new Error("Signer is required");
122
+ this.quote = config.quote;
123
+ this.requiredAppOptIns = config.quote.requiredAppOptIns;
124
+ this.swapTxns = config.swapTxns;
125
+ this.algodClient = config.algodClient;
126
+ this.address = this.validateAddress(config.address);
127
+ this.signer = config.signer;
128
+ this.middleware = config.middleware ?? [];
129
+ this.note = config.note;
130
+ }
131
+ /**
132
+ * Get the status of this composer's transaction group
133
+ *
134
+ * @returns The current status of the transaction group
135
+ */
136
+ getStatus() {
137
+ return this.atc.getStatus();
138
+ }
139
+ /**
140
+ * Get the number of transactions currently in this atomic group
141
+ *
142
+ * @returns The number of transactions in the group
143
+ */
144
+ count() {
145
+ return this.atc.count();
146
+ }
147
+ /**
148
+ * Add a transaction to the atomic group
149
+ *
150
+ * Transactions are added in the order methods are called. For example:
151
+ * ```typescript
152
+ * composer
153
+ * .addTransaction(txn1) // Added first
154
+ * .addSwapTransactions() // Added second
155
+ * .addTransaction(txn2) // Added third
156
+ * ```
157
+ *
158
+ * @param transaction - The transaction to add
159
+ * @returns This composer instance for chaining
160
+ * @throws Error if the composer is not in the BUILDING status
161
+ * @throws Error if the maximum group size is exceeded
162
+ * @throws Error if attempting to compose with a non-composable swap (Tinyman v1)
163
+ */
164
+ addTransaction(transaction, signer = this.defaultSigner) {
165
+ if (this.quote.flattenedRoute?.["Tinyman"] !== void 0 && this.quote.flattenedRoute["Tinyman"] > 0) throw new Error("Cannot add transactions to a swap group that uses Tinyman v1. Tinyman v1 produces non-composable transaction groups. Asset opt-ins, opt-outs, and other transactions must be sent in separate groups. If you need to support Tinyman v1 swaps, you must handle multi-group coordination manually.");
166
+ this.atc.addTransaction({
167
+ txn: transaction,
168
+ signer
169
+ });
170
+ return this;
171
+ }
172
+ /**
173
+ * Add a method call to the atomic group
174
+ *
175
+ * The `signer` property in the `methodCall` parameter is optional. If not provided,
176
+ * the signer will default to the one passed as the second parameter, or the
177
+ * configured signer from the constructor if no second parameter is provided.
178
+ *
179
+ * @param methodCall - The method call to add
180
+ * @param signer - The signer to use for the method call (defaults to constructor signer)
181
+ * @returns This composer instance for chaining
182
+ * @throws Error if attempting to compose with a non-composable swap (Tinyman v1)
183
+ */
184
+ addMethodCall(methodCall, signer = this.defaultSigner) {
185
+ if (this.quote.flattenedRoute?.["Tinyman"] !== void 0 && this.quote.flattenedRoute["Tinyman"] > 0) throw new Error("Cannot add method calls to a swap group that uses Tinyman v1. Tinyman v1 produces non-composable transaction groups. Asset opt-ins, opt-outs, and other transactions must be sent in separate groups. If you need to support Tinyman v1 swaps, you must handle multi-group coordination manually.");
186
+ this.atc.addMethodCall({
187
+ ...methodCall,
188
+ signer: methodCall.signer ?? signer
189
+ });
190
+ return this;
191
+ }
192
+ /**
193
+ * Add swap transactions to the atomic group
194
+ *
195
+ * This method automatically processes required app opt-ins, executes middleware hooks,
196
+ * and adds all swap transactions from the quote. Can only be called once per composer instance.
197
+ *
198
+ * Middleware hooks are executed in this order:
199
+ * 1. beforeSwap() - Add transactions before swap transactions
200
+ * 2. Swap transactions (from API)
201
+ * 3. afterSwap() - Add transactions after swap transactions
202
+ *
203
+ * @returns This composer instance for chaining
204
+ * @throws Error if the swap transactions have already been added
205
+ * @throws Error if the composer is not in the BUILDING status
206
+ * @throws Error if the maximum group size is exceeded
207
+ */
208
+ async addSwapTransactions() {
209
+ if (this.swapTransactionsAdded) throw new Error("Swap transactions have already been added");
210
+ if (this.getStatus() !== SwapComposerStatus.BUILDING) throw new Error("Cannot add swap transactions when composer status is not BUILDING");
211
+ const beforeTxns = await this.executeMiddlewareHooks("beforeSwap");
212
+ if (this.atc.count() + beforeTxns.length > SwapComposer.MAX_GROUP_SIZE) throw new Error(`Adding beforeSwap transactions exceeds the maximum atomic group size of ${SwapComposer.MAX_GROUP_SIZE}`);
213
+ for (const txnWithSigner of beforeTxns) this.atc.addTransaction(txnWithSigner);
214
+ const { txns: processedTxns, inputTxnRelativeIndex, outputTxnRelativeIndex } = await this.processSwapTransactions();
215
+ const afterTxns = await this.executeMiddlewareHooks("afterSwap");
216
+ if (this.atc.count() + processedTxns.length + afterTxns.length > SwapComposer.MAX_GROUP_SIZE) throw new Error(`Adding swap transactions exceeds the maximum atomic group size of ${SwapComposer.MAX_GROUP_SIZE}`);
217
+ if (inputTxnRelativeIndex !== void 0) this.inputTransactionIndex = this.atc.count() + inputTxnRelativeIndex;
218
+ if (outputTxnRelativeIndex !== void 0) this.outputTransactionIndex = this.atc.count() + outputTxnRelativeIndex;
219
+ for (const txnWithSigner of processedTxns) this.atc.addTransaction(txnWithSigner);
220
+ for (const txnWithSigner of afterTxns) this.atc.addTransaction(txnWithSigner);
221
+ this.swapTransactionsAdded = true;
222
+ return this;
223
+ }
224
+ /**
225
+ * Finalize the transaction group by assigning group IDs
226
+ *
227
+ * This method builds the atomic transaction group, assigning group IDs to all transactions
228
+ * if there is more than one transaction. After calling this method, the composer's status
229
+ * will be at least BUILT.
230
+ *
231
+ * @returns Array of transactions with their associated signers
232
+ *
233
+ * @throws Error if the group contains 0 transactions
234
+ *
235
+ * @example
236
+ * ```typescript
237
+ * const composer = await router.newSwap({ quote, address, slippage, signer })
238
+ * composer.addTransaction(customTxn)
239
+ *
240
+ * // Build the group to inspect transactions before signing
241
+ * const txnsWithSigners = composer.buildGroup()
242
+ * console.log('Group ID:', txnsWithSigners[0].txn.group)
243
+ * console.log('Group length:', txnsWithSigners.length)
244
+ * console.log('Status:', composer.getStatus()) // BUILT
245
+ * ```
246
+ */
247
+ buildGroup() {
248
+ return this.atc.buildGroup();
249
+ }
250
+ /**
251
+ * Sign the transaction group
252
+ *
253
+ * Automatically adds swap transactions if not already added, builds the atomic group,
254
+ * and signs all transactions using the configured signer.
255
+ *
256
+ * @returns A promise that resolves to an array of signed transaction blobs
257
+ *
258
+ * @example
259
+ * ```typescript
260
+ * const signedTxns = await composer.sign()
261
+ * ```
262
+ */
263
+ async sign() {
264
+ if (this.getStatus() >= SwapComposerStatus.SIGNED) return this.atc.gatherSignatures();
265
+ if (!this.swapTransactionsAdded) await this.addSwapTransactions();
266
+ return await this.atc.gatherSignatures();
267
+ }
268
+ /**
269
+ * Submit the signed transactions to the network
270
+ *
271
+ * This method signs the transaction group (if not already signed) and submits
272
+ * it to the Algorand network. Does not wait for confirmation.
273
+ *
274
+ * @returns The transaction IDs
275
+ * @throws Error if the transaction group has already been submitted
276
+ *
277
+ * @example
278
+ * ```typescript
279
+ * const txIds = await composer.submit()
280
+ * console.log('Submitted transactions:', txIds)
281
+ * ```
282
+ */
283
+ async submit() {
284
+ if (!this.swapTransactionsAdded) await this.addSwapTransactions();
285
+ return await this.atc.submit(this.algodClient);
286
+ }
287
+ /**
288
+ * Execute the swap
289
+ *
290
+ * Signs the transaction group, submits it to the network, and waits for confirmation.
291
+ * This is the primary method for executing swaps and combines sign(), submit(), and
292
+ * waitForConfirmation() into a single call.
293
+ *
294
+ * @param waitRounds - The number of rounds to wait for confirmation (default: 10)
295
+ * @returns Object containing the confirmed round and transaction IDs
296
+ * @throws Error if the transaction group has already been committed
297
+ *
298
+ * @example
299
+ * ```typescript
300
+ * const result = await composer.execute()
301
+ * console.log(`Confirmed in round ${result.confirmedRound}`)
302
+ * console.log('Transaction IDs:', result.txIds)
303
+ * ```
304
+ */
305
+ async execute(waitRounds = DEFAULT_CONFIRMATION_ROUNDS) {
306
+ if (!this.swapTransactionsAdded) await this.addSwapTransactions();
307
+ try {
308
+ const { txIDs, ...result } = await this.atc.execute(this.algodClient, waitRounds);
309
+ if (this.summaryData) {
310
+ if (this.inputTransactionIndex !== void 0) this.summaryData.inputTxnId = txIDs[this.inputTransactionIndex];
311
+ if (this.outputTransactionIndex !== void 0) this.summaryData.outputTxnId = txIDs[this.outputTransactionIndex];
312
+ }
313
+ await this.extractActualOutputAmount();
314
+ return {
315
+ ...result,
316
+ txIds: txIDs
317
+ };
318
+ } catch (error) {
319
+ const { logSwapExecutionFailure } = await import("./debug-8aRsrfhx.mjs");
320
+ logSwapExecutionFailure({
321
+ quote: this.quote,
322
+ address: this.address,
323
+ slippage: 0,
324
+ transactionCount: this.count(),
325
+ middlewareCount: this.middleware.length,
326
+ groupSize: this.count()
327
+ }, error);
328
+ throw error;
329
+ }
330
+ }
331
+ /**
332
+ * Get the transaction ID of the user-signed input transaction
333
+ *
334
+ * Returns the transaction ID of the payment or asset transfer transaction
335
+ * that sends the input asset. This is the transaction whose note field can
336
+ * be customized via the `note` config option.
337
+ *
338
+ * The transaction ID is only available after the group has been built
339
+ * (after calling buildGroup(), sign(), submit(), or execute()).
340
+ *
341
+ * @returns The transaction ID, or undefined if the group hasn't been built yet
342
+ * or if the input transaction index couldn't be determined
343
+ *
344
+ * @example
345
+ * ```typescript
346
+ * const swap = await router.newSwap({
347
+ * quote,
348
+ * address,
349
+ * slippage,
350
+ * signer,
351
+ * note: new TextEncoder().encode('tracking-123')
352
+ * })
353
+ *
354
+ * await swap.execute()
355
+ * const inputTxId = swap.getInputTransactionId()
356
+ * console.log('Input transaction ID:', inputTxId)
357
+ * ```
358
+ */
359
+ getInputTransactionId() {
360
+ if (this.getStatus() < SwapComposerStatus.BUILT) return;
361
+ if (this.inputTransactionIndex === void 0) return;
362
+ return (this.atc.buildGroup()[this.inputTransactionIndex]?.txn)?.txID();
363
+ }
364
+ /**
365
+ * Get a summary of the swap amounts and fees
366
+ *
367
+ * Returns the exact input and output amounts, total transaction fees,
368
+ * and transaction IDs. This is useful for displaying a complete summary
369
+ * after a swap has been executed.
370
+ *
371
+ * Only available after calling execute() - returns undefined before execution.
372
+ *
373
+ * @returns SwapSummary containing exact amounts and fees, or undefined if not yet executed
374
+ *
375
+ * @example
376
+ * ```typescript
377
+ * const swap = await router.newSwap({ quote, address, slippage, signer })
378
+ * const result = await swap.execute()
379
+ *
380
+ * const summary = swap.getSummary()
381
+ * if (summary) {
382
+ * console.log('Sent:', summary.inputAmount, 'Received:', summary.outputAmount)
383
+ * console.log('Total fees:', summary.totalFees, 'microAlgos')
384
+ * }
385
+ * ```
386
+ */
387
+ getSummary() {
388
+ if (!this.summaryData || this.summaryData.outputAmount === void 0 || this.summaryData.inputTxnId === void 0 || this.summaryData.outputTxnId === void 0 || this.summaryData.outputSender === void 0) return;
389
+ const txns = this.atc.buildGroup();
390
+ const totalFees = txns.reduce((sum, tws) => sum + tws.txn.fee, 0n);
391
+ return {
392
+ inputAssetId: this.summaryData.inputAssetId,
393
+ outputAssetId: this.summaryData.outputAssetId,
394
+ inputAmount: this.summaryData.inputAmount,
395
+ outputAmount: this.summaryData.outputAmount,
396
+ type: this.quote.type,
397
+ totalFees,
398
+ transactionCount: txns.length,
399
+ inputTxnId: this.summaryData.inputTxnId,
400
+ outputTxnId: this.summaryData.outputTxnId,
401
+ inputSender: this.summaryData.inputSender,
402
+ outputSender: this.summaryData.outputSender
403
+ };
404
+ }
405
+ /**
406
+ * Validates an Algorand address
407
+ */
408
+ validateAddress(address) {
409
+ if (!isValidAddress(address)) throw new Error(`Invalid Algorand address: ${address}`);
410
+ return address;
411
+ }
412
+ /**
413
+ * Processes app opt-ins and decodes swap transactions from API response
414
+ *
415
+ * Also initializes summaryData with input transaction details and output transaction ID
416
+ */
417
+ async processSwapTransactions() {
418
+ const appOptIns = await this.processRequiredAppOptIns();
419
+ const swapTxns = [];
420
+ let inputTxnRelativeIndex;
421
+ let inputTxn;
422
+ for (let i = 0; i < this.swapTxns.length; i++) {
423
+ const swapTxn = this.swapTxns[i];
424
+ if (!swapTxn) continue;
425
+ try {
426
+ const txn = decodeUnsignedTransaction(Buffer.from(swapTxn.data, "base64"));
427
+ delete txn.group;
428
+ if (swapTxn.signature !== false) swapTxns.push({
429
+ txn,
430
+ signer: this.createSwapSigner(swapTxn.signature)
431
+ });
432
+ else {
433
+ if (this.note !== void 0) txn.note = this.note;
434
+ inputTxnRelativeIndex = appOptIns.length + swapTxns.length;
435
+ inputTxn = txn;
436
+ swapTxns.push({
437
+ txn,
438
+ signer: this.defaultSigner
439
+ });
440
+ }
441
+ } catch (error) {
442
+ throw new Error(`Failed to process swap transaction at index ${i}: ${error instanceof Error ? error.message : String(error)}`);
443
+ }
444
+ }
445
+ if (inputTxn) {
446
+ const paymentAmount = inputTxn.payment?.amount;
447
+ const assetTransferAmount = inputTxn.assetTransfer?.amount;
448
+ const inputAmount = paymentAmount ?? assetTransferAmount ?? 0n;
449
+ this.summaryData = {
450
+ inputAssetId: BigInt(this.quote.fromASAID),
451
+ outputAssetId: BigInt(this.quote.toASAID),
452
+ inputAmount,
453
+ inputSender: this.address
454
+ };
455
+ }
456
+ const outputTxnRelativeIndex = swapTxns.length > 0 ? appOptIns.length + swapTxns.length - 1 : void 0;
457
+ return {
458
+ txns: [...appOptIns, ...swapTxns],
459
+ inputTxnRelativeIndex,
460
+ outputTxnRelativeIndex
461
+ };
462
+ }
463
+ /**
464
+ * Creates opt-in transactions for apps the user hasn't opted into yet
465
+ */
466
+ async processRequiredAppOptIns() {
467
+ const userApps = (await this.algodClient.accountInformation(this.address).do())?.appsLocalState?.map((app) => Number(app.id)) || [];
468
+ const appsToOptIn = this.requiredAppOptIns.filter((appId) => !userApps.includes(appId));
469
+ if (appsToOptIn.length === 0) return [];
470
+ const suggestedParams = await this.algodClient.getTransactionParams().do();
471
+ return appsToOptIn.map((appId) => ({
472
+ txn: makeApplicationOptInTxnFromObject({
473
+ sender: this.address,
474
+ appIndex: appId,
475
+ suggestedParams
476
+ }),
477
+ signer: this.defaultSigner
478
+ }));
479
+ }
480
+ /**
481
+ * The default signer function that uses the configured signer
482
+ */
483
+ defaultSigner = async (txnGroup, indexesToSign) => {
484
+ return (await this.signer(txnGroup, indexesToSign)).filter((txn) => txn !== null);
485
+ };
486
+ /**
487
+ * Creates a TransactionSigner function for Deflex pre-signed transactions
488
+ */
489
+ createSwapSigner(signature) {
490
+ return async (txnGroup, indexesToSign) => {
491
+ return indexesToSign.map((i) => {
492
+ const txn = txnGroup[i];
493
+ if (!txn) throw new Error(`Transaction at index ${i} not found`);
494
+ return this.signSwapTransaction(txn, signature);
495
+ });
496
+ };
497
+ }
498
+ /**
499
+ * Re-signs a Deflex transaction using the provided logic signature or secret key
500
+ */
501
+ signSwapTransaction(transaction, signature) {
502
+ try {
503
+ if (signature.type === "logic_signature") {
504
+ const valueArray = signature.value;
505
+ const decoded = msgpackRawDecode(new Uint8Array(Object.values(valueArray)));
506
+ if (!decoded.lsig) throw new Error("Logic signature structure missing lsig field");
507
+ const lsig = decoded.lsig;
508
+ return signLogicSigTransactionObject(transaction, new LogicSigAccount(lsig.l, lsig.arg)).blob;
509
+ } else if (signature.type === "secret_key") {
510
+ const valueArray = signature.value;
511
+ return signTransaction(transaction, new Uint8Array(Object.values(valueArray))).blob;
512
+ } else throw new Error(`Unsupported signature type: ${signature.type}`);
513
+ } catch (error) {
514
+ throw new Error(`Failed to re-sign transaction: ${error instanceof Error ? error.message : String(error)}`);
515
+ }
516
+ }
517
+ /**
518
+ * Execute middleware hooks (beforeSwap or afterSwap)
519
+ */
520
+ async executeMiddlewareHooks(hookName) {
521
+ const allTxns = [];
522
+ const quote = "createdAt" in this.quote ? this.quote : {
523
+ ...this.quote,
524
+ quote: this.quote.quote === "" ? 0n : BigInt(this.quote.quote),
525
+ amount: 0n,
526
+ createdAt: Date.now()
527
+ };
528
+ const quoteContext = {
529
+ fromASAID: BigInt(this.quote.fromASAID),
530
+ toASAID: BigInt(this.quote.toASAID),
531
+ amount: quote.amount,
532
+ type: this.quote.type,
533
+ address: quote.address,
534
+ algodClient: this.algodClient
535
+ };
536
+ for (const mw of this.middleware) {
537
+ if (!await mw.shouldApply(quoteContext) || !mw[hookName]) continue;
538
+ const suggestedParams = await this.algodClient.getTransactionParams().do();
539
+ const swapContext = {
540
+ quote,
541
+ address: this.address,
542
+ algodClient: this.algodClient,
543
+ suggestedParams,
544
+ fromASAID: BigInt(this.quote.fromASAID),
545
+ toASAID: BigInt(this.quote.toASAID),
546
+ signer: this.defaultSigner
547
+ };
548
+ const txns = await mw[hookName](swapContext);
549
+ allTxns.push(...txns);
550
+ }
551
+ return allTxns;
552
+ }
553
+ /**
554
+ * Extract the actual output amount from the confirmed output transaction's inner transactions
555
+ *
556
+ * Analyzes only the output transaction (last app call in the swap) to find the
557
+ * inner transaction that transfers the output asset to the user.
558
+ */
559
+ async extractActualOutputAmount() {
560
+ if (!this.summaryData?.outputTxnId) return;
561
+ const outputAssetId = this.summaryData.outputAssetId;
562
+ const userAddress = this.address;
563
+ try {
564
+ const innerTxns = (await this.algodClient.pendingTransactionInformation(this.summaryData.outputTxnId).do()).innerTxns;
565
+ if (!innerTxns) return;
566
+ for (const innerTxn of innerTxns) {
567
+ const txn = innerTxn.txn.txn;
568
+ const payment = txn.payment;
569
+ const assetTransfer = txn.assetTransfer;
570
+ const receiver = payment?.receiver ?? assetTransfer?.receiver;
571
+ if (!receiver) continue;
572
+ if (receiver.toString() !== userAddress) continue;
573
+ const senderAddress = txn.sender.toString();
574
+ if (outputAssetId === 0n && payment?.amount != null) {
575
+ this.summaryData.outputAmount = payment.amount;
576
+ this.summaryData.outputSender = senderAddress;
577
+ } else if (outputAssetId !== 0n && assetTransfer) {
578
+ if (assetTransfer.assetIndex === outputAssetId && assetTransfer.amount != null) {
579
+ this.summaryData.outputAmount = assetTransfer.amount;
580
+ this.summaryData.outputSender = senderAddress;
581
+ }
582
+ }
583
+ }
584
+ } catch {}
585
+ }
586
+ };
587
+
588
+ //#endregion
589
+ //#region src/utils.ts
590
+ /**
591
+ * HTTP error with status code and response data
592
+ */
593
+ var HTTPError = class extends Error {
594
+ constructor(status, statusText, data) {
595
+ super(`HTTP ${status} ${statusText}`);
596
+ this.status = status;
597
+ this.statusText = statusText;
598
+ this.data = data;
599
+ this.name = "HTTPError";
600
+ }
601
+ };
602
+ /**
603
+ * Make an HTTP request and parse JSON response
604
+ *
605
+ * Simple wrapper around native fetch for API calls. Throws HTTPError for
606
+ * non-2xx responses.
607
+ *
608
+ * @param url - The URL to request
609
+ * @param options - Fetch options
610
+ * @returns Parsed JSON response
611
+ * @throws HTTPError if the response status is not ok
612
+ */
613
+ async function request(url, options) {
614
+ const response = await fetch(url, options);
615
+ if (!response.ok) {
616
+ let errorData;
617
+ try {
618
+ errorData = await response.json();
619
+ } catch {
620
+ try {
621
+ errorData = await response.text();
622
+ } catch {
623
+ errorData = "Failed to parse error response";
624
+ }
625
+ }
626
+ throw new HTTPError(response.status, response.statusText, JSON.stringify(errorData));
627
+ }
628
+ return response.json();
629
+ }
630
+
631
+ //#endregion
632
+ //#region src/client.ts
633
+ /**
634
+ * Client for interacting with the Haystack order router API
635
+ *
636
+ * The RouterClient provides methods to fetch swap quotes and create transaction composers
637
+ * for executing swaps on the Algorand blockchain. It handles API communication, transaction
638
+ * validation, and automatic asset/app opt-in detection.
639
+ *
640
+ * @example
641
+ * ```typescript
642
+ * const router = new RouterClient({
643
+ * apiKey: 'your-api-key',
644
+ * })
645
+ * ```
646
+ *
647
+ * @example
648
+ * ```typescript
649
+ * const router = new RouterClient({
650
+ * apiKey: 'your-api-key',
651
+ * apiBaseUrl: 'https://hayrouter.txnlab.dev',
652
+ * algodUri: 'https://mainnet-api.4160.nodely.dev/',
653
+ * algodToken: '',
654
+ * algodPort: 443,
655
+ * referrerAddress: 'REFERRER_ADDRESS...',
656
+ * feeBps: 15,
657
+ * autoOptIn: false,
658
+ * })
659
+ * ```
660
+ */
661
+ var RouterClient = class {
662
+ config;
663
+ algodClient;
664
+ middleware;
665
+ /**
666
+ * Create a new RouterClient instance
667
+ *
668
+ * @param config - Configuration parameters
669
+ * @param config.apiKey - API key for Haystack Router API (required)
670
+ * @param config.apiBaseUrl - Base URL for the Haystack Router API (default: https://hayrouter.txnlab.dev)
671
+ * @param config.algodUri - Algod node URI (default: https://mainnet-api.4160.nodely.dev/)
672
+ * @param config.algodToken - Algod node token (default: '')
673
+ * @param config.algodPort - Algod node port (default: 443)
674
+ * @param config.referrerAddress - Referrer address for fee sharing (receives 25% of swap fees)
675
+ * @param config.feeBps - Fee in basis points (default: 15 = 0.15%, max: 300 = 3.00%)
676
+ * @param config.autoOptIn - Automatically detect and add required opt-in transactions (default: false)
677
+ * @param config.debugLevel - Debug logging level (default: 'none')
678
+ * @param config.middleware - Array of middleware to apply to swaps (default: [])
679
+ */
680
+ constructor(config) {
681
+ const debugLevel = config.debugLevel ?? DEFAULT_DEBUG_LEVEL;
682
+ Logger.setLevel(debugLevel);
683
+ this.config = {
684
+ apiKey: this.validateApiKey(config.apiKey),
685
+ apiBaseUrl: config.apiBaseUrl ?? DEFAULT_API_BASE_URL,
686
+ algodUri: config.algodUri ?? DEFAULT_ALGOD_URI,
687
+ algodToken: config.algodToken ?? DEFAULT_ALGOD_TOKEN,
688
+ algodPort: config.algodPort ?? DEFAULT_ALGOD_PORT,
689
+ referrerAddress: config.referrerAddress ? this.validateAddress(config.referrerAddress) : void 0,
690
+ feeBps: this.validateFeeBps(config.feeBps ?? DEFAULT_FEE_BPS),
691
+ autoOptIn: config.autoOptIn ?? DEFAULT_AUTO_OPT_IN,
692
+ debugLevel
693
+ };
694
+ this.algodClient = new Algodv2(this.config.algodToken, this.config.algodUri, this.config.algodPort);
695
+ this.middleware = config.middleware ?? [];
696
+ }
697
+ /**
698
+ * Fetch a swap quote from the Haystack Router API
699
+ *
700
+ * Requests optimal swap routing from the Haystack Router API. The quote includes routing
701
+ * information, price impact, required opt-ins, and an encrypted transaction payload.
702
+ *
703
+ * @param params - Parameters for the quote request
704
+ * @param params.fromASAID - The ID of the asset to swap from
705
+ * @param params.toASAID - The ID of the asset to swap to
706
+ * @param params.amount - The amount of the asset to swap in base units
707
+ * @param params.type - The type of the quote (default: 'fixed-input')
708
+ * @param params.disabledProtocols - List of protocols to disable (default: [])
709
+ * @param params.maxGroupSize - The maximum group size (default: 16)
710
+ * @param params.maxDepth - The maximum depth (default: 4)
711
+ * @param params.address - The address of the account that will perform the swap (recommended when using `config.autoOptIn` or `params.optIn`)
712
+ * @param params.optIn - Whether to include asset opt-in transaction
713
+ * - If true: API reduces maxGroupSize by 1 and includes opt-in (always included, even if not needed)
714
+ * - If false: No opt-in transaction included
715
+ * - If undefined: Falls back to `config.autoOptIn` behavior with account check (if `params.address` is provided)
716
+ * @returns A FetchQuoteResponse object with routing information
717
+ *
718
+ * @example
719
+ * ```typescript
720
+ * const quote = await router.fetchQuote({
721
+ * address: 'ABC...',
722
+ * fromASAID: 0, // ALGO
723
+ * toASAID: 31566704, // USDC
724
+ * amount: 1_000_000, // 1 ALGO
725
+ * })
726
+ * ```
727
+ */
728
+ async fetchQuote(params) {
729
+ const { fromASAID, toASAID, amount, type = "fixed-input", disabledProtocols = [], maxGroupSize = DEFAULT_MAX_GROUP_SIZE, maxDepth = DEFAULT_MAX_DEPTH, optIn, address, _allowNonComposableSwaps = false } = params;
730
+ if (_allowNonComposableSwaps && (optIn === true || this.config.autoOptIn)) throw new Error("Cannot use _allowNonComposableSwaps with optIn or autoOptIn. When allowing non-composable swaps (Tinyman v1), you must handle asset opt-ins and opt-outs in separate transaction groups.");
731
+ const allDisabledProtocols = _allowNonComposableSwaps ? [...new Set(["Humble", ...disabledProtocols])] : [...new Set([...DEPRECATED_PROTOCOLS, ...disabledProtocols])];
732
+ let includeOptIn = optIn;
733
+ if (includeOptIn === void 0 && this.config.autoOptIn) if (address) includeOptIn = await this.needsAssetOptIn(this.validateAddress(address), toASAID);
734
+ else console.warn("autoOptIn is enabled but no address provided to fetchQuote(). Asset opt-in check skipped.");
735
+ const url = new URL(`${this.config.apiBaseUrl}/fetchQuote`);
736
+ url.searchParams.append("apiKey", this.config.apiKey);
737
+ url.searchParams.append("algodUri", this.config.algodUri);
738
+ url.searchParams.append("algodToken", this.config.algodToken);
739
+ url.searchParams.append("algodPort", String(this.config.algodPort));
740
+ url.searchParams.append("feeBps", this.config.feeBps.toString());
741
+ url.searchParams.append("fromASAID", BigInt(fromASAID).toString());
742
+ url.searchParams.append("toASAID", BigInt(toASAID).toString());
743
+ url.searchParams.append("amount", BigInt(amount).toString());
744
+ url.searchParams.append("type", type);
745
+ url.searchParams.append("disabledProtocols", allDisabledProtocols.join(","));
746
+ url.searchParams.append("maxGroupSize", maxGroupSize.toString());
747
+ url.searchParams.append("maxDepth", maxDepth.toString());
748
+ if (typeof includeOptIn === "boolean") url.searchParams.append("optIn", String(includeOptIn));
749
+ if (this.config.referrerAddress) url.searchParams.append("referrerAddress", this.config.referrerAddress);
750
+ return request(url.toString());
751
+ }
752
+ /**
753
+ * Check if asset opt-in is required for the output asset
754
+ *
755
+ * Convenience method to determine if an address needs to opt into the output asset
756
+ * of a swap. This is useful when you want to get a quote without requiring wallet
757
+ * connection upfront, but need to know whether to set `optIn: true` in fetchQuote()
758
+ * to ensure proper routing (as opt-ins reduce maxGroupSize by 1).
759
+ *
760
+ * Note: If you enable `config.autoOptIn`, this check is handled automatically when
761
+ * an address is provided to fetchQuote().
762
+ *
763
+ * @param address - The address to check
764
+ * @param assetId - The output asset ID to check
765
+ * @returns True if asset opt-in is required, false otherwise (always false for ALGO)
766
+ *
767
+ * @example
768
+ * ```typescript
769
+ * // Check if opt-in needed for output asset before fetching quote
770
+ * const needsOptIn = await router.needsAssetOptIn(userAddress, toAssetId)
771
+ * const quote = await router.fetchQuote({
772
+ * fromAssetId,
773
+ * toAssetId,
774
+ * amount,
775
+ * optIn: needsOptIn,
776
+ * })
777
+ * ```
778
+ */
779
+ async needsAssetOptIn(address, assetId) {
780
+ const accountInfo = await this.algodClient.accountInformation(address).do();
781
+ return BigInt(assetId) !== 0n && accountInfo?.assets?.find((asset) => asset.assetId === BigInt(assetId)) === void 0;
782
+ }
783
+ /**
784
+ * Fetch swap transactions from the Haystack Router API
785
+ *
786
+ * Decrypts the quote payload and generates executable swap transactions for the
787
+ * specified signer address with the given slippage tolerance.
788
+ *
789
+ * @param params - Parameters for the swap transaction request
790
+ * @param params.quote - The quote response from fetchQuote()
791
+ * @param params.address - The address of the signer
792
+ * @param params.slippage - The slippage tolerance as a percentage (e.g., 1 for 1%)
793
+ * @returns A FetchSwapTxnsResponse object with transaction data
794
+ */
795
+ async fetchSwapTransactions(params) {
796
+ const { quote, address, slippage } = params;
797
+ this.validateAddress(address);
798
+ const url = new URL(`${this.config.apiBaseUrl}/fetchExecuteSwapTxns`);
799
+ const body = {
800
+ apiKey: this.config.apiKey,
801
+ address,
802
+ txnPayloadJSON: quote.txnPayload,
803
+ slippage
804
+ };
805
+ return request(url.toString(), {
806
+ method: "POST",
807
+ headers: { "Content-Type": "application/json" },
808
+ body: JSON.stringify(body)
809
+ });
810
+ }
811
+ /**
812
+ * Fetch a quote and return an enhanced quote result
813
+ *
814
+ * This is the recommended way to fetch quotes. It returns an object that
815
+ * extends the raw API response with additional metadata and type normalization.
816
+ *
817
+ * @param params - Parameters for the quote request
818
+ * @param params.fromASAID - The ID of the asset to swap from
819
+ * @param params.toASAID - The ID of the asset to swap to
820
+ * @param params.amount - The amount of the asset to swap in base units
821
+ * @param params.type - The type of the quote (default: 'fixed-input')
822
+ * @param params.disabledProtocols - List of protocols to disable (default: [])
823
+ * @param params.maxGroupSize - The maximum group size (default: 16)
824
+ * @param params.maxDepth - The maximum depth (default: 4)
825
+ * @param params.address - The address of the account that will perform the swap (recommended when using `config.autoOptIn` or `params.optIn`)
826
+ * @param params.optIn - Whether to include asset opt-in transaction
827
+ * @returns A SwapQuote enhanced quote result
828
+ *
829
+ * @example
830
+ * ```typescript
831
+ * const quote = await router.newQuote({
832
+ * address: 'ABC...',
833
+ * fromASAID: 0,
834
+ * toASAID: 31566704,
835
+ * amount: 1_000_000,
836
+ * })
837
+ *
838
+ * console.log(quote.quote) // bigint - quoted amount
839
+ * console.log(quote.fromASAID) // number - input asset ID
840
+ * console.log(quote.toASAID) // number - output asset ID
841
+ * console.log(quote.amount) // bigint - original request amount
842
+ * console.log(quote.createdAt) // number - timestamp
843
+ * ```
844
+ */
845
+ async newQuote(params) {
846
+ if (params._allowNonComposableSwaps) throw new Error("The _allowNonComposableSwaps parameter is not supported with newQuote(). This parameter is only available with fetchQuote() for advanced use cases. The newQuote() method is designed for composable swaps with middleware support.");
847
+ let adjustedParams = { ...params };
848
+ const quoteContext = {
849
+ fromASAID: BigInt(params.fromASAID),
850
+ toASAID: BigInt(params.toASAID),
851
+ amount: BigInt(params.amount),
852
+ type: params.type ?? "fixed-input",
853
+ address: params.address ?? void 0,
854
+ algodClient: this.algodClient
855
+ };
856
+ for (const mw of this.middleware) if (await mw.shouldApply(quoteContext) && mw.adjustQuoteParams) adjustedParams = await mw.adjustQuoteParams(adjustedParams);
857
+ const response = await this.fetchQuote(adjustedParams);
858
+ return {
859
+ ...response,
860
+ quote: response.quote === "" ? 0n : BigInt(response.quote),
861
+ amount: BigInt(params.amount),
862
+ address: params.address ?? void 0,
863
+ createdAt: Date.now()
864
+ };
865
+ }
866
+ /**
867
+ * Create a SwapComposer instance
868
+ *
869
+ * This factory method creates a composer that allows you to add custom transactions
870
+ * before and after the swap transactions, with automatic handling of pre-signed transactions
871
+ * and opt-ins.
872
+ *
873
+ * @param config.quote - The quote result from newQuote() or raw API response from fetchQuote()
874
+ * @param config.address - The address of the signer
875
+ * @param config.slippage - The slippage tolerance
876
+ * @param config.signer - Transaction signer function
877
+ * @returns A SwapComposer instance ready for building transaction groups
878
+ *
879
+ * @example
880
+ * ```typescript
881
+ * // Basic swap
882
+ * const quote = await router.newQuote({ ... })
883
+ * await router.newSwap({ quote, address, slippage, signer })
884
+ * .execute()
885
+ * ```
886
+ *
887
+ * @example
888
+ * ```typescript
889
+ * // Advanced swap with custom transactions
890
+ * const quote = await router.newQuote({ ... })
891
+ * const swap = await router.newSwap({
892
+ * quote,
893
+ * address,
894
+ * slippage,
895
+ * signer,
896
+ * })
897
+ *
898
+ * console.log(swap.getStatus()) // BUILDING
899
+ *
900
+ * const signedTxns = await swap
901
+ * .addTransaction(beforeTxn)
902
+ * .addSwapTransactions() // Adds swap transactions to the group
903
+ * .addTransaction(afterTxn)
904
+ * .sign()
905
+ *
906
+ * console.log(swap.getStatus()) // SIGNED
907
+ *
908
+ * const result = await swap.execute(waitRounds)
909
+ * console.log(result.confirmedRound, result.txIds)
910
+ *
911
+ * console.log(swap.getStatus()) // COMMITTED
912
+ * ```
913
+ */
914
+ async newSwap(config) {
915
+ const { quote, address, slippage, signer, note } = config;
916
+ return new SwapComposer({
917
+ quote,
918
+ swapTxns: (await this.fetchSwapTransactions({
919
+ quote,
920
+ address,
921
+ slippage
922
+ })).txns,
923
+ algodClient: this.algodClient,
924
+ address,
925
+ signer,
926
+ middleware: this.middleware,
927
+ note,
928
+ debugLevel: this.config.debugLevel
929
+ });
930
+ }
931
+ /**
932
+ * Validates the API key
933
+ */
934
+ validateApiKey(apiKey) {
935
+ if (!apiKey) throw new Error("API key is required");
936
+ return apiKey;
937
+ }
938
+ /**
939
+ * Validates an Algorand address
940
+ */
941
+ validateAddress(address) {
942
+ if (!isValidAddress(address)) throw new Error(`Invalid Algorand address: ${address}`);
943
+ return address;
944
+ }
945
+ /**
946
+ * Validates the fee in basis points (max 300 = 3.00%)
947
+ */
948
+ validateFeeBps(feeBps) {
949
+ if (feeBps < 0 || feeBps > MAX_FEE_BPS) throw new Error(`Invalid fee: ${feeBps} basis points (must be between 0 and ${MAX_FEE_BPS})`);
950
+ return feeBps;
951
+ }
952
+ };
953
+
954
+ //#endregion
955
+ //#region src/middleware.ts
956
+ /**
957
+ * Middleware that automatically adds an asset opt-out transaction when swapping
958
+ * the full balance of an input asset, leaving it with a zero balance.
959
+ *
960
+ * @example
961
+ * ```typescript
962
+ * import { RouterClient, AutoOptOutMiddleware } from '@txnlab/haystack-router'
963
+ *
964
+ * const autoOptOut = new AutoOptOutMiddleware({
965
+ * excludedAssets: [31566704], // Don't auto-opt-out of USDC
966
+ * })
967
+ *
968
+ * const router = new RouterClient({
969
+ * apiKey: 'your-api-key',
970
+ * middleware: [autoOptOut],
971
+ * })
972
+ *
973
+ * // When swapping full balance, opt-out transaction is automatically added
974
+ * const quote = await router.newQuote({
975
+ * address: userAddress,
976
+ * fromASAID: someAssetId,
977
+ * toASAID: 0,
978
+ * amount: fullBalance, // If this equals account's full balance, opt-out is added
979
+ * type: 'fixed-input',
980
+ * })
981
+ * ```
982
+ */
983
+ var AutoOptOutMiddleware = class {
984
+ name = "AutoOptOut";
985
+ version = "1.0.0";
986
+ excludedAssets;
987
+ constructor(config = {}) {
988
+ this.excludedAssets = new Set((config.excludedAssets ?? []).map((id) => BigInt(id)));
989
+ }
990
+ async shouldApply(context) {
991
+ if (context.type !== "fixed-input") return false;
992
+ if (!context.address) return false;
993
+ if (context.fromASAID === 0n) return false;
994
+ if (this.excludedAssets.has(context.fromASAID)) return false;
995
+ try {
996
+ const assetHolding = (await context.algodClient.accountInformation(context.address).do()).assets?.find((asset) => asset.assetId === context.fromASAID);
997
+ if (!assetHolding) return false;
998
+ return assetHolding.amount === context.amount;
999
+ } catch (error) {
1000
+ console.warn(`AutoOptOutMiddleware: Failed to fetch account info for ${context.address}:`, error);
1001
+ return false;
1002
+ }
1003
+ }
1004
+ async adjustQuoteParams(params) {
1005
+ const maxGroupSize = (params.maxGroupSize ?? 16) - 1;
1006
+ return {
1007
+ ...params,
1008
+ maxGroupSize
1009
+ };
1010
+ }
1011
+ async afterSwap(context) {
1012
+ return [{
1013
+ txn: makeAssetTransferTxnWithSuggestedParamsFromObject({
1014
+ sender: context.address,
1015
+ receiver: context.address,
1016
+ amount: 0n,
1017
+ assetIndex: context.fromASAID,
1018
+ closeRemainderTo: context.address,
1019
+ suggestedParams: context.suggestedParams
1020
+ }),
1021
+ signer: context.signer
1022
+ }];
1023
+ }
1024
+ };
1025
+
1026
+ //#endregion
1027
+ export { AutoOptOutMiddleware, DEFAULT_ALGOD_PORT, DEFAULT_ALGOD_TOKEN, DEFAULT_ALGOD_URI, DEFAULT_API_BASE_URL, DEFAULT_AUTO_OPT_IN, DEFAULT_CONFIRMATION_ROUNDS, DEFAULT_DEBUG_LEVEL, DEFAULT_FEE_BPS, DEFAULT_MAX_DEPTH, DEFAULT_MAX_GROUP_SIZE, DEPRECATED_PROTOCOLS, HTTPError, MAX_FEE_BPS, Protocol, RouterClient, SwapComposer, SwapComposerStatus, request };
1028
+ //# sourceMappingURL=index.mjs.map