@bananapus/nana-sdk-core 3.3.0 → 3.5.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.
Files changed (69) hide show
  1. package/dist/cjs/review/heldWrite.js +1 -1
  2. package/dist/cjs/review/heldWrite.js.map +1 -1
  3. package/dist/cjs/review/messages.d.ts +3 -1
  4. package/dist/cjs/review/messages.d.ts.map +1 -1
  5. package/dist/cjs/review/messages.js +4 -2
  6. package/dist/cjs/review/messages.js.map +1 -1
  7. package/dist/cjs/review/writeHold.d.ts +9 -6
  8. package/dist/cjs/review/writeHold.d.ts.map +1 -1
  9. package/dist/cjs/review/writeHold.js +12 -4
  10. package/dist/cjs/review/writeHold.js.map +1 -1
  11. package/dist/cjs/review/writeRecovery.d.ts +3 -2
  12. package/dist/cjs/review/writeRecovery.d.ts.map +1 -1
  13. package/dist/cjs/review/writeRecovery.js +3 -2
  14. package/dist/cjs/review/writeRecovery.js.map +1 -1
  15. package/dist/cjs/v6/defifa.d.ts +4 -2
  16. package/dist/cjs/v6/defifa.d.ts.map +1 -1
  17. package/dist/cjs/v6/defifa.js +10 -6
  18. package/dist/cjs/v6/defifa.js.map +1 -1
  19. package/dist/cjs/v6/executionErrors.d.ts +69 -11
  20. package/dist/cjs/v6/executionErrors.d.ts.map +1 -1
  21. package/dist/cjs/v6/executionErrors.js +160 -75
  22. package/dist/cjs/v6/executionErrors.js.map +1 -1
  23. package/dist/cjs/v6/index.d.ts +2 -1
  24. package/dist/cjs/v6/index.d.ts.map +1 -1
  25. package/dist/cjs/v6/index.js +4 -1
  26. package/dist/cjs/v6/index.js.map +1 -1
  27. package/dist/cjs/v6/pay.d.ts +35 -8
  28. package/dist/cjs/v6/pay.d.ts.map +1 -1
  29. package/dist/cjs/v6/pay.js +487 -48
  30. package/dist/cjs/v6/pay.js.map +1 -1
  31. package/dist/cjs/v6/uniswapV3.d.ts +21 -0
  32. package/dist/cjs/v6/uniswapV3.d.ts.map +1 -0
  33. package/dist/cjs/v6/uniswapV3.js +72 -0
  34. package/dist/cjs/v6/uniswapV3.js.map +1 -0
  35. package/dist/esm/review/heldWrite.js +2 -2
  36. package/dist/esm/review/heldWrite.js.map +1 -1
  37. package/dist/esm/review/messages.d.ts +3 -1
  38. package/dist/esm/review/messages.d.ts.map +1 -1
  39. package/dist/esm/review/messages.js +3 -1
  40. package/dist/esm/review/messages.js.map +1 -1
  41. package/dist/esm/review/writeHold.d.ts +9 -6
  42. package/dist/esm/review/writeHold.d.ts.map +1 -1
  43. package/dist/esm/review/writeHold.js +12 -4
  44. package/dist/esm/review/writeHold.js.map +1 -1
  45. package/dist/esm/review/writeRecovery.d.ts +3 -2
  46. package/dist/esm/review/writeRecovery.d.ts.map +1 -1
  47. package/dist/esm/review/writeRecovery.js +3 -2
  48. package/dist/esm/review/writeRecovery.js.map +1 -1
  49. package/dist/esm/v6/defifa.d.ts +4 -2
  50. package/dist/esm/v6/defifa.d.ts.map +1 -1
  51. package/dist/esm/v6/defifa.js +10 -6
  52. package/dist/esm/v6/defifa.js.map +1 -1
  53. package/dist/esm/v6/executionErrors.d.ts +69 -11
  54. package/dist/esm/v6/executionErrors.d.ts.map +1 -1
  55. package/dist/esm/v6/executionErrors.js +159 -76
  56. package/dist/esm/v6/executionErrors.js.map +1 -1
  57. package/dist/esm/v6/index.d.ts +2 -1
  58. package/dist/esm/v6/index.d.ts.map +1 -1
  59. package/dist/esm/v6/index.js +2 -1
  60. package/dist/esm/v6/index.js.map +1 -1
  61. package/dist/esm/v6/pay.d.ts +35 -8
  62. package/dist/esm/v6/pay.d.ts.map +1 -1
  63. package/dist/esm/v6/pay.js +487 -49
  64. package/dist/esm/v6/pay.js.map +1 -1
  65. package/dist/esm/v6/uniswapV3.d.ts +21 -0
  66. package/dist/esm/v6/uniswapV3.d.ts.map +1 -0
  67. package/dist/esm/v6/uniswapV3.js +68 -0
  68. package/dist/esm/v6/uniswapV3.js.map +1 -0
  69. package/package.json +1 -1
@@ -3,8 +3,10 @@ import { DEFAULT_ALLOW_OVERSPENDING, MAX_RESERVED_PERCENT, NATIVE_TOKEN, } from
3
3
  import { jbBuybackHookRegistryAbi, jbMultiTerminalAbi, } from "../generated/juicebox.js";
4
4
  import { createHookMetadata, hookMetadataId } from "../utils/hook.js";
5
5
  import { slippageFloor } from "./cashOut.js";
6
+ import { getAccountingContexts } from "./terminals.js";
6
7
  import { v6Address } from "./types.js";
7
- import { quoteUniswapV4ExactInputSingle, uniswapV4PoolId, } from "./uniswapV4.js";
8
+ import { quoteUniswapV3ExactInputSingle } from "./uniswapV3.js";
9
+ import { quoteUniswapV4ExactInputSingle, uniswapV4PoolId, uniswapV4SwapDirection, } from "./uniswapV4.js";
8
10
  /**
9
11
  * Build a `pay(projectId, token, amount, beneficiary, minReturnedTokens, memo,
10
12
  * metadata)` transaction request for a v6 terminal.
@@ -279,6 +281,8 @@ async function resolvedBuybackHookOf(client, chainId, projectId) {
279
281
  return hook === zeroAddress ? null : hook;
280
282
  }
281
283
  const maxBigInt = (a, b) => (a > b ? a : b);
284
+ /** What to tell the payer when {@link PreparedUserPay.quoteFailed} is set. */
285
+ export const PAY_QUOTE_FAILED_MESSAGE = "Couldn't read the pool's price, so this payment's minimum is only the mint rate.";
282
286
  const poolKeyOfAbi = [
283
287
  {
284
288
  type: "function",
@@ -303,13 +307,337 @@ const poolKeyOfAbi = [
303
307
  ],
304
308
  },
305
309
  ];
310
+ /**
311
+ * Whether a preview reverted with the buyback hook's
312
+ * `JBBuybackHook_SpecifiedSlippageExceeded`, which viem keeps as `raw` on the
313
+ * error's cause chain. The full revert decoder would weigh on every pay card.
314
+ */
315
+ function rejectsMinimum(error) {
316
+ for (let e = error; e; e = e.cause) {
317
+ if (typeof e.raw === "string" && e.raw.startsWith("0xe2d708a9")) {
318
+ return true;
319
+ }
320
+ }
321
+ return false;
322
+ }
323
+ /** `prepareUserPay`'s tolerance on its quote and fallback minimum: 1%. */
324
+ export const PAY_DEFAULT_SLIPPAGE_BPS = 100n;
325
+ /**
326
+ * The buyback pool a payment's spec reports, and the terminal token it is
327
+ * keyed to, or null. A router converts the paid token first, so its pool is
328
+ * keyed to another token the project accepts. The pool id is the key's hash,
329
+ * so a match proves the key.
330
+ */
331
+ async function buybackPoolOf(client, { chainId, hook, projectId, token, poolId, }) {
332
+ const poolOf = async (terminalToken) => ({
333
+ terminalToken,
334
+ key: await client.readContract({
335
+ address: hook,
336
+ abi: poolKeyOfAbi,
337
+ functionName: "poolKeyOf",
338
+ args: [
339
+ projectId,
340
+ terminalToken.toLowerCase() === NATIVE_TOKEN.toLowerCase()
341
+ ? zeroAddress
342
+ : terminalToken,
343
+ ],
344
+ }),
345
+ });
346
+ const paid = await poolOf(token);
347
+ if (uniswapV4PoolId(paid.key) === poolId)
348
+ return paid;
349
+ const contexts = await getAccountingContexts(client, { chainId, projectId });
350
+ const pools = await Promise.all(contexts
351
+ .filter((c) => c.token.toLowerCase() !== token.toLowerCase())
352
+ .map((c) => poolOf(c.token)));
353
+ return pools.find((p) => uniswapV4PoolId(p.key) === poolId) ?? null;
354
+ }
355
+ const previewPayForOutputs = jbMultiTerminalAbi.find((item) => item.type === "function" && item.name === "previewPayFor").outputs;
356
+ // Only these reads of the router and core: their full ABIs would weigh on every pay card.
357
+ const routerConversionAbi = [
358
+ {
359
+ type: "function",
360
+ name: "projectIdOf",
361
+ stateMutability: "view",
362
+ inputs: [{ name: "token", type: "address" }],
363
+ outputs: [{ name: "", type: "uint256" }],
364
+ },
365
+ {
366
+ type: "function",
367
+ name: "primaryTerminalOf",
368
+ stateMutability: "view",
369
+ inputs: [
370
+ { name: "projectId", type: "uint256" },
371
+ { name: "token", type: "address" },
372
+ ],
373
+ outputs: [{ name: "", type: "address" }],
374
+ },
375
+ {
376
+ type: "function",
377
+ name: "previewTerminalPayOf",
378
+ stateMutability: "view",
379
+ inputs: [
380
+ { name: "destTerminal", type: "address" },
381
+ { name: "projectId", type: "uint256" },
382
+ { name: "token", type: "address" },
383
+ { name: "amount", type: "uint256" },
384
+ { name: "beneficiary", type: "address" },
385
+ { name: "metadata", type: "bytes" },
386
+ ],
387
+ outputs: previewPayForOutputs,
388
+ },
389
+ {
390
+ type: "function",
391
+ name: "terminalsOf",
392
+ stateMutability: "view",
393
+ inputs: [{ name: "projectId", type: "uint256" }],
394
+ outputs: [{ name: "", type: "address[]" }],
395
+ },
396
+ {
397
+ type: "function",
398
+ name: "terminalOf",
399
+ stateMutability: "view",
400
+ inputs: [{ name: "projectId", type: "uint256" }],
401
+ outputs: [{ name: "", type: "address" }],
402
+ },
403
+ {
404
+ type: "function",
405
+ name: "ROUTER",
406
+ stateMutability: "view",
407
+ inputs: [],
408
+ outputs: [{ name: "", type: "address" }],
409
+ },
410
+ {
411
+ type: "function",
412
+ name: "wrappedNativeToken",
413
+ stateMutability: "view",
414
+ inputs: [],
415
+ outputs: [{ name: "", type: "address" }],
416
+ },
417
+ {
418
+ type: "function",
419
+ name: "previewSwapAmountOutOf",
420
+ stateMutability: "view",
421
+ inputs: [
422
+ { name: "tokenIn", type: "address" },
423
+ { name: "tokenOut", type: "address" },
424
+ { name: "amount", type: "uint256" },
425
+ { name: "metadata", type: "bytes" },
426
+ ],
427
+ outputs: [{ name: "amountOut", type: "uint256" }],
428
+ },
429
+ {
430
+ type: "function",
431
+ name: "discoverBestPool",
432
+ stateMutability: "view",
433
+ inputs: [
434
+ { name: "normalizedTokenIn", type: "address" },
435
+ { name: "normalizedTokenOut", type: "address" },
436
+ ],
437
+ outputs: [
438
+ {
439
+ name: "",
440
+ type: "tuple",
441
+ components: [
442
+ { name: "isV4", type: "bool" },
443
+ { name: "v3Pool", type: "address" },
444
+ {
445
+ name: "v4Key",
446
+ type: "tuple",
447
+ components: [
448
+ { name: "currency0", type: "address" },
449
+ { name: "currency1", type: "address" },
450
+ { name: "fee", type: "uint24" },
451
+ { name: "tickSpacing", type: "int24" },
452
+ { name: "hooks", type: "address" },
453
+ ],
454
+ },
455
+ ],
456
+ },
457
+ ],
458
+ },
459
+ ];
460
+ const v3PoolFeeAbi = [
461
+ {
462
+ type: "function",
463
+ name: "fee",
464
+ stateMutability: "view",
465
+ inputs: [],
466
+ outputs: [{ name: "", type: "uint24" }],
467
+ },
468
+ ];
469
+ /**
470
+ * Whether a payment to `terminal` reaches the current router: directly,
471
+ * through the gateway, or through the registry's terminal for the project
472
+ * (which may be the gateway). The conversion reads quote that router.
473
+ */
474
+ async function reachesRouter(client, chainId, projectId, terminal) {
475
+ const addressOf = (name) => {
476
+ try {
477
+ return v6Address(name, chainId).toLowerCase();
478
+ }
479
+ catch {
480
+ return null;
481
+ }
482
+ };
483
+ let reached = terminal.toLowerCase();
484
+ if (reached === addressOf("JBRouterTerminalRegistry")) {
485
+ reached = (await client.readContract({
486
+ address: terminal,
487
+ abi: routerConversionAbi,
488
+ functionName: "terminalOf",
489
+ args: [projectId],
490
+ })).toLowerCase();
491
+ }
492
+ if (reached === addressOf("JBRouterTerminalGateway")) {
493
+ reached = (await client.readContract({
494
+ address: reached,
495
+ abi: routerConversionAbi,
496
+ functionName: "ROUTER",
497
+ })).toLowerCase();
498
+ }
499
+ const router = addressOf("JBRouterTerminal");
500
+ return router !== null && reached === router;
501
+ }
502
+ /**
503
+ * What the router terminal's swap of the whole payment into `tokenOut` is
504
+ * quoted to return (fee and price impact included), and the floor it reverts
505
+ * below, from the pool the router picks. Null when the route is not that one
506
+ * swap, such as when the router cashes a project token out first.
507
+ */
508
+ async function routerConversionOf(client, { chainId, tokenIn, tokenOut, amount, }) {
509
+ const router = v6Address("JBRouterTerminal", chainId);
510
+ const [sourceProjectId, wrapped] = await Promise.all([
511
+ client.readContract({
512
+ address: v6Address("JBTokens", chainId),
513
+ abi: routerConversionAbi,
514
+ functionName: "projectIdOf",
515
+ args: [tokenIn],
516
+ }),
517
+ client.readContract({
518
+ address: router,
519
+ abi: routerConversionAbi,
520
+ functionName: "wrappedNativeToken",
521
+ }),
522
+ ]);
523
+ // The router cashes a project token out before any swap.
524
+ if (sourceProjectId !== 0n)
525
+ return null;
526
+ const isNative = (t) => t.toLowerCase() === NATIVE_TOKEN.toLowerCase() ||
527
+ t.toLowerCase() === zeroAddress;
528
+ const wrap = (t) => (isNative(t) ? wrapped : t);
529
+ // The router wraps or unwraps the native token 1:1, without a pool.
530
+ if (wrap(tokenIn).toLowerCase() === wrap(tokenOut).toLowerCase())
531
+ return null;
532
+ const [floor, pool] = await Promise.all([
533
+ client.readContract({
534
+ address: router,
535
+ abi: routerConversionAbi,
536
+ functionName: "previewSwapAmountOutOf",
537
+ args: [tokenIn, tokenOut, amount, "0x"],
538
+ }),
539
+ client.readContract({
540
+ address: router,
541
+ abi: routerConversionAbi,
542
+ functionName: "discoverBestPool",
543
+ args: [wrap(tokenIn), wrap(tokenOut)],
544
+ }),
545
+ ]);
546
+ let expected;
547
+ if (pool.isV4) {
548
+ // V4 pools hold the native token itself, which the router uses for wrapped input.
549
+ const unwrap = (t) => wrap(t).toLowerCase() === wrapped.toLowerCase() ? zeroAddress : t;
550
+ const zeroForOne = uniswapV4SwapDirection({
551
+ poolKey: pool.v4Key,
552
+ tokenIn: unwrap(tokenIn),
553
+ tokenOut: unwrap(tokenOut),
554
+ });
555
+ if (zeroForOne === null)
556
+ return null;
557
+ expected = await quoteUniswapV4ExactInputSingle(client, {
558
+ chainId,
559
+ poolKey: pool.v4Key,
560
+ zeroForOne,
561
+ amountIn: amount,
562
+ });
563
+ }
564
+ else {
565
+ expected = await quoteUniswapV3ExactInputSingle(client, {
566
+ chainId,
567
+ tokenIn: wrap(tokenIn),
568
+ tokenOut: wrap(tokenOut),
569
+ fee: await client.readContract({
570
+ address: pool.v3Pool,
571
+ abi: v3PoolFeeAbi,
572
+ functionName: "fee",
573
+ }),
574
+ amountIn: amount,
575
+ });
576
+ }
577
+ return expected >= floor ? { expected, floor } : null;
578
+ }
579
+ /**
580
+ * The destination terminal's preview of a router payment whose conversion
581
+ * returns what it is quoted to, and the router's floor. Null when the route is
582
+ * not one swap of the whole payment.
583
+ */
584
+ async function quotedRoutePreviewOf(client, { chainId, projectId, tokenIn, tokenOut, amount, beneficiary, metadata, }) {
585
+ const conversion = await routerConversionOf(client, {
586
+ chainId,
587
+ tokenIn,
588
+ tokenOut,
589
+ amount,
590
+ });
591
+ if (!conversion)
592
+ return null;
593
+ const destination = await client.readContract({
594
+ address: v6Address("JBDirectory", chainId),
595
+ abi: routerConversionAbi,
596
+ functionName: "primaryTerminalOf",
597
+ args: [projectId, tokenOut],
598
+ });
599
+ const [, beneficiaryTokenCount, reservedTokenCount, specs] = await client.readContract({
600
+ address: v6Address("JBRouterTerminal", chainId),
601
+ abi: routerConversionAbi,
602
+ functionName: "previewTerminalPayOf",
603
+ args: [
604
+ destination,
605
+ projectId,
606
+ tokenOut,
607
+ conversion.expected,
608
+ beneficiary,
609
+ metadata,
610
+ ],
611
+ });
612
+ return {
613
+ preview: { beneficiaryTokenCount, reservedTokenCount },
614
+ specs,
615
+ floor: conversion.floor,
616
+ };
617
+ }
618
+ /**
619
+ * The buyback spec the destination returns when the router's conversion
620
+ * returns what it is quoted to, and the router's floor. The hook's input is
621
+ * the conversion less anything taken before it, such as an NFT split, and the
622
+ * hook mints instead of swapping once minting that input reaches a fixed
623
+ * minimum, so the minimum is set from this spec. Null when the route is not
624
+ * one swap of the whole payment.
625
+ */
626
+ async function routedBuybackSpecOf(client, { hook, reservedPercent, ...route }) {
627
+ const routed = await quotedRoutePreviewOf(client, route);
628
+ if (!routed)
629
+ return null;
630
+ const raw = routed.specs.find((s) => s.hook.toLowerCase() === hook.toLowerCase());
631
+ const spec = raw && decodeBuybackPaySpec(raw, reservedPercent);
632
+ return spec ? { spec, floor: routed.floor } : null;
633
+ }
306
634
  /**
307
635
  * Prepare a person's payment with {@link buildUserPayMetadata}: preview it,
308
636
  * and set the minimum the terminal enforces on the beneficiary's balance.
309
637
  *
310
- * When the project's buyback hook has a pool in the paid token, this quotes
311
- * that pool (fee and price impact included) and sends the slippage-floored
312
- * quote as an explicit minimum, so the payer gets the pool's rate whenever it
638
+ * When the project's buyback hook would swap, this quotes its pool (fee and
639
+ * price impact included) and sends the slippage-floored quote as an explicit
640
+ * minimum, so the payer gets the pool's rate whenever it
313
641
  * beats minting. The hook compares that minimum to the swap output plus the
314
642
  * leftover mint: the whole leftover mint when the swapped tokens go through
315
643
  * the reserved split (`skipSplits` false, and always on hook 1.1.1, which
@@ -321,23 +649,40 @@ const poolKeyOfAbi = [
321
649
  * which reaches zero on large buys against a thin pool and sends them to
322
650
  * minting even when the pool pays far more.
323
651
  *
324
- * Otherwise (no pool, a router converting the token first, or a failed quote)
325
- * the minimum is the payer's mint entitlement when the hook would swap, since
326
- * the hook never settles below it, and the terminal's own issuance when it
327
- * would not. If the payment then reverts, {@link decodePayFailure} says why.
652
+ * Behind a router terminal, its registry or its gateway, when the router
653
+ * converts the paid token first with one swap, the pool is the one in the
654
+ * token it converts to, quoted on what that swap is quoted to return. If the
655
+ * conversion returns noticeably less, the hook's minimum reverts the payment.
656
+ * If it returns more, the hook mints once minting reaches the minimum, which
657
+ * happens only when the pool beats minting by less than `slippageBps` plus
658
+ * that gain. The payer still receives at least the minimum, and no fixed
659
+ * minimum avoids this when the pool's edge is inside the conversion's
660
+ * uncertainty. Other routes, such as cashing a project token out first, keep
661
+ * the fallback. When the project has no buyback hook and accepts one token,
662
+ * the minimum is what minting the quoted conversion issues, instead of the
663
+ * router's floor.
664
+ *
665
+ * Otherwise (no pool, or a failed quote) the minimum is the payer's mint
666
+ * entitlement when the hook would swap, since the hook never settles below it,
667
+ * and the terminal's own issuance when it would not. A quote whose reads fail
668
+ * twice returns `quoteFailed`, so the app can say the pool's rate is not
669
+ * protected. If the payment then
670
+ * reverts, {@link decodePayFailure} says why. To let the payer accept less,
671
+ * prepare again with a larger `slippageBps`: the hook's minimum is in the
672
+ * metadata, so lowering `minReturnedTokens` alone does not relax it.
328
673
  *
329
674
  * When the ruleset calls buyback hook 1.4 directly (or through the omnichain
330
675
  * deployer) instead of through the registry, the metadata is keyed to the hook,
331
676
  * which reads nothing else.
332
677
  *
333
678
  * @param args.slippageBps Tolerance on the pool quote and on the fallback
334
- * minimum. Defaults to 100 (1%).
679
+ * minimum. Defaults to {@link PAY_DEFAULT_SLIPPAGE_BPS}.
335
680
  * @param args.skipSplits Whether the beneficiary keeps the swapped tokens
336
681
  * without the reserved split. Defaults to true, as a person's pay card does;
337
682
  * pass false for payments that should honor the project's splits, such as
338
683
  * fees or revenue routed into a project.
339
684
  */
340
- export async function prepareUserPay(client, { tiers, slippageBps = 100n, skipSplits = true, ...args }) {
685
+ export async function prepareUserPay(client, { tiers, slippageBps = PAY_DEFAULT_SLIPPAGE_BPS, skipSplits = true, ...args }) {
341
686
  const { chainId, projectId, token } = args;
342
687
  let metadata = buildUserPayMetadata({ chainId, tiers, skipSplits });
343
688
  let read = await readPay(client, { ...args, metadata });
@@ -359,32 +704,115 @@ export async function prepareUserPay(client, { tiers, slippageBps = 100n, skipSp
359
704
  maxBigInt(mintEntitlementOf(buyback, weight) - 2n, 0n)
360
705
  : terminalBeneficiaryTokenCount, slippageBps),
361
706
  };
707
+ // A failed read is retried once: a dropped request must not silently weaken
708
+ // the minimum.
709
+ const withRetry = async (prepare) => {
710
+ try {
711
+ return await prepare();
712
+ }
713
+ catch {
714
+ try {
715
+ return await prepare();
716
+ }
717
+ catch {
718
+ return { ...fallback, quoteFailed: true };
719
+ }
720
+ }
721
+ };
722
+ const isRouter = args.terminal.toLowerCase() !==
723
+ v6Address("JBMultiTerminal", chainId).toLowerCase();
724
+ if (!buyback && isRouter) {
725
+ // The router previews minting on its conversion's floor, which sits up to
726
+ // its sigmoid slippage below the pool's quote. Require what minting the
727
+ // quoted conversion issues when the router has only that route: the
728
+ // project's one terminal is the multi-terminal, accepting one token, and
729
+ // the payment reaches the router these reads quote.
730
+ // ponytail: one accepted token only; multi-token projects need the
731
+ // router's route pick exposed.
732
+ return withRetry(async () => {
733
+ const [terminals, contexts, viaRouter] = await Promise.all([
734
+ client.readContract({
735
+ address: v6Address("JBDirectory", chainId),
736
+ abi: routerConversionAbi,
737
+ functionName: "terminalsOf",
738
+ args: [projectId],
739
+ }),
740
+ getAccountingContexts(client, { chainId, projectId }),
741
+ reachesRouter(client, chainId, projectId, args.terminal),
742
+ ]);
743
+ const tokenOut = contexts.length === 1 ? contexts[0].token : null;
744
+ if (!viaRouter ||
745
+ !tokenOut ||
746
+ terminals.length !== 1 ||
747
+ terminals[0].toLowerCase() !==
748
+ v6Address("JBMultiTerminal", chainId).toLowerCase()) {
749
+ return fallback;
750
+ }
751
+ const routed = await quotedRoutePreviewOf(client, {
752
+ chainId,
753
+ projectId,
754
+ tokenIn: token,
755
+ tokenOut,
756
+ amount: args.amount,
757
+ beneficiary: args.beneficiary,
758
+ metadata,
759
+ });
760
+ if (!routed)
761
+ return fallback;
762
+ return {
763
+ metadata,
764
+ preview: routed.preview,
765
+ minReturnedTokens: slippageFloor(routed.preview.beneficiaryTokenCount, slippageBps),
766
+ };
767
+ });
768
+ }
362
769
  if (!buyback || buyback.amountToSwapWith === 0n)
363
770
  return fallback;
364
- try {
365
- const poolKey = await client.readContract({
366
- address: buyback.hook,
367
- abi: poolKeyOfAbi,
368
- functionName: "poolKeyOf",
369
- args: [
370
- projectId,
371
- token.toLowerCase() === NATIVE_TOKEN.toLowerCase()
372
- ? zeroAddress
373
- : token,
374
- ],
771
+ return withRetry(async () => {
772
+ const pool = await buybackPoolOf(client, {
773
+ chainId,
774
+ hook: buyback.hook,
775
+ projectId,
776
+ token,
777
+ poolId: buyback.poolId,
375
778
  });
376
- // A router converts the paid token first, so its pool is a different one.
377
- if (uniswapV4PoolId(poolKey) !== buyback.poolId)
779
+ if (!pool)
378
780
  return fallback;
781
+ let swap = buyback;
782
+ if (pool.terminalToken !== token) {
783
+ // A router converts the payment first, and its preview gave the hook the
784
+ // router's slippage floor. A minimum quoted on that would let a normal
785
+ // conversion mint while the pool pays more, so quote what the hook
786
+ // receives when the conversion returns its quote. A conversion well
787
+ // below its quote then fails with a reason.
788
+ const routed = await routedBuybackSpecOf(client, {
789
+ chainId,
790
+ projectId,
791
+ tokenIn: token,
792
+ tokenOut: pool.terminalToken,
793
+ amount: args.amount,
794
+ beneficiary: args.beneficiary,
795
+ metadata,
796
+ hook: buyback.hook,
797
+ reservedPercent: buyback.reservedPercent,
798
+ });
799
+ // The floor preview must be this route's: no more than the floor reached the hook.
800
+ if (!routed ||
801
+ routed.floor < buyback.amountToSwapWith ||
802
+ routed.spec.poolId !== buyback.poolId) {
803
+ return fallback;
804
+ }
805
+ swap = routed.spec;
806
+ }
379
807
  const quote = await quoteUniswapV4ExactInputSingle(client, {
380
808
  chainId,
381
- poolKey,
382
- zeroForOne: !buyback.projectTokenIs0,
383
- amountIn: buyback.amountToSwapWith,
809
+ poolKey: pool.key,
810
+ zeroForOne: !swap.projectTokenIs0,
811
+ amountIn: swap.amountToSwapWith,
384
812
  });
385
813
  const minimumSwapAmountOut = slippageFloor(quote, slippageBps);
386
814
  // The pool loses to minting: leave the payment on the mint path.
387
- if (minimumSwapAmountOut <= buyback.tokenCountWithoutHook)
815
+ if (minimumSwapAmountOut <= swap.tokenCountWithoutHook)
388
816
  return fallback;
389
817
  const quotedMetadata = buildUserPayMetadata({
390
818
  chainId,
@@ -395,33 +823,46 @@ export async function prepareUserPay(client, { tiers, slippageBps = 100n, skipSp
395
823
  });
396
824
  // The hook rejects a minimum it cannot route, such as on a pool with no
397
825
  // liquidity in range, and then the payment stays on the mint path.
398
- const [, quotedBeneficiaryCount, quotedReservedCount, quotedSpecs] = await client.readContract({
399
- address: args.terminal,
400
- abi: jbMultiTerminalAbi,
401
- functionName: "previewPayFor",
402
- args: [projectId, token, args.amount, args.beneficiary, quotedMetadata],
403
- });
404
- const swaps = quotedSpecs.some((s) => !s.noop && s.hook.toLowerCase() === buyback.hook.toLowerCase());
405
- if (!swaps)
826
+ let quotedPreview;
827
+ try {
828
+ quotedPreview = await client.readContract({
829
+ address: args.terminal,
830
+ abi: jbMultiTerminalAbi,
831
+ functionName: "previewPayFor",
832
+ args: [projectId, token, args.amount, args.beneficiary, quotedMetadata],
833
+ });
834
+ }
835
+ catch (error) {
836
+ if (rejectsMinimum(error))
837
+ return fallback;
838
+ throw error;
839
+ }
840
+ const [, quotedBeneficiaryCount, quotedReservedCount, quotedSpecs] = quotedPreview;
841
+ // It must still swap, and in the quoted pool: a router may pick another route.
842
+ const quotedSpec = quotedSpecs.find((s) => s.hook.toLowerCase() === buyback.hook.toLowerCase());
843
+ if (!quotedSpec ||
844
+ quotedSpec.noop ||
845
+ decodeBuybackPaySpec(quotedSpec, buyback.reservedPercent)?.poolId !==
846
+ buyback.poolId) {
406
847
  return fallback;
848
+ }
407
849
  // A router folds the hook's estimate into its counts, so only the
408
850
  // multi-terminal's own counts are known to be its issuance.
409
- const issuedByTerminal = args.terminal.toLowerCase() ===
410
- v6Address("JBMultiTerminal", chainId).toLowerCase()
411
- ? {
851
+ const issuedByTerminal = isRouter
852
+ ? { beneficiaryTokenCount: 0n, reservedTokenCount: 0n }
853
+ : {
412
854
  beneficiaryTokenCount: quotedBeneficiaryCount,
413
855
  reservedTokenCount: quotedReservedCount,
414
- }
415
- : { beneficiaryTokenCount: 0n, reservedTokenCount: 0n };
416
- const directMint = directMintOf(buyback, weight);
856
+ };
857
+ const directMint = directMintOf(swap, weight);
417
858
  // The swapped tokens skip the split, or are reminted through it with the
418
859
  // direct mint. The terminal's own issuance, such as for NFT splits, adds.
419
860
  const settle = (swapTokenCount) => {
420
- const split = buyback.skipSplits
421
- ? splitOf(directMint, buyback.reservedPercent)
422
- : splitOf(swapTokenCount + directMint, buyback.reservedPercent);
861
+ const split = swap.skipSplits
862
+ ? splitOf(directMint, swap.reservedPercent)
863
+ : splitOf(swapTokenCount + directMint, swap.reservedPercent);
423
864
  return {
424
- beneficiaryTokenCount: (buyback.skipSplits ? swapTokenCount : 0n) +
865
+ beneficiaryTokenCount: (swap.skipSplits ? swapTokenCount : 0n) +
425
866
  split.beneficiaryTokenCount +
426
867
  issuedByTerminal.beneficiaryTokenCount,
427
868
  reservedTokenCount: split.reservedTokenCount + issuedByTerminal.reservedTokenCount,
@@ -432,10 +873,7 @@ export async function prepareUserPay(client, { tiers, slippageBps = 100n, skipSp
432
873
  preview: settle(quote),
433
874
  minReturnedTokens: settle(minimumSwapAmountOut).beneficiaryTokenCount,
434
875
  };
435
- }
436
- catch {
437
- return fallback;
438
- }
876
+ });
439
877
  }
440
878
  /**
441
879
  * Build pay metadata instructing a project's JB721 tiers hook to mint specific tiers.