Skip to content

bnb-chain/mpp-sdk

Repository files navigation

@bnb-chain/mpp

EVM Charge implementation of the Machine Payments Protocol (mppx)draft-evm-charge-00.

Brings the BNB Chain ecosystem (BSC, opBNB) plus the wider EVM landscape (Ethereum, Base, Arbitrum, Optimism, Polygon, Avalanche, Linea) into the mppx HTTP payment authentication framework. Composes with Mppx.create() to expose 402 Payment Required flows for permit2, transaction, hash, and authorization (EIP-3009) credentials.

Capabilities

Area Supported
Credential types authorization (EIP-3009), permit2 (single + batch with splits), transaction (EIP-1559), hash
Challenge binding mppx-managed (under Mppx.create), mppx-hmac (bare verify), stored-lookup (draft §6 zero-deviation)
Settlement Server-side broadcast for permit2 / authorization (settlement signer pays gas); payer-broadcast for hash / transaction
Tokens / chains Curated (chain, token) matrix — see Tokens / Chains
Receipt draft §7.6 Payment-Receipt via a browser-safe codec (buildEvmReceipt / serializeEvmReceipt)
Replay protection 3-state atomic store (inflight / consumed / rejected); durable backend required in production

All four credential paths are live end-to-end (see examples/charge-server + examples/charge-demo). For the full picture see docs/ — architecture, spec compliance / extensions, replay store, and example walkthroughs. Release notes are managed with Changesets (.changeset/); CHANGELOG.md is generated at publish time — see docs/releasing.md for the release pipeline.

v1 limits: curated token presets only (no arbitrary BYO ERC-20), and the SDK adds one spec extension (methodDetails.permit2Spender) that draft-evm-charge-00 doesn't define but Permit2 settlement requires — see docs/spec-compliance.md.

Install

pnpm add @bnb-chain/mpp mppx viem

Peers: mppx ^0.6.28, viem ^2.51.0. Node ≥ 22 (development uses Node 22 stable).

Quickstart

For a full end-to-end walkthrough — server route + client credential + receipt, runnable on BSC Testnet — see docs/quickstart.md.

import { Mppx } from 'mppx/server'
import { chargeAsync } from '@bnb-chain/mpp/server'
import { privateKeyToAccount } from 'viem/accounts'
import { createRedisReplayStore } from './your-replay-store-adapter' // see § "Replay store" below

const settlement = privateKeyToAccount(process.env.SETTLEMENT_PRIVATE_KEY as `0x${string}`)

const handler = Mppx.create({
  methods: [
    await chargeAsync({
      chain: 'ethereum',
      token: 'USDC',
      recipient: '0xYourMerchantAddress',
      credentialTypes: ['permit2', 'transaction', 'hash'],
      settlementAccount: settlement,
      challengeBinding: { mode: 'mppx-managed' },
      // Replay store — REQUIRED in production. See § "Replay store" below
      // for what counts as a durable atomic store and the dev-only default.
      store: createRedisReplayStore({ url: process.env.REDIS_URL! }),
    }),
  ],
  secretKey: process.env.MPPX_SECRET_KEY!,
  // No `transport` here — chargeAsync() factory auto-wires evmHttpTransport
  // on the per-method transport slot (spec §13.4.1 C2 auto-wire).
})

// Wire into your HTTP server (Hono / Express / Next.js — see mppx docs).

Replay store

params.store is the replay store — the durable atomic backend that guarantees a given credential settles at most once (reserve → settle → markConsumed). It is independent of challengeBinding: the same store backs mppx-managed, mppx-hmac, and stored-lookup modes. Spec §9 + draft-evm-charge-00 both require:

  1. The store MUST be durable across processes / pods — a single Node process Map is not enough on multi-pod deployments (replay protection silently becomes per-pod, allowing N concurrent settlements of the same credential).
  2. The reserve / mark-consumed transitions MUST be atomicreserve under a key that's already consumed MUST fail without racing.

What the SDK enforces and what it doesn't:

  • NODE_ENV=production + params.store omittedpreflightCharge throws at startup. Production deployments MUST pass a store explicitly.
  • NODE_ENV=production + any params.store → accepted on presence alone. The SDK can't structurally tell a Redis client from a Map wrapper across the FFI boundary, so durability is a deployment-side claim. Honoring spec §9 is on you; the SDK just makes sure you remembered to wire something.
  • NODE_ENV=development / unset + omitted → defaults to Store.memory() (mppx in-process) with a one-time console.warn, so the gap is visible before any production cutover.
  • NODE_ENV=test + omitted → silent default to memory (no log noise in vitest / vp test runs).

Suggested durable backends:

  • RedisSET key value NX for atomic reserve; works on Upstash, AWS ElastiCache, self-hosted. Do NOT attach a backend TTL (PX / EXPIRE) — terminal slots must never expire, and stale inflight slots are reclaimed by reserve() itself (see docs/replay-store.md).
  • PostgresINSERT ... ON CONFLICT DO NOTHING for atomic reserve; works on Neon, Supabase, RDS.
  • Cloudflare KV / DOput with conditional-write for KV, or Durable Objects' single-writer model for stronger consistency.

Store.memory() from mppx is acceptable only for tests and local single-process development; it MUST NOT back a production deployment regardless of how many replicas are running (spec §9).

For routes that take human-readable amounts, the top-level barrel exports chargeFromDecimal:

import { chargeFromDecimal } from '@bnb-chain/mpp'

const request = chargeFromDecimal({ amount: '1.50', decimals: 6 })
// -> { amount: '1500000' }

Spec Compliance

This SDK implements draft-evm-charge-00 layered on mppx@0.6.28 (commit 5aed74b). The compliance choices + the one spec extension are summarized below; full detail (incl. permit2Spender) lives in docs/spec-compliance.md.

1. Challenge binding

This deployment uses the challengeBinding.mode configured on ServerParameters. Three modes are supported:

  • 'mppx-managed' — Compose with Mppx.create()'s HTTP entry. mppx automatically runs Challenge.verify + Expires.assert; the SDK helper only enforces the method='evm' + intent='charge' guards.
  • 'mppx-hmac' — Bare Method.toServer(...).verify path. The SDK helper runs the full Challenge.verify({ secretKey }) + Expires.assert chain because Mppx.create() is not in the pipeline. Use this for custom integrations.
  • 'stored-lookup' (draft §6 zero-deviation) — the deployment persists every issued challenge via rememberChallenge(challengeStore, challenge) at issuance time. On verify, the SDK helper re-derives the canonical wire form of each auth-param field from the inbound credential and constant-time compares against the stored snapshot. Standalone wrt HMAC — the deployment can run completely without a server secret. Production requires a durable Store.AtomicStore<ChallengeItemMap> backend (Redis / Postgres / Cloudflare KV); Store.memory() is test/dev only.

2. Receipt compatibility

draft-evm-charge-00 §7.6 requires EVM Charge receipts to include
`method`, `challengeId`, `reference`, `status`, `timestamp`, `chainId`,
and optionally `externalId`.

Current mppx `Receipt.Schema` does not preserve `challengeId` / `chainId`.
v1 ships a SDK-provided `evmHttpTransport` and the `charge(...)` / `chargeAsync(...)`
factory wires it on the per-method transport slot automatically — deployments
do NOT need to (and should not) configure `Mppx.create({ transport })` for
this. The resulting `Payment-Receipt` header preserves the full EVM Charge
receipt payload deterministically, independent of any future change to mppx
default `Receipt.serialize` behavior.

No deployment-side wiring is required — the Quickstart example deliberately omits transport from Mppx.create.

3. v1 token support

v1 token support:
- v1 only supports curated token presets.
- Custom ERC-20 / BYO token is not supported in v1.
- `currency` on wire is always the resolved curated ERC-20 address.
- BYO token metadata, token registry input, EIP-3009 probing, and
  arbitrary ERC-20 support are future work.

Preset name semantics (hard rule):
- `USDC` preset means Circle native USDC only.
  BSC has no Circle native USDC; the curated matrix does NOT include
  (`bsc`, `USDC`). Binance-Peg USDC must not be represented as `USDC`
  in v1; if introduced later it must use a distinct preset name.
- Bridged / wrapped variants of `USDT`, `EURC`, `FDUSD`, `U` are
  subject to the same rule: do not reuse the native preset name.
- `FDUSD` preset means the First Digital Labs "First Digital USD"
  token (BSC mainnet `0xc5f0...6409`).
- `U` preset means the official "$U" / United Stables token (BSC
  mainnet `0xcE24...6666`). Symbol on-chain is the single letter `U`;
  the product name is "$U". The BSC testnet sibling deploy at
  `0x2Ae9...0a66` does NOT implement EIP-3009 (different deployment)
  and is intentionally absent from the matrix.
- `TEST_USDT` is a testnet-only preset. It MUST NOT appear in any
  mainnet curated matrix entry, and it MUST NOT be treated as Tether
  official mainnet `USDT`. Its contract addresses come from testnet
  deployments (mock / third-party / self-deployed) that have no
  official Tether provenance; do not rely on EIP-3009, decimals,
  or mint authority assumptions from mainnet `USDT`.

4. permit2Spender (spec extension)

draft-evm-charge-00 does not define a methodDetails.permit2Spender field, but Permit2 settlement requires one: Permit2 hashes msg.sender (the on-chain caller) as the EIP-712 spender, so the payer must sign Permit2 typed data with the settlement signer's address or the on-chain permitWitnessTransferFrom reverts InvalidSigner. The SDK adds permit2Spender to methodDetails (OPTIONAL on the wire; REQUIRED for permit2 credentials) and the server factory injects it automatically from settlementAccount.address. It affects only the permit2 path — hash / transaction / authorization are unaffected. Full rationale + the verifier cross-check in docs/spec-compliance.md and docs/adr/0001-permit2-spender.md.

Tokens

The SDK ships a curated matrix — every (chain, token) pair lands alongside its explorer-verified address, on-chain decimals() confirmation, and matrix-lock unit tests (src/server/curated.test.ts). EIP-3009 entries additionally have their domain name/version derived from on-chain DOMAIN_SEPARATOR() and locked in the same test file. A newly added pair ships with authorization off (advertising permit2 / transaction / hash) until that on-chain transferWithAuthorization + DOMAIN_SEPARATOR() probe confirms EIP-3009 support and the exact domain — inferring the domain from the token symbol would be a verification bug. Live settlement tests are added separately, alongside the corresponding test fixtures, and are gated under the live vitest project (see test/live/*.live.test.ts).

The table below lists the EIP-3009-enabled anchors (where the authorization path is live). Broader issuer-native stablecoin coverage (probe-gated, authorization off) is summarized under Expanded coverage.

Chain Token Contract Decimals EIP-3009
ethereum USDC 0xa0b8...eb48 6 yes (Circle native, domain USD Coin / 2)
ethereum USDT 0xdac1...1ec7 6 no
base USDC 0x8335...2913 6 yes (Circle native, domain USD Coin / 2)
bsc BINANCE_PEG_USDT 0x55d3...7955 18 no
bsc FDUSD 0xc5f0...6409 18 yes (First Digital Labs, domain First Digital USD / 1)
bsc U 0xcE24...6666 18 yes (United Stables $U, domain United Stables / 1)
sepolia USDC 0x1c7D...7238 6 yes (Circle native, domain USDC / 2) — testnet, see examples/charge-demo
bsc-testnet TEST_USDT 0x3376...4dDd 18 no — testnet-only (PancakeSwap test USDT), see examples/charge-server

Expanded coverage

Issuer-native stablecoins curated across the supported chains. These advertise permit2 / transaction / hash; authorization (EIP-3009) is off pending a per-chain probe (see the note above). Contract addresses + decimals for every pair live in src/server/curated.ts and are locked by src/server/curated.test.ts.

  • Circle USDCarbitrum, optimism, polygon, avalanche, linea, plus testnets base-sepolia, arbitrum-sepolia, optimism-sepolia, polygon-amoy, avalanche-fuji, linea-sepolia. Source: Circle USDC addresses.
  • Circle EURCethereum, base, avalanche, plus testnets sepolia, base-sepolia, avalanche-fuji. Source: Circle EURC addresses.
  • PayPal USD (PYUSD)ethereum, arbitrum, plus testnets sepolia, arbitrum-sepolia. Source: Paxos PYUSD.
  • Paxos USDP / USDGethereum. Source: Paxos USDP / Paxos USDG.
  • First Digital USD (FDUSD)ethereum, arbitrum (plus the existing bsc entry, which is EIP-3009-probed). Source: First Digital Labs.
  • Tether USD₮avalanche only (issuer-native; bridged L2 USDT on Arbitrum/Optimism/Polygon is deliberately excluded). Source: Tether supported protocols.
  • BNB Chain (BSC) — native EIP-3009 tokens FDUSD and U are in the table above. Bridged stablecoins use distinct BINANCE_PEG_* names so they never wear native-issuer semantics: BINANCE_PEG_USDC (0x8AC7…580d, 18 dec, not Circle-native USDC's 6), BINANCE_PEG_USDT (BSC-USD, 0x55d3…7955, 18 dec — migrated off the bare USDT alias, which now means native Tether only, e.g. Ethereum), and BINANCE_PEG_DAI (0x1AF3…DBc3, 18 dec) — all standard BEP-20, no EIP-3009.
  • opBNB — deliberately not in the default matrix yet. Stablecoin provenance there (native vs bridged, exact addresses, decimals) must be cross-verified against the BNB Chain bridge/token list, opBNBScan verified-contract pages, and on-chain decimals() / symbol() / name() probes before any entry lands.

Chains

Preset chainId Default confirmations Notes
ethereum 1 12
base 8453 1
arbitrum 42161 1
optimism 10 1
polygon 137 5 reorg buffer
avalanche 43114 1
linea 59144 1
bsc 56 3 reorg buffer
opbnb 204 1
sepolia / _-sepolia / _-amoy / avalanche-fuji / bsc-testnet / opbnb-testnet various 0 dev velocity

The default confirmations depth is overridable via ServerParameters.confirmations and applies to all four credential paths — the verification depth for hash / transaction AND the settlement-receipt wait for permit2 / authorization. Two related ServerParameters knobs: settlementTimeoutMs caps how long the settling verifiers hold the HTTP request open waiting for the settlement receipt (unset → viem's 180 s default; set it below your load balancer's idle timeout), and inflightTtlMs sets the age after which a stale inflight replay slot becomes reclaimable by a retry (default 10 min; keep it comfortably above settlementTimeoutMs — see docs/replay-store.md).

Permit2 deployment is auto-probed at preflightCharge time via eth_getCode against the resolved address. v1 does not open arbitrary BYO chain — rpcUrl and chainOverride may only override an existing preset's RPC / viem Chain metadata.

Scripts

pnpm install        # via corepack, pnpm@11.0.8
pnpm check          # vp lint --fix + vp fmt --write
pnpm check:ci       # vp lint + vp fmt --check
pnpm check:types    # tsgo -b + tsgo -p tsconfig.test.json + example workspaces
pnpm test           # vp test --config vite.config.ts (unit + interop + live)
pnpm build          # zile -> dist/

# Vitest projects: pass flags through with `--` so pnpm doesn't
# intercept them as its own options.
pnpm test -- --project unit       # source + test/unit
pnpm test -- --project interop    # cross-impl / wire compat
pnpm test -- --project live       # gated on testnet RPC + signing keys

License

MIT

About

No description, website, or topics provided.

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors