@pafi-dev/issuer 0.2.0 → 0.3.0-beta.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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Address, Hex, PublicClient, Chain } from 'viem';
2
- import { PointTokenDomainConfig, MintRequest, EIP712Signature, MintParams, SwapParams, ReceiverConsent, PathKey, PoolKey } from '@pafi-dev/core';
2
+ import { PointTokenDomainConfig, MintRequest, EIP712Signature, MintParams, SwapParams, MintRequestV2, SignatureStruct, PartialUserOperation, BurnConsent, ReceiverConsent, PathKey, PoolKey, SponsorshipScenario } from '@pafi-dev/core';
3
3
  export { encodeExtData } from '@pafi-dev/core';
4
4
 
5
5
  /**
@@ -78,6 +78,24 @@ interface IPointLedger {
78
78
  * supplied when the status is `MINTED`.
79
79
  */
80
80
  updateMintStatus(lockId: string, status: MintingStatus, txHash?: Hex): Promise<void>;
81
+ /**
82
+ * Reserve a pending off-chain credit before the burn tx is submitted.
83
+ *
84
+ * Returns a lockId that the burn indexer uses to correlate the
85
+ * on-chain burn event back to this credit request.
86
+ *
87
+ * Throws if the ledger doesn't support the reverse flow (legacy
88
+ * implementations) — callers should catch and fall back.
89
+ */
90
+ reservePendingCredit?(userAddress: Address, amount: bigint, durationMs: number, tokenAddress?: Address): Promise<string>;
91
+ /**
92
+ * Finalize a reserved credit when the on-chain Burn event is seen by
93
+ * the BurnIndexer. Idempotent — safe to call multiple times with the
94
+ * same txHash (no double-credit).
95
+ *
96
+ * Throws if the lockId is unknown or already resolved.
97
+ */
98
+ resolveCreditByBurnTx?(lockId: string, txHash: Hex): Promise<void>;
81
99
  }
82
100
 
83
101
  /**
@@ -109,6 +127,10 @@ declare class MemoryPointLedger implements IPointLedger {
109
127
  releaseLock(lockId: string): Promise<void>;
110
128
  deductBalance(userAddress: Address, amount: bigint, txHash: Hex, tokenAddress?: Address): Promise<void>;
111
129
  updateMintStatus(lockId: string, status: MintingStatus, txHash?: Hex): Promise<void>;
130
+ private pendingCredits;
131
+ private nextCreditId;
132
+ reservePendingCredit(userAddress: Address, amount: bigint, durationMs: number, tokenAddress?: Address): Promise<string>;
133
+ resolveCreditByBurnTx(lockId: string, txHash: Hex): Promise<void>;
112
134
  /**
113
135
  * Auto-expire any PENDING lock past its expiry. Called lazily on every
114
136
  * read/write so the in-memory state stays self-cleaning without a timer.
@@ -536,93 +558,145 @@ declare class RelayService {
536
558
  * decide whether to release the ledger lock (`SUBMIT_FAILED` and
537
559
  * `SIMULATION_FAILED` are safe to release; `TX_REVERTED` and `TIMEOUT`
538
560
  * need manual review because the tx may still land).
561
+ *
562
+ * @deprecated Since 0.3.0 — will be replaced by `prepareMint()` +
563
+ * `prepareBurn()` in the v1.4 sponsored-UserOp flow. The SC team
564
+ * still needs to finalize Relayer v2 ABI before the replacements
565
+ * can ship (blocker B1). Kept for v0.2.x consumers. Removed in 2.0.
539
566
  */
540
567
  submitMintAndSwap(params: SubmitMintAndSwapParams): Promise<RelayResult>;
568
+ /**
569
+ * Build an unsigned UserOp for Scenario 1 (Mint).
570
+ *
571
+ * Flow:
572
+ * 1. Encode `Relayer.mint(request, userSig, issuerSig)` as the inner call
573
+ * 2. Optionally append a PT fee transfer from user → feeRecipient
574
+ * (fee recovery happens on-chain via BatchExecutor, not via an
575
+ * operator wallet)
576
+ * 3. Wrap all inner calls into `BatchExecutor.execute(calls[])`
577
+ * 4. Return a `PartialUserOperation` ready for:
578
+ * - gas estimation (Bundler)
579
+ * - paymaster sponsorship (PAFI Backend)
580
+ * - user signature (Privy)
581
+ */
582
+ prepareMint(params: PrepareMintParams): PartialUserOperation;
583
+ /**
584
+ * Build an unsigned UserOp for Scenario 2 (Burn/Redeem).
585
+ *
586
+ * Two modes:
587
+ * - `mode: 'burn'` — direct `PointToken.burn(amount)`; `msg.sender`
588
+ * via EIP-7702 delegation is the user, so no signature needed
589
+ * on-chain (the BurnConsent was already verified off-chain by
590
+ * the issuer backend before we got here)
591
+ * - `mode: 'burnWithSig'` — `PointToken.burnWithSig(consent, sig)`;
592
+ * used when the issuer hasn't verified the consent and the
593
+ * contract has to do it on-chain
594
+ */
595
+ prepareBurn(params: PrepareBurnParams): PartialUserOperation;
596
+ }
597
+ interface PrepareMintParams {
598
+ /** User EOA that will send the UserOp (via EIP-7702 delegation). */
599
+ userAddress: Address;
600
+ /** ERC-4337 account nonce (not the MintRequest nonce — different namespace). */
601
+ aaNonce: bigint;
602
+ /** Deployed Relayer v2 contract address (chain-specific). */
603
+ relayerAddress: Address;
604
+ /** BatchExecutor delegation target (chain-specific). */
605
+ batchExecutorAddress: Address;
606
+ /** PointToken being minted — used for optional fee transfer call. */
607
+ pointTokenAddress: Address;
608
+ /** EIP-712-signed MintRequest fields. */
609
+ mintRequest: MintRequestV2;
610
+ /** User's EIP-712 signature over `mintRequest`. */
611
+ userSignature: SignatureStruct;
612
+ /** Issuer's EIP-712 signature over `mintRequest`. */
613
+ issuerSignature: SignatureStruct;
614
+ /** Gas limits — defaults are conservative; caller can tighten. */
615
+ callGasLimit?: bigint;
616
+ verificationGasLimit?: bigint;
617
+ preVerificationGas?: bigint;
618
+ }
619
+ type PrepareBurnParams = PrepareBurnDirectParams | PrepareBurnWithSigParams;
620
+ interface PrepareBurnCommonParams {
621
+ userAddress: Address;
622
+ aaNonce: bigint;
623
+ pointTokenAddress: Address;
624
+ batchExecutorAddress: Address;
625
+ callGasLimit?: bigint;
626
+ verificationGasLimit?: bigint;
627
+ preVerificationGas?: bigint;
628
+ }
629
+ interface PrepareBurnDirectParams extends PrepareBurnCommonParams {
630
+ mode: "burn";
631
+ amount: bigint;
632
+ }
633
+ interface PrepareBurnWithSigParams extends PrepareBurnCommonParams {
634
+ mode: "burnWithSig";
635
+ burnConsent: BurnConsent;
636
+ consentSignature: SignatureStruct;
541
637
  }
542
638
 
543
639
  interface FeeManagerConfig {
544
- /** Provider used for gas price + balance reads. */
640
+ /** Provider used for gas price reads. */
545
641
  provider: PublicClient;
546
- /** Operator wallet whose native balance the manager monitors. */
547
- operatorWallet: OperatorWalletLike;
548
- /** USDT token address on the target chain (used for rebalance swaps). */
549
- usdtAddress: Address;
550
- /** Wrapped-native token address (WETH on Base/Ethereum, WMATIC, etc). */
551
- nativeWrappedAddress: Address;
552
642
  /**
553
- * Typical gas used by a `mintAndSwap` transaction. Default: 500_000. The
554
- * manager multiplies this by current gas price to get the native cost,
555
- * then converts to USDT via the injected `quoteNativeToUsdt`.
643
+ * Typical gas used by a single sponsored UserOp. Default: 500_000.
644
+ * The manager multiplies this by current gas price to get native
645
+ * cost, then converts via the injected `quoteNativeToFee`.
556
646
  */
557
- mintAndSwapGasUnits?: bigint;
647
+ gasUnits?: bigint;
558
648
  /**
559
- * Safety margin applied to the gas estimate before charging the user.
560
- * Expressed as a basis-point multiplier, e.g. 12_000 = 120%. Default 12_000.
649
+ * Safety margin applied before charging the user, as basis points.
650
+ * 12_000 = 120%. Default: 12_000.
561
651
  */
562
652
  gasPremiumBps?: number;
563
653
  /**
564
- * Price conversion: given an amount of native token (wei), return the
565
- * equivalent amount of USDT. Injected so the manager is chain-agnostic
566
- * production wires this to `@pafi/core` V4 quoting or an oracle feed.
567
- */
568
- quoteNativeToUsdt: (amountNative: bigint) => Promise<bigint>;
569
- /**
570
- * Rebalance trigger: when the operator's native balance falls below
571
- * `rebalanceThresholdWei`, `rebalanceIfNeeded()` swaps `rebalanceUsdtAmount`
572
- * worth of USDT into native. Both optional — omit to disable rebalancing.
573
- */
574
- rebalanceThresholdWei?: bigint;
575
- rebalanceUsdtAmount?: bigint;
576
- /**
577
- * Actual swap executor — the manager calls this when a rebalance is
578
- * triggered. Injected so the SDK does not hard-code a DEX choice; the
579
- * issuer wires it to the UniversalRouter (via `@pafi/core swap/`) or
580
- * whatever liquidity venue they trust. Required iff the rebalance
581
- * fields above are set.
654
+ * Quote function given an amount of native wei, return the
655
+ * equivalent amount in the fee currency (PT raw units in v1.4,
656
+ * USDT 6-decimal in legacy v1.2 flows).
657
+ *
658
+ * Injected so the manager stays chain- and token-agnostic. Issuers
659
+ * wire it to `@pafi-dev/core` V4 quoting, a subgraph query, or an
660
+ * oracle feed.
582
661
  */
583
- swapUsdtToNative?: (amountUsdt: bigint) => Promise<void>;
662
+ quoteNativeToFee: (amountNative: bigint) => Promise<bigint>;
584
663
  }
585
664
  /**
586
- * Manages the operator wallet's economics:
665
+ * Computes how much fee to collect from the user to cover the gas cost
666
+ * of a sponsored UserOp.
667
+ *
668
+ * ## v1.4 scope change
587
669
  *
588
- * 1. `estimateGasFee()` how many USDT to deduct from the swap proceeds
589
- * to cover the operator's gas cost for the upcoming `mintAndSwap`.
590
- * 2. `rebalanceIfNeeded()` — when the operator's native balance gets
591
- * low, swap some of the accumulated USDT fee back into native gas
592
- * token so the operator never runs dry.
670
+ * The fee is now expressed in the **fee currency** chosen by the caller
671
+ * (PT for mint/burn, USDT for swap/perp_deposit) not hardcoded to USDT.
593
672
  *
594
- * Both calculations are intentionally injection-based: gas estimation and
595
- * USDT→native swapping both depend on DEX state, which the SDK deliberately
596
- * does not own. Issuers supply the conversion + swap functions.
673
+ * **Operator rebalancing is gone.** In v1.4 the operator no longer holds
674
+ * ETH directly gas is paid by Coinbase Paymaster via the paymaster-proxy
675
+ * (see [SPONSORED_PATH_FLOW.md]). The fee collected here is an
676
+ * application-level ERC-20 transfer inside the same UserOp batch, not a
677
+ * reimbursement to a wallet that needs topping up.
678
+ *
679
+ * `rebalanceIfNeeded()` and `swapUsdtToNative` were removed in 0.3.0.
597
680
  */
598
681
  declare class FeeManager {
599
682
  private readonly provider;
600
- private readonly operatorWallet;
601
- private readonly mintAndSwapGasUnits;
683
+ private readonly gasUnits;
602
684
  private readonly gasPremiumBps;
603
- private readonly quoteNativeToUsdt;
604
- private readonly rebalanceThresholdWei?;
605
- private readonly rebalanceUsdtAmount?;
606
- private readonly swapUsdtToNative?;
685
+ private readonly quoteNativeToFee;
607
686
  constructor(config: FeeManagerConfig);
608
687
  /**
609
- * Estimate the USDT fee the operator should charge for a single
610
- * `mintAndSwap`. Computed as:
688
+ * Estimate the fee (in the caller's fee currency) to charge for the
689
+ * next sponsored UserOp:
611
690
  *
612
- * nativeCost = gasUnits × gasPrice
613
- * premiumNativeCost = nativeCost × premiumBps / 10_000
614
- * usdtFee = quoteNativeToUsdt(premiumNativeCost)
615
- */
616
- estimateGasFee(): Promise<bigint>;
617
- /**
618
- * Check the operator's native balance and, if it has dropped below the
619
- * configured threshold, trigger a USDT→native rebalance via the injected
620
- * `swapUsdtToNative` function.
691
+ * nativeCost = gasUnits × gasPrice
692
+ * withPremium = nativeCost × premiumBps / 10_000
693
+ * fee = quoteNativeToFee(withPremium)
621
694
  *
622
- * Returns `true` if a rebalance was performed, `false` otherwise.
623
- * Silently no-ops when rebalance is not configured.
695
+ * For backward compatibility with v0.2.x code that reads `gasFeeUsdt`
696
+ * from the response, the name `estimateGasFee` is kept — but the
697
+ * currency depends on how the caller wired `quoteNativeToFee`.
624
698
  */
625
- rebalanceIfNeeded(): Promise<boolean>;
699
+ estimateGasFee(): Promise<bigint>;
626
700
  }
627
701
 
628
702
  /**
@@ -751,6 +825,12 @@ declare class MintingGateway {
751
825
  private readonly now;
752
826
  private readonly defaultLockBufferMs;
753
827
  constructor(config: MintingGatewayConfig);
828
+ /**
829
+ * @deprecated Since 0.3.0 — will be renamed to `processMint()` once
830
+ * the SC team finalizes Relayer v2 ABI. The new flow drops the
831
+ * swap steps entirely (no more single-call mint+swap); users swap
832
+ * separately on PAFI Web. Kept here for v0.2.x consumers. Removed in 2.0.
833
+ */
754
834
  processMintAndCashOut(request: MintAndCashOutRequest): Promise<MintAndCashOutResponse>;
755
835
  private computeLockDurationMs;
756
836
  /**
@@ -782,6 +862,16 @@ interface MintEvent {
782
862
  /** Log index within the tx, for deterministic ordering */
783
863
  logIndex: number;
784
864
  }
865
+ /** Decoded Transfer(from=user → 0x0) event used to finalize a burn-for-credit. */
866
+ interface BurnEvent {
867
+ /** The burner — user whose PT was burned. */
868
+ from: Address;
869
+ /** Amount burned. */
870
+ amount: bigint;
871
+ blockNumber: bigint;
872
+ txHash: Hex;
873
+ logIndex: number;
874
+ }
785
875
  /**
786
876
  * Cursor persistence interface — the indexer reports the next block
787
877
  * number it is about to process so the caller can write it to Redis /
@@ -901,10 +991,102 @@ declare class PointIndexer {
901
991
  private finalize;
902
992
  }
903
993
 
994
+ interface BurnIndexerConfig {
995
+ provider: PublicClient;
996
+ pointTokenAddress: Address;
997
+ ledger: IPointLedger;
998
+ /** Block to start from on first run. Ignored if cursor store has value. */
999
+ fromBlock?: bigint;
1000
+ cursorStore?: IIndexerCursorStore;
1001
+ /**
1002
+ * Reorg safety — only treat events as final after this many
1003
+ * confirmations. Default: 3.
1004
+ */
1005
+ confirmations?: number;
1006
+ /** Blocks per getLogs call. Default: 2000. */
1007
+ batchSize?: number;
1008
+ /** Polling interval (ms). Default: 5000. */
1009
+ pollIntervalMs?: number;
1010
+ now?: () => number;
1011
+ }
1012
+ /**
1013
+ * Mirror of `PointIndexer` for the reverse direction — watches
1014
+ * `Transfer(user → 0x0)` events (ERC-20 burns) on the PointToken
1015
+ * contract and finalizes pending off-chain credits.
1016
+ *
1017
+ * Finalization flow:
1018
+ * 1. For each Burn event at `{from, amount, txHash}`:
1019
+ * 2. Call `ledger.resolveCreditByBurnTx(lockId, txHash)` where `lockId`
1020
+ * is resolved by the caller's `onMatchCredit` hook or a
1021
+ * ledger-specific lookup. The SDK does not prescribe the matching
1022
+ * strategy — issuers with a Postgres ledger can JOIN by
1023
+ * `(from, amount, status=PENDING)`; the in-memory ledger matches
1024
+ * by `lockId` supplied out-of-band.
1025
+ *
1026
+ * When no pending credit matches an observed Burn event, the indexer
1027
+ * logs + skips — **it never credits off-chain** from a Burn that was
1028
+ * not first reserved via `reservePendingCredit()`. This prevents
1029
+ * spurious credits from one-off admin burns or direct burn calls
1030
+ * outside the issuer SDK.
1031
+ */
1032
+ declare class BurnIndexer {
1033
+ private readonly provider;
1034
+ private readonly pointTokenAddress;
1035
+ private readonly ledger;
1036
+ private readonly cursorStore;
1037
+ private readonly startBlock;
1038
+ private readonly confirmations;
1039
+ private readonly batchSize;
1040
+ private readonly pollIntervalMs;
1041
+ /**
1042
+ * Caller-supplied matcher. Return the lockId to resolve for a given
1043
+ * burn event, or `undefined` to skip. Runs synchronously via the
1044
+ * ledger's query path.
1045
+ *
1046
+ * Default: try `ledger.resolveCreditByBurnTx` keyed on a synthetic
1047
+ * lock id `burn-${from}-${amount}` — the in-memory ledger assigns
1048
+ * incrementing IDs so callers with the memory ledger must provide a
1049
+ * custom matcher. Real DB-backed ledgers override this to JOIN on
1050
+ * their `pending_credits` table.
1051
+ */
1052
+ matchLockId: (event: BurnEvent) => Promise<string | undefined>;
1053
+ private running;
1054
+ private timer;
1055
+ constructor(config: BurnIndexerConfig);
1056
+ start(): void;
1057
+ stop(): void;
1058
+ tick(): Promise<void>;
1059
+ private scheduleNext;
1060
+ /**
1061
+ * Scan `[from, to]` inclusive for burn events. Callers can drive this
1062
+ * directly to backfill a specific range without `start()`. Cursor is
1063
+ * advanced to `to + 1` on completion.
1064
+ */
1065
+ processBlockRange(from: bigint, to: bigint): Promise<void>;
1066
+ private decodeBurnEvents;
1067
+ /**
1068
+ * Resolve a matching pending credit for this burn event and call
1069
+ * `ledger.resolveCreditByBurnTx(lockId, txHash)`. If no match found,
1070
+ * log + skip.
1071
+ */
1072
+ private finalize;
1073
+ }
1074
+
904
1075
  interface ApiConfigResponse {
905
1076
  chainId: number;
906
1077
  contracts: {
1078
+ /**
1079
+ * Legacy single-token field — kept for backward compat with v0.1.x
1080
+ * frontends. Prefer `pointTokens` for multi-token issuers.
1081
+ */
907
1082
  pointToken?: Address;
1083
+ /**
1084
+ * All supported PointToken addresses (v0.2.0+). Single-token issuers
1085
+ * will have one entry that matches `pointToken`. Multi-token
1086
+ * issuers expose the full list here so the frontend can render a
1087
+ * token picker.
1088
+ */
1089
+ pointTokens?: Address[];
908
1090
  relay?: Address;
909
1091
  issuerRegistry?: Address;
910
1092
  pointTokenFactory?: Address;
@@ -912,6 +1094,17 @@ interface ApiConfigResponse {
912
1094
  poolManager?: Address;
913
1095
  usdt?: Address;
914
1096
  };
1097
+ /**
1098
+ * Absolute URL that the Issuer App opens after a successful claim to
1099
+ * let the user swap PT → USDT or deposit into the perp DEX on PAFI
1100
+ * Web. Mobile opens this in an in-app browser
1101
+ * (SFSafariViewController / Chrome Custom Tabs). Desktop opens in a
1102
+ * popup. See [MOBILE_SDK_INTEGRATION.md] "PAFI Web Handoff" section.
1103
+ *
1104
+ * Optional — if omitted, the Issuer App should hide the "Open PAFI"
1105
+ * button.
1106
+ */
1107
+ pafiWebUrl?: string;
915
1108
  }
916
1109
  interface ApiNonceResponse {
917
1110
  nonce: string;
@@ -967,6 +1160,7 @@ interface ApiUserResponse {
967
1160
  balance: bigint;
968
1161
  isMinter: boolean;
969
1162
  }
1163
+ /** @deprecated Since 0.3.0 — use `ApiClaimRequest` (mint-only) instead. Removed in 2.0. */
970
1164
  interface ApiClaimAndSwapRequest {
971
1165
  chainId: number;
972
1166
  pointTokenAddress: Address;
@@ -985,6 +1179,7 @@ interface ApiClaimAndSwapRequest {
985
1179
  /** Unix seconds. */
986
1180
  swapDeadline: bigint;
987
1181
  }
1182
+ /** @deprecated Since 0.3.0 — use `ApiClaimResponse` instead. Removed in 2.0. */
988
1183
  interface ApiClaimAndSwapResponse {
989
1184
  txHash: Hex;
990
1185
  lockId: string;
@@ -1032,6 +1227,12 @@ interface IssuerApiHandlersConfig {
1032
1227
  pointTokenAddresses?: Address[];
1033
1228
  chainId: number;
1034
1229
  contracts: ApiConfigResponse["contracts"];
1230
+ /**
1231
+ * Optional — URL that the Issuer App opens for PT→USDT swap or perp
1232
+ * deposit after a successful claim. Surfaced in `/config` response.
1233
+ * See [MOBILE_SDK_INTEGRATION.md] "PAFI Web Handoff".
1234
+ */
1235
+ pafiWebUrl?: string;
1035
1236
  /** Required by `handleGasFee`; omit to disable the endpoint. */
1036
1237
  feeManager?: FeeManager;
1037
1238
  /** Required by `handlePools`; omit to disable the endpoint. */
@@ -1065,6 +1266,7 @@ declare class IssuerApiHandlers {
1065
1266
  private readonly defaultToken;
1066
1267
  private readonly chainId;
1067
1268
  private readonly contracts;
1269
+ private readonly pafiWebUrl?;
1068
1270
  private readonly feeManager?;
1069
1271
  private readonly poolsProvider?;
1070
1272
  constructor(config: IssuerApiHandlersConfig);
@@ -1115,12 +1317,155 @@ declare class IssuerApiHandlers {
1115
1317
  /**
1116
1318
  * `POST /claim-and-swap`
1117
1319
  *
1118
- * The terminal handler: forwards the verified consent to the
1119
- * MintingGateway, which runs the 11-step flow.
1320
+ * @deprecated Since 0.3.0 the single-call mint-then-swap flow is
1321
+ * retired in v1.4. Use the new `handleClaim()` (mint only) and let
1322
+ * the user swap separately on PAFI Web. See
1323
+ * [V1.4_V1.5_OVERVIEW.md §4] for the new scenario model. Will be
1324
+ * removed in 2.0.
1325
+ *
1326
+ * Legacy behavior: the terminal handler forwards the verified
1327
+ * consent to the MintingGateway, which runs the 11-step flow.
1120
1328
  */
1121
1329
  handleClaimAndSwap(userAddress: Address, request: ApiClaimAndSwapRequest): Promise<ApiClaimAndSwapResponse>;
1122
1330
  }
1123
1331
 
1332
+ /**
1333
+ * v1.4 reverse flow — **Variant A**: user-initiated PT redeem.
1334
+ *
1335
+ * User has on-chain PT, wants to convert back to off-chain points. They
1336
+ * sign a `BurnConsent`, the issuer backend verifies it, reserves an
1337
+ * off-chain credit, and returns an unsigned UserOp that the frontend
1338
+ * submits via the Bundler. When the burn lands, the `BurnIndexer`
1339
+ * (elsewhere) resolves the credit.
1340
+ *
1341
+ * **Mocked SC contracts** — this handler compiles + wires end-to-end
1342
+ * against `@pafi-dev/core/contracts` mock ABIs. When SC ships real
1343
+ * ABIs, no changes here — only `contracts/index.ts` re-export flips.
1344
+ */
1345
+ interface PTRedeemHandlerConfig {
1346
+ ledger: IPointLedger;
1347
+ relayService: RelayService;
1348
+ provider: PublicClient;
1349
+ /** PointToken contract address (chain-specific). */
1350
+ pointTokenAddress: Address;
1351
+ /** BatchExecutor delegation target (chain-specific). */
1352
+ batchExecutorAddress: Address;
1353
+ /** Chain id — used for domain separator when verifying BurnConsent. */
1354
+ chainId: number;
1355
+ /**
1356
+ * EIP-712 domain fields. Must match the on-chain PointToken's domain
1357
+ * separator exactly, or signature verification fails. `name` is
1358
+ * typically the PointToken's ERC-20 name (e.g. "PAFI Starbucks
1359
+ * Points"). `verifyingContract` defaults to `pointTokenAddress`.
1360
+ */
1361
+ domain: {
1362
+ name: string;
1363
+ verifyingContract?: Address;
1364
+ };
1365
+ /**
1366
+ * How long the pending credit stays reserved if the burn never lands.
1367
+ * Default: 15 min — long enough for a bundler submission + confirmation.
1368
+ */
1369
+ redeemLockDurationMs?: number;
1370
+ /** Clock injection for tests; defaults to `Date.now`. */
1371
+ now?: () => number;
1372
+ }
1373
+ interface PTRedeemRequest {
1374
+ userAddress: Address;
1375
+ amount: bigint;
1376
+ /** Serialized EIP-712 signature over the BurnConsent. */
1377
+ consentSignature: Hex;
1378
+ consent: BurnConsent;
1379
+ /** ERC-4337 account nonce for the user's EOA. */
1380
+ aaNonce: bigint;
1381
+ }
1382
+ interface PTRedeemResponse {
1383
+ /** Lock id from the ledger — client polls status with this. */
1384
+ lockId: string;
1385
+ /** Unsigned UserOp — FE attaches paymaster + user signature + submits. */
1386
+ userOp: PartialUserOperation;
1387
+ /** Seconds until the lock expires if the burn doesn't land. */
1388
+ expiresInSeconds: number;
1389
+ }
1390
+ declare class PTRedeemError extends Error {
1391
+ code: "INVALID_CONSENT" | "SIGNATURE_MISMATCH" | "AMOUNT_MISMATCH" | "EXPIRED_CONSENT" | "LEDGER_NOT_SUPPORTED";
1392
+ constructor(code: "INVALID_CONSENT" | "SIGNATURE_MISMATCH" | "AMOUNT_MISMATCH" | "EXPIRED_CONSENT" | "LEDGER_NOT_SUPPORTED", message: string);
1393
+ }
1394
+ declare class PTRedeemHandler {
1395
+ private readonly ledger;
1396
+ private readonly relayService;
1397
+ private readonly pointTokenAddress;
1398
+ private readonly batchExecutorAddress;
1399
+ private readonly chainId;
1400
+ private readonly domain;
1401
+ private readonly redeemLockDurationMs;
1402
+ private readonly now;
1403
+ constructor(config: PTRedeemHandlerConfig);
1404
+ handle(request: PTRedeemRequest): Promise<PTRedeemResponse>;
1405
+ }
1406
+
1407
+ /**
1408
+ * v1.4 reverse flow — **Variant B**: auto top-up on voucher redemption.
1409
+ *
1410
+ * User tries to redeem a voucher for `requiredAmount` off-chain points
1411
+ * but their off-chain balance is short. If their on-chain PT balance is
1412
+ * enough to cover the shortfall, this handler auto-triggers a burn for
1413
+ * exactly the shortfall amount so the voucher can proceed.
1414
+ *
1415
+ * Required off-chain: 500
1416
+ * Available off-chain: 300
1417
+ * Shortfall: 200
1418
+ * On-chain PT: 250 ← enough, top-up fires
1419
+ * → burn 200 PT, credit 200 off-chain, voucher proceeds with 500
1420
+ *
1421
+ * Delegates the actual burn construction to {@link PTRedeemHandler}
1422
+ * — this handler is pure business logic (calculating shortfall +
1423
+ * checking on-chain balance) on top.
1424
+ */
1425
+ interface TopUpRedemptionHandlerConfig {
1426
+ ledger: IPointLedger;
1427
+ ptRedeemHandler: PTRedeemHandler;
1428
+ provider: PublicClient;
1429
+ /** PointToken contract address (chain-specific). */
1430
+ pointTokenAddress: Address;
1431
+ }
1432
+ interface TopUpRedemptionRequest {
1433
+ userAddress: Address;
1434
+ /** Total points the voucher redemption requires off-chain. */
1435
+ requiredAmount: bigint;
1436
+ /**
1437
+ * The user's pre-signed `BurnConsent` + signature, prepared by the FE
1438
+ * with amount set to a worst-case upper bound. Handler inspects the
1439
+ * shortfall and uses the consent if the shortfall ≤ consent.amount.
1440
+ */
1441
+ redeemRequest: Pick<PTRedeemRequest, "consent" | "consentSignature" | "aaNonce">;
1442
+ }
1443
+ type TopUpRedemptionResponse = {
1444
+ action: "NO_TOP_UP_NEEDED";
1445
+ offChainBalance: bigint;
1446
+ } | {
1447
+ action: "INSUFFICIENT_ONCHAIN";
1448
+ offChainBalance: bigint;
1449
+ onChainBalance: bigint;
1450
+ shortfall: bigint;
1451
+ } | {
1452
+ action: "TOP_UP_STARTED";
1453
+ shortfall: bigint;
1454
+ redeem: PTRedeemResponse;
1455
+ };
1456
+ declare class TopUpRedemptionError extends Error {
1457
+ code: "INSUFFICIENT_ONCHAIN_BALANCE" | "CONSENT_AMOUNT_TOO_LOW" | "LEDGER_NOT_SUPPORTED";
1458
+ constructor(code: "INSUFFICIENT_ONCHAIN_BALANCE" | "CONSENT_AMOUNT_TOO_LOW" | "LEDGER_NOT_SUPPORTED", message: string);
1459
+ }
1460
+ declare class TopUpRedemptionHandler {
1461
+ private readonly ledger;
1462
+ private readonly ptRedeemHandler;
1463
+ private readonly provider;
1464
+ private readonly pointTokenAddress;
1465
+ constructor(config: TopUpRedemptionHandlerConfig);
1466
+ handle(request: TopUpRedemptionRequest): Promise<TopUpRedemptionResponse>;
1467
+ }
1468
+
1124
1469
  /**
1125
1470
  * Config for `createSubgraphPoolsProvider`.
1126
1471
  */
@@ -1219,26 +1564,29 @@ interface SubgraphNativeUsdtQuoterConfig {
1219
1564
  now?: () => number;
1220
1565
  }
1221
1566
  /**
1222
- * Create a `quoteNativeToUsdt` function backed by the PAFI subgraph's
1223
- * `Bundle.ethPriceUSD`.
1567
+ * Create a native→USDT quoter backed by the PAFI subgraph's
1568
+ * `Bundle.ethPriceUSD`. The returned function has the shape
1569
+ * `(amountNative: bigint) => Promise<bigint>` and can be passed as
1570
+ * `quoteNativeToFee` to `FeeManager` — in v1.4 the fee currency
1571
+ * is configured at the integration layer, not hardcoded here.
1224
1572
  *
1225
- * Used by `FeeManager.estimateGasFee()` to convert the operator's native
1226
- * gas cost into the USDT amount deducted from the user's cashout. Price
1227
- * precision is not critical here — a 1-2% drift is acceptable since
1228
- * the operator already takes a `gasPremiumBps` buffer.
1573
+ * Used by `FeeManager.estimateGasFee()` to convert the gas cost into
1574
+ * an ERC-20 amount charged as part of the sponsored UserOp batch.
1575
+ * Price precision is not critical — a 1-2% drift is acceptable since
1576
+ * the fee manager applies a `gasPremiumBps` buffer.
1229
1577
  *
1230
- * The result is cached in-process with a short TTL (default 30s). If the
1231
- * subgraph is unreachable, falls back to `fallbackEthPriceUsd` so gas
1232
- * estimation doesn't block cashouts during a subgraph outage.
1578
+ * The result is cached in-process with a short TTL (default 30s). If
1579
+ * the subgraph is unreachable, falls back to `fallbackEthPriceUsd` so
1580
+ * gas estimation doesn't block user flow during a subgraph outage.
1233
1581
  *
1234
1582
  * @example
1235
1583
  * ```ts
1236
- * import { createSubgraphNativeUsdtQuoter, createIssuerService } from "@pafi/issuer";
1584
+ * import { createSubgraphNativeUsdtQuoter, createIssuerService } from "@pafi-dev/issuer";
1237
1585
  *
1238
1586
  * const service = createIssuerService({
1239
1587
  * // ...other config
1240
1588
  * fee: {
1241
- * quoteNativeToUsdt: createSubgraphNativeUsdtQuoter({
1589
+ * quoteNativeToFee: createSubgraphNativeUsdtQuoter({
1242
1590
  * subgraphUrl: "https://graph.pacificfinance.org/subgraphs/name/pafi",
1243
1591
  * }),
1244
1592
  * },
@@ -1247,6 +1595,204 @@ interface SubgraphNativeUsdtQuoterConfig {
1247
1595
  */
1248
1596
  declare function createSubgraphNativeUsdtQuoter(config: SubgraphNativeUsdtQuoterConfig): (amountNative: bigint) => Promise<bigint>;
1249
1597
 
1598
+ /**
1599
+ * Combined off-chain + on-chain balance for a single user / token pair.
1600
+ *
1601
+ * - `offChain` — the issuer's ledger balance (available, excluding locks)
1602
+ * - `onChain` — the user's ERC-20 balance from `PointToken.balanceOf`
1603
+ * - `total` — `offChain + onChain` (what the Issuer App displays)
1604
+ */
1605
+ interface CombinedBalance {
1606
+ offChain: bigint;
1607
+ onChain: bigint;
1608
+ total: bigint;
1609
+ }
1610
+ interface BalanceAggregatorConfig {
1611
+ provider: PublicClient;
1612
+ ledger: IPointLedger;
1613
+ }
1614
+ /**
1615
+ * v1.4 utility — aggregates off-chain + on-chain point balances into a
1616
+ * single view for the "combined balance" UI in the Issuer App.
1617
+ *
1618
+ * The `/user` API handler uses this internally; the helper is exposed
1619
+ * publicly so Issuer Apps can call it directly without going through
1620
+ * the HTTP layer (e.g., for server-rendered pages or admin dashboards).
1621
+ *
1622
+ * See [REQUIREMENTS_V2.md] §1 — "The Issuer App displays a combined
1623
+ * balance (off-chain points + on-chain PT) and does not surface USDT."
1624
+ */
1625
+ declare class BalanceAggregator {
1626
+ private readonly provider;
1627
+ private readonly ledger;
1628
+ constructor(config: BalanceAggregatorConfig);
1629
+ /**
1630
+ * Combined balance for a single (user, token) pair. Fetches off-chain
1631
+ * + on-chain in parallel.
1632
+ */
1633
+ getCombinedBalance(user: Address, pointToken: Address): Promise<CombinedBalance>;
1634
+ /**
1635
+ * Combined balance for multiple tokens owned by the same user. Runs
1636
+ * all lookups in parallel. Returns a Map keyed by the token address
1637
+ * (same casing as supplied — caller should normalize if needed).
1638
+ */
1639
+ getCombinedBalanceMulti(user: Address, pointTokens: Address[]): Promise<Map<Address, CombinedBalance>>;
1640
+ }
1641
+
1642
+ interface RetryConfig {
1643
+ /**
1644
+ * Max total attempts including the first try. Default: 1 (no retry).
1645
+ * Set to 3 to get 2 retries after the initial call.
1646
+ *
1647
+ * Only applies when the server error body carries `safeToRetry: true`
1648
+ * or the failure is a transient network/timeout error.
1649
+ */
1650
+ maxAttempts?: number;
1651
+ /**
1652
+ * Initial backoff delay in ms. Default: 500. Each subsequent retry
1653
+ * doubles this (exponential backoff) and adds ±20% jitter.
1654
+ */
1655
+ initialDelayMs?: number;
1656
+ /**
1657
+ * Hard ceiling for a single backoff (ms). Default: 10_000.
1658
+ */
1659
+ maxDelayMs?: number;
1660
+ /**
1661
+ * Upper bound on `retryAfter` from the server. If the server asks us
1662
+ * to wait longer than this (e.g. rate limit until UTC midnight), the
1663
+ * client gives up rather than blocking. Default: 30_000.
1664
+ */
1665
+ maxRetryAfterMs?: number;
1666
+ }
1667
+ interface PafiBackendConfig {
1668
+ /**
1669
+ * PAFI Backend API base URL. Example:
1670
+ * https://api.pacificfinance.org
1671
+ * https://staging-api.pacificfinance.org
1672
+ */
1673
+ url: string;
1674
+ /** PAFI-assigned issuer ID (e.g., "gg56"). Sent in X-Issuer-Id header. */
1675
+ issuerId: string;
1676
+ /** Per-issuer API key (or JWT) for the Authorization header. */
1677
+ apiKey: string;
1678
+ /** Optional fetch override for tests. */
1679
+ fetchImpl?: typeof fetch;
1680
+ /**
1681
+ * Timeout (ms) for each request. Default: 10_000. PAFI Backend should
1682
+ * respond in <1s for the happy path; this is just the sanity bound.
1683
+ */
1684
+ timeoutMs?: number;
1685
+ /**
1686
+ * Retry policy for transient failures (5xx, 429, timeouts, network).
1687
+ * Omit or pass `{ maxAttempts: 1 }` to disable retry entirely.
1688
+ */
1689
+ retry?: RetryConfig;
1690
+ }
1691
+ /** Paired with `POST /paymaster/sponsor`. See SPONSORED_PATH_FLOW.md §4.1 */
1692
+ interface SponsorshipRequest {
1693
+ chainId: number;
1694
+ scenario: SponsorshipScenario;
1695
+ userOp: PartialUserOperation;
1696
+ target: {
1697
+ /** The allowlisted contract this batch call targets. */
1698
+ contract: Address;
1699
+ /** Function selector / name — validated against allowlist. */
1700
+ function: string;
1701
+ /** The PointToken involved (for scenario context). */
1702
+ pointToken?: Address;
1703
+ };
1704
+ }
1705
+ interface SponsorshipResponse {
1706
+ paymaster: Address;
1707
+ paymasterData: Hex;
1708
+ paymasterVerificationGasLimit: bigint;
1709
+ paymasterPostOpGasLimit: bigint;
1710
+ /** Unix seconds when this sponsorship expires. Re-request after. */
1711
+ expiresAt: number;
1712
+ }
1713
+ /**
1714
+ * Machine-readable error codes returned by PAFI Backend.
1715
+ *
1716
+ * Source of truth: `apps/paymaster-proxy` `CalldataValidationError`,
1717
+ * `RateLimitError`, `CoinbaseClientError`. Keep in sync.
1718
+ */
1719
+ type PafiBackendErrorCode = "MISSING_ISSUER_ID" | "MISSING_API_KEY" | "ISSUER_UNAUTHORIZED" | "CALLDATA_INVALID" | "CALLDATA_EMPTY_BATCH" | "TARGET_NOT_ALLOWLISTED" | "FUNCTION_NOT_ALLOWED" | "SCENARIO_MISMATCH" | "SCENARIO_DISABLED" | "RATE_LIMIT_EXCEEDED" | "RATE_LIMIT_EXCEEDED_DAILY" | "RATE_LIMIT_EXCEEDED_PER_USER" | "RATE_LIMITER_UNAVAILABLE" | "PAYMASTER_REJECTED" | "PAYMASTER_UNAVAILABLE" | "PAYMASTER_TIMEOUT" | "BAD_REQUEST" | "INTERNAL_ERROR" | "TIMEOUT" | "NETWORK_ERROR";
1720
+ declare class PafiBackendError extends Error {
1721
+ code: PafiBackendErrorCode;
1722
+ httpStatus: number;
1723
+ details?: unknown | undefined;
1724
+ /**
1725
+ * Seconds to wait before retry. Populated from the server body
1726
+ * (e.g. rate limit returns the number of seconds until UTC midnight).
1727
+ */
1728
+ readonly retryAfter?: number;
1729
+ /**
1730
+ * `safeToRetry` as reported by the server body. Prefer this over the
1731
+ * code-based heuristic when available — the server knows more about
1732
+ * whether the same request will succeed on retry.
1733
+ */
1734
+ private readonly serverSafeToRetry?;
1735
+ constructor(code: PafiBackendErrorCode, message: string, httpStatus: number, details?: unknown | undefined, opts?: {
1736
+ retryAfter?: number;
1737
+ safeToRetry?: boolean;
1738
+ });
1739
+ /**
1740
+ * Whether the caller can safely retry the same request.
1741
+ *
1742
+ * If the server provided `safeToRetry` in the body, trust that.
1743
+ * Otherwise fall back to a code-based heuristic.
1744
+ */
1745
+ get safeToRetry(): boolean;
1746
+ }
1747
+
1748
+ /**
1749
+ * HTTP client for the PAFI Backend paymaster proxy service. See
1750
+ * [SPONSORED_PATH_FLOW.md] for the full flow + API contract.
1751
+ *
1752
+ * This client sits between `@pafi/issuer`'s RelayService and the
1753
+ * PAFI Backend. It does NOT talk to Coinbase Paymaster directly —
1754
+ * PAFI Backend holds that integration.
1755
+ */
1756
+ declare class PafiBackendClient {
1757
+ private readonly url;
1758
+ private readonly issuerId;
1759
+ private readonly apiKey;
1760
+ private readonly fetchImpl;
1761
+ private readonly timeoutMs;
1762
+ private readonly retry;
1763
+ constructor(config: PafiBackendConfig);
1764
+ /**
1765
+ * Request paymaster sponsorship for a pre-built UserOperation.
1766
+ * See [SPONSORED_PATH_FLOW.md §4.1] for the API contract.
1767
+ *
1768
+ * Retries automatically on transient failures (5xx, timeouts, network
1769
+ * errors, and errors the server flags with `safeToRetry: true`) up to
1770
+ * `retry.maxAttempts`. 4xx errors that are not `safeToRetry` fail fast.
1771
+ *
1772
+ * @throws PafiBackendError on final failure after exhausting retries
1773
+ */
1774
+ requestSponsorship(req: SponsorshipRequest): Promise<SponsorshipResponse>;
1775
+ private postWithRetry;
1776
+ /**
1777
+ * Pick the delay before the next retry.
1778
+ * - If the server sent `retryAfter` (seconds), honor it (capped by
1779
+ * `maxRetryAfterMs`) — returns null if the server wait exceeds the
1780
+ * cap, signalling the caller should give up.
1781
+ * - Otherwise: exponential backoff with ±20% jitter, capped at
1782
+ * `maxDelayMs`.
1783
+ */
1784
+ private computeBackoff;
1785
+ private sleep;
1786
+ private post;
1787
+ /** JSON replacer that stringifies bigints. Paired with bigintReviver. */
1788
+ private bigintReplacer;
1789
+ /**
1790
+ * JSON reviver that coerces specific numeric-string fields back to
1791
+ * bigint. The server must send these fields as decimal strings.
1792
+ */
1793
+ private bigintReviver;
1794
+ }
1795
+
1250
1796
  /**
1251
1797
  * Top-level configuration for `createIssuerService`. Everything except
1252
1798
  * the chain metadata, wallets, auth secret, and `signer` is optional and
@@ -1304,10 +1850,9 @@ interface IssuerServiceConfig {
1304
1850
  /**
1305
1851
  * Fee management config. If omitted the `handleGasFee` endpoint will
1306
1852
  * throw "not configured" at request time. Pass any subset of fields
1307
- * to opt in — provider + operatorWallet are inherited from the outer
1308
- * config automatically.
1853
+ * to opt in — `provider` is inherited from the outer config.
1309
1854
  */
1310
- fee?: Omit<FeeManagerConfig, "provider" | "operatorWallet">;
1855
+ fee?: Omit<FeeManagerConfig, "provider">;
1311
1856
  /**
1312
1857
  * Pool discovery function for `handlePools`. If omitted the endpoint
1313
1858
  * throws "not configured" at request time.
@@ -1377,4 +1922,4 @@ declare function createIssuerService(config: IssuerServiceConfig): IssuerService
1377
1922
  /** SDK package version — bumped on every release */
1378
1923
  declare const PAFI_ISSUER_SDK_VERSION = "0.1.0";
1379
1924
 
1380
- export { type ApiBuildConsentTypedDataRequest, type ApiBuildConsentTypedDataResponse, type ApiClaimAndSwapRequest, type ApiClaimAndSwapResponse, type ApiConfigResponse, type ApiGasFeeResponse, type ApiLoginRequest, type ApiLoginResponse, type ApiNonceResponse, type ApiPoolsRequest, type ApiPoolsResponse, type ApiUserRequest, type ApiUserResponse, type AuthContext, AuthError, type AuthErrorCode, AuthService, type AuthServiceConfig, DefaultPolicyEngine, type DefaultPolicyEngineOptions, FeeManager, type FeeManagerConfig, type IIndexerCursorStore, type IIssuerSigner, type IPointLedger, type IPolicyEngine, type ISessionStore, InMemoryCursorStore, IssuerApiHandlers, type IssuerApiHandlersConfig, type IssuerService, type IssuerServiceConfig, type LockedMintRequest, type LoginResult, MemoryPointLedger, MemorySessionStore, type MemorySessionStoreOptions, type MintAndCashOutRequest, type MintAndCashOutResponse, type MintEvent, MintingGateway, type MintingGatewayConfig, MintingGatewayError, type MintingGatewayErrorCode, type MintingStatus, NonceManager, type OperatorWalletLike, PAFI_ISSUER_SDK_VERSION, PointIndexer, type PointIndexerConfig, type PolicyDecision, type PolicyEvalRequest, type PoolsProvider, PrivateKeySigner, type PrivateKeySignerOptions, RelayError, type RelayErrorCode, type RelayResult, RelayService, type RelayServiceConfig, type Session, type SubgraphNativeUsdtQuoterConfig, type SubgraphPoolsProviderConfig, type SubmitMintAndSwapParams, authenticateRequest, createIssuerService, createSubgraphNativeUsdtQuoter, createSubgraphPoolsProvider };
1925
+ export { type ApiBuildConsentTypedDataRequest, type ApiBuildConsentTypedDataResponse, type ApiClaimAndSwapRequest, type ApiClaimAndSwapResponse, type ApiConfigResponse, type ApiGasFeeResponse, type ApiLoginRequest, type ApiLoginResponse, type ApiNonceResponse, type ApiPoolsRequest, type ApiPoolsResponse, type ApiUserRequest, type ApiUserResponse, type AuthContext, AuthError, type AuthErrorCode, AuthService, type AuthServiceConfig, BalanceAggregator, type BalanceAggregatorConfig, type BurnEvent, BurnIndexer, type BurnIndexerConfig, type CombinedBalance, DefaultPolicyEngine, type DefaultPolicyEngineOptions, FeeManager, type FeeManagerConfig, type IIndexerCursorStore, type IIssuerSigner, type IPointLedger, type IPolicyEngine, type ISessionStore, InMemoryCursorStore, IssuerApiHandlers, type IssuerApiHandlersConfig, type IssuerService, type IssuerServiceConfig, type LockedMintRequest, type LoginResult, MemoryPointLedger, MemorySessionStore, type MemorySessionStoreOptions, type MintAndCashOutRequest, type MintAndCashOutResponse, type MintEvent, MintingGateway, type MintingGatewayConfig, MintingGatewayError, type MintingGatewayErrorCode, type MintingStatus, NonceManager, type OperatorWalletLike, PAFI_ISSUER_SDK_VERSION, PTRedeemError, PTRedeemHandler, type PTRedeemHandlerConfig, type PTRedeemRequest, type PTRedeemResponse, PafiBackendClient, type PafiBackendConfig, PafiBackendError, type PafiBackendErrorCode, PointIndexer, type PointIndexerConfig, type PolicyDecision, type PolicyEvalRequest, type PoolsProvider, PrivateKeySigner, type PrivateKeySignerOptions, RelayError, type RelayErrorCode, type RelayResult, RelayService, type RelayServiceConfig, type Session, type SponsorshipRequest, type SponsorshipResponse, type SubgraphNativeUsdtQuoterConfig, type SubgraphPoolsProviderConfig, type SubmitMintAndSwapParams, TopUpRedemptionError, TopUpRedemptionHandler, type TopUpRedemptionHandlerConfig, type TopUpRedemptionRequest, type TopUpRedemptionResponse, authenticateRequest, createIssuerService, createSubgraphNativeUsdtQuoter, createSubgraphPoolsProvider };