Trust, but verify.
Call inference from a contract.
Plumb gives you two independent verification paths: one for off-chain callers (the ed25519 receipt you verify in your code) and one for on-chain callers (the PipeOracle contract, where a Solidity consumer requests inference and the oracle delivers a signer-cosigned result).
V.01The canonical payload
Every receipt is signed over a fixed, newline-separated text format — not JSON, not a struct. The payload order and field names never change; the v1 prefix is the versioning mechanism.
v1 request_hash=0x7a1b2c... response_hash=0xabcdef... model=anthropic/claude-sonnet-4.5 cost_micro=482000 issued_at=2026-04-22T18:30:15.120Z
Any divergence — different case on the model id, trailing whitespace, millisecond precision change — produces a different signature. The spec is pinned across the TS server, browser clients, and Python SDK with a shared fixture of known-vector inputs + expected sha256 hashes that every implementation has to match.
V.02Client-side verify (3 lines of Python)
from plumb_sdk import Client
c = Client(base_url="https://api.plumbtech.xyz")
rcpt = c.receipts.get("rc_01HVBZ...")
assert c.verify_receipt(rcpt), "signature did not match canonical payload"
print("verified", rcpt.id, "·", rcpt.cost_micro, "μPLMB")verify_receipt rebuilds the canonical payload from your copy of the receipt, fetches the public key for signer_key_id from the key registry, and runs ed25519. The explorer does the same in-browser via @noble/ed25519 — click the "Verify" button on any receipt page.
Signer rotation
The active signer key can be rotated via PLUMB_SIGNER_KEY_ID + PLUMB_SIGNER_KEY_ED25519. Past receipts stay verifiable forever — the key registry loads a list of historical keys from PLUMB_SIGNER_HISTORICAL_KEYS_JSON so a receipt with signer_key_id = srv-2026-03 can still be verified after rotation to srv-2026-05. The /v1/verification/keys endpoint publishes all of them with their validity windows.
PipeOracle: inference for contracts
A Solidity contract calls oracle.request(modelHash, inputHash, callback). The oracle emits JobRequested, the Plumb worker picks up the event, fetches the input bytes by hash, runs inference, and submits fulfill(jobId, result, sig) — an EIP-191 signature over keccak256(address, chainId, "FULFILL", jobId, resultHash).
The oracle recovers the signer, stores fulfilled = true before calling the consumer, and invokes pipeCallback(jobId, result) via a low-level call capped at 100_000 gas. A reverting consumer emits a CallbackFailed(id, reason) event but doesn't undo the fulfilled state — so the same signed result can't be replayed later. Gas griefing is bounded by the stipend.
Minimal on-chain consumer
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IPipeOracle, IPipeCallback} from "@plumb/contracts/src/IPipeOracle.sol";
contract Example is IPipeCallback {
IPipeOracle public oracle;
mapping(uint256 => bytes) public results;
error NotOracle();
constructor(IPipeOracle _oracle) { oracle = _oracle; }
function ask(bytes32 modelHash, bytes32 inputHash) external returns (uint256) {
return oracle.request(modelHash, inputHash, address(this));
}
function pipeCallback(uint256 jobId, bytes calldata result) external {
if (msg.sender != address(oracle)) revert NotOracle();
results[jobId] = result;
}
}↳ The input bytes are uploaded out-of-band via POST /pipe/inputs before the contract call — the hash committed on-chain is the keccak256 of those bytes. See Pipe details.
Roadmap: TEE attestation + zkML
Receipts today carry verification_mode: "vanilla" — the server runs inference, the server signs. Attested modes are specified but gated behind hardware: tee-nitro and tee-sgx will ride a TEE attestation quote alongside the signature, and verifiers check both. zkml is further out and scoped to small-enough models. The interface is ready (Attester is already abstract behind StubAttester), the hardware integration is the remaining work.
↳ Verification deep dive
↳ Pipe oracle details
↳ Deployed contract addresses