@pafi-dev/issuer
v0.13.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).
What's new in 0.13.0 (breaking)
Tracks @pafi-dev/[email protected]. The ReceiverConsent mint path was
removed from the entire SDK chain because the deployed PointToken
contract never had it — what was conceptually a "sponsored mint" is
actually the path-2 MintForRequest sig-gated mint with sponsor-relayer
paying gas.
| Change | Detail |
|---|---|
| ApiUserResponse.receiverConsentNonce removed | The /user endpoint no longer returns this field. Callers using it for signing should call the path-2 mint flow with mintRequestNonce instead. |
| UserDto.receiverConsentNonce removed | Wire DTO drops the field — IssuerApiAdapter.user() response is one field smaller. |
| Action required for issuer backends | Bump @pafi-dev/core to 0.10.0, remove any receiverConsentNonce field from your own DTOs / mobile API surface, drop FE code that signed ReceiverConsent typed data. |
What's new in v1.6 / 0.12.x
| Change | Detail |
|---|---|
| RelayService.prepareMint branches direct vs wrapper | Auto-detects MintFeeWrapper from chainId. Direct: pointToken.mint(...). Wrapper: mintFeeWrapper.mintWithFee(...) |
| PTClaimHandler auto-resolves wrapper | No env config needed — getContractAddresses(chainId).mintFeeWrapper. Override via mintFeeWrapperAddress for fork tests |
| PointIndexer wrapper mode | Listens to MintFeeWrapper.MintWithFee(pointToken, to, gross, net, fee) instead of Transfer(0x0→user) when wrapper is configured |
| IssuerStateValidator v1.6 struct | Reads 7-field Issuer struct + queries MintingOracle.tokenCaps() for caps |
| /config exposes mintFeeBpsByToken | FE can preview "you'll receive net = gross × (1-bps/10000)" before user signs |
| Operator fee fallback enabled by default | Mint not blocked when subgraph has no pool yet |
| PafiBackendClient error envelope fix | Reads nested error.message — exposes Pimlico AA codes instead of generic "HTTP 500" |
Requirements
- Node.js ≥ 18
- TypeScript ≥ 5.0
viem^2.0.0 (peer)@pafi-dev/core(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 (v1.6 wrapper-aware), FeeManager
├── pools — createSubgraphPoolsProvider, NativePtQuoter
├── policy — IPolicyEngine + DefaultPolicyEngine
├── balance — BalanceAggregator (off-chain + on-chain)
├── indexer — PointIndexer (wrapper + direct mode), BurnIndexer
├── issuer-state — IssuerStateValidator (v1.6 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
(v1.6: routes to wrapper.mintWithFee)
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 proxyv1.6 wrapper-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 } = 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 | v1.6: 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 | v1.6 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 — v1.6 default)
// - 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 |
v1.5 → v1.6 migration checklist
pnpm add @pafi-dev/issuer@latest @pafi-dev/core@latest(≥ 0.12.7 + ≥ 0.9.6)- Update env: drop
MINT_FEE_WRAPPER_ADDRESSif previously set (SDK auto-resolves from chainId) - Re-deploy your issuer backend —
RelayServicenow branches based on wrapper presence; calldata changes frommint(...)tomintWithFee(...) - Verify on-chain prerequisites (PAFI ops responsibility):
IssuerRegistry.addIssuer(...)cascade configures wrapper recipientsMintingOracle.registerToken(pointToken, issuer)sets capPointToken.addMinter(signerAddress)whitelists your signer
- Indexer will pick up
MintWithFeeevents automatically. If you wrote custom Transfer-event consumers, switch toMintWithFee. /configresponse now includesmintFeeBpsByToken— FE can preview fee without round-trippinggetMintFeeBps()separately.
References
- Architecture:
ARCHITECTURE.mdat SDK root - Fee flow & math:
docs/FEE_FLOW.md - v1.6 SC commits:
cc26f62(struct simplification), wrapper added
License
Apache-2.0
