Browse the beta.3 manual
docs/PILOT-GUIDE.mdHistorical record / original scope retained

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

  1. Create or select a dedicated merchant wallet in Railway Wallet on a trusted device. Do not create the spending wallet on the PPOps server.

  2. 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
  3. Record the receiver’s 0zk address. 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.

Source evidence