@pafi-dev/issuer
v0.43.0
Published
Issuer backend API and services for the PAFI point token system
Readme
@pafi-dev/issuer
Backend SDK for PAFI issuer servers — claim, redeem, perp deposit,
mobile prepare/submit, EIP-7702 delegation, status polling, redemption
restriction enforcement, and the IssuerApiAdapter that thins issuer
controllers to one line per endpoint.
Server-only. Pulls in signer wallets, HTTP clients, ledger
interfaces. Don't bundle into a browser app — use
@pafi-dev/trading (FE swap/quote) or @pafi-dev/core (primitives).
v0.20.x — Bundler-driven gas estimate
v0.20.0 — operator fee gasUnits now come from a Pimlico bundler
estimate cached server-side at PAFI's sponsor-relayer
(POST /v1/estimate-gas-fee). Eliminates the hardcoded
SCENARIO_GAS_UNITS table at the consumer layer.
- New:
FeeManagerConfig.bundlerClient?: BundlerEstimatorClient. When set,feeManager.estimateGasFee({ partialUserOp, scenario, contractAddress })fetches gas units from the wired estimator instead of running the legacygasUnits × premiummath. Backwards-compatible — omitbundlerClientand the legacy hardcoded path runs. - New:
createPafiEstimatorClient({ baseUrl, apiKey, issuerId })HTTP adapter — POSTs to PAFI sponsor-relayer's/v1/estimate-gas-fee. Pimlico key never touches issuer infra; the bundler call happens server-side at PAFI. - New:
RelayService.previewMintUserOp/previewBurnUserOp— build a dummy partial UserOp (placeholder 65-byte sig, no HSM call) for the bundler estimate.PTClaimHandler/PTRedeemHandlerwire these automatically. - Changed:
DEFAULT_PREMIUM_BPS12_000 → 10_000 (100%, no double-pad). PAFI sponsor-relayer applies its own 110% premium upstream; adding another SDK-side pad would over-charge users by ~21% vs. actual gas. - Changed:
PTClaimHandlernow passes the FeeManager-computedfeeAmountexplicitly intoRelayService.prepareMint, so the in-batch PT transfer matches the value returned in the response. Pre-v0.20 the handler relied onRelayService.resolveFeefalling back toquoteOperatorFeePt(which uses the legacy 120% premium) — causing a ~20% mismatch between displayed fee and actual transfer.
v0.21.0 — bug fix follow-up to v0.20: closes the resolveFee
mismatch in PTClaimHandler (above). Strongly recommended over
v0.20.0 for any new consumer wiring.
v0.15.x — Uniswap V3 migration (breaking)
v0.15.0 — initial V3 migration:
createSubgraphPoolsProviderreturns the Uniswap V3PoolKeyshape ({ token0, token1, fee }) — sourced from@pafi-dev/core. Any consumer typed against an older shape with extra fields will see compile-time type errors and must be updated.- New
onError?: (err: Error) => voidoption — forward recoverable errors (network, GraphQL, parse) to your observability stack. Throws inside the callback are swallowed so the provider stays total. feeTieris range-checked (uint24, < 1_000_000) before building thePoolKey; invalid values are skipped with a console.error +onErrorinvocation.- Subgraph endpoint default →
…/pafi-subgraph-v4(the active PAFI Base-mainnet subgraph; see@pafi-dev/corefor the constant). - Bumps peer-deps on
@pafi-dev/coreto^0.13.0(V3 types + ABIs).
v0.15.1 — documentation-only cleanup: refreshed JSDoc comments in nativePtQuoter, feeManager, relayService to match the V3 implementation. No behavior change.
v0.15.2 — POINT_TOKEN_V2_ABI → POINT_TOKEN_ABI rename across core+issuer (canonical name, no version suffix). Breaking export name change; the back-compat alias was removed. Requires @pafi-dev/core ^0.14.0.
Requirements
- Node.js ≥ 18
- TypeScript ≥ 5.0
viem^2.0.0 (peer)@pafi-dev/core^0.14.0 (transitive — re-exported)
Installation
pnpm add @pafi-dev/issuer @pafi-dev/core viem
# Optional ledger backend:
pnpm add @pafi-dev/issuer-postgres typeorm pgWhat's in this package
@pafi-dev/issuer
├── handlers — PTClaimHandler, PTRedeemHandler, PerpDepositHandler
├── api/IssuerApiAdapter
│ — single class, every endpoint is one line in controller
├── api/handleMobilePrepare/Submit
│ — mobile claim/redeem orchestrators
├── api/handleClaimStatus/handleRedeemStatus
│ — polling helpers w/ bundler-receipt fallback
├── api/handleDelegateSubmit
│ — EIP-7702 delegation submit (empty-batch + auth)
├── api/createSdkErrorMapper
│ — framework-agnostic error → HTTP status mapper
├── ledger — IPointLedger interface + InMemoryCursorStore
├── userop-store — IPendingUserOpStore + MemoryPendingUserOpStore
├── pafi-backend — sponsor-relayer client + relay/paymaster helpers
├── auth — ISessionStore, AuthService (SIWE), MemorySessionStore
├── relay — RelayService (wrapper-aware), FeeManager
├── pools — createSubgraphPoolsProvider, NativePtQuoter
├── policy — IPolicyEngine + DefaultPolicyEngine
├── indexer — PointIndexer (wrapper + direct mode), BurnIndexer
├── issuer-state — IssuerStateValidator (7-field struct + oracle.tokenCaps)
├── redemption — RedemptionService (per-issuer policy enforcement)
└── errors — PafiSdkError base class for typed errorsArchitecture
HTTP request (NestJS / Fastify / Express)
↓
Auth guard ← issuer-specific (SIWE / Privy / NextAuth)
↓
Controller ← thin: routing + DTO + auth context
↓
IssuerApiAdapter ← orchestrates flows
↓
PTClaimHandler ← signs MintForRequest, locks balance, builds UserOp
(routes to wrapper.mintWithFee when wrapper configured)
PTRedeemHandler ← signs BurnRequest, reserves credit, builds UserOp
PerpDepositHandler ← Orderly Vault via PAFI Relay
↓
IPointLedger ← your DB impl (Postgres recommended)
RelayService ← UserOp builders (auto-branches direct vs wrapper)
PafiBackendClient ← sponsor-relayer proxyWrapper-mediated mint flow
[FE / mobile] → POST /claim/prepare
↓
[gg56] IssuerApiAdapter.claimPrepare()
↓
[gg56] PTClaimHandler:
1. Lock off-chain balance (gross amount)
2. Pre-validate via IssuerStateValidator (oracle cap check)
3. Sign MintForRequest EIP-712 with receiver = wrapper
4. Build UserOp callData:
BatchExecute([
{ mintFeeWrapper, mintWithFee(pt, user, gross, deadline, sig) },
{ pointToken, transfer(pafiFeeRecipient, operatorFeePT) }
])
↓
[gg56] PafiBackendClient.requestSponsorship() → sponsor-relayer
↓
[sponsor-relayer] decode calldata, recognize MINT_WITH_FEE (0x5284d08b),
validate intent, forward to Pimlico paymaster
↓
[gg56] returns { userOp, typedData, userOpHash, sponsored: true|false,
typedDataFallback, userOpHashFallback }
↓
[FE / mobile] sign typedData via signTypedData_v4
↓
[FE / mobile] POST /claim/submit { lockId, signature, variant }
↓
[gg56] forwards signed UserOp to Pimlico bundler → on-chain mint
↓
[chain] wrapper.mintWithFee → PointToken.mint(gross to wrapper) →
wrapper splits fee to recipients → transfers net to user
↓
[gg56] PointIndexer (wrapper mode) catches MintWithFee event →
deduct off-chain balance, lock → MINTEDQuick start (NestJS)
Minimal reference at examples/nestjs-issuer/ — full working backend.
1. Wire IssuerApiAdapter
import { Provider } from "@nestjs/common";
import {
IssuerApiAdapter,
IssuerStateValidator,
PTClaimHandler,
PTRedeemHandler,
PerpDepositHandler,
MemoryPendingUserOpStore,
type IssuerService,
} from "@pafi-dev/issuer";
import { PostgresPointLedger } from "@pafi-dev/issuer-postgres";
import { getContractAddresses } from "@pafi-dev/core";
export const issuerApiAdapterProvider: Provider = {
provide: ISSUER_API_ADAPTER,
useFactory: (issuerService, provider, walletClient, dataSource, config) => {
const ledger = new PostgresPointLedger(dataSource);
const { issuerRegistry, batchExecutor, usdc } = getContractAddresses(8453);
const chainId = config.get<number>("CHAIN_ID");
const pointToken = config.get<`0x${string}`>("POINT_TOKEN_ADDRESS");
return new IssuerApiAdapter({
issuerService,
ledger,
provider,
issuerSignerWallet: walletClient,
pafiIssuerId: config.get("PAFI_ISSUER_ID"),
ptClaimHandler: new PTClaimHandler({
ledger,
relayService: issuerService.relay,
provider,
issuerSignerWallet: walletClient,
pointTokenDomainName: config.get("POINT_TOKEN_DOMAIN_NAME"),
feeService: issuerService.fee,
issuerStateValidator: new IssuerStateValidator(provider, issuerRegistry),
// mintFeeWrapperAddress: optional override; SDK auto-resolves from chainId
}),
ptRedeemHandler: new PTRedeemHandler({
ledger,
relayService: issuerService.relay,
provider,
pointTokenAddress: pointToken,
batchExecutorAddress: batchExecutor,
chainId,
domain: { name: config.get("POINT_TOKEN_DOMAIN_NAME"), verifyingContract: pointToken },
burnerSignerWallet: walletClient,
feeService: issuerService.fee,
}),
perpHandler: new PerpDepositHandler({
provider, feeService: issuerService.fee, pointTokenAddress: pointToken,
}),
pendingUserOpStore: new MemoryPendingUserOpStore(),
pafiBackendClient, // optional — sponsor-relayer client for sponsored flows
});
},
inject: [...],
};Skip handlers you don't need — adapter throws clear " not wired" if the corresponding method is called.
2. Slim controller
@Controller()
export class IssuerController {
constructor(@Inject(ISSUER_API_ADAPTER) private api: IssuerApiAdapter) {}
@Post("claim/prepare")
@UseGuards(JwtGuard)
async claimPrepare(@User() user: AuthContext, @Body() body) {
const [aaNonce, mintRequestNonce] = await Promise.all([
fetchAaNonce(this.provider, user.userAddress),
fetchMintRequestNonce(this.provider, body.pointTokenAddress, user.userAddress),
]);
return wrap(() => this.api.claimPrepare({
authenticatedAddress: user.userAddress,
chainId: body.chainId,
pointTokenAddress: body.pointTokenAddress,
amount: BigInt(body.amount),
aaNonce, mintRequestNonce,
}));
}
}3. Wire error mapper
import { createSdkErrorMapper, type SdkErrorBody } from "@pafi-dev/issuer";
const sdkErrorMapper = createSdkErrorMapper({
notFound: (b) => new NotFoundException(b),
forbidden: (b) => new ForbiddenException(b),
unprocessable: (b) => new UnprocessableEntityException(b),
serviceUnavailable: (b) => new ServiceUnavailableException(b),
});
async function wrap<T>(fn: () => Promise<T>): Promise<T> {
try { return await fn(); }
catch (err) { sdkErrorMapper(err); }
}IssuerApiAdapter methods
| Method | HTTP route | Notes |
| --- | --- | --- |
| pools(authedAddr, chainId, pointToken) | GET /pools | |
| user(authedAddr, chainId, userAddr, pointToken) | GET /user | |
| config(chainId) | GET /config | Includes mintFeeBpsByToken + contracts.mintFeeWrapper |
| claim({ ...nonces }) | POST /claim (web — sync calls[]) | |
| redeem({ amount, aaNonce, ... }) | POST /redeem | |
| perpDeposit({ amount, brokerId, aaNonce, ... }) | POST /perp-deposit | |
| claimPrepare(...) / claimSubmit(...) | Mobile claim flow | wrapper-aware |
| redeemPrepare(...) / redeemSubmit(...) | Mobile redeem flow | |
| claimStatus(authedAddr, lockId) / redeemStatus(...) | Status polling | |
| delegateStatus(authedAddr, chainId) | EIP-7702 — check delegation | |
| delegatePrepare(authedAddr, chainId) | EIP-7702 — build auth hash | |
| delegateSubmit({ authSig, ... }) | EIP-7702 — relay empty-batch | |
Redemption restriction (v0.10+)
Per-issuer policy enforced at /redeem/prepare time. Fetched from
PAFI issuer-api with 5-min cache + fail-open default.
import { RedemptionService, PolicyProvider } from "@pafi-dev/issuer";
import { PostgresRedemptionHistoryStore } from "@pafi-dev/issuer-postgres";
const redemption = new RedemptionService({
policyProvider: new PolicyProvider({
chainId: 8453,
issuerId: "gg56",
apiKey: process.env.PAFI_API_KEY,
}),
historyStore: new PostgresRedemptionHistoryStore(dataSource),
});Wire to createIssuerService({ redemption: {...} }). Adapter exposes
/redemption/preview + /redemption/evaluate automatically.
Mobile prepare/submit flow
// 1. mobile → POST /claim/prepare → backend runs:
const prepared = await api.claimPrepare({
authenticatedAddress, chainId, pointTokenAddress, amount, aaNonce, mintRequestNonce,
});
// returns: {
// lockId, userOpHash, typedData,
// userOpHashFallback, typedDataFallback,
// sponsored, // true if paymaster signed; false → use fallback
// needsDelegation, // true if user EOA not delegated yet
// feeAmount, signatureDeadline, expiresInSeconds,
// }
// 2. mobile signs typedData via Privy/MetaMask signTypedData_v4
// (NOT personal_sign — Pimlico Simple7702Account does raw ecrecover)
// If `sponsored: false`, sign `typedDataFallback` instead
// 3. mobile → POST /claim/submit:
const result = await api.claimSubmit({
authenticatedAddress, lockId, signature,
variant: prepared.sponsored ? "sponsored" : "fallback",
});
// returns: { userOpHash } — bundler hash for status pollinghandleMobileSubmit enforces ownership: entry.sender ===
authenticatedAddress. Lock has 15-min TTL.
PointIndexer modes
// Default — auto-resolves wrapper from chainId
const service = createIssuerService({
chainId: 8453,
// ...
indexer: { autoStart: true, pollIntervalMs: 5000 },
});
// Indexer listens to:
// - mode wrapper: MintFeeWrapper.MintWithFee filtered by pointToken
// (when wrapper is configured at chainId)
// - mode direct: PointToken.Transfer(0x0 → user) (legacy / no wrapper)Override for fork tests:
indexer: { mintFeeWrapperAddress: "0x000...dead" } // force direct modeError classes
All SDK errors inherit PafiSdkError. Subclasses + HTTP mapping:
| Error | httpStatus | safeToRetry |
| --- | --- | --- |
| PendingUserOpNotFoundError | not_found | false |
| PendingUserOpForbiddenError | forbidden | false |
| LockNotFoundError | not_found | false |
| IssuerStateError (ISSUER_NOT_REGISTERED / ISSUER_INACTIVE / MINT_CAP_EXCEEDED) | unprocessable | true if cap exceeded |
| PerpDepositError | unprocessable | true if RELAY_FEE_EXCEEDS_AMOUNT |
| PTClaimError, PTRedeemError | unprocessable | false |
| BundlerNotConfiguredError | service_unavailable | false |
| BundlerRejectedError | unprocessable | false |
| RedemptionPolicyError (REDEMPTION_DENIED / RATE_LIMIT_EXCEEDED / ...) | unprocessable | varies |
References
- Architecture:
ARCHITECTURE.mdat SDK root - Fee flow & math: see
@pafi-dev/coreREADME — Operator fee quoter
License
Apache-2.0
