Settlement

The one-sentence version

Every couple of minutes, Plumb takes a stack of fresh receipts and records a single summary of them all on the Base blockchain — so that even if Plumb disappeared tomorrow, your receipts would still be verifiable.

Why bother with the blockchain?

A receipt signed by Plumb is only as trustworthy as Plumb. If the company vanishes, gets hacked, or decides to rewrite history, their signatures alone don't save you.

The blockchain is a public record that no single party — Plumb included — can edit. Once something is on it, it's there forever, and anyone in the world can check.

So Plumb takes a short fingerprint of a batch of receipts and commits it to the chain. Now your receipt is backed by two things: the Plumb signature (easy to check, slightly trust-dependent) and a blockchain record (slow to change, trust-free).

The "end of day at a store" analogy

A bank doesn't wire money for every coffee you buy. It batches transactions and settles them at the end of the day. Coffees are individually small; one daily summary keeps the system cheap and auditable.

Plumb does the same. Individual receipts are cheap to create (pure math on the server). Putting each one on-chain would be expensive — every blockchain transaction costs real money in gas fees. So Plumb rolls up ~500 receipts at a time into one cryptographic summary and puts just that summary on the chain. Every receipt in the batch is covered, but the total on-chain cost is a single transaction.

What "a summary" means here

The summary isn't a zipped file of all the receipts. It's a single 32-byte number — a Merkle root — produced by a special hashing process. The clever property: given the root and a short "proof," anyone can verify that a specific receipt was included in the batch, without needing to see any of the other receipts.

So the chain stores one tiny number and your individual receipt stays private, but the math lets you prove later: "yes, this exact receipt was part of batch X, which was committed at block Y on date Z."

The rhythm

Every two minutes, Plumb's worker wakes up and checks: are there receipts that haven't been committed yet?

  • No receipts → go back to sleep.
  • Yes → take up to 500 of them, build the summary, sign it with an operator key, and submit the transaction to Base.
  • If there are more than 1,000 waiting, it doesn't even wait two minutes — it fires off a batch immediately so nothing sits around too long.

Once the transaction is confirmed on-chain, each receipt in that batch gets stamped in Plumb's database with the transaction hash (settlement_tx_hash). That's the pointer you can follow on Blockscout to see the on-chain record.

What the on-chain record gives you

After a receipt is settled, you get a second, stronger verification path:

  1. The Plumb signature proves the operator issued the receipt.
  2. The on-chain record proves the receipt existed by the time that blockchain block was mined — a timestamp nobody, including Plumb, can rewrite.

If there's ever a dispute — say, a receipt someone claims is backdated — you have an independent, public timestamp to point to.

Handling bumps in the road

A few things could go wrong, and Plumb has handling for each:

  • The worker crashes mid-batch. The next time it wakes up, it asks the chain "have you already seen this summary?" If yes, it skips resubmitting and just updates the database. No double-spend, no double-fee.

  • The worker tries to do two things at once. The same operator key signs both receipt batches and another kind of on-chain call (Pipe, explained separately). If both fire at the same moment, they could collide on the blockchain. Plumb uses an internal queue that forces them to take turns — one always completes before the next starts.

  • The blockchain reorganizes. Occasionally the most recent few blocks on a chain get rewritten. Plumb's watcher notices this by double-checking the block hashes it last saw, and if something changed, it rolls back 10 blocks and replays. For the settlement side specifically, deep reorgs that un-land a committed batch are a known edge case — an operator reconciliation pass would fix any stale transaction pointers. Rare enough that it's a documented limit, not a daily concern.

Verifying your own settlement

If you want to prove a receipt really was in a particular on-chain batch, the SDK gives you a "proof" — a short list of hashes that, when combined with your receipt, reconstruct the batch's summary number.

from plumb_sdk import Client
from plumb_sdk.merkle import verify_membership

c = Client(base_url="https://api.plumbtech.xyz")
proof = c.receipts.settlement_proof("c7f3e0a4-...")

assert verify_membership(
    leaf=(receipt.id, receipt.request_hash, receipt.response_hash, receipt.cost_micro),
    proof=proof.proof,
    root=proof.root,
)

The root in the SDK's response is the same number recorded on Base. You can look it up on Blockscout under the settlement transaction's ReceiptsCheckpointed event and confirm the two match. Again — you're the one doing the check, not Plumb.

Limits worth knowing

  • Settlement isn't instant. Your receipt is valid the moment it's signed. But the on-chain backup takes up to a couple of minutes — or longer if the chain is congested. The settlement_tx_hash field is empty until that happens.
  • Settlement doesn't catch an outright dishonest operator. Same caveat as for receipts and verification: if the operator signs a false claim, the math checks out on the false claim. Settlement proves the claim was committed by a certain time, not that the claim was true. The forthcoming hardware-attestation feature ("TEE attestation") addresses this.
  • Base Sepolia is a testnet today. Plumb currently settles to Base Sepolia, which is a free test version of Base. Migrating to Base mainnet is a planned step as the system hardens.

For developers

The on-chain summary is a Merkle root built with OpenZeppelin's StandardMerkleTree — each leaf is keccak256(keccak256(abi.encode(id, requestHash, responseHash, costMicro))) over ['uint256','bytes32','bytes32','uint256']. Internal nodes use sorted-pair hashing so proofs don't carry position bits.

The worker fires a BullMQ job on the plumb-checkpoints queue every 120 seconds or on 1000+ unsettled receipts. Each job pulls up to 500 rows, builds the tree, checks Settlement.checkpointedRoots(root) for idempotence, EIP-191-signs keccak256(abi.encode(settlementAddress, chainId, "CHECKPOINT", root, count, totalCost)), submits via packages/onchain/src/tx-queue.ts's runExclusive(signerAddress, …) to serialize per-signer nonce, waits one confirmation, and stamps settlement_tx_hash on the included rows.

The watcher tracks chain progress in the onchain_cursors table with a last_block_hash field; detectReorg() in packages/onchain/src/reorg.ts compares stored vs live block hashes and rolls the cursor back REORG_ROLLBACK_DEPTH = 10 blocks on mismatch. The checkpoint side itself relies on a 1-confirmation wait and operator reconciliation for deep reorgs — extending full reorg tracking to the settlement path is a planned follow-up.

Contract: Settlement.checkpointReceipts(root, count, totalCost, sig) is caller-agnostic; only the signer's EIP-191 signature is checked. The signer is owner-rotatable via setSigner(next) using the OpenZeppelin Ownable2Step pattern.