@pafi-dev/issuer 0.1.2 → 0.3.0-alpha.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, ReceiverConsent, PathKey, PoolKey, SponsorshipScenario, PartialUserOperation } from '@pafi-dev/core';
3
3
  export { encodeExtData } from '@pafi-dev/core';
4
4
 
5
5
  /**
@@ -18,6 +18,12 @@ interface LockedMintRequest {
18
18
  lockId: string;
19
19
  userAddress: Address;
20
20
  amount: bigint;
21
+ /**
22
+ * Which PointToken this lock belongs to. Added in 0.2.0 for multi-token
23
+ * issuer support. Optional for backward compat with 0.1.x ledgers —
24
+ * single-token consumers can ignore it.
25
+ */
26
+ tokenAddress?: Address;
21
27
  /** Lifecycle status */
22
28
  status: MintingStatus;
23
29
  /** When the lock was created (epoch ms) */
@@ -33,20 +39,28 @@ interface LockedMintRequest {
33
39
  *
34
40
  * Issuers replace the in-memory default with their own database-backed
35
41
  * implementation (Postgres, Redis, etc.).
42
+ *
43
+ * **Multi-token support (0.2.0):**
44
+ * Every mutating method accepts an optional `tokenAddress` parameter so
45
+ * balances can be scoped per `(user, token)`. Single-token issuers can
46
+ * ignore the parameter entirely — legacy 0.1.x implementations remain
47
+ * compatible. Multi-token issuers must persist + query balances keyed by
48
+ * `(userAddress, tokenAddress)`.
36
49
  */
37
50
  interface IPointLedger {
38
51
  /** Get a user's available off-chain point balance (excluding locked). */
39
- getBalance(userAddress: Address): Promise<bigint>;
52
+ getBalance(userAddress: Address, tokenAddress?: Address): Promise<bigint>;
40
53
  /**
41
54
  * Lock an amount for a pending mint request. Locked amounts are reserved
42
55
  * but not yet deducted; they protect against double-spend during the
43
56
  * EIP-712 validity window.
44
57
  *
45
58
  * @param lockDurationMs how long the lock should be held before auto-expiry
59
+ * @param tokenAddress which PointToken this lock is for (0.2.0+)
46
60
  * @returns lockId — opaque handle used by `releaseLock` / `updateMintStatus`
47
61
  * @throws if the user's available balance is below `amount`
48
62
  */
49
- lockForMinting(userAddress: Address, amount: bigint, lockDurationMs: number): Promise<string>;
63
+ lockForMinting(userAddress: Address, amount: bigint, lockDurationMs: number, tokenAddress?: Address): Promise<string>;
50
64
  /** Release a previously created lock (e.g. on tx failure / cancel). */
51
65
  releaseLock(lockId: string): Promise<void>;
52
66
  /**
@@ -54,16 +68,34 @@ interface IPointLedger {
54
68
  * mint has been observed by the indexer. Should also resolve any matching
55
69
  * lock so the funds aren't double-counted.
56
70
  */
57
- deductBalance(userAddress: Address, amount: bigint, txHash: Hex): Promise<void>;
71
+ deductBalance(userAddress: Address, amount: bigint, txHash: Hex, tokenAddress?: Address): Promise<void>;
58
72
  /** Credit points to a user's balance (e.g. from merchant activity). */
59
- creditBalance(userAddress: Address, amount: bigint, reason: string): Promise<void>;
73
+ creditBalance(userAddress: Address, amount: bigint, reason: string, tokenAddress?: Address): Promise<void>;
60
74
  /** List currently-pending locked mint requests for a user. */
61
- getLockedRequests(userAddress: Address): Promise<LockedMintRequest[]>;
75
+ getLockedRequests(userAddress: Address, tokenAddress?: Address): Promise<LockedMintRequest[]>;
62
76
  /**
63
77
  * Transition a lock to a new lifecycle status. The on-chain tx hash is
64
78
  * supplied when the status is `MINTED`.
65
79
  */
66
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>;
67
99
  }
68
100
 
69
101
  /**
@@ -75,6 +107,10 @@ interface IPointLedger {
75
107
  * Concurrency model: single-process, single-threaded (Node.js event loop).
76
108
  * The lock check + insert is atomic within a tick because no awaits sit
77
109
  * between balance read and lock write.
110
+ *
111
+ * **Multi-token (0.2.0):** Balances are keyed by `(user, token)`. If callers
112
+ * omit `tokenAddress`, the literal string "default" is used — that keeps
113
+ * single-token usage working exactly like 0.1.x.
78
114
  */
79
115
  declare class MemoryPointLedger implements IPointLedger {
80
116
  private balances;
@@ -84,13 +120,17 @@ declare class MemoryPointLedger implements IPointLedger {
84
120
  constructor(opts?: {
85
121
  now?: () => number;
86
122
  });
87
- getBalance(userAddress: Address): Promise<bigint>;
88
- getLockedRequests(userAddress: Address): Promise<LockedMintRequest[]>;
89
- creditBalance(userAddress: Address, amount: bigint, _reason: string): Promise<void>;
90
- lockForMinting(userAddress: Address, amount: bigint, lockDurationMs: number): Promise<string>;
123
+ getBalance(userAddress: Address, tokenAddress?: Address): Promise<bigint>;
124
+ getLockedRequests(userAddress: Address, tokenAddress?: Address): Promise<LockedMintRequest[]>;
125
+ creditBalance(userAddress: Address, amount: bigint, _reason: string, tokenAddress?: Address): Promise<void>;
126
+ lockForMinting(userAddress: Address, amount: bigint, lockDurationMs: number, tokenAddress?: Address): Promise<string>;
91
127
  releaseLock(lockId: string): Promise<void>;
92
- deductBalance(userAddress: Address, amount: bigint, txHash: Hex): Promise<void>;
128
+ deductBalance(userAddress: Address, amount: bigint, txHash: Hex, tokenAddress?: Address): Promise<void>;
93
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>;
94
134
  /**
95
135
  * Auto-expire any PENDING lock past its expiry. Called lazily on every
96
136
  * read/write so the in-memory state stays self-cleaning without a timer.
@@ -518,93 +558,76 @@ declare class RelayService {
518
558
  * decide whether to release the ledger lock (`SUBMIT_FAILED` and
519
559
  * `SIMULATION_FAILED` are safe to release; `TX_REVERTED` and `TIMEOUT`
520
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.
521
566
  */
522
567
  submitMintAndSwap(params: SubmitMintAndSwapParams): Promise<RelayResult>;
523
568
  }
524
569
 
525
570
  interface FeeManagerConfig {
526
- /** Provider used for gas price + balance reads. */
571
+ /** Provider used for gas price reads. */
527
572
  provider: PublicClient;
528
- /** Operator wallet whose native balance the manager monitors. */
529
- operatorWallet: OperatorWalletLike;
530
- /** USDT token address on the target chain (used for rebalance swaps). */
531
- usdtAddress: Address;
532
- /** Wrapped-native token address (WETH on Base/Ethereum, WMATIC, etc). */
533
- nativeWrappedAddress: Address;
534
573
  /**
535
- * Typical gas used by a `mintAndSwap` transaction. Default: 500_000. The
536
- * manager multiplies this by current gas price to get the native cost,
537
- * then converts to USDT via the injected `quoteNativeToUsdt`.
574
+ * Typical gas used by a single sponsored UserOp. Default: 500_000.
575
+ * The manager multiplies this by current gas price to get native
576
+ * cost, then converts via the injected `quoteNativeToFee`.
538
577
  */
539
- mintAndSwapGasUnits?: bigint;
578
+ gasUnits?: bigint;
540
579
  /**
541
- * Safety margin applied to the gas estimate before charging the user.
542
- * Expressed as a basis-point multiplier, e.g. 12_000 = 120%. Default 12_000.
580
+ * Safety margin applied before charging the user, as basis points.
581
+ * 12_000 = 120%. Default: 12_000.
543
582
  */
544
583
  gasPremiumBps?: number;
545
584
  /**
546
- * Price conversion: given an amount of native token (wei), return the
547
- * equivalent amount of USDT. Injected so the manager is chain-agnostic
548
- * production wires this to `@pafi/core` V4 quoting or an oracle feed.
549
- */
550
- quoteNativeToUsdt: (amountNative: bigint) => Promise<bigint>;
551
- /**
552
- * Rebalance trigger: when the operator's native balance falls below
553
- * `rebalanceThresholdWei`, `rebalanceIfNeeded()` swaps `rebalanceUsdtAmount`
554
- * worth of USDT into native. Both optional — omit to disable rebalancing.
555
- */
556
- rebalanceThresholdWei?: bigint;
557
- rebalanceUsdtAmount?: bigint;
558
- /**
559
- * Actual swap executor — the manager calls this when a rebalance is
560
- * triggered. Injected so the SDK does not hard-code a DEX choice; the
561
- * issuer wires it to the UniversalRouter (via `@pafi/core swap/`) or
562
- * whatever liquidity venue they trust. Required iff the rebalance
563
- * fields above are set.
585
+ * Quote function given an amount of native wei, return the
586
+ * equivalent amount in the fee currency (PT raw units in v1.4,
587
+ * USDT 6-decimal in legacy v1.2 flows).
588
+ *
589
+ * Injected so the manager stays chain- and token-agnostic. Issuers
590
+ * wire it to `@pafi-dev/core` V4 quoting, a subgraph query, or an
591
+ * oracle feed.
564
592
  */
565
- swapUsdtToNative?: (amountUsdt: bigint) => Promise<void>;
593
+ quoteNativeToFee: (amountNative: bigint) => Promise<bigint>;
566
594
  }
567
595
  /**
568
- * Manages the operator wallet's economics:
596
+ * Computes how much fee to collect from the user to cover the gas cost
597
+ * of a sponsored UserOp.
598
+ *
599
+ * ## v1.4 scope change
569
600
  *
570
- * 1. `estimateGasFee()` how many USDT to deduct from the swap proceeds
571
- * to cover the operator's gas cost for the upcoming `mintAndSwap`.
572
- * 2. `rebalanceIfNeeded()` — when the operator's native balance gets
573
- * low, swap some of the accumulated USDT fee back into native gas
574
- * token so the operator never runs dry.
601
+ * The fee is now expressed in the **fee currency** chosen by the caller
602
+ * (PT for mint/burn, USDT for swap/perp_deposit) not hardcoded to USDT.
575
603
  *
576
- * Both calculations are intentionally injection-based: gas estimation and
577
- * USDT→native swapping both depend on DEX state, which the SDK deliberately
578
- * does not own. Issuers supply the conversion + swap functions.
604
+ * **Operator rebalancing is gone.** In v1.4 the operator no longer holds
605
+ * ETH directly gas is paid by Coinbase Paymaster via the paymaster-proxy
606
+ * (see [SPONSORED_PATH_FLOW.md]). The fee collected here is an
607
+ * application-level ERC-20 transfer inside the same UserOp batch, not a
608
+ * reimbursement to a wallet that needs topping up.
609
+ *
610
+ * `rebalanceIfNeeded()` and `swapUsdtToNative` were removed in 0.3.0.
579
611
  */
580
612
  declare class FeeManager {
581
613
  private readonly provider;
582
- private readonly operatorWallet;
583
- private readonly mintAndSwapGasUnits;
614
+ private readonly gasUnits;
584
615
  private readonly gasPremiumBps;
585
- private readonly quoteNativeToUsdt;
586
- private readonly rebalanceThresholdWei?;
587
- private readonly rebalanceUsdtAmount?;
588
- private readonly swapUsdtToNative?;
616
+ private readonly quoteNativeToFee;
589
617
  constructor(config: FeeManagerConfig);
590
618
  /**
591
- * Estimate the USDT fee the operator should charge for a single
592
- * `mintAndSwap`. Computed as:
619
+ * Estimate the fee (in the caller's fee currency) to charge for the
620
+ * next sponsored UserOp:
593
621
  *
594
- * nativeCost = gasUnits × gasPrice
595
- * premiumNativeCost = nativeCost × premiumBps / 10_000
596
- * usdtFee = quoteNativeToUsdt(premiumNativeCost)
597
- */
598
- estimateGasFee(): Promise<bigint>;
599
- /**
600
- * Check the operator's native balance and, if it has dropped below the
601
- * configured threshold, trigger a USDT→native rebalance via the injected
602
- * `swapUsdtToNative` function.
622
+ * nativeCost = gasUnits × gasPrice
623
+ * withPremium = nativeCost × premiumBps / 10_000
624
+ * fee = quoteNativeToFee(withPremium)
603
625
  *
604
- * Returns `true` if a rebalance was performed, `false` otherwise.
605
- * Silently no-ops when rebalance is not configured.
626
+ * For backward compatibility with v0.2.x code that reads `gasFeeUsdt`
627
+ * from the response, the name `estimateGasFee` is kept — but the
628
+ * currency depends on how the caller wired `quoteNativeToFee`.
606
629
  */
607
- rebalanceIfNeeded(): Promise<boolean>;
630
+ estimateGasFee(): Promise<bigint>;
608
631
  }
609
632
 
610
633
  /**
@@ -733,6 +756,12 @@ declare class MintingGateway {
733
756
  private readonly now;
734
757
  private readonly defaultLockBufferMs;
735
758
  constructor(config: MintingGatewayConfig);
759
+ /**
760
+ * @deprecated Since 0.3.0 — will be renamed to `processMint()` once
761
+ * the SC team finalizes Relayer v2 ABI. The new flow drops the
762
+ * swap steps entirely (no more single-call mint+swap); users swap
763
+ * separately on PAFI Web. Kept here for v0.2.x consumers. Removed in 2.0.
764
+ */
736
765
  processMintAndCashOut(request: MintAndCashOutRequest): Promise<MintAndCashOutResponse>;
737
766
  private computeLockDurationMs;
738
767
  /**
@@ -764,6 +793,16 @@ interface MintEvent {
764
793
  /** Log index within the tx, for deterministic ordering */
765
794
  logIndex: number;
766
795
  }
796
+ /** Decoded Transfer(from=user → 0x0) event used to finalize a burn-for-credit. */
797
+ interface BurnEvent {
798
+ /** The burner — user whose PT was burned. */
799
+ from: Address;
800
+ /** Amount burned. */
801
+ amount: bigint;
802
+ blockNumber: bigint;
803
+ txHash: Hex;
804
+ logIndex: number;
805
+ }
767
806
  /**
768
807
  * Cursor persistence interface — the indexer reports the next block
769
808
  * number it is about to process so the caller can write it to Redis /
@@ -883,10 +922,102 @@ declare class PointIndexer {
883
922
  private finalize;
884
923
  }
885
924
 
925
+ interface BurnIndexerConfig {
926
+ provider: PublicClient;
927
+ pointTokenAddress: Address;
928
+ ledger: IPointLedger;
929
+ /** Block to start from on first run. Ignored if cursor store has value. */
930
+ fromBlock?: bigint;
931
+ cursorStore?: IIndexerCursorStore;
932
+ /**
933
+ * Reorg safety — only treat events as final after this many
934
+ * confirmations. Default: 3.
935
+ */
936
+ confirmations?: number;
937
+ /** Blocks per getLogs call. Default: 2000. */
938
+ batchSize?: number;
939
+ /** Polling interval (ms). Default: 5000. */
940
+ pollIntervalMs?: number;
941
+ now?: () => number;
942
+ }
943
+ /**
944
+ * Mirror of `PointIndexer` for the reverse direction — watches
945
+ * `Transfer(user → 0x0)` events (ERC-20 burns) on the PointToken
946
+ * contract and finalizes pending off-chain credits.
947
+ *
948
+ * Finalization flow:
949
+ * 1. For each Burn event at `{from, amount, txHash}`:
950
+ * 2. Call `ledger.resolveCreditByBurnTx(lockId, txHash)` where `lockId`
951
+ * is resolved by the caller's `onMatchCredit` hook or a
952
+ * ledger-specific lookup. The SDK does not prescribe the matching
953
+ * strategy — issuers with a Postgres ledger can JOIN by
954
+ * `(from, amount, status=PENDING)`; the in-memory ledger matches
955
+ * by `lockId` supplied out-of-band.
956
+ *
957
+ * When no pending credit matches an observed Burn event, the indexer
958
+ * logs + skips — **it never credits off-chain** from a Burn that was
959
+ * not first reserved via `reservePendingCredit()`. This prevents
960
+ * spurious credits from one-off admin burns or direct burn calls
961
+ * outside the issuer SDK.
962
+ */
963
+ declare class BurnIndexer {
964
+ private readonly provider;
965
+ private readonly pointTokenAddress;
966
+ private readonly ledger;
967
+ private readonly cursorStore;
968
+ private readonly startBlock;
969
+ private readonly confirmations;
970
+ private readonly batchSize;
971
+ private readonly pollIntervalMs;
972
+ /**
973
+ * Caller-supplied matcher. Return the lockId to resolve for a given
974
+ * burn event, or `undefined` to skip. Runs synchronously via the
975
+ * ledger's query path.
976
+ *
977
+ * Default: try `ledger.resolveCreditByBurnTx` keyed on a synthetic
978
+ * lock id `burn-${from}-${amount}` — the in-memory ledger assigns
979
+ * incrementing IDs so callers with the memory ledger must provide a
980
+ * custom matcher. Real DB-backed ledgers override this to JOIN on
981
+ * their `pending_credits` table.
982
+ */
983
+ matchLockId: (event: BurnEvent) => Promise<string | undefined>;
984
+ private running;
985
+ private timer;
986
+ constructor(config: BurnIndexerConfig);
987
+ start(): void;
988
+ stop(): void;
989
+ tick(): Promise<void>;
990
+ private scheduleNext;
991
+ /**
992
+ * Scan `[from, to]` inclusive for burn events. Callers can drive this
993
+ * directly to backfill a specific range without `start()`. Cursor is
994
+ * advanced to `to + 1` on completion.
995
+ */
996
+ processBlockRange(from: bigint, to: bigint): Promise<void>;
997
+ private decodeBurnEvents;
998
+ /**
999
+ * Resolve a matching pending credit for this burn event and call
1000
+ * `ledger.resolveCreditByBurnTx(lockId, txHash)`. If no match found,
1001
+ * log + skip.
1002
+ */
1003
+ private finalize;
1004
+ }
1005
+
886
1006
  interface ApiConfigResponse {
887
1007
  chainId: number;
888
1008
  contracts: {
1009
+ /**
1010
+ * Legacy single-token field — kept for backward compat with v0.1.x
1011
+ * frontends. Prefer `pointTokens` for multi-token issuers.
1012
+ */
889
1013
  pointToken?: Address;
1014
+ /**
1015
+ * All supported PointToken addresses (v0.2.0+). Single-token issuers
1016
+ * will have one entry that matches `pointToken`. Multi-token
1017
+ * issuers expose the full list here so the frontend can render a
1018
+ * token picker.
1019
+ */
1020
+ pointTokens?: Address[];
890
1021
  relay?: Address;
891
1022
  issuerRegistry?: Address;
892
1023
  pointTokenFactory?: Address;
@@ -894,6 +1025,17 @@ interface ApiConfigResponse {
894
1025
  poolManager?: Address;
895
1026
  usdt?: Address;
896
1027
  };
1028
+ /**
1029
+ * Absolute URL that the Issuer App opens after a successful claim to
1030
+ * let the user swap PT → USDT or deposit into the perp DEX on PAFI
1031
+ * Web. Mobile opens this in an in-app browser
1032
+ * (SFSafariViewController / Chrome Custom Tabs). Desktop opens in a
1033
+ * popup. See [MOBILE_SDK_INTEGRATION.md] "PAFI Web Handoff" section.
1034
+ *
1035
+ * Optional — if omitted, the Issuer App should hide the "Open PAFI"
1036
+ * button.
1037
+ */
1038
+ pafiWebUrl?: string;
897
1039
  }
898
1040
  interface ApiNonceResponse {
899
1041
  nonce: string;
@@ -949,6 +1091,7 @@ interface ApiUserResponse {
949
1091
  balance: bigint;
950
1092
  isMinter: boolean;
951
1093
  }
1094
+ /** @deprecated Since 0.3.0 — use `ApiClaimRequest` (mint-only) instead. Removed in 2.0. */
952
1095
  interface ApiClaimAndSwapRequest {
953
1096
  chainId: number;
954
1097
  pointTokenAddress: Address;
@@ -967,6 +1110,7 @@ interface ApiClaimAndSwapRequest {
967
1110
  /** Unix seconds. */
968
1111
  swapDeadline: bigint;
969
1112
  }
1113
+ /** @deprecated Since 0.3.0 — use `ApiClaimResponse` instead. Removed in 2.0. */
970
1114
  interface ApiClaimAndSwapResponse {
971
1115
  txHash: Hex;
972
1116
  lockId: string;
@@ -1002,9 +1146,24 @@ interface IssuerApiHandlersConfig {
1002
1146
  ledger: IPointLedger;
1003
1147
  /** Used by `handleUser` to read on-chain nonces and minter status. */
1004
1148
  provider: PublicClient;
1005
- pointTokenAddress: Address;
1149
+ /**
1150
+ * Legacy single-token config. Prefer `pointTokenAddresses` for multi-token
1151
+ * issuers (0.2.0+).
1152
+ */
1153
+ pointTokenAddress?: Address;
1154
+ /**
1155
+ * All supported PointToken addresses. Handlers accept any address in this
1156
+ * list; others are rejected with "unsupported pointToken".
1157
+ */
1158
+ pointTokenAddresses?: Address[];
1006
1159
  chainId: number;
1007
1160
  contracts: ApiConfigResponse["contracts"];
1161
+ /**
1162
+ * Optional — URL that the Issuer App opens for PT→USDT swap or perp
1163
+ * deposit after a successful claim. Surfaced in `/config` response.
1164
+ * See [MOBILE_SDK_INTEGRATION.md] "PAFI Web Handoff".
1165
+ */
1166
+ pafiWebUrl?: string;
1008
1167
  /** Required by `handleGasFee`; omit to disable the endpoint. */
1009
1168
  feeManager?: FeeManager;
1010
1169
  /** Required by `handlePools`; omit to disable the endpoint. */
@@ -1027,9 +1186,18 @@ declare class IssuerApiHandlers {
1027
1186
  private readonly gateway;
1028
1187
  private readonly ledger;
1029
1188
  private readonly provider;
1030
- private readonly pointTokenAddress;
1189
+ /**
1190
+ * Set of supported PointToken addresses (checksum-normalized). Handlers
1191
+ * validate the request's `pointTokenAddress` against this set.
1192
+ */
1193
+ private readonly supportedTokens;
1194
+ /** First supported token — used as default when a handler doesn't
1195
+ * receive a `pointTokenAddress` in the request (shouldn't happen in
1196
+ * practice, but keeps type-narrowing happy). */
1197
+ private readonly defaultToken;
1031
1198
  private readonly chainId;
1032
1199
  private readonly contracts;
1200
+ private readonly pafiWebUrl?;
1033
1201
  private readonly feeManager?;
1034
1202
  private readonly poolsProvider?;
1035
1203
  constructor(config: IssuerApiHandlersConfig);
@@ -1080,8 +1248,14 @@ declare class IssuerApiHandlers {
1080
1248
  /**
1081
1249
  * `POST /claim-and-swap`
1082
1250
  *
1083
- * The terminal handler: forwards the verified consent to the
1084
- * MintingGateway, which runs the 11-step flow.
1251
+ * @deprecated Since 0.3.0 the single-call mint-then-swap flow is
1252
+ * retired in v1.4. Use the new `handleClaim()` (mint only) and let
1253
+ * the user swap separately on PAFI Web. See
1254
+ * [V1.4_V1.5_OVERVIEW.md §4] for the new scenario model. Will be
1255
+ * removed in 2.0.
1256
+ *
1257
+ * Legacy behavior: the terminal handler forwards the verified
1258
+ * consent to the MintingGateway, which runs the 11-step flow.
1085
1259
  */
1086
1260
  handleClaimAndSwap(userAddress: Address, request: ApiClaimAndSwapRequest): Promise<ApiClaimAndSwapResponse>;
1087
1261
  }
@@ -1184,26 +1358,29 @@ interface SubgraphNativeUsdtQuoterConfig {
1184
1358
  now?: () => number;
1185
1359
  }
1186
1360
  /**
1187
- * Create a `quoteNativeToUsdt` function backed by the PAFI subgraph's
1188
- * `Bundle.ethPriceUSD`.
1361
+ * Create a native→USDT quoter backed by the PAFI subgraph's
1362
+ * `Bundle.ethPriceUSD`. The returned function has the shape
1363
+ * `(amountNative: bigint) => Promise<bigint>` and can be passed as
1364
+ * `quoteNativeToFee` to `FeeManager` — in v1.4 the fee currency
1365
+ * is configured at the integration layer, not hardcoded here.
1189
1366
  *
1190
- * Used by `FeeManager.estimateGasFee()` to convert the operator's native
1191
- * gas cost into the USDT amount deducted from the user's cashout. Price
1192
- * precision is not critical here — a 1-2% drift is acceptable since
1193
- * the operator already takes a `gasPremiumBps` buffer.
1367
+ * Used by `FeeManager.estimateGasFee()` to convert the gas cost into
1368
+ * an ERC-20 amount charged as part of the sponsored UserOp batch.
1369
+ * Price precision is not critical — a 1-2% drift is acceptable since
1370
+ * the fee manager applies a `gasPremiumBps` buffer.
1194
1371
  *
1195
- * The result is cached in-process with a short TTL (default 30s). If the
1196
- * subgraph is unreachable, falls back to `fallbackEthPriceUsd` so gas
1197
- * estimation doesn't block cashouts during a subgraph outage.
1372
+ * The result is cached in-process with a short TTL (default 30s). If
1373
+ * the subgraph is unreachable, falls back to `fallbackEthPriceUsd` so
1374
+ * gas estimation doesn't block user flow during a subgraph outage.
1198
1375
  *
1199
1376
  * @example
1200
1377
  * ```ts
1201
- * import { createSubgraphNativeUsdtQuoter, createIssuerService } from "@pafi/issuer";
1378
+ * import { createSubgraphNativeUsdtQuoter, createIssuerService } from "@pafi-dev/issuer";
1202
1379
  *
1203
1380
  * const service = createIssuerService({
1204
1381
  * // ...other config
1205
1382
  * fee: {
1206
- * quoteNativeToUsdt: createSubgraphNativeUsdtQuoter({
1383
+ * quoteNativeToFee: createSubgraphNativeUsdtQuoter({
1207
1384
  * subgraphUrl: "https://graph.pacificfinance.org/subgraphs/name/pafi",
1208
1385
  * }),
1209
1386
  * },
@@ -1212,17 +1389,228 @@ interface SubgraphNativeUsdtQuoterConfig {
1212
1389
  */
1213
1390
  declare function createSubgraphNativeUsdtQuoter(config: SubgraphNativeUsdtQuoterConfig): (amountNative: bigint) => Promise<bigint>;
1214
1391
 
1392
+ /**
1393
+ * Combined off-chain + on-chain balance for a single user / token pair.
1394
+ *
1395
+ * - `offChain` — the issuer's ledger balance (available, excluding locks)
1396
+ * - `onChain` — the user's ERC-20 balance from `PointToken.balanceOf`
1397
+ * - `total` — `offChain + onChain` (what the Issuer App displays)
1398
+ */
1399
+ interface CombinedBalance {
1400
+ offChain: bigint;
1401
+ onChain: bigint;
1402
+ total: bigint;
1403
+ }
1404
+ interface BalanceAggregatorConfig {
1405
+ provider: PublicClient;
1406
+ ledger: IPointLedger;
1407
+ }
1408
+ /**
1409
+ * v1.4 utility — aggregates off-chain + on-chain point balances into a
1410
+ * single view for the "combined balance" UI in the Issuer App.
1411
+ *
1412
+ * The `/user` API handler uses this internally; the helper is exposed
1413
+ * publicly so Issuer Apps can call it directly without going through
1414
+ * the HTTP layer (e.g., for server-rendered pages or admin dashboards).
1415
+ *
1416
+ * See [REQUIREMENTS_V2.md] §1 — "The Issuer App displays a combined
1417
+ * balance (off-chain points + on-chain PT) and does not surface USDT."
1418
+ */
1419
+ declare class BalanceAggregator {
1420
+ private readonly provider;
1421
+ private readonly ledger;
1422
+ constructor(config: BalanceAggregatorConfig);
1423
+ /**
1424
+ * Combined balance for a single (user, token) pair. Fetches off-chain
1425
+ * + on-chain in parallel.
1426
+ */
1427
+ getCombinedBalance(user: Address, pointToken: Address): Promise<CombinedBalance>;
1428
+ /**
1429
+ * Combined balance for multiple tokens owned by the same user. Runs
1430
+ * all lookups in parallel. Returns a Map keyed by the token address
1431
+ * (same casing as supplied — caller should normalize if needed).
1432
+ */
1433
+ getCombinedBalanceMulti(user: Address, pointTokens: Address[]): Promise<Map<Address, CombinedBalance>>;
1434
+ }
1435
+
1436
+ interface RetryConfig {
1437
+ /**
1438
+ * Max total attempts including the first try. Default: 1 (no retry).
1439
+ * Set to 3 to get 2 retries after the initial call.
1440
+ *
1441
+ * Only applies when the server error body carries `safeToRetry: true`
1442
+ * or the failure is a transient network/timeout error.
1443
+ */
1444
+ maxAttempts?: number;
1445
+ /**
1446
+ * Initial backoff delay in ms. Default: 500. Each subsequent retry
1447
+ * doubles this (exponential backoff) and adds ±20% jitter.
1448
+ */
1449
+ initialDelayMs?: number;
1450
+ /**
1451
+ * Hard ceiling for a single backoff (ms). Default: 10_000.
1452
+ */
1453
+ maxDelayMs?: number;
1454
+ /**
1455
+ * Upper bound on `retryAfter` from the server. If the server asks us
1456
+ * to wait longer than this (e.g. rate limit until UTC midnight), the
1457
+ * client gives up rather than blocking. Default: 30_000.
1458
+ */
1459
+ maxRetryAfterMs?: number;
1460
+ }
1461
+ interface PafiBackendConfig {
1462
+ /**
1463
+ * PAFI Backend API base URL. Example:
1464
+ * https://api.pacificfinance.org
1465
+ * https://staging-api.pacificfinance.org
1466
+ */
1467
+ url: string;
1468
+ /** PAFI-assigned issuer ID (e.g., "gg56"). Sent in X-Issuer-Id header. */
1469
+ issuerId: string;
1470
+ /** Per-issuer API key (or JWT) for the Authorization header. */
1471
+ apiKey: string;
1472
+ /** Optional fetch override for tests. */
1473
+ fetchImpl?: typeof fetch;
1474
+ /**
1475
+ * Timeout (ms) for each request. Default: 10_000. PAFI Backend should
1476
+ * respond in <1s for the happy path; this is just the sanity bound.
1477
+ */
1478
+ timeoutMs?: number;
1479
+ /**
1480
+ * Retry policy for transient failures (5xx, 429, timeouts, network).
1481
+ * Omit or pass `{ maxAttempts: 1 }` to disable retry entirely.
1482
+ */
1483
+ retry?: RetryConfig;
1484
+ }
1485
+ /** Paired with `POST /paymaster/sponsor`. See SPONSORED_PATH_FLOW.md §4.1 */
1486
+ interface SponsorshipRequest {
1487
+ chainId: number;
1488
+ scenario: SponsorshipScenario;
1489
+ userOp: PartialUserOperation;
1490
+ target: {
1491
+ /** The allowlisted contract this batch call targets. */
1492
+ contract: Address;
1493
+ /** Function selector / name — validated against allowlist. */
1494
+ function: string;
1495
+ /** The PointToken involved (for scenario context). */
1496
+ pointToken?: Address;
1497
+ };
1498
+ }
1499
+ interface SponsorshipResponse {
1500
+ paymaster: Address;
1501
+ paymasterData: Hex;
1502
+ paymasterVerificationGasLimit: bigint;
1503
+ paymasterPostOpGasLimit: bigint;
1504
+ /** Unix seconds when this sponsorship expires. Re-request after. */
1505
+ expiresAt: number;
1506
+ }
1507
+ /**
1508
+ * Machine-readable error codes returned by PAFI Backend.
1509
+ *
1510
+ * Source of truth: `apps/paymaster-proxy` `CalldataValidationError`,
1511
+ * `RateLimitError`, `CoinbaseClientError`. Keep in sync.
1512
+ */
1513
+ 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";
1514
+ declare class PafiBackendError extends Error {
1515
+ code: PafiBackendErrorCode;
1516
+ httpStatus: number;
1517
+ details?: unknown | undefined;
1518
+ /**
1519
+ * Seconds to wait before retry. Populated from the server body
1520
+ * (e.g. rate limit returns the number of seconds until UTC midnight).
1521
+ */
1522
+ readonly retryAfter?: number;
1523
+ /**
1524
+ * `safeToRetry` as reported by the server body. Prefer this over the
1525
+ * code-based heuristic when available — the server knows more about
1526
+ * whether the same request will succeed on retry.
1527
+ */
1528
+ private readonly serverSafeToRetry?;
1529
+ constructor(code: PafiBackendErrorCode, message: string, httpStatus: number, details?: unknown | undefined, opts?: {
1530
+ retryAfter?: number;
1531
+ safeToRetry?: boolean;
1532
+ });
1533
+ /**
1534
+ * Whether the caller can safely retry the same request.
1535
+ *
1536
+ * If the server provided `safeToRetry` in the body, trust that.
1537
+ * Otherwise fall back to a code-based heuristic.
1538
+ */
1539
+ get safeToRetry(): boolean;
1540
+ }
1541
+
1542
+ /**
1543
+ * HTTP client for the PAFI Backend paymaster proxy service. See
1544
+ * [SPONSORED_PATH_FLOW.md] for the full flow + API contract.
1545
+ *
1546
+ * This client sits between `@pafi/issuer`'s RelayService and the
1547
+ * PAFI Backend. It does NOT talk to Coinbase Paymaster directly —
1548
+ * PAFI Backend holds that integration.
1549
+ */
1550
+ declare class PafiBackendClient {
1551
+ private readonly url;
1552
+ private readonly issuerId;
1553
+ private readonly apiKey;
1554
+ private readonly fetchImpl;
1555
+ private readonly timeoutMs;
1556
+ private readonly retry;
1557
+ constructor(config: PafiBackendConfig);
1558
+ /**
1559
+ * Request paymaster sponsorship for a pre-built UserOperation.
1560
+ * See [SPONSORED_PATH_FLOW.md §4.1] for the API contract.
1561
+ *
1562
+ * Retries automatically on transient failures (5xx, timeouts, network
1563
+ * errors, and errors the server flags with `safeToRetry: true`) up to
1564
+ * `retry.maxAttempts`. 4xx errors that are not `safeToRetry` fail fast.
1565
+ *
1566
+ * @throws PafiBackendError on final failure after exhausting retries
1567
+ */
1568
+ requestSponsorship(req: SponsorshipRequest): Promise<SponsorshipResponse>;
1569
+ private postWithRetry;
1570
+ /**
1571
+ * Pick the delay before the next retry.
1572
+ * - If the server sent `retryAfter` (seconds), honor it (capped by
1573
+ * `maxRetryAfterMs`) — returns null if the server wait exceeds the
1574
+ * cap, signalling the caller should give up.
1575
+ * - Otherwise: exponential backoff with ±20% jitter, capped at
1576
+ * `maxDelayMs`.
1577
+ */
1578
+ private computeBackoff;
1579
+ private sleep;
1580
+ private post;
1581
+ /** JSON replacer that stringifies bigints. Paired with bigintReviver. */
1582
+ private bigintReplacer;
1583
+ /**
1584
+ * JSON reviver that coerces specific numeric-string fields back to
1585
+ * bigint. The server must send these fields as decimal strings.
1586
+ */
1587
+ private bigintReviver;
1588
+ }
1589
+
1215
1590
  /**
1216
1591
  * Top-level configuration for `createIssuerService`. Everything except
1217
1592
  * the chain metadata, wallets, auth secret, and `signer` is optional and
1218
1593
  * falls back to the in-memory dev defaults — that makes the happy path
1219
1594
  * a single-call wire-up while still letting production issuers plug in
1220
1595
  * their own ledger, session store, policy engine, and KMS signer.
1596
+ *
1597
+ * **Multi-token (0.2.0):** Pass `pointTokenAddresses: Address[]` to
1598
+ * support multiple PointTokens under a single issuer backend. Legacy
1599
+ * `pointTokenAddress: Address` still works for single-token deployments.
1600
+ * When both are provided, `pointTokenAddresses` takes precedence.
1221
1601
  */
1222
1602
  interface IssuerServiceConfig {
1223
1603
  chainId: number;
1224
- /** Address of the deployed PointToken (one per issuer instance). */
1225
- pointTokenAddress: Address;
1604
+ /**
1605
+ * Address of the deployed PointToken. Legacy single-token shortcut;
1606
+ * prefer `pointTokenAddresses` for multi-token issuers.
1607
+ */
1608
+ pointTokenAddress?: Address;
1609
+ /**
1610
+ * All PointToken addresses this issuer supports. Takes precedence over
1611
+ * `pointTokenAddress`. Factory creates one `PointIndexer` per address.
1612
+ */
1613
+ pointTokenAddresses?: Address[];
1226
1614
  /** Address of the deployed Relay contract. */
1227
1615
  relayAddress: Address;
1228
1616
  /**
@@ -1256,10 +1644,9 @@ interface IssuerServiceConfig {
1256
1644
  /**
1257
1645
  * Fee management config. If omitted the `handleGasFee` endpoint will
1258
1646
  * throw "not configured" at request time. Pass any subset of fields
1259
- * to opt in — provider + operatorWallet are inherited from the outer
1260
- * config automatically.
1647
+ * to opt in — `provider` is inherited from the outer config.
1261
1648
  */
1262
- fee?: Omit<FeeManagerConfig, "provider" | "operatorWallet">;
1649
+ fee?: Omit<FeeManagerConfig, "provider">;
1263
1650
  /**
1264
1651
  * Pool discovery function for `handlePools`. If omitted the endpoint
1265
1652
  * throws "not configured" at request time.
@@ -1295,6 +1682,16 @@ interface IssuerService {
1295
1682
  relayService: RelayService;
1296
1683
  feeManager: FeeManager | undefined;
1297
1684
  gateway: MintingGateway;
1685
+ /**
1686
+ * All indexers keyed by PointToken address. For multi-token issuers there
1687
+ * is one per configured token. Single-token issuers will find one entry.
1688
+ */
1689
+ indexers: Map<Address, PointIndexer>;
1690
+ /**
1691
+ * First indexer. Kept for backward compat with 0.1.x callers that
1692
+ * expected `service.indexer` to be a single instance.
1693
+ * @deprecated use `indexers.get(tokenAddress)` for multi-token.
1694
+ */
1298
1695
  indexer: PointIndexer;
1299
1696
  handlers: IssuerApiHandlers;
1300
1697
  }
@@ -1312,11 +1709,11 @@ interface IssuerService {
1312
1709
  * - `indexer.autoStart` → false
1313
1710
  *
1314
1711
  * Throws synchronously if any required field (`signer`, `provider`,
1315
- * `operatorWallet`, `auth.jwtSecret`, `pointTokenAddress`, ) is missing.
1712
+ * `operatorWallet`, `auth.jwtSecret`, at least one point token) is missing.
1316
1713
  */
1317
1714
  declare function createIssuerService(config: IssuerServiceConfig): IssuerService;
1318
1715
 
1319
1716
  /** SDK package version — bumped on every release */
1320
1717
  declare const PAFI_ISSUER_SDK_VERSION = "0.1.0";
1321
1718
 
1322
- 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 };
1719
+ 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, 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, authenticateRequest, createIssuerService, createSubgraphNativeUsdtQuoter, createSubgraphPoolsProvider };