Controlled pilot guide
The controlled pilot procedure; not independent adoption evidence.
This manual targets 0.1.0-beta.3. Read the source and availability policy before installing. Historical reports retain their original scope.
Historical evidence, not a new 0.1.0-beta.3 verification. Read the record's date and version before interpreting any PASS result.
This guide runs one real PPOps payment without ever placing a mnemonic or RAILGUN spending key on the PPOps host. Complete it before inviting an external merchant.
Components and trust
- Merchant receiver wallet: a full RAILGUN wallet kept on a separate trusted device. It owns the receiver and exports only its shareable viewing key to PPOps.
- PPOps host: stores the viewing key, encrypted RAILGUN database key, merchant identity key, API token and local reconciliation database. It cannot spend receiver funds.
- Payer wallet: an independent full RAILGUN wallet with enough private native Arbitrum USDC for Gate A. Its separate public self-signer needs Arbitrum ETH; Gate B additionally needs private balance for the Broadcaster fee.
- External infrastructure: two independent Arbitrum RPC origins, at least one compatible PPOI node, the RAILGUN indexing/artifact dependencies inherited by the Wallet SDK, and a RAILGUN Broadcaster/Waku path for payer submission.
The reference payer is the separate ppops-payer diagnostic harness built
directly on the official RAILGUN Wallet SDK. Railway Wallet remains optional
compatibility evidence; its slow and ambiguous synchronization no longer blocks
the PPOps mainnet gate.
1. Prepare the merchant receiver
-
Create or select a dedicated merchant wallet in Railway Wallet on a trusted device. Do not create the spending wallet on the PPOps server.
-
In wallet settings, open Show Shareable Viewing Key. Copy only that key into a new server-side file and set mode
0600:chmod 600 /secure/path/merchant.viewing-key -
Record the receiver’s
0zkaddress. The shareable viewing key reveals the complete receiver history across supported chains and cannot be revoked for that wallet. Transport and store it as confidential financial data.
2. Initialize PPOps
Use native Arbitrum USDC, not bridged USDC.e:
npm ci
npm run build
node dist/cli.js init \
--config ./ppops.config.json \
--viewing-key-file /secure/path/merchant.viewing-key \
--network Arbitrum \
--token-address 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 \
--token-symbol USDC \
--token-decimals 6 \
--rpc-url https://your-first-rpc.example \
--rpc-url https://your-independent-rpc.example \
--rpc-url https://your-third-rpc.example \
--poi-node https://your-compatible-ppoi.example \
--webhook-url http://127.0.0.1:8790/webhooks/ppops
node dist/cli.js config-validate --config ./ppops.config.json
node dist/cli.js preflight --config ./ppops.config.json
The Wallet SDK currently documents https://ppoi.fdi.network as a public
community aggregator. It passed PPOps ppoi_health preflight on 2026-08-23,
but that health check proves only API compatibility and point-in-time
reachability. Production availability, list policy and trust remain the
operator’s responsibility.
For a self-pilot, start the local evidence receiver in a second terminal. It is loopback-only, verifies the exact HMAC body and headers, rejects event-ID/body mismatches, persists event IDs and event types for deduplication, and never stores event payloads:
npm run pilot:webhook-receiver -- \
--key-file ./secrets/webhook-hmac-key \
--key-id v1 \
--state-file ./pilot/webhook-events.sqlite \
--port 8790
This receiver is gate tooling, not a production merchant backend. A real merchant must implement the same verification and durable event-ID dedupe in its fulfillment system.
Publish the merchant signer printed by init through an authenticated channel
that is independent of the checkout server. Start PPOps only after the payer or
pilot operator has pinned that signer:
node dist/cli.js serve --config ./ppops.config.json
3. Create the pilot intent
Do not create a short-lived intent until the payer already has enough native
USDC in the Spendable private balance bucket for the exact Gate A payment (and,
for Gate B, the Broadcaster fee). A first-time shield enters RAILGUN’s
Unshield-Only Standby Period (initially about one hour) before it becomes
spendable. Funding and
shielding are onboarding, not checkout; complete them first. Otherwise the
intent may expire while the payer is still in ShieldPending.
Read the generated API token from its file into the merchant backend’s secret store. Do not paste it into tickets or public evidence. Create an intent with a new idempotency key:
POST /v1/intents
Authorization: Bearer <API token>
Idempotency-Key: pilot-order-0001
Content-Type: application/json
{
"externalReference": "PILOT-ORDER-0001",
"amountAtomic": "10000",
"expiresAt": <current Unix time + 3600>
}
Replace the angle-bracket expression with an integer immediately before sending
the request; it deliberately makes the displayed body illustrative rather than
copy-paste JSON. 10000 atomic units is 0.01 USDC. Retry the identical
request and verify that PPOps returns the same intent. Keep the returned
checkout path, recipient, memo and descriptor together.
For a deliberately late-payment test, use a separate intent and label that
evidence explicitly. Do not accidentally turn the primary happy-path pilot
into PAID_LATE by starting checkout before payer readiness.
4. Pay with the reference SDK harness
Run ppops-payer on the independent payer host, never inside the PPOps process.
Initialize it with a creation block at or before the payer’s first RAILGUN note,
place its recovery mnemonic in an owner-only local file, derive the expected
Railway-compatible public gas signer without printing its key, and synchronize:
cd tools/ppops-payer
node dist/cli.js derive-self-signing-key \
--config ./payer.config.json \
--expected-address PINNED_PAYER_EVM_ADDRESS \
--derivation-index 0
node dist/cli.js sync --config ./payer.config.json
Stop unless the returned 0zk address is the independently expected payer and
native USDC is in the Spendable bucket. Then verify the fresh request:
node dist/cli.js request-verify \
--request http://127.0.0.1:8787/pay/INTENT_ID/request.json \
--expected-signer PINNED_MERCHANT_SIGNER
Gate A deliberately self-signs so Railway and Broadcaster behavior are excluded from the primitive test. It publicly links the supplied EVM signer to the otherwise encrypted transfer. Submit only with explicit identity, amount and gas bounds:
node dist/cli.js prepare-self-signed \
--config ./payer.config.json \
--request http://127.0.0.1:8787/pay/INTENT_ID/request.json \
--expected-signer PINNED_MERCHANT_SIGNER \
--expected-payer PINNED_PAYER_0ZK_ADDRESS \
--expected-self-signer PINNED_PAYER_EVM_ADDRESS \
--max-amount-atomic 10000 \
--max-gas-cost-wei 1000000000000000
node dist/cli.js pay-self-signed \
--config ./payer.config.json \
--request http://127.0.0.1:8787/pay/INTENT_ID/request.json \
--expected-signer PINNED_MERCHANT_SIGNER \
--expected-payer PINNED_PAYER_0ZK_ADDRESS \
--expected-self-signer PINNED_PAYER_EVM_ADDRESS \
--max-amount-atomic 10000 \
--max-gas-cost-wei 1000000000000000 \
--confirm-intent INTENT_ID
node dist/cli.js finalize-poi \
--config ./payer.config.json \
--intent-id INTENT_ID \
--expected-payer PINNED_PAYER_0ZK_ADDRESS
The example gas bound is 0.001 ETH; it is a maximum, not a target or fee
estimate. The harness validates the signed descriptor, exact native-USDC amount,
recipient, memo, private balance, RAILGUN proxy target and zero ETH value before
submission. It reloads the live request after proof generation, computes and
journals the transaction hash before broadcast, and waits up to two minutes for
a receipt. PENDING, SUBMITTING or SUBMITTED is not permission to retry;
resolve the recorded hash first.
The mined transfer’s output is not immediately PPOI-spendable. finalize-poi
uses the exact MINED journal record to locate the payer’s sent commitments,
generate the output PPOI and require ProofSubmitted or Valid
acknowledgement. It does not create another payment. Supply
--expected-railgun-txid when independent receiver evidence is available and
should be cross-checked.
The preparation command exercises sync, proof generation, population and every
bound but returns paymentSubmitted: false; it neither signs nor journals a
transaction. Inspect that result before running the value-bearing command.
Controlled result on 2026-08-30: the direct SDK payer prepared a 0.01 USDC
Arbitrum transfer in 7.8 seconds, confirmed sufficient spendable native USDC,
generated the proof and bounded the populated transaction at
56190171212000 wei maximum gas cost. It created no submission-journal record
and broadcast no transaction. The later approved submission mined once with a
populated maximum of 54267840000000 wei under the 0.001 ETH ceiling. PPOps
observed the exact settlement, held it pending while PPOI was missing, then
recorded FINALIZED + SPENDABLE + MATCHED -> PAID after payer-side PPOI
submission. Restart, duplicate-webhook and isolated-restore snapshots passed.
A final repeat after cleanup failures were made fatal completed in 10.7 seconds,
reported 54286600000000 wei maximum gas cost and again left
recorded: false. The variance is expected from live fee and RPC conditions;
neither value is a future gas quote.
After a successful first sync, the encrypted payer wallet loads without the mnemonic. Keep the recovery backup offline and remove the mnemonic from the operational payer host if desired. Do not remove the encrypted database key or self-signing key needed by this diagnostic path.
After Gate A reaches PAID, Gate B replaces the public self-signer with a Waku
Broadcaster. The implemented path does not load the optional EVM key. The payer
mnemonic, spending key and wallet database remain off the PPOps host in both
modes.
Gate B: Broadcaster path
First pin the reviewed fee-signer list in an ignored owner-only file and run the
non-financial preflight documented in
tools/ppops-payer/docs/GATE-B.md:
node dist/cli.js broadcaster-preflight \
--config ./payer.config.json \
--broadcaster-config ./broadcaster.config.json
The controlled 2026-08-30 preflight found at least five LightPush and at least
five Filter peers and a ready native-USDC quote with observed reliability
between 0.84 and 1. It did not open the payer wallet, create a proof or
submit a transaction.
The later complete no-send preparation did open the existing payer wallet and
generate the private-transfer proof. It observed a 70373-atomic (0.070373 USDC) Broadcaster fee for the 10000-atomic payment and a 1226761 gas
estimate with three-provider agreement. It returned paymentSubmitted: false,
wrote no journal and left the merchant intent open with zero received. The
observed fee is not a later quote or spending authorization.
After the failed funded trial, a second no-send diagnostic exercised the
post-proof guard. Its pre-proof estimate was 1128365, the exact populated
calldata simulated at 1123239 through all three configured RPCs, and the live
fee was 64892 atomic (0.064892 USDC). It returned
paymentSubmitted: false and wrote no journal. This rules out an obvious final
calldata/proof rejection under those RPC views, but it does not validate remote
fee extraction, PPOI checks, Broadcaster runtime health or transaction sending.
For a new, independently approved intent, run prepare-broadcaster with exact
amount and atomic-USDC fee ceilings. Only after reviewing that no-send result may
the operator run pay-broadcaster with --confirm-intent. After proof
generation, a strict configured-RPC majority must first simulate the exact
populated proxy calldata with a positive gas estimate. Before that simulation,
the payer also rejects any populated input reserved by another active journal
record. Either failure aborts before journal mutation or Waku. The payer then
reloads the live signed request and journals the exact encrypted-submission
quote fingerprint, bounded fee, payer identity and nullifiers before Waku. It
tolerates fee-ID/expiry rotation only when the Broadcaster address, token and
fee rate remain identical; otherwise it requires a new proof.
A Waku-returned hash is only an untrusted report: the payer must recover the
canonical transaction hash from those nullifiers before receipt lookup or
SUBMITTED. Gas reads use a bounded upper-median policy, while a strict
configured-provider majority must agree on an identical receipt before
MINED. PENDING or an error must first be resolved with
recover-broadcaster. Only an explicitly eligible retry-broadcaster run may
submit another variant: it preserves the signed request, payer and exact
nullifier set, excludes previously attempted Broadcaster identities and stops
after three retry reservations. A different intent cannot claim those
unresolved nullifiers. Never delete or edit the journal to force a fresh spend.
The first controlled funded lineage did not pass Gate B. One initial submission and three bounded same-nullifier variants used two selected Broadcaster identities; none returned a usable hash, and recovery more than 15 minutes after the final attempt found no canonical transaction. Its retry cap is exhausted, so that lineage remains in manual review and no more variants may be sent.
A follow-up prepare-only proof selected at least one input from that unresolved
lineage and was rejected with SUBMISSION_ALREADY_RECORDED before final
simulation or Waku. The Wallet SDK’s Spendable balance is therefore not, by
itself, authority to create a fresh local intent while a prior nullifier remains
reserved.
A later independently funded payer lineage passed Gate B on 2026-08-30. After
the fresh shield became Spendable, prepare-only mode observed a 0.067110 USDC fee and passed final-calldata quorum simulation. The value-bearing command
paid 0.01 USDC with a 0.066912 USDC Broadcaster fee, resolved the reported
hash independently from reserved nullifiers, mined on the first attempt, used
no EVM self-signer, completed all output PPOI and reached PPOps
FINALIZED + SPENDABLE + MATCHED -> PAID. The signed restart/restore report was
regenerated from that payment.
Gate B confirms that the transaction was successfully submitted through a Broadcaster without using a public EVM self-signing key.
5. Accept the payment
Do not fulfill based only on the payer’s transaction screen. PPOps must observe the settlement and derive all three required dimensions:
chainStatus = FINALIZED
poiStatus = SPENDABLE
matchStatus = MATCHED
Only then may the intent become PAID or PAID_LATE. Verify the authenticated
intent-status endpoint and exactly one valid payment.confirmed webhook. The
pilot receiver exposes metadata-minimal evidence at:
curl --fail http://127.0.0.1:8790/stats
After one successful payment and any number of identical delivery retries, it
must show payment.confirmed: 1. receivedEventCount may be higher because
PPOps also emits settlement.observed and legitimate state-transition events;
each distinct event ID is still stored only once.
duplicateDeliveriesByType.payment.confirmed must become at least 1 after the
controlled replay below.
Prove an actual duplicate delivery after the original confirmation has reached the receiver. This command reconstructs the exact stored event payload, signs a fresh delivery and fails unless the receiver replies that the event was already persisted:
node dist/cli.js mainnet-gate-replay \
--config ./ppops.config.json \
--intent-id pi_REPLACE_WITH_INTENT_ID
Then capture the pre-restart snapshot. --expected-signer must be the address
that the payer pinned outside PPOps, not an address copied from checkout:
node dist/cli.js mainnet-gate-snapshot \
--config ./ppops.config.json \
--phase before \
--intent-id pi_REPLACE_WITH_INTENT_ID \
--expected-signer 0xREPLACE_WITH_PINNED_SIGNER \
--output ./pilot-evidence/before.json
The snapshot command reruns RPC/PPOI preflight and fails closed unless the runtime uses Arbitrum, native USDC, finalized-block finality, at least two RPC origins, a healthy PPOI node, only finalized/spendable/matched settlements, fresh quorum agreement on every settlement receipt and block hash, exactly one confirmation event, a delivered outbox entry and receiver-side deduplication for the confirmation type. Identifiers are represented by API-token-keyed fingerprints; invoice IDs, private references and transaction identifiers are omitted. These operator snapshots still disclose exact amounts and timestamps, so keep them private.
6. Finish the release gate
Stop and restart PPOps with the original state, wait for /v1/ready, and capture
the second phase with the same base URL:
node dist/cli.js mainnet-gate-snapshot \
--config ./ppops.config.json \
--phase restart \
--intent-id pi_REPLACE_WITH_INTENT_ID \
--expected-signer 0xREPLACE_WITH_PINNED_SIGNER \
--output ./pilot-evidence/restart.json
Create a release backup, restore it under an isolated configuration and start
that restored daemon on another loopback port (for example 8788). Once it is
ready, capture the restore phase using the restored config and origin:
node dist/cli.js mainnet-gate-snapshot \
--config ./restore/ppops.config.json \
--base-url http://127.0.0.1:8788 \
--phase restore \
--intent-id pi_REPLACE_WITH_INTENT_ID \
--expected-signer 0xREPLACE_WITH_PINNED_SIGNER \
--output ./pilot-evidence/restore.json
Finally verify the three authenticated snapshots with the original instance secret set and write the release artifact:
node dist/cli.js mainnet-gate-verify \
--config ./ppops.config.json \
--before ./pilot-evidence/before.json \
--restart ./pilot-evidence/restart.json \
--restore ./pilot-evidence/restore.json \
--output ./artifacts/mainnet-gate-report.json
The verifier requires three distinct daemon-instance fingerprints, the same origin before/after restart, a different origin for restore, stable intent, settlement and single-confirmation fingerprints, and valid keyed attestations. It signs the final metadata-minimal report with the merchant identity key. A reviewer who obtained the expected signer independently can verify that signature without receiving the API token or any wallet secret:
node dist/cli.js mainnet-gate-report-verify \
--file ./artifacts/mainnet-gate-report.json \
--expected-signer 0xREPLACE_WITH_PINNED_SIGNER
The signature authenticates the merchant’s report; it does not pretend to prove that the operator actually followed the commands. Retain terminal/service records alongside the report.
Complete every remaining item in MAINNET-GATE.md. Preserve raw transaction
evidence privately; publish only redacted evidence. Preserve operator verification
records alongside the signed public report.
Optional Railway Wallet compatibility
Railway may still be tested manually by sending the exact amount and exact
ppops:v1 value in its Private memo field. That path is not the reference
gate and a stalled Railway scan is not allowed to block SDK-level validation.