HSP is a verifiable settlement layer for stablecoin payments.
Instead of trusting a payment processor’s “paid” flag, every payment carries cryptographic
evidence that anyone can re-check. A payer signs a Mandate; an adapter settles it
and emits a signed Receipt; a verifier checks the pair (plus any compliance
Attestations) against one rule:
You do not need to run any infrastructure. The SDK is open source at
github.com/project-hsp/hsp — the canonical developer
guide & full API reference live there in
docs/guide.md.
This page is a 5-minute onboarding front door for this deployment: the organizer hosts the shared
sandbox, so you point the SDK at it and pay. The sandbox values marked <…> below
(Coordinator URL, API key, faucet) are shared once it is live.
Payment rails move money; almost none give the relying party a cryptographic answer to “did the thing I authorized actually happen, under the conditions I demanded?” HSP is that answer layer. The flow is always the same three moves:
The payer signs a mandate — an EIP-712 message: pay this much of this token to this recipient on this chain, meeting these requirements.
The payer’s own wallet broadcasts the on-chain transfer. HSP is zero-custody: no service ever holds your funds.
A verifier independently checks the receipt against the mandate and a pinned trust policy. ACCEPT means ship — and never requires trusting the Coordinator.
Three wire objects carry the evidence — and that is the entire protocol surface:
| Object | Signed by | Says |
|---|---|---|
| Mandate | the payer | “I intend to pay X to Y, and I require capabilities Z” |
| Receipt | an adapter | “I observed the settlement that satisfies this mandate” |
| Attestation | an issuer | “I vouch the subject is KYC’d / not sanctioned / …” |
One pay() call drives five moves. Here is what actually happens on the wire — and which of
them a relying party can re-check:
The SDK assembles the EIP-712 Mandate (payer, token, amount, recipient, chainId, deadline,
requiredCapabilities, nonce) and your wallet signs it. paymentId = keccak256(mandate).
POST /payments hands the signed mandate (plus any attestations) to the Coordinator, which
runs an admission check and stores it as PROPOSED.
Your own wallet broadcasts the ERC-20 transfer(recipient, amount). No service ever
touches the funds — this is what zero-custody means concretely.
POST /payments/:id/observe with the txHash. The Coordinator waits for confirmations, finds
the matching Transfer log, and the adapter key signs a Receipt attesting exactly
what it saw.
The verifier checks the receipt’s proof (and attestations) against the mandate and a pinned policy.
requiredCapabilities ⊆ satisfiedCapabilities ⇒ ACCEPT
⇒ status SETTLED. A merchant re-runs this step alone, offline, to
decide whether to ship — no trust in the Coordinator required.
Compliance is not a bolt-on: KYC and sanctions are ordinary capabilities in those sets, toggled per payment. The protocol is a pre-1.0 draft — these concepts are stable; wire details may still change.
Every scenario below is implemented, tested against real chains, and inspectable in the Explorer — the decision trace shows exactly which capability was required and what satisfied it. The matrix is two settlement paths × two policies:
| # | Scenario | What it demonstrates | Requires |
|---|---|---|---|
| 1 | Public · evm-transfer | a plain ERC-20 stablecoin transfer, verified — the hello-world | ∅ |
| 2 | Public · x402 | paying via real Coinbase x402 v2 (HTTP-native, client-pull) | ∅ |
| 3 | Compliant · evm-transfer | a transfer that also carries KYC + sanctions attestations | attests:kyc + attests:sanctions |
| 4 | Compliant · x402 | KYC / sanctions enforced over the x402 path | attests:kyc + attests:sanctions |
Build agentic commerce, paid APIs (x402 paywalls), compliant settlement flows — or your own settlement adapter (§07).
Coming soon — private payments. A later release will add and open up privacy-preserving settlement: hide the recipient (stealth addresses) or the amount (a shielded pool), each with optional view-key disclosure to a regulator. They slot in as adapters — the capability model already reserves room for them, so nothing else on this page changes when they ship.
You consume HSP through a small layered stack — each layer is independently usable, and each lower layer is what the one above is built on. All of it runs from TypeScript source; no build step.
pay() + independent verify() — most developers start hereThe Coordinator is the only service you talk to over the network. It registers mandates, observes
your on-chain settlement, runs the verifier, stores the (mandate, receipt, attestations) triple,
and serves status, a web Explorer, and this portal. It is custody-free — it
signs observations with an adapter key, never moves money.
The SDK is distributed as a repository for the hackathon (not yet on npm). The organizer shares the repo link together with the sandbox details. Then:
Integrated before June 29? The wire changed (breaking) — signatures made against the old struct no longer verify. See the wire changelog (§13) for the two-minute migration.
git clone https://github.com/project-hsp/hsp && cd hsp # everything runs in Docker (no node on your host) — see the repo README, # or use your own Node 20+ toolchain: npm install
Open the faucet page (/faucet/) and paste your address — you receive gas + test USDC, rate-limited per address & IP. Or by API:
curl -X POST -H 'content-type: application/json' \ -d '{"address":"0xYOUR_ADDRESS"}' <FAUCET_URL>/faucet
import { HSPClient } from '@hsp/sdk'; import { resolveChain } from '@hsp/core/chains/index'; const chain = resolveChain('hashkey-testnet'); // pinned testnet USDC const hsp = new HSPClient({ coordinatorUrl: process.env.HSP_COORDINATOR_URL, // <COORDINATOR_URL> apiKey: process.env.HSP_API_KEY, // <API_KEY> signer: { kind: 'privateKey', privateKey: process.env.HSP_PRIVATE_KEY }, chain, }); // USDC has 6 decimals → 1 USDC = 1_000_000 base units const handle = await hsp.pay({ to: '0xRecipient', amount: 1_000_000n }); await handle.awaitSettled(); // → terminal status (usually "SETTLED")
That single pay() call builds the mandate, signs it, registers it, broadcasts the ERC-20
transfer from your own wallet, asks the Coordinator to observe it, and returns a handle.
Open /explorer and paste the paymentId — see the
mandate, the receipt, and the subset chain that produced ACCEPT.
Typed requirements, written verb:object:version. proves:… — the settlement
structurally proves something. attests:… — an issuer vouches for something.
A pure function of (mandate, receipt, attestations, policy). ACCEPT iff
requiredCapabilities ⊆ satisfiedCapabilities. You can run it yourself — you never have
to trust the Coordinator’s word.
Three backends, one wire signature. The signing account is also the settling account
(wallet-settling: Transfer.from must equal the mandate signer).
Equals the mandateHash. Use it to query status, deep-link the Explorer, and de-duplicate:
one on-chain transfer settles at most one payment.
Status is derived from the admitted receipts: PROPOSED → ATTEMPTED → SETTLED → DISPUTED — or terminal FAILED. A mandate past its deadline with nothing admitted reads EXPIRED.
A capability is verb:object:version; the verifier compares canonical sets byte-for-byte —
no fuzzy matching, no partial credit.
| Capability | Verb | Satisfied by |
|---|---|---|
| proves:settlement-verified:v1[via=…] | proves | a trust-minimized settlement proof (SPV / light-client / ZK) |
| attests:kyc:v1 | attests | a KYC attestation from a trusted issuer |
| attests:sanctions:v1 | attests | a sanctions-screening attestation |
A public payment requires the empty set (trivially satisfied). A compliant payment requires
attests:kyc + attests:sanctions — the verifier ACCEPTs only if both are present and
signed by an issuer the deployment’s policy trusts (see the trusted set at /issuers).
The sandbox’s adapters are operator-attested observations, so today they satisfy no
proves:* capability — proves:settlement-verified is reserved in the vocabulary for a
future trust-minimized adapter. The capabilities you can actually require right now are the empty set
(public) and attests:kyc / attests:sanctions (compliant).
One wire signature, three sources for the key — and the signing account is always the settling account
(Transfer.from must equal the mandate signer).
// 1) raw key — scripts / demos / small testnet amounts signer: { kind: 'privateKey', privateKey: '0x…' } // 2) any viem account — mnemonic, HD, hardware wallet signer: { kind: 'viemAccount', account } // 3) a real wallet (browser) — the key never leaves it signer: { kind: 'eip1193', provider: window.ethereum, address }
The SDK fetches matching attestations from the issuer and registers them alongside your mandate; the verifier credits them.
const hsp = new HSPClient({ /* …as above… */ issuerUrl: process.env.HSP_ISSUER_URL }); await hsp.pay({ to: '0xRecipient', amount: 1_000_000n, profile: { compliance: ['kyc', 'sanctions'] }, // signs the caps in });
You sign an HSP mandate and an EIP-3009 authorization; a stock facilitator settles on-chain (you pay no gas), then the Coordinator confirms the transfer and signs the verifiable HSP receipt.
await hsp.payX402({ merchant: '0xMerchant', facilitatorUrl: process.env.HSP_FACILITATOR_URL, amount: 1_000_000n, // profile: { compliance: ['kyc','sanctions'] } // optional });
Pin the adapter’s observation address once (out-of-band, from /chains)
and verify locally — the proof is yours to check.
import { HSPVerifier } from '@hsp/sdk'; const verifier = new HSPVerifier({ chain, adapterAddress: PINNED }); const d = await verifier.verify(mandate, receipt, attestations); if (d.ok && d.outcomeClass === 'ACCEPT') ship();
Gate a route: an unpaid request gets 402 with the price; the client retries with an
X-PAYMENT header, the facilitator settles, and your handler runs.
import { x402Gate } from '@hsp/sdk'; app.get('/premium', x402Gate({ facilitatorUrl, merchant, amount: 10_000n, // 0.01 USDC }), (req, res) => res.json({ data: 'paid content' }));
awaitSettled() polls until a terminal state — then branch on the outcome.
const h = await hsp.pay({ to, amount }); const s = await h.awaitSettled({ timeoutMs: 120_000 }); if (s.status === 'SETTLED') ship(); else console.error(s.status, s.lastDecision?.errorCode);
Want to settle a different way — a new rail, a new proof? @hsp/devkit gives you a
template plus a conformance runner that checks your adapter against the protocol’s generic obligations
using the real verifier. The verifier never changes; only your proof schema does.
# 1. start from a template that already passes conformance cp packages/devkit/template/my-adapter.ts my-team/adapter.ts cp packages/devkit/template/run-conformance.ts my-team/run-conformance.ts # 2. make it settle YOUR way, then self-test: npx tsx my-team/run-conformance.ts # → forged signatures, replays, deadline, observation reuse… all exercised
Then submit (adapterId, instanceKey, signing address, reorgPolicy) to the organizer to be
registered in the sandbox Coordinator’s trust set — payers can settle through you. No protocol changes,
no permission from the spec.
The MCP server is pure and key-less — it holds no private key and signs nothing. It gives an
agent tools to verify, explain, and build HSP objects, and now to pay — but key-lessly: it
prepares the unsigned payment, a wallet MCP (or the user’s wallet) signs it, and it
submits the signed result. @hsp/sdk (HSPClient.pay / payX402) is
still an option for paying from code.
A pure, key-less MCP server: ten tools that construct, verify, explain, and now pay — key-lessly.
It signs nothing and holds no signing key. Paying is prepare → a wallet MCP signs →
submit: hsp_prepare_payment returns the unsigned mandate + settlement in standard wallet-RPC
shapes, the agent routes them to a wallet MCP (Phantom, Coinbase, MetaMask, …) to sign, and
hsp_submit_payment relays the signed result to the Coordinator. HSP_CHAIN is the
only var needed to reason; to pay, add a Coordinator URL + write key (not a signing key).
// .mcp.json — hsp (key-less) + a wallet MCP as the signer "hsp": { "command": "npx", "args": ["tsx", "packages/mcp/src/index.ts"], "env": { "HSP_CHAIN": "hashkey-testnet", // URL + WRITE key for prepare/submit — NOT a signing key: "HSP_COORDINATOR_URL": "<COORDINATOR_URL>", "HSP_API_KEY": "<your-team-key>", // optional — only widen what hsp_verify can check: "HSP_PINNED_ADAPTER_ADDRESS": "0x…" } }, // the EXTERNAL SIGNER — agent routes prepare's toSign[] here, then submits: "phantom": { "command": "npx", "args": ["-y", "@phantom/mcp-server@latest"] }
An AI skill that teaches an agent to verify & reason about HSP payments — verify / explain a
received payment, inspect/decode wire objects, resolve capabilities, and check requirements. It moves no
money and holds no key. Drop it into your agent’s skills directory. (To pay, the hsp MCP
prepares the unsigned payment and a wallet MCP signs it — key-less; @hsp/sdk is still an option
for paying from code.)
| Tool | Does |
|---|---|
| hsp_verify | run the verifier over (mandate, receipt[, attestations]) — ACCEPT iff required ⊆ satisfied; pins the adapter, doesn’t trust a Coordinator |
| hsp_explain | the same decision narrated — ship?, outcomeClass → action, error-code meaning, required vs provided caps, trust boundary |
| hsp_inspect | decode a mandate / receipt / attestation into plain labelled fields (read-only) |
| hsp_capability | resolve verb:object:version → id + meaning, or list the baseline vocabulary |
| hsp_capability_diff | compare required vs satisfied capability sets → what’s missing |
| hsp_build_requirements | emit a §7.7 PayeeRequirement (public | compliance) |
| hsp_check_requirements | pre-flight: does a mandate satisfy a given PayeeRequirement? |
| hsp_build_mandate | construct an UNSIGNED Mandate + its mandateHash (signing is external — a wallet MCP or @hsp/sdk) |
| hsp_prepare_payment | prepare a payment → register the mandate and return the UNSIGNED toSign[] in standard wallet-RPC shapes (eth_signTypedData_v4 mandate + eth_sendTransaction / EIP-3009 settlement). Signs nothing — route to a wallet MCP / wallet |
| hsp_submit_payment | relay the externally-signed mandate + settlement to the Coordinator — re-verifies the signature (a tampered body is rejected), observes, returns SETTLED. Holds no key, only a write key |
| Method | Path | Purpose |
|---|---|---|
| POST | /payments | register a signed mandate (+ attestations) |
| POST | /payments/:id/observe | ask the Coordinator to observe your settlement tx |
| POST | /payments/:id/receipts | submit an adapter-signed receipt (for custom adapters) |
| GET | /payments/:id | payment status + the stored triple |
| GET | /payments/:id/explain | label-resolved decision trace (what the Explorer shows) |
| GET | /payments | browse all payments — the detail list (Bearer key) |
| GET | /requirements?chain= | the deployment’s requirement advertisement |
| GET | /chains | chain registry + the adapter address to pin |
| GET | /stats | public aggregate dashboard (JSON) |
| GET | /docs, /explorer | this portal + the decision-trace UI |
| GET | /issuers | compliance attestation issuers this deployment trusts |
| GET | /adapter-operators | adapter operators whose signed receipts this deployment produces |
| GET | /verify | be your own verifier — what to pin + a copy-paste @hsp/sdk snippet (trustless, you run it) |
| POST | /verifier/verify | hosted convenience verifier (separate service) — runs verify() for you; using it trusts that operator |
Write endpoints need Authorization: Bearer <API_KEY>. paymentId = mandateHash
(idempotent); a 202 from observe means the tx is still confirming — the SDK retries for you.
# POST /payments Authorization: Bearer <API_KEY> { "chain": "hashkey-testnet", "mandate": { "body": { /* 8 EIP-712 fields */ }, "signature": "0x…" }, "attestations": [ /* optional — for compliant payments */ ] } # → 201 { "paymentId": "0x…", "status": "PROPOSED" }
# POST /payments/0x…/observe Authorization: Bearer <API_KEY> { "txHash": "0x…" } # → 200 observed & verified | 202 still confirming (retry) # GET /payments/0x… (public — no key needed) { "status": "SETTLED", "mandate": { /* … */ }, "receipts": [ { /* adapter-signed */ } ], "lastDecision": { "ok": true, "outcomeClass": "ACCEPT" } }
Every verifier decision carries an outcomeClass — the HTTP-status-code of settlement.
Branch on the class; log the errorCode.
const d = await verifier.verify(mandate, receipt, attestations); switch (d.outcomeClass) { case 'ACCEPT': ship(); break; case 'RETRYABLE': await retryLater(); break; case 'POLICY': fix(d.errorCode); break; // mandate / attestations case 'PERMANENT': giveUp(d.errorCode); // structurally invalid }
| Class | Meaning | What to do |
|---|---|---|
| ACCEPT | settled and verified under your policy | ship |
| RETRYABLE | transient — not observable yet, stale state | retry |
| POLICY | a policy / requirement isn’t met | fix the mandate / attestations |
| PERMANENT | structurally invalid — contradicts the mandate | give up / debug |
| Frequent code | It means |
|---|---|
| HSP-MAND-EXPIRED | settled after the mandate deadline (an on-time settlement stays verifiable later) |
| HSP-RCPT-SIG | receipt not signed by a trusted adapter — check your pinned address |
| HSP-RCPT-PROOF | amount / recipient / token mismatch — exact-amount only; no fee-on-transfer or multi-log txs on the public path |
| HSP-RCPT-OBS-REUSED | this on-chain transfer already settled another mandate (one settlement, one payment) |
| HSP-MAND-REQ-INSUFFICIENT | mandate doesn’t require everything the deployment’s policy floor demands |
HSP is zero-custody: your wallet settles; the Coordinator only signs observations with an
adapter key — it cannot move funds. A relying party (a merchant, an auditor, a platform) pins the
adapter’s address once (from GET /chains, out-of-band) and runs the
verifier itself. If the Coordinator lied, your independent verification fails — that is the whole design.
Record the adapter address once and hardcode it. Never re-fetch it from the party you are trying not to trust.
Example/script signing keys are for small testnet amounts. Anything that matters belongs in a real wallet — use the eip1193 path; the key stays in the wallet. (The MCP holds no key — paying is done by @hsp/sdk.)
The faucet rate-limits per address & IP; team API keys are per-team — don’t share. Keep any payer key you hand a script or agent minimally funded — testnet where you can; on mainnet, only what the payment needs.
The hackathon sandbox runs the full stack so you run nothing yourself. One set of endpoints serves both HashKey Chain networks — testnet (chainId 133) and mainnet (177). You choose per payment with the chain field; there is nothing separate to spin up or connect to for mainnet. One HTTPS host fronts every service; point your SDK at these base URLs:
| You set | Value | What it is |
|---|---|---|
| HSP_COORDINATOR_URL | <COORDINATOR_URL> | the hub — register, observe, verify, Explorer (both networks) |
| HSP_ISSUER_URL | <COORDINATOR_URL>/issuer | mock compliance issuer — testing only (kyc + sanctions) |
| HSP_FACILITATOR_URL | <COORDINATOR_URL>/facilitator | per-network x402 facilitator — the one endpoint that differs by network. This URL is testnet only; for mainnet x402 use <COORDINATOR_URL>/facilitator-mainnet. (Each holds its own gas key — see §12 mainnet notes below.) |
| HSP_API_KEY | get one → /register | your team’s write key (Bearer) — self-service; works on both networks |
| HSP_CHAIN | hashkey-testnet | hashkey | which network this payment settles on — hashkey-testnet (start here) or hashkey (mainnet, real money). Same endpoints either way. |
Faucet (testnet gas + USDC): /faucet/ — rate-limited per address & IP.
The adapter address to pin for independent verification is published at /chains.
No external accounts to register. Grab your own API key at /register (self-service), and generate your own testnet wallet (a private key) funded from the faucet. No Circle, Coinbase, KYC-provider, RPC, or npm sign-up — the mock issuer and the x402 facilitator are part of this sandbox. The Explorer is public: looking up a payment by its id needs no key (an API key is only for browsing the full payment list).
| Name | Chain ID | Stablecoin | Note |
|---|---|---|---|
| hashkey-testnet | 133 | USDC · 6 dec | faucet-friendly — start here · RPC testnet.hsk.xyz |
| anvil-dev | 31337 | per-run MockERC20 | local development |
| hashkey | 177 | USDC.e · 6 dec | mainnet — real money |
| ethereum | 1 | USDC · 6 dec | mainnet — real money |
Registry defaults shown; when this page is served by a live Coordinator the table reflects its actual configuration.
This sandbox also serves hashkey — HashKey Chain mainnet — for public payments. Everything about the flow is identical to testnet, except that it is real money and you are on your own for funds:
Start on hashkey-testnet; move to mainnet only when your flow is stable. Same base URLs, same API key — just chain: "hashkey" in your SDK config and mandates (chainId: 177).
This sandbox tracks the current wire (v1). If you vendored the repo or hand-rolled signing before a change landed, this section is how you find out — and how you migrate.
Symptom if you are still on v0: your signature verifies locally, but POST /payments rejects with HSP-MAND-SIGNER (PERMANENT). The signature itself is fine — it is over the old struct, so the digest this verifier recomputes no longer matches the one you signed. The rejection’s errorDetail reports the recovered address and the server-side digest, so the mismatch is directly visible.
| Aspect | v0 (before 2026-06-29) | v1 (current) |
|---|---|---|
| EIP-712 primaryType | MandateBody | Mandate |
| Struct fields | 8 | 11 — adds grantRef, requirementRef, settlementBinding (all bytes32; zero for a simple self-pay) |
| Delegation | — | optional DelegationGrant — a Principal authorizes an Agent to sign mandates; payer of record stays the Principal |
| Receipt | preimage typehash also changed (old receipts do not cross-verify) | |
| EIP-712 domain | unchanged — name "HSP", version "1", chainId 133, verifyingContract 0x0000…0001 | |
The v1 typed data (sign exactly these fields, in this order):
Mandate {
nonce bytes32, signer Signer, grantRef bytes32, requirementRef bytes32,
recipient Recipient, token address, amount uint256, chainId uint256,
deadline uint64, settlementBinding bytes32, requiredCapabilitiesHash bytes32
}
Signer { profileId bytes32, payload bytes }
Recipient { kind uint8, payload bytes }
Empty requiredCapabilities still hashes to bytes32(0); signer.payload is still abi.encode(address).
Migration: update your vendored repo to current main and re-sign — nothing else in your integration changes. If you hand-roll the typed data: switch primaryType to Mandate, add the three new fields as 32 zero bytes in the order above, re-sign. The last v0 state is frozen at tag v0-mandatebody — diff it against main to see every wire change: git diff v0-mandatebody main -- packages/core.
v0 and v1 signatures cannot cross-verify (different typehashes) — fail-closed by design, never a silent wrong-accept. If you hit anything this page does not explain, the errorDetail string in the rejection is the first thing to share when asking for help.