@pafi-dev/core
v0.39.10
Published
EIP-712 signing, contract interaction, Uniswap V3 path helpers, and ERC-4337 UserOp building for the PAFI point token system
Downloads
2,502
Readme
@pafi-dev/core
Pure primitives for the PAFI point-token system. EIP-712 signing, contract ABIs + addresses, ERC-4337 UserOp building, EIP-7702 delegation, sponsor-auth signing, perp deposit calldata, fee quoting, MintFeeWrapper helpers.
Browser + Node-safe. Zero HTTP client. Peer-dep: viem ^2.
Requirements
- Node.js ≥ 18 (or modern browser with native
fetch) - TypeScript ≥ 5.0
viem^2.0.0 (peer)
Installation
pnpm add @pafi-dev/core viemPackage layout
Core ships data + primitives only. Higher-level orchestration in siblings:
| Concern | Package |
| --- | --- |
| Pure primitives (this package) | @pafi-dev/core |
| Issuer backend (claim / redeem / mobile flows) | @pafi-dev/issuer |
| Issuer DB ledger (TypeORM + Postgres) | @pafi-dev/issuer-postgres |
| Trading (swap + quote, FE-callable) | @pafi-dev/trading |
Exports cheatsheet
| Symbol / area | Provides |
| --- | --- |
| getContractAddresses(chainId) | All deployed PAFI addresses keyed by chain |
| signMintRequest, verifyMintRequest, buildMintRequestTypedData | EIP-712 sign/recover for MintForRequest (5 fields: user, receiver, amount, nonce, deadline) |
| signBurnRequest, verifyBurnRequest, buildBurnRequestTypedData | EIP-712 for BurnRequest |
| signSponsorAuth, verifySponsorAuth, buildAndSignSponsorAuth | EIP-712 for sponsor-relayer auth |
| buildPartialUserOperation, assembleUserOperation, computeUserOpHash | ERC-4337 v0.7+ UserOp builder + hash |
| encodeBatchExecute, decodeBatchExecuteCalls | BatchExecutor (Pimlico Simple7702Account) calldata |
| buildDelegationUserOp, computeAuthorizationHash, parseEip7702DelegatedAddress, splitAuthorizationSig, buildEip7702Authorization, delegateDirect | EIP-7702 helpers (build + introspect) |
| buildPerpDepositViaRelay, buildPerpDepositWithGasDeduction, ORDERLY_RELAY_ABI, ORDERLY_VAULT_ABI, BROKER_HASHES, TOKEN_HASHES, computeAccountId | Orderly perp deposit calldata + types |
| quoteOperatorFeePt, quoteOperatorFeeUsdt | Gas-reimbursement fee quoter (Chainlink + V3 subgraph) |
| getMintFeeBps, getMintFeeRecipients | Read wrapper fee config per pointToken |
| getTokenCap | Read MintingOracle.tokenCaps(pointToken) |
| verifyMintCap, getPointTokenIssuer | MintingOracle read helpers |
| getIssuer, isActiveIssuer, issuerRegistryGetIssuerFlatAbi | IssuerRegistry helpers (7-field record) |
| getMintRequestNonce, getBurnRequestNonce, getPointTokenBalance, isMinter, getTokenName, getPointTokenIssuerAddress | PointToken read helpers |
| fetchPafiPools, PAFI_SUBGRAPH_URL | Pool discovery — endpoint is …/pafi-subgraph-v4 (PAFI V3-fork DEX + extensions); parser tolerates both V3 and legacy V4 subgraph responses |
| getPafiServiceUrls(chainId) | Hardcoded PAFI service endpoints (sponsorRelayer, issuerApi) |
| PoolKey ({ token0, token1, fee }), V3Path ({ tokens, fees }), QuoteResult | Uniswap V3 routing types used by @pafi-dev/trading |
| encodeV3Path, encodeV3PathReversed, computeV3PoolAddress | V3 packed-bytes path encoding + CREATE2 pool-address derivation |
| V3_FACTORY_ADDRESSES, V3_SWAP_ROUTER_ADDRESSES, QUOTER_V2_ADDRESSES, V3_POOL_INIT_CODE_HASH | PAFI's V3-fork deployment (Base mainnet) |
| pointTokenAbi, issuerRegistryAbi, mintingOracleAbi, mintFeeWrapperAbi, pointTokenFactoryAbi, erc20Abi, permit2Abi, universalRouterAbi, v3QuoterV2Abi | Contract ABIs (regenerated from Foundry artifacts) |
| createLoginMessage | EIP-4361 SIWE login builder |
Contract addresses
import { getContractAddresses } from "@pafi-dev/core";
const addr = getContractAddresses(8453); // Base mainnet
// addr.issuerRegistry, addr.mintingOracle, addr.mintFeeWrapper,
// addr.usdt, addr.usdc (optional, perp-deposit + sponsor-relayer stable fee),
// addr.batchExecutor, addr.pafiFeeRecipient,
// addr.chainlinkEthUsd, addr.orderlyRelay, addr.universalRouterPer-issuer
pointTokenclones are NOT in the chain-level address bag. Read from your issuer env (e.g.POINT_TOKEN_ADDRESS) orPointTokenFactory.createToken()at runtime.
EIP-712 — MintForRequest
import { signMintRequest, verifyMintRequest } from "@pafi-dev/core";
import { privateKeyToAccount } from "viem/accounts";
import { createWalletClient, http } from "viem";
const wallet = createWalletClient({
account: privateKeyToAccount(MINTER_PK),
transport: http(),
});
const sig = await signMintRequest(
wallet,
{ name: "POINT", chainId: 8453, verifyingContract: pointTokenAddress },
{
user, // off-chain spender (drives nonces)
receiver: wrapperAddress, // on-chain caller of PointToken.mint
// (= wrapper for wrapper-mediated; = user for direct)
amount: 1000n * 10n ** 18n,
nonce: currentMintRequestNonce, // from PointToken.mintRequestNonces(user)
deadline: BigInt(Math.floor(Date.now() / 1000) + 900),
},
);
// sig.serialized — bytes hex passed to PointToken.mint(...) or wrapper.mintWithFee(...)Wrapper-mediated mint puts receiver = wrapperAddress; direct mint puts
receiver = user. The contract requires msg.sender == receiver.
MintFeeWrapper helpers
import { getMintFeeBps, getMintFeeRecipients, getContractAddresses } from "@pafi-dev/core";
const { mintFeeWrapper } = getContractAddresses(8453);
const POINT_TOKEN = "0x855c2046...";
const bps = await getMintFeeBps(provider, mintFeeWrapper, POINT_TOKEN);
// bps = 50 (= 0.50%)
const recipients = await getMintFeeRecipients(provider, mintFeeWrapper, POINT_TOKEN);
// [{ account: 0x..., basisPoints: 20 }, { account: 0x..., basisPoints: 30 }]Wrapper deducts gross × bps / 10000 PT on every mint and distributes
to recipients. User receives gross × (1 - bps/10000) net.
Operator fee quoter
Two execution paths depending on whether your caller has access to a bundler-driven estimator (recommended) or needs a self-contained quote.
Recommended: bundler-driven via FeeManager (issuer SDK)
For issuer backends (@pafi-dev/issuer) and trading helpers
(@pafi-dev/trading), the FeeManager class is wired with a
BundlerEstimatorClient (typically createPafiEstimatorClient(...))
that hits PAFI sponsor-relayer's POST /v1/estimate-gas-fee. Server-side
that endpoint calls Pimlico's eth_estimateUserOperationGas, caches by
(scenario, contract codehash, paymaster) in Redis (5 min TTL), applies
PAFI's 110% premium, and returns gas units. SDK FeeManager then converts
to PT/USDT via the helpers below with premiumBps: 10_000 (no
double-pad). This is the path used by every /claim/prepare and
/redeem/prepare flow.
Same Redis key is read by sponsor-relayer's FeeValidator when verifying
the submitted UserOp — so the issuer's quote and PAFI's expected fee
match exactly (no formula drift; only ±5% slack for ETH-price movement
between quote-time and submit-time).
Self-contained: direct helpers (FE / fallback path)
When no backend round-trip is possible (FE direct swapDirect, or a
caller without BundlerEstimatorClient wired), use these helpers
directly. They run the same math but with hardcoded SCENARIO_GAS_UNITS
(per-scenario table) and default premiumBps: 12_000 (legacy 120%
self-contained margin — higher than bundler-driven because there's no
PAFI-side premium to layer on).
import { quoteOperatorFeeUsdt, quoteOperatorFeePt } from "@pafi-dev/core";
// USDT-denominated (replaces issuer's GET /gas-fee). Chainlink-only.
const gasFeeUsdt = await quoteOperatorFeeUsdt({
provider, chainId: 8453, scenario: "mint",
});
// PT-denominated. Adds V3 subgraph call for ptPerUsdt.
const gasFeePt = await quoteOperatorFeePt({
provider, chainId: 8453, scenario: "mint",
pointTokenAddress: POINT_TOKEN,
allowStaleFallback: true, // recommended: don't block on subgraph outage
});Both return bigint raw units. Fallback prices (0.1 USDT/PT,
3000 USD/ETH) used when oracles unreachable + allowStaleFallback: true.
Scenario table
| Scenario | gasUnits | Notes |
|---|---|---|
| mint | 500_000n | Standard mint with wrapper |
| burn | 500_000n | Standard burn |
| swap | 700_000n | Single hop; multi-hop adds headroom inside the premium |
| perp-deposit | 800_000n | Heavier — Orderly Vault interaction |
| delegate | 200_000n | EIP-7702 no-op (gratis-sponsored as one-time onboarding) |
| erc20-transfer | 200_000n | Tiny 2-call batch; bypasses bundler estimator (round-trip > savings) |
Fee math (raw bigint, no scaling shortcuts)
withPremium = gasUnits × gasPrice × premiumBps / 10000 (wei)
fee_USDT_6dec = withPremium × ethPrice_8dec / 10^(18+8-6)
fee_PT_18dec = withPremium × ethPrice_8dec × ptPerUsdt_18dec / 10^26ptPerUsdt_18dec comes from the V3 subgraph (token0Price /
token1Price inverted to PT-per-USDT, scaled to 18 decimals).
ERC-4337 UserOp building
import { encodeBatchExecute, buildPartialUserOperation } from "@pafi-dev/core";
const callData = encodeBatchExecute([
{ target: mintFeeWrapper, value: 0n, data: mintWithFeeCallData },
{ target: pointToken, value: 0n, data: feeTransferCallData },
]);
const partial = buildPartialUserOperation({
sender: userAddress,
nonce: aaNonce,
callData,
gasLimits: {
callGasLimit: 300_000n,
verificationGasLimit: 150_000n,
preVerificationGas: 50_000n,
},
});computeUserOpHash(userOp, chainId) — EntryPoint v0.8 EIP-712 digest.
Mobile signs this hash via eth_signTypedData_v4 (NOT personal_sign —
Pimlico Simple7702Account does raw ecrecover without EIP-191 prefix).
EIP-7702 — delegateDirect
FE one-shot delegation, no AA / no sponsor:
import { delegateDirect } from "@pafi-dev/core";
import { useSign7702Authorization, useWallets } from "@privy-io/react-auth";
const { signAuthorization } = useSign7702Authorization();
const wallet = useWallets().find((w) => w.walletClientType === "privy");
const result = await delegateDirect({
userAddress: wallet.address,
chainId: 8453,
publicClient,
walletClient,
signAuthorization,
// optional: skipIfAlreadyDelegated (default true), waitForReceipt (default true)
});
// result.status: "already-delegated" | "broadcasted"
// result.txHash, result.delegatedTo, result.receipt?Privy embedded wallet only — external wallets (MetaMask) can't sign
EIP-7702 (no raw secp256k1_sign exposed). User pays ~$0.01–0.10 ETH gas.
EIP-7702 — attachDelegationIfNeeded (atomic activation, v0.21+)
Merge the 7702 SetCode authorization into the user's FIRST action UserOp instead of running a separate delegate transaction. Pimlico bundles both into ONE bundler tx — no "setup" step for the user.
import { attachDelegationIfNeeded } from "@pafi-dev/core";
import { useSign7702Authorization } from "@privy-io/react-auth";
const { signAuthorization } = useSign7702Authorization();
// Before EVERY sponsored UserOp (mint / swap / transfer / perp-deposit):
const authPart = await attachDelegationIfNeeded({
rpc: publicClient, // viem PublicClient
account: userAddress, // user's EOA
expectedDelegate: "0xe6Cae83BdE06E4c305530e199D7217f42808555B", // BatchExecutor
chainId: 8453, // Base mainnet
signAuthorization, // Privy hook result
});
// Spread into permissionless smartClient.sendTransaction — the field is
// stripped + forwarded to Pimlico as `eip7702Auth` automatically.
await smartClient.sendTransaction({
to: pointToken,
data: encodeMintCalldata(amount),
...(authPart ?? {}), // { authorization } when needed, nothing otherwise
});Returns undefined when the EOA already delegates to expectedDelegate
— caller passes no authorization field on subsequent UserOps.
Compared to delegateDirect:
| Trait | delegateDirect | attachDelegationIfNeeded |
|---|---|---|
| Number of on-chain txs | 2 (delegate + action) | 1 (atomic) |
| User signs signAuthorization | once per session | once per session |
| Submits UserOp itself | no — only delegates | no — caller does it |
| Gas mode | user-paid ETH | works with paymaster + EOA-paid fallback |
| Best for | one-shot pre-onboarding | every action call |
Concurrency-safe: parallel calls for the same (chainId, account)
join the same in-flight promise — exactly ONE Privy popup is shown even
when React re-fires the callback or the user double-clicks. The cache
auto-evicts when settled so retries always re-check on-chain state.
Wrong-impl handling: if the EOA already delegates to a different contract (e.g. an older Coinbase 7702 impl), the helper signs a new authorization to switch the delegate — atomically, with the next action.
Sponsor-auth signing
import { buildAndSignSponsorAuth } from "@pafi-dev/core";
const sponsorAuth = await buildAndSignSponsorAuth({
userAddress,
callData: userOp.callData,
chainId: 8453,
scenario: "mint", // "mint" | "burn" | "swap" | "perp-deposit" | "delegate"
issuerId: ISSUER_ID,
issuerSignerWallet, // KMS-backed in prod
});
// Pass to sponsor-relayer's POST /paymaster/sponsor as `sponsorAuth` fieldIssuer signer must be registered in IssuerRegistry (signerAddress
field) and whitelisted via PointToken.addMinter(signer).
Verify on-chain state (debug)
import {
getIssuer, getTokenCap, getMintFeeBps,
getMintRequestNonce, isMinter,
} from "@pafi-dev/core";
// Issuer registry record (7-field flat record)
const issuer = await getIssuer(provider, registry, issuerAddress);
// { issuerAddress, signerAddress, name, symbol, active, pointToken, mintingOracle }
// Per-token cap (read from oracle, not issuer struct)
const cap = await getTokenCap(provider, mintingOracle, pointToken);
// { declaredTotalSupply, capBasisPoints }
// Fee config
const bps = await getMintFeeBps(provider, wrapper, pointToken);
// Mint state
const nonce = await getMintRequestNonce(provider, pointToken, user);
const authorized = await isMinter(provider, pointToken, signerAddress);References
- Architecture:
ARCHITECTURE.mdat SDK root
License
Apache-2.0
