Docs

Changelog

All notable changes to this project are listed here. The format follows Keep a Changelog, and versions follow Semantic Versioning. From 1.0.0 on, a breaking change comes only in a new major version (2.0, then 3.0); before 1.0.0, a minor version could change interfaces.

[3.0.0] — 2026-11-06

Breaking changes (3.0)

Five changes need action from some users; each but the first has an option that keeps the 2.x behaviour. What to change, who is affected and what does not change: Upgrading to 3.0.

  • Node.js 22 or later: every package declares engines >=22; CI tests Node.js 22 and 24. No opt-out: stay on 2.x.
  • createAIProxy refuses a metered path of a format formats leaves out (HTTP 400 bad_path, not forwarded) instead of passing it on without a receipt (refuseOtherFormats defaults to true). Opt-out: refuseOtherFormats: false.
  • createMcpProxy refuses a browser Origin not in allowedOrigins (HTTP 403, preflight included); clients that send no Origin are not affected, and createMcpEndpoint is unchanged. Opt-out: allowedOrigins: '*'.
  • tapeapi-verify is strict by default: a receipt that does not verify is an error to the client (HTTP 502, or an error event at the end of a stream). Opt-out: --no-strict.
  • createMcpProxy and tapeapi-mcp negotiate the upstream revision (upstreamNegotiation: 'auto': server/discover first, then initialize). Opt-out: upstreamNegotiation: 'legacy', --upstream-negotiation legacy.

Not a change, a longer promise: conform: false (the 1.x resolution, deprecated since 2.0) is kept through all of 3.x and removed no earlier than 4.0; its one DEPRECATED warning per client now says so (it said "kept in 2.x").

Experimental changes

Everything below is experimental, outside the compatibility promise; the 2.0 default resolution (TAP-10 / TAP-11 §2) is unchanged. In eight groups: the Agent Passport and policy circuits (3.0); interop and the operator kit; the client-side budget and spend statement; bounded agents before any audit; hardening from internal reviews; an x402 batch-settlement enforcement backend; spec notes and receipt references (a proposal); docs. The escrow that paid calls rely on is still not audited and not deployed, so nothing below that pays works on mainnet.

3.0: Agent Passport and policy circuits (experimental)

  • @tapeapi/passport 0.1.0, a new package next to the SDK: a read-only resolver for Agent Passports, following an open draft, TapeOutProtocol/TAPs pull request #83 at head cae25e6 (not merged; every result carries that head, and the package records it with the SHA-256 7720772c…fca4 of the draft's vectors). The draft has been updated since (now cbf4e03): the package implements the text at cae25e6, and a package that follows the new text is planned. It decides, at one pinned block under strict agreement, chain first and files last, whether one wallet holds both an ERC-8004 agent identity and a TapeOut circuit and published the link, and answers with one state word (CONFIRMED, BROKEN, AMBIGUOUS, wallet-unset, stale-agent-wallet, UNAVAILABLE), one named reason and every warning. It interoperates by reading ERC-8004's deployed public registry on BNB Smart Chain; it is not an extension of ERC-8004 and is not endorsed by its authors. Nothing on mainnet is known to resolve to CONFIRMED yet. The registries are upgradeable (UUPS; the owner is a proxy shaped like a 3-of-5 Safe): an unknown implementation makes every result UNAVAILABLE. Gifted identities and circuits do not resolve as passports. Without log-capable nodes, site operators and the holder history are reported as not checked, never as passed. Holders whose wallet signs EIP-712 only have no sealed inbox, which is not a passport failure. Also: the registration file written from chain reads, an A2A card for CONFIRMED only (display only, never a source of signers), and the optional ./feedback subpath (§10, conditional on the unmerged review draft #54). The draft's 147 vectors pass as a pinned test fixture (not in the package); TapeAPI's own extra cases (capital look-alikes, decimal literals in a registration file) are kept apart. In the SDK: the experimental api.chain.processorNumber, and the commands tapeapi passport, tapeapi-doctor --passport (one check after manifest-format, not one of the 14), tapeapi connect --passport and tapeapi init --passport (writes .well-known/agent-registration.json locally after read-only checks and prints the holder's own putFile steps; it sends nothing). They load the package only when asked and say how to get it when it is missing. The holder console (the one tapeapi init points to) proposes a 30-day delegation instead of 90 days, W-1's default limit; the holder can type any whole number of days from 1 to 366 (7 and 90 included). tapeapi-doctor warns from 7 days left instead of 30, and so do the new-api sidecar's RENEW line and its daily check (new --delegation-warn-days / --delegation-escalate-days, 7 and 3). Guide: Agent Passport.
  • New package @tapeapi/policy-circuit 0.1.0 (experimental, next to the SDK, never part of it): policy circuits after the Policy Circuit Gate TAP draft (TAPs pull request #84, a conditional draft, submitted, now a draft PR, not merged; followed at its head 0b73174: a class and a unit per asset in the tables, and an asset the tables do not list overflows). A spending rule written as a tapnet/1 circuit (128 input pins, 8 output pins, at most 49,152 bytes) is re-run over a 128-bit request word built from the request and the signed tables (amounts counted in the request's own asset's unit, rounded up and saturated; decimal strings for integers above 2^53, an unsafe JS number refused); PolicyBinding and its revocation are checked in TAP-11's EIP-712 domain on the SDK's sig and canon (checkPolicyBinding, verifyPolicyBinding; revocation signers recovered, not taken as given; contract binders only through an EIP-1271 reader at a TAP-10 pinned block, otherwise binderChecked: false); and createPolicyGate().admit() gates a service's requests and keeps a trace that replays, failing closed in every row of the draft's failure table. Fixed in code: bindings to a mandate (TAP-18, from #47, merged as a Draft; merging is not adoption) or an escrow session (#59, #60) are conditional at best, never enforceable (enforceable reports the signer's authority, never that anyone enforces the binding); labels are display only; no file, no verdict; an x402 batch-settlement channel is never an escrow-session subject (policy-subject-x402), and a gate for an escrow-session binding needs the session (policy-session-required). The gate reads the binder again before every decision, so a transfer of the circuit ends the earlier holder's bindings at the next decision. It is an off-chain check that binds only the parties that check it, not a signing guardrail; nothing is deployed for it and it sends no transaction. Tests: all 188 cases of the draft's vectors (a copy pinned by SHA-256, not in the package), the checks outside them, the enforceable ladder over every binding vector and random bindings, and a differential test against the draft's Python TAP-02 reference (when python3 is present). Not in it: a budget-to-circuit compiler and a server hook. Guide: Policy circuits (en, zh-CN).
  • The 0.x packages' versions (release rule): @tapeapi/interop, @tapeapi/passport and @tapeapi/policy-circuit keep versions of their own, and one also takes a new 0.x version whenever its @tapeapi/sdk or @tapeapi/server range changes, so two releases never carry a tgz of the same name and version with different contents.

Added: interop and the operator kit (experimental)

Interop and the operator kit, both experimental: outside the 2.x compatibility promise, they may change or go away in any 2.x release, and the 2.0 default resolution (TAP-10 / TAP-11 §2) is unchanged. Experimental: visitor grants. A visitor can let site A call a few named methods of service B for a short time, and revoke it. B checks the grant; nothing in it moves money. This is a prototype in the SDK, not a TAP. Every cross-app call is an off-chain request, so the site is no longer "100% on-chain"; grants and revocations are enforced by the called service only and are useless against a malicious one; a receipt proves what was signed, not that the data is right, nor which upstream model ran; until shells check it, the visitor compares the caller named in the grant with the site the wallet names. The escrow that paid calls rely on is still not audited and not deployed, so nothing below that pays works on mainnet; the interop examples run on the SDK's fake chain and in tests only. TAP-11, TAP-13 and TAP-14 to TAP-18 are merged as Draft (merging is not adoption). The new guide page is Interop between apps and containers.

  • examples/container-interop/: three kinds of container-to-container interop with the existing API, with a command-line entry (run.mjs) and a gaps table in its README: (a) an agent calls another container's paid method inside the cap of a mandate (the escrow is not audited and not deployed: not usable on mainnet); (b) a task thread from container A to container B, with A's own acceptance check; (c) two containers in a private channel (TAPI-26 v2 labels, the default since 2.0, passed explicitly) and a signed receipt for a request. A receipt proves only that the container signed that answer to that request. Fake chain only, on the 1.x resolution path (its own client says dev: true; the clients it takes from the agent-service demo world pass conform: false).
  • examples/app-interop/ (a local demo): one app calls another through TapeAPI. App A finds app B by name, reads its manifest, calls a free method with a visitor grant and a priced one with a voucher, and checks every answer. Fake chain only; the escrow there is a model (the contract is not audited and not deployed). Its clients read the fake chain the 1.x way and say so with dev: true: the 2.0 default refuses the fake chain's own hub and factory, so the conformance mode is not exercised. examples/app-interop/wallet-render/ is a local, static page for checking how a wallet displays the grant before signing (eth_requestAccounts, eth_chainId and eth_signTypedData_v4 only; no transaction, no network request), with an offline checker of the copied result.
  • Visitor grants between apps: new package @tapeapi/interop (experimental, a prototype), next to the SDK. Not a specification and not a TAP; no service knows the format, and the whole package is outside the compatibility promise. A visitor's wallet signs a VisitorGrant (its own EIP-712 domain "TapeAPI Visitor Grant", never TAP-11's) that lets one app call a few methods of another app's service for a short time (1 hour by default, 7 days at most), a bounded number of times, with no payment; the first field is a statement generated from the other fields. The calling app adds the top-level request member grant with a call proof signed by its one-off key (withGrant); the called service checks it (verifyVisitorGrant, grantMethods), counts it, takes revocations (grantRevokeHandler, free method grant_revoke) and writes result.grant into its signed answer; refusals are BAD_REQUEST with the message prefix grant: <reason> and no error.data. Self-test vectors spec/vectors/interop-visitor-grant.json, reproduced by verify.py. Boundaries: a cross-app call is an off-chain request; revocation and the count are enforced by the called service only; the reference store memoryGrantStore is in memory, one process, and loses revocations, counts and replay records on restart (a production service persists them); its revocations are bounded (maxRevocations, 100 000 by default), dropped once their grant has expired, and a full store refuses a new one. An answer lost after delivery is counted once: when the SDK retries the same id (after a transport failure, on the next endpoint), the service sends the envelope it signed the first time again, byte for byte (not signed or counted again), if it still keeps it (bound to the grant, the id and the request, for at most 300 seconds after its ts; memoryGrantStore's maxAnswers, 1 000 by default, and maxAnswerBytes, 16 MiB); otherwise, as before, grant: replayed. Until shells check it, the visitor compares the caller named in the grant with the site the wallet names; how wallets display it was not tested. The grant check's chain reads (chainReadsFromApi) follow the 2.0 default resolution: siteStatus at a TAP-10 pinned block, with opening and activation checked and a cache of at most 60 seconds, so a name the default resolve refuses is refused by the grant check too (grant: caller-not-opened, grant: caller-unpaid, and audience-not-opened, audience-unpaid for the called service itself). The check reads the called service's holder and the caller's name only after its cheap steps and the visitor's signature, so a grant that fails one of them costs no chain read. grantMethods gives a call with a grant its own deadline (timeoutMs, 20 seconds by default, GRANT_HANDLER_TIMEOUT_MS; keep it below the provider's handlerTimeoutMs): past it the call is answered INTERNAL and not counted, even if the handler finishes later. An optional rate-limit window per (caller, visitor), keyed on what the check proved and shared by all their grants, lives in the grant store (grantMethods' perGrantWindow: { calls, windowS }, off by default; memoryGrantStore's take); one more call is refused grant: rate-limited and not counted. The server's limiter is unchanged: a call with a grant still spends the IP's free budget, and bad grants are not counted in its failure budget.
  • @tapeapi/server: handlers get a read-only ctx.request (experimental): a frozen JSON copy of the request's top-level members other than method and params (its id, a voucher if sent, and any member TAP-13 does not define, such as grant). Nothing the provider signs or bills changes. Two more experimental members serve a retry of the same id: ctx.onSigned(fn) is told the envelope of this call once it has passed every check and is about to be sent, and a handler may return ctx.resend(env) to send again an ok envelope this provider signed earlier over this very request (same id, method and params; checked), on a free call only.
  • examples/new-api-sidecar: the operator's kit (experimental; OPERATOR-KIT.md, both languages). For a relay that already runs new-api: docker-compose.sidecar.yml, tapeapi-sidecar.env.example (placeholders only) and add-sidecar.sh add the sidecar to the existing compose under a hostname of its own (the API hostname stays as it is; the script reads only the address in /tapeapi/v1/health, never a key). Two new optional variables, in the sidecar (server.mjs) and in the Worker (examples/ai-proxy/worker.js, with wrangler.toml): OPERATOR_SITE_URL, where GET / and HEAD / on the sidecar's host answer 302 (https, no credentials, query or fragment, not tapeapi.fun, not the sidecar itself; anything else keeps the sidecar in setup mode, naming the variable); and SIGNER_KEY_GENERATE=1 with SIGNER_KEY_FILE, which makes the signing key on the operator's own server when the file does not exist yet (mode 0600, created exclusively, never overwritten; only the address is printed; not when SIGNER_ADDRESS is set). The holder console's first delegation still signs only for the key its step 3 makes, so a key made on the server cannot be delegated from the console today; OPERATOR-KIT.md says so. A daily check for the operator's own GitHub repository, templates/tapeapi-daily-check.yml with templates/daily-check.mjs: once a day, tapeapi-doctor by name with the 2.0 default resolution and the price converter's --check; the activation's end and the fee come from the doctor's structured --json fields (paidUntilSeconds, monthlyFeeWei); 30 days or fewer left, or a monthlyFee() that is no longer 0, is a warning, and a failed check, no activation, 14 days or fewer, or a price drift needs action; an alert is an issue in that repository, nothing else. It uses no secret and pins every action to a commit; TAPEAPI_COMMIT is the commit of this repository the operator has read. Nothing in the kit names an operator, and the rules are the same for every service.
  • reconcile and new-api, end to end on a mock. examples/new-api-sidecar/fake-new-api.mjs also serves the usage log in the shape tapeapi reconcile --preset new-api expects (GET /api/log/self: data.items with upstream_request_id, model_name, prompt_tokens, completion_tokens, quota, created_at; GET /api/log/token for a relay key, a bare data array), one row per answered call, quota from its own ratios; the new reconcile-new-api.test.mjs runs the sidecar (FORMATS=openai-chat), tapeapi-verify --log, the pulled export and tapeapi reconcile --preset new-api --amount-scale <quota_per_unit> through the real binary: every row matched by upstream_request_id with amounts compared (exit 0), then a row without that id joined by time, model and tokens, a differing row, a receipt with no row and a row with no receipt (exit 1), and the dashboard CSV's headers refused (exit 2). The reconcile guide gains a "With new-api" section with the export commands and the caveat that the dashboard's CSV export is another file. The field names are still read from new-api's source, not checked against a real instance, and the mock is not new-api.

Added: a client-side budget and a spend statement (experimental, 3.0)

  • A client-side budget from your own policy file (experimental, 3.0): createVerifyingFetch({ policy, budgetStore }), ai.createBudget, ai.budgetFileStore, ai.memoryBudgetStore. The same { "tapeapiPolicy": 1, rules } file that tapeapi reconcile --policy reads now also refuses a call before it is sent when its worst case (ai.maxAmountOf over the request's declared output limit and its size in bytes) would take a covering rule over its limit: BUDGET_EXCEEDED (the code WebMCP's spending budget already used; here data: { reason: 'budget-cap', rule, period, window, limit, currency?, cap, spent, worstCase }), or INVALID_ARGUMENT with data.reason when the call cannot be bounded (no output limit, no price-table entry, no price in the rule's currency, a body that is not JSON, a store that cannot be written). The store is an append-only JSON-lines file (mode 600) of verified receipt envelopes and one "sent" line per call, so the count survives a restart and a call with no verified receipt counts at its worst case, unless it ended in a definite failure that claims nothing (an HTTP 4xx or 5xx with no receipt): then a "released" line drops it (a timeout or a broken connection keeps counting). tapeapi receipts and tapeapi reconcile read the store as a receipt file (sent and released lines are skipped, not listed as skipped). The agent kit's limiter takes the same budget: createSpendLimiter().payer({ budget, tokenDecimals }) admits each voucher's price before it is handed out, and a voucher released before it is accepted gets an offsetting released line.
  • tapeapi-verify --policy <file> [--budget-store <file>] (experimental, 3.0): the same budget in the local proxy (Claude Code, Codex, OpenAI SDKs through it). A refused call gets HTTP 400 with x-should-retry: false (code budget_exceeded, or budget_no_limit, budget_no_price, ... when it cannot be bounded), as the proxy's other refusals before forwarding, and is not forwarded; its --log line carries budget. Off unless given. A convenience in your own process, not an enforcement anyone else can rely on; it bounds the prices the receipts claim, not what anyone pays, and needs no escrow. Off unless given: defaults are unchanged.
  • tapeapi receipts statement --receipts <file|dir> --from <t> --to <t> [--policy] [--mandate] (experimental, 3.0): a spend statement per service, model and UTC day, JSON or text (en, zh), with the policy's and the mandate's caps alongside, counted as reconcile counts (its totals equal reconcile's on the same files). Signed by nothing: it is the agent's or buyer's own summary. The agent kit's spendStatement / spendEvidence (phase 1) are not reused: they state an escrow channel's claimedOf at delivery and need an escrow.
  • The policy-file code (policyOf, policyCheck, claimOf, the amount helpers) moved from reconcile-checks.js to sdk/src/policy.js so the fetch can use it without an import cycle; reconcile-checks.js re-exports every name. tapeapi reconcile's policy note now reads "reconcile enforces nothing" instead of "it was not enforced when the calls were made" (a client-side budget may have read the same file).

Changed: bounded agents before any audit (experimental, phase 1; the escrow is not audited and not deployed, so none of this works on mainnet)

  • Spend limiter: one call is bounded by the method's listed price too: the per-call ceiling is the lower of maxCallPrice (or 10% of the cap) and the method's priceBEM (spendDecision({ listedPrice }); api.call passes extra.method to the payer), and a method listed free is never paid. With the client-side budget above (payer({ budget, tokenDecimals })), the two compose as an AND in a fixed order: the limiter's own rules first (the per-call ceiling, the cap over claimedAtCheck, the stops; pure, in the token's smallest unit), then the budget's rules (the voucher's price in the token's label, per covering rule of the policy file). A limiter refusal writes no budget line; a budget refusal, or any later failure before the voucher leaves, releases the lease and offsets the line; the listed price never changes what the budget counts (the voucher's price, not the method's).
  • Close-out in explicit, resumable steps (BEM drill F10): closeOutPlan adds sendNow / sendLater (a withdraw before its window reverts and wastes gas); new closeOutNext and kit.closeOutStep hand out requestWithdraw, then nothing through the 48-hour cooldown, then withdraw, each step once, from the chain at a pinned block and a JSON state the console keeps, so a restart in the window resumes.
  • Buyer's scan before funding (session keys survive a circuit's transfer): new kit.inheritedSessions reads the SessionAuthorized logs of the mandate's channels from a block at least 30 days old and reports the live sessions the console's history does not record (on the container path, a previous holder's); holderPlan({ sessionScan }) refuses on them (inherited-session-live), on logs it cannot read (session-logs-unavailable) and on a scan that starts too late (session-scan-short), or with refuse: false returns the facts. It makes the exposure visible; it cannot end a session. The scan needs deployBlocks (each escrow's deployment block): a node whose log window does not reach back answers an empty list rather than an error, so the escrow's own constructor log there must be served, or the scan is session-logs-unavailable; fromBlock must be strictly older than 30 days, and a session whose expiry is the current second counts as live, as settle reads it.
  • Provider start-up checks the escrow's token: listen() reads the escrow's token() and refuses to start when it is not the token the manifest prices in (BEM on BNB Smart Chain, or createProvider({ escrowToken }); UNSUPPORTED_PAYMENT_TOKEN, reason escrow-token-mismatch; with dev a log line); an escrow without token() or nodes that cannot answer are logged, never taken for a match; Workers call checkEscrowToken().
  • 3.0: the escrow token check guards every paid call, on Workers too: handleRequest() and invoke() run it before the first paid call, so a Worker no longer awaits checkEscrowToken() itself, and an escrow without token() or nodes that cannot read it now refuse paid calls as a mismatch does: an unsigned HTTP 503 UNSUPPORTED_PAYMENT_TOKEN (data.reason escrow-token-mismatch, escrow-no-token or escrow-token-unreadable), nothing signed or billed; a refused call counts against the caller's free budget, as unverified traffic does. A definite answer (match, mismatch, skipped) is kept for the instance, and checkEscrowToken() called directly returns it instead of reading again (review R30B-2); a failed read is tried again at the next paid call and never kept as a match. The check is skipped, and paid calls go on without it, with no priced method, no escrow, settlementFacts or enforcement configured, no rpcUrls, or a chain other than BNB Smart Chain without escrowToken. Free methods, listen() refusing to start on a mismatch, and dev are unchanged; stats().escrowTokenRefused is new. api.tapeapi.fun and relay.tapeapi.fun serve free methods only and configure no escrow, so they are not affected.
  • The unsigned 503 UNSUPPORTED_PAYMENT_TOKEN is documented (docs only): TAPI-22 §3.2 (en, zh; an informative note, no rule changed; TAPI-21 is untouched) and the provide and consume guides say what its three reasons mean to a caller: escrow-token-mismatch is kept for the provider instance (do not retry), while escrow-no-token and escrow-token-unreadable are read again at the next paid call. The SDK reports the answer as PROVIDER_UNAVAILABLE, as before.

Fixed: hardening from internal reviews (experimental features above)

  • The client-side budget counts more conservatively (experimental, 3.0, internal review BR-1..BR-9). A receipt that answers a call now counts under the names the request went by as well as the reported model (an alias or a dated snapshot no longer escapes a model rule), in the window of the call's own time (not the signer's clock), and at the call's worst case where it claims nothing in the rule's currency (no usage, or a reported model the price table lacks); each receipt answers one call. A model the price table does not know is covered by every model rule of its service. Refused as unbounded (budget-no-limit): an output limit of 0, a Chat n that is not a whole number of at least 1, and a Responses call with previous_response_id or conversation. A store whose last line was cut short no longer swallows the next line; a receipt envelope carrying extra top-level keys is always read as a receipt; budgets on one store file read each other's new lines before every admit, under a lock file <store>.lock (flag wx, stale after 30 s) when budgetFileStore is given writeFileSync and unlinkSync, as tapeapi-verify --budget-store does. A call to a model the price table lacks names that model and the rule's model in its refusal. tapeapi-verify --save-requests no longer saves a call the budget refused. tapeapi reconcile: a time with no offset (2026-10-22 10:00:00) is read as UTC, in the bill and in --since / --until alike (it was the machine's zone); --map naming a key twice or a column the header has twice is a usage mistake, and a row whose id belongs to a receipt already joined (the same id billed twice) stays bill-only instead of being joined by time.
  • Visitor grants hardened (experimental, internal review IR-1..IR-6, IR-12). The check order is now call proof, replay (a resend of a call still in flight is refused there too, before any read or the per-grant window), the visitor's signature, then revocation, so a forged grant cannot probe a visitor's revocations, and a revoked grant is never answered with a kept envelope; a positive EIP-1271 answer is cached for cacheS. memoryGrantStore keeps revocations of grant hashes it never saw within a budget of their own (maxUnseenRevocations, half of maxRevocations by default), so throwaway keys cannot crowd out the revocations of grants it has seen. A call id that is not well-formed Unicode is refused. chainReadsFromApi caches eth_getCode per address and bounds its cache (maxCacheEntries). grantManifestProblems(manifest, { methods }) names a grant method whose handler grantMethods did not wrap. spec/TAPI-28.md (en, zh) states the new order and the limits that remain (a revocation names no service; holder checks are point reads; a forged signature can cost one EIP-1271 read). New refusal vectors spec/vectors/interop-visitor-grant-negative.json: 48 requests refused at TAPI-28 steps 1 to 8, each with its reason, and control cases, rebuilt byte for byte by the SDK tests and recomputed independently by verify.py; interop-visitor-grant.json is unchanged. Still a prototype, not a TAP.
  • Close-out at the finalized block, by the chain's time, and replacing a stuck step: closeOut / closeOutStep read their facts at the block the nodes call finalized, and a step is due only once that block's time says so (a clock ahead of the chain no longer hands out a withdraw that reverts); inheritedSessions judges liveness by the earlier of the clock and the pinned block's time. New closeOutSent(state, { provider, nonce }) records the nonce a step was sent with; a step handed out again then carries replaceNonce, to be sent with that nonce so it replaces the stuck transaction instead of a second requestWithdraw restarting the 48 hours.
  • Close-out state from the future: a kept state whose issuedAt is after now (a clock set back, a file edited by hand) no longer holds a step back for good; the step is handed out again and logged (state-from-future).
  • Tests (no change to shipped code): property tests with a seeded PRNG and no new dependency (receipts, prices, toolsSha256, reconcile, the voucher digest, grants); three timing-dependent tests made deterministic with an injected clock instead of retried; the budget's UTC day windows and the MCP caller tag's day no longer depend on when a run happens; every child process a test file starts itself is bounded (300 s, then SIGKILL; six that already had a shorter bound keep it), while the children that an example script starts when a test runs it are not.
  • Release check (no change to shipped code): the SDK tarball's unpacked-size guard in scripts/packages.test.mjs is 3 MiB (it was 2.5 MiB; the SDK unpacks to 2,593,875 bytes). It catches a file packed by mistake; it is not a budget.

Experimental: x402 batch-settlement enforcement backend

  • Prototype, experimental: { enforcement: 'x402-batch' }, x402 batch-settlement channels as the enforcement behind the phase-1 checks, the spend limiter and the budget. The enforcement is now a pluggable backend (enforcementBackend, ENFORCEMENTS; createProvider({ enforcement }) on the provider side); escrow-channel moved behind it with no change in behaviour. The new backend uses contracts already deployed on BNB Smart Chain that we did not write and have not reviewed (x402BatchSettlement 0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003, Permit2DepositCollector, Permit2; their repository carries external review reports we have not assessed): verifyMandate(signed, { enforcement: 'x402-batch', x402: { terms } }) reads the channel at one pinned block, x402Payer signs the agent key's vouchers and stops at its sessionExpiry (default one hour: the contract has no expiry), x402Acceptance checks them for a provider and gives it its own claim and settle, x402Tx builds every unsigned transaction. EOA payers only (a TapeOut container's isValidSignature answers 0: x402-payer-not-eoa); no contribution, no treasury; the service sets the withdraw delay and the client refuses one above its cap (48 hours by default); what a receipt proves is unchanged. The SDK sends nothing and pays no gas for anyone. Tested on a fake contract and once on a local fork; never on mainnet. See Container agents.

Spec notes and receipt references (proposal)

Two kinds of change. The first three entries are editorial: specification and documentation wording only; no rule, field, digest or vector changed. The last three are a proposal, not normative: the two helpers and the sidecar option are experimental and change nothing unless called or set, and none of the three is part of TAPI-21 or of any TAP.

  • TAPI-20 §3.8, two informative notes: toolsSha256 covers exactly name, title, description, inputSchema, outputSchema and annotations of each tool, and nothing else (not icons, not _meta, nothing outside the tools; pages merged first); the reference MCP proxy and tapeapi-mcp pass on only those members. A server whose tools/list depends on the caller's authorization (allowed by MCP 2026-07-28) cannot be pinned by one whole-list digest: callers that see another list are refused (tapeapi-mcp refuses the calls; the proxy answers TOOLS_CHANGED). Recorded in the Revision row.
  • What a receipt's answer hash covers: TAPI-21 §1 and §8 (with a new Revision row), the READMEs, the guides (AI providers, billing layer, reconcile, receipts and payment protocols, en and zh-CN), the AI sidecar example, the verify page and the notes of tapeapi connect, tapeapi receipts and the relay trial no longer say "exactly which response bytes": a whole answer is hashed as its exact bytes, a stream as its event data read by the §3.5 rule (not its comments, event names, line ends or chunking).
  • Receipts and payment protocols: dated again: the x402 and AP2 files were rechecked on 2026-10-27 (read-only) and each statement says which reading it comes from: x402's v2 specification changed on 2026-10-26 (an optional errorMessage; still no request or response hash), the batch-settlement directory now also holds EVM, SVM and Cloudflare network bindings (not summarised), AP2's latest tag is still v0.2.0.
  • ai.receiptRef(envelope) (@experimental (3.0)): one stable reference per signed usage receipt, the SHA-256 of the canonical JSON of its eight members (container and sig in lower case, v as 27/28, other top-level members left out), so the header form, the SSE comment and the receipt method's copy have the same name, for other records (a payment record, a review, an on-chain job) to cite. It identifies a receipt and proves nothing until the receipt is verified.
  • ai.priceRefOf(field) (@experimental (3.0)) and createAIProxy({ priceRef: true }) (off by default): the SHA-256 of the canonical JSON of the manifest's ai.models, reduced to the members TAPI-20 §3.9 defines, signed into every receipt as result.priceRef when the option is on, so a verifier can tell which price table priced a receipt. verifyUsageReceipt reports a malformed priceRef as a shape problem and a different basis as a warning; a different basis never excuses an amount that does not match. Off (the default), receipts are byte for byte as before; turning it on changes the signed content of every receipt, which the reference verifiers of 3.0 accept and ignore, while the submitted AI-receipt TAP draft does not allow a sidecar to add a result member. Neither addition is part of TAPI-21 or of any TAP.
  • Two new vector files, made by the real code (sdk/test/receipt-refs-vectors.test.mjs) and checked independently by spec/vectors/verify.py: spec/vectors/tapi-21-price-ref.json (five price tables, four receipts signed with and without the option, a receipt checked against another table) and spec/vectors/tapi-21-receipt-ref.json (the references of eleven receipts, re-serialisations that keep one, changes that do not). No existing vector file changed.

Added: docs (experimental documents)

  • Guide: Interop between apps and containers (en, zh-CN; experimental), with the boundary paragraph above. Not on the home page, the directory or the playground.
  • Receipts and payment protocols (en, zh-CN): where a TapeAPI usage receipt sits next to x402, AP2, ERC-8004, ERC-8183 and IETF attestation-receipt drafts, and how a bounded-agent mandate lines up with an AP2 Payment Mandate (direction only: no adapter, no payment bridge); primary sources dated, unverified points marked.
  • Circuit Jobs (en, zh-CN; 3.0, documentation only): what the draft TAPs #85 (submitted, now a draft PR) describes (an ERC-8183 job settled by the #74 dispute game), why TapeAPI ships no code for it (its Solidity reference is not audited and not deployed, nothing deployed, an upgradeable kernel, no demand evidence), and how it relates to receipts, the passport and policy circuits. Also: one table of the delegation and binding windows of TAP-18 (#47), #84, #83 W-1, TAP-11 and what tapeapi init and the console propose today (Agent Passport), and one sentence in the receipts guide on the experimental x402 batch-settlement backend.
  • Release candidates (release rule): a 3.x RC is not published as a GitHub release unless the server and every 0.x package first take the SDK by a range that accepts the pre-release (such as ^3.0.0-0); ^3.0.0 does not, so npm would look for @tapeapi/sdk on the registry. scripts/packages.test.mjs fails when the SDK is a pre-release and a range does not match it.
  • TAP status brought up to date (2026-11-06). Seven of our drafts are merged as Draft (TAP-11, TAP-13, and, from 2026-11-03, TAP-14 to TAP-18; merging is not adoption); seven are submitted (in review; #60 is now a draft PR). The README, the roadmap, the guides and the code comments name the numbered drafts by number, with the pull request next to it. Text only; no behaviour changed.

[2.5.0] — 2026-11-06

TapeAPI 2.5: accountable first — the numbers line up. Experimental additions for the people who hold the ledger, on either side of a receipt. An operator's billing layer can charge from the receipt with the rule every receipt is signed with (ai.amountOf, ai.pricesOf, public again) and size a hold before the call (ai.maxAmountOf, ai.maxPricesOf, ai.completionLimitOf); the new-api and LiteLLM sidecars take an allowlist of upstream response headers (UPSTREAM_RESPONSE_HEADERS) and a receipt lookup limit the operator sets (RECEIPT_LOOKUPS_PER_MIN); a guide describes a billing layer in front of the sidecar. A buyer's tapeapi reconcile sets receipts against the requests that were sent (--requests), a mandate's caps (--mandate) and the buyer's own limits (--policy), from local files only, and lists each fact with the receipt line it comes from; --fail-on makes named kinds exit status 4 for a script, and tapeapi-verify --save-requests keeps the salted request bytes for that join (off by default: the files hold the prompts in clear). MCP clients of revision 2026-07-28 are served without initialize (server/discover, header checks), clients of earlier revisions get byte for byte the answers of 2.4.0, and createMcpProxy and tapeapi-mcp can be asked (upstreamNegotiation: 'auto', --upstream-negotiation auto) to read an upstream that speaks only 2026-07-28. All of this is experimental: outside the 2.x compatibility promise, off unless called or set, and nothing in it makes or takes a payment. Three things change without being asked for (see Changed): a value in the MCP _meta protocol-version key that is not a revision is refused, a request sent with MCP-Protocol-Version: 2026-07-28 but without the 2026-07-28 _meta is refused, and tapeapi reconcile --json carries three more keys. Pushing this release redeploys the public service, the relay and the website as usual; the public service's /mcp then answers server/discover and serves 2026-07-28 clients, and nothing else about the live services changes but the version number they report: no variable is set or needed, and the REST methods, the manifest and the receipts are as in 2.4.0. A receipt still shows what its signer said, not which model ran; these checks list facts, not verdicts.

Added: helpers for a billing layer in front of the AI sidecar (experimental)

All of these are experimental (outside the 2.x compatibility promise) and change nothing unless called or set: a sidecar that sets none of the new variables answers, signs and limits exactly as in 2.4.0. Nothing here makes or takes a payment, and a receipt still shows what its signer says, not which model ran.

  • ai.amountOf and ai.pricesOf are public again, with a worst-case hold: ai.maxAmountOf, ai.maxPricesOf, ai.completionLimitOf (in @tapeapi/sdk/ai and the root ai namespace, @experimental (2.5) in types/ai.d.ts). amountOf(price, usage) is the rule every receipt is signed with (per 1M tokens over disjoint buckets, rounded up once to 8 decimals); 1.0 had stopped exporting it (RC-7), and an operator's billing layer had to copy it by hand. maxAmountOf(price, { promptTokens, completionTokens }) is the most one call can cost within those counts: every prompt token at the dearest of input, cacheRead, cacheWrite, cacheWrite1h (a cache write can cost more than input), every completion token at the dearer of output and reasoning, rounded up once, so never below amountOf for a usage within the counts; maxPricesOf(entry, bounds) gives it per currency. completionLimitOf(body, format) reads the completion limit a request declares (Chat Completions: the larger of max_completion_tokens and max_tokens, times n; Responses: max_output_tokens; Messages: max_tokens; Embeddings: 0), or null. Limits, stated in the declarations and the guide: a model that ignores max_tokens, a hidden system prompt, images or tool definitions can exceed any bound computed from the price table; settle on the receipt and keep a hard cap. Tests: the public amountOf and pricesOf reproduce every amount of sdk/test/fixtures/ai-receipt-vectors.json, and maxAmountOf is never below amountOf over a grid of usages within the bounds. The SDK grows by these five functions only; the playground's vendored copy of the SDK is rebuilt.
  • createAIProxy({ upstreamResponseHeaders }) and UPSTREAM_RESPONSE_HEADERS in the new-api and LiteLLM sidecars: an allowlist of the upstream response headers passed on to callers, so headers that name an operator's channels or costs need no second proxy. Set, only the listed upstream headers (case-insensitive) and content-type (always kept, as its media type with charset and boundary only: other parameters could carry what the list hides) and retry-after (always kept, so clients can back off) pass, on every /v1/* answer; the sidecar's own headers (the receipt, the sidecar-error mark, CORS) are added as before. Headers are not part of a receipt, so receipts and answer bytes are the same with or without it (tested on a whole answer and a stream). Unset or empty (the default), every upstream header passes as before. It filters headers only: the answer body, including its id and model, passes unchanged.
  • RECEIPT_LOOKUPS_PER_MIN in the new-api and LiteLLM sidecars: the receipt method's lookups per client IP per minute, as the Cloudflare Worker (examples/ai-proxy/worker.js) already read it. Unset or empty: 10, as before; 0 refuses every lookup, as in the Worker; off gives the method no budget of its own (RATE_IP only); anything else is setup mode naming the variable, in the Worker too (it used to fail inside createAIProxy). 2.4 did not read this variable or UPSTREAM_RESPONSE_HEADERS: a malformed value left over from then is now setup mode. A billing layer that looks receipts up from one address shares one bucket and can now raise it without changing code. env.example and docker-compose.yml of both packages list both variables, commented out or empty.
  • Guide: "A billing layer in front of the sidecar" (docs/guides/billing-layer.md, zh-CN/billing-layer.md): the topology (billing layer, sidecar, upstream; requests and answers byte for byte, the receipt passed on), why FORMATS must match the formats the billing layer meters and the price table's formats, charging from the receipt with ai.amountOf, sizing the hold with ai.maxPricesOf and what it cannot bound (models that ignore max_tokens, hidden prompts, images, the reported model, concurrency), receipt lookups, upstream headers, disclosing any model-name rewriting and logging both the requested and the upstream name, suggested wording ("Receipts in TapeAPI format, signed by <operator>; TapeAPI does not operate or endorse this service") and never claiming that a receipt proves the upstream model; and a table for upgrading a sidecar from 2.0 to 2.4 (FORMATS and the price converter in 2.1, receipts and reconcile in 2.2, init and prices in 2.3, nothing for the AI sidecar in 2.4).

Added: MCP revision 2026-07-28 (experimental)

  • MCP revision 2026-07-28 for clients that send no initialize (experimental: outside the 2.x compatibility promise, it may change or go away in any 2.x release). createMcpServer in @tapeapi/sdk/mcp, and so createMcpEndpoint, createMcpProxy's /mcp, tapeapi-mcp and the public Worker's /mcp and /mcp/pinned, serve a request whose params._meta['io.modelcontextprotocol/protocolVersion'] is 2026-07-28 (with a clientCapabilities object) statelessly: the result carries resultType: "complete" and _meta['io.modelcontextprotocol/serverInfo'] (placed beside a receipt, which keeps its place), list results ttlMs: 0 / cacheScope: "public"; server/discover answers supportedVersions (2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26), capabilities, instructions and the cache hints; another version is refused with -32022 { supported, requested }, a version that is not a string or a missing clientCapabilities with -32602. On HTTP (createMcpEndpoint, createMcpProxy) a single such request is also checked against its MCP-Protocol-Version, Mcp-Method and Mcp-Name headers (=?base64?...?= decoded): a missing or different value is -32020 with HTTP 400; a refused version or malformed _meta is HTTP 400; an unknown method is HTTP 404. These checks cannot be skipped (review 2026-10-22): a JSON array body (a batch) that holds a 2026-07-28 message, or that comes with MCP-Protocol-Version: 2026-07-28, is refused with HTTP 400 and -32600 (id: null), since a gateway routing on Mcp-Name could otherwise be passed a header naming one tool while the batched body called another (Streamable HTTP "Sending Messages": the body is one message; "Server Validation") (review MR-1); a single request sent with MCP-Protocol-Version: 2026-07-28 but no 2026-07-28 _meta is refused with 400 and -32020 when its Mcp-Method or Mcp-Name header, or a _meta version it carries, does not match the body (review MR-2). Only the exact header value 2026-07-28 counts: any other value is answered as 2.4.0 answered it. New SDK exports: requestEra, MCP_MODERN_PROTOCOL_VERSIONS, MCP_SUPPORTED_PROTOCOL_VERSIONS, MCP_ERRORS, PROTOCOL_VERSION_META, CLIENT_CAPABILITIES_META, SERVER_INFO_META. Requests of revisions 2025-11-25, 2025-06-18 and 2025-03-26 get byte for byte the answers of 2.4.0: server/test/fixtures/mcp-legacy-2.4.0.json holds 290 exchanges recorded from the 2.4.0 tree and the tests replay them; 285 match byte for byte, and the other 5 are the one recorded request that no client of an earlier revision sends (the 2026-07-28 header without its _meta, on each of the five HTTP entry points; see Changed). A randomized comparison against the 2.4.0 code (9 000 exchanges, legacy-shaped requests only) found no difference. Nothing signed changes (receipts, envelopes, verify links, toolsSha256). MCP_PROTOCOL_VERSIONS (the initialize list), and the initialize the proxy and tapeapi-mcp send to an upstream MCP server, are unchanged by default. createMcpProxy takes upstreamNegotiation: 'auto' (experimental; default 'legacy', as before): it probes the upstream with server/discover and speaks 2026-07-28 to one that offers it, so an upstream that speaks only 2026-07-28 can be proxied; otherwise it falls back to initialize. stats() gains upstreamEra. tapeapi-mcp takes the same choice as --upstream-negotiation legacy|auto (experimental; default legacy, as before) for reading a taped-out MCP server's tools from its mcp.endpoint, with the same fallback rules; its calls go through the signed TapeAPI path either way, and the toolsSha256 it checks and pins is the same (tested against an endpoint that speaks only 2026-07-28, which the default cannot read). The official TypeScript client v2 (@modelcontextprotocol/client 2.3.1) was run locally against these entry points in its legacy, auto and pinned-2026-07-28 modes and against 2025-06-18 and 2025-03-26. Kept as before, on purpose: ping is answered for a 2026-07-28 request; a batch of older-revision messages is answered message by message with HTTP 200, as before; Mcp-Param-* headers are not checked on any entry point, and the MCP guide now says so plainly (review MR-9). Checked on 2026-10-21 against https://modelcontextprotocol.io/specification/2026-07-28/ (basic/versioning, basic/index, server/discover, basic/transports/streamable-http, basic/transports/stdio, changelog). The MCP guide (English and Chinese) describes it.
  • /mcp/pinned holds its tools/list to MCP_TOOLS_SHA256 as it holds the calls (review MR-7b, experimental): while the tools hash to anything else, tools/list is refused as the signing proxy refuses it (-32603, data: { code: "TOOLS_CHANGED", published, current }, HTTP 200). New option createMcpEndpoint({ pinnedToolsSha256 }) (experimental); without it nothing changes. tapeapi-mcp then reports that the tools could not be read, and still sends nothing. With MCP_TOOLS_SHA256 unset (as on the live service), /mcp/pinned stays 404 as in 2.4.0.
  • Opt-in Origin check (review MR-8, experimental): allowedOrigins on createMcpEndpoint and createMcpProxy, MCP_ALLOWED_ORIGINS on the public Worker, ALLOWED_ORIGINS in examples/mcp-proxy/index.mjs. An unlisted Origin is 403 before anything runs; a listed one is echoed instead of *; no Origin, answered as usual. Default unchanged (every origin, CORS *). The MCP guide says to set it for a proxy run locally in front of tools with side effects.

Added: reconcile request facts, mandate caps and local limits (experimental)

  • tapeapi reconcile: facts about each request, a mandate's caps and your own limits (experimental, outside the 2.x compatibility promise; files only: no network, no key, nothing uploaded, no telemetry). Every fact names the receipt line it comes from, and the request file or bill line it uses; none is a verdict, and none changes the exit status unless --fail-on asks (--json carries the counts). A receipt proves only what its signer said; it does not prove which model ran, and these checks cannot tell which model ran. Guide: Reconcile your receipts.
    • --requests <dir>: request bodies you saved, joined to the receipts by SHA-256 (requestSha256). Listed: prompt_tokens that differ within one request class (same model beyond --tolerance-tokens; across models beyond --prompt-spread, default 16), prompt_tokens above the request's size in bytes, completion_tokens above the request's output limit (with reasoning_tokens; the limit is ai.completionLimitOf's, the rule a billing layer sizes its hold with: for Chat the larger of max_completion_tokens and max_tokens, times n), and the model names the receipts carry for one requested model, by stream and non-stream. Only the exact bytes sent match: tapeapi-verify salts requests unless --no-salt; tapeapi-verify --save-requests <dir> keeps the salted bytes (below).
    • Three optional --map keys give the same facts from a bill: request, request_bytes, max_tokens. A receipt and its bill row whose model names differ only as aliases in the snapshot are listed (the join counts them as one model, so they are not a mismatch).
    • --mandate <file>: per scope item, the receipts of its provider signed in [notBefore, expires), summed and set against the cap (--token-decimals, --mandate-currency, --near); other containers and receipts outside the period are listed apart. A phase-0 mandate names no funds and is not compared; the signature is not checked.
    • --policy <file>: your own limits ({ "tapeapiPolicy": 1, "rules": [...] }: amount, calls, prompt and completion tokens per service, model and call, day, month or total). Nothing is enforced at call time; the windows whose receipts claim more are listed. An unknown key is a usage mistake.
    • --bill is optional when any of the three is given. No per-provider preset is added: an operator exports its ledger with columns --map can name (the guide lists them).
    • --fail-on <kinds> (opt-in): findings, mandate-above-cap, policy, comma-separated. When the exit status would be 0 and a named kind is listed, it is 4; 1, 2 and 3 stay as they are, and without --fail-on nothing changes (no new JSON key either; with it, failOn: { kinds, counts, listed }). A kind that could never be listed is a usage mistake: policy without --policy, mandate-above-cap without --mandate and --token-decimals. For a script or CI step that should stop on a fact; the facts are still facts, not verdicts.
  • tapeapi-verify --save-requests <dir> (experimental, off by default: a privacy trade-off). It writes the exact bytes of every metered request it sends, salt included, to <dir>, one <sha256>.request file each, so tapeapi reconcile --requests <dir> joins salted receipts. These files hold the prompts, files and tool output in clear, which neither the receipt nor --log holds; --help, the start-up line and both guides say so. The directory is created 700, an existing one open to group or others is refused (exit 1), each file is 600 and written once; a request over 16 MiB (which reconcile skips) is not written; a failed write is logged and the call goes on. Free paths are not saved, and the --log format is unchanged. Tested end to end: a salted JSON call and a stream through the real binary, their files the bytes that reached the sidecar (one compared byte for byte, both by hash), and reconcile joining both.

Changed

  • MCP: a single request sent with MCP-Protocol-Version: 2026-07-28 but no 2026-07-28 _meta is refused with HTTP 400 (review MR-2b, 2026-10-22): -32602 (the required _meta field is missing) when no header contradicts the body, -32020 when one does. 2.4.0 answered it as an older-revision request; no client of an older revision sends that header value. initialize keeps the older handshake whatever the header says. On each of the five HTTP entry points this is the one recorded 2.4.0 exchange that is answered differently.
  • MCP: a value of _meta['io.modelcontextprotocol/protocolVersion'] that is not a revision is refused. From 2.2.0 to 2.4.0 such a request was answered: "2027-01-01" (a later date) as 2026-07-28, "latest", "2026-7-28" and a number as an earlier revision. Now a string is -32022 and a number -32602 (on HTTP, status 400), as revision 2026-07-28 asks; no client of 2025-11-25 or earlier sends the key. A legacy revision in that key ("2025-11-25", ...) is still answered as before.
  • tapeapi reconcile prints more without the new options. Every --json output gains the keys checks (always), mandate and policy (null unless asked for) and notes.findings; with a manifest snapshot, the text output gains a findings section when one price-table entry carries several model names in the receipts, or a receipt and its bill row differ only as aliases. The exit status is unchanged (0 / 1 / 2 / 3 as in 2.4.0): findings change it only when the new --fail-on asks (exit 4).
  • The playground's vendored SDK and the site's asset hashes are rebuilt for the changed sdk/src/mcp.js and sdk/src/ai-public.js (whose header comment now says which price helpers are public again, as experimental).

[2.4.0] — 2026-10-21

TapeAPI 2.4.0: MCP tool results that the public service's Worker can sign and declare. The public Worker's code (examples/public-api) has eight tool_* methods whose signed results are MCP tool results, and a second MCP endpoint, /mcp/pinned, whose only tools they are; createMcpEndpoint takes mcpResults to serve such methods; and the holder console accepts an mcp field that binds some of the methods, not all. All of this is experimental: outside the 2.x compatibility promise, it may change or go away in any 2.x release. The public service (api.tapeapi.fun), the relay (relay.tapeapi.fun) and the website are redeployed from this repository's main, so they run 2.4 once it is pushed: the console's rule changes then. The rest stays off: the tool methods and /mcp/pinned exist only while MCP_TOOLS_SHA256 is set on the Worker, which a deploy neither sets nor clears and which is not set on the public service, and without it a 2.4 Worker answers as a 2.3 one, apart from the version; 11.1013.tape declares them only once its holder publishes a new manifest on chain. Until then its manifest and tapeapi connect 11.1013.tape (not-ai) are as they were. The variable's meaning changes for anyone who set it under 2.3 (see Changed). In the SDK only the card tapeapi connect shows for an MCP tool service changes (see Changed); the rest of the SDK's code and the command line tools are unchanged apart from the version. Nothing below makes or takes a payment, and a receipt still shows what its signer says, not which model ran.

Added

  • The public service's tool methods and /mcp/pinned (experimental, outside the 2.x compatibility promise). The public Worker's code (examples/public-api) has eight more free, read-only methods, tool_block_number, tool_balance, tool_token_info, tool_token_balance, tool_nft_owner, tool_pair_price, tool_bnb_usd and tool_tape_name: each makes the same read as its REST twin and answers, signed, exactly an MCP tool result { content: [{ type: 'text', text: <the answer as JSON> }], structuredContent: <the answer> }, which is what TAPI-20 §3.8 and TAPs #16 §4.2 require of a method bound to a tool; a bad argument is a signed BAD_REQUEST as before. They are the only tools of a second MCP endpoint, /mcp/pinned, and only while the variable MCP_TOOLS_SHA256 is set (64 hex digits, taken lowercased, surrounding spaces ignored; any other value, such as one with 0x, keeps the whole service in setup mode, and its problem names MCP_TOOLS_SHA256 and the form it needs): the manifest then lists them after the eight REST methods and ends with mcp: { endpoint: <PUBLIC_URL>/mcp/pinned, toolsSha256 }, and only they are held to that value (a signed TOOLS_CHANGED, HTTP 409, if their tool text no longer matches, or with current null while their tool list cannot be built; the REST methods are not tools of that endpoint and keep answering). tapeapi-mcp then verifies and shows their answers, and offers only them; tapeapi connect lists all 16 as the manifest's free methods, not as tools, since it does not read the endpoint (see Changed). With the variable set, /mcp and the REST methods give the same answers as without it (the same results, the same /mcp tools), and without it the Worker is exactly what it was (tested end to end on a local fake chain). On the public service this stays off: api.tapeapi.fun runs this code once 2.4 is pushed, but serves the tool methods and /mcp/pinned only once MCP_TOOLS_SHA256 is set on it by hand, and 11.1013.tape declares them only once its holder publishes such a manifest on chain; until both, its manifest and answers are as before (apart from the version) and tapeapi connect 11.1013.tape says not-ai. New in @tapeapi/server/mcp: the createMcpEndpoint option mcpResults (default false): when the methods' signed results are MCP tool results, a result is passed on after the provenance line (imitations of that line labelled, as createMcpProxy does) instead of as JSON text.

Changed

  • The public Worker's MCP_TOOLS_SHA256 declares /mcp/pinned, no longer /mcp (experimental). In 2.3 the variable added mcp: { endpoint: <PUBLIC_URL>/mcp, toolsSha256 } to the manifest and held the eight REST methods, the tools of /mcp, to it. From 2.4 /mcp is never declared and never held: the variable adds the tool methods, declares /mcp/pinned, and must be the digest of /mcp/pinned's tools (mcpToolsSha256(mcpToolsManifestOf(env)) from examples/public-api/worker.js). A value computed for /mcp under 2.3 and kept across a redeploy now declares /mcp/pinned with the wrong digest: the tool methods answer a signed TOOLS_CHANGED (409) while the REST methods and /mcp keep answering. The public service has never set the variable; without it nothing changes.
  • The holder console accepts an mcp field whose endpoint's tools are some of the methods, not all (site/console). It refused a manifest with any method that is not one of the tools at mcp.endpoint (the rule of the signing proxy, where every method is a tool); TAPs #16 §4.1 allows methods that are not bound. It now refuses only an mcp field that binds no method, and before the wallet asks it says which methods are the tools and which are plain methods that MCP clients do not show to a model. The digest, hidden-text and shape checks are unchanged.
  • tapeapi connect lists an MCP tool service's free methods as methods, not as tools (experimental). A manifest may bind only some of its methods (TAPs #16 §4.1; with the variable set, the public Worker's binds eight of sixteen), and which methods are tools only tools/list at the endpoint says, which this command does not read. The card's row said "tools 16 (the manifest's free methods)"; it now says "methods 16 (the manifest's free methods; the tools are the ones tools/list names)", and the note under it says that the card does not say which or how many are tools. tapeapi-mcp, which reads the list, offers only the tools, as before. --json keeps service.tools (2.3) with the same list.

[2.3.0] — 2026-10-19

TapeAPI 2.3.0: from nothing to live, step by step, and fewer checks lost to a node that failed for a moment. tapeapi init walks an operator through the steps to a live AI service, one step per run, and checks each step read-only; tapeapi prices runs the new-api price converter and shows a price table with its source record; tapeapi connect also shows an MCP tool service that a manifest declares; and a chain read whose nodes failed fast and by the network is asked again, at most twice, before it is unavailable. The new commands, the connect card and the retry are experimental: outside the 2.x compatibility promise, they may change or go away in any 2.x release. One default changes: the retry is on in the SDK, in the commands and in createProvider (rpcRetries: 0 gives the previous behaviour request for request); a read answered in its first round is unchanged, and with the defaults the retries add less than 5.2 s to a read that fails. tapeapi --help is 12 lines longer, and tapeapi-mcp now refuses an mcp field that TAPI-20 §3.8 does not allow (capital hex digits, an endpoint with a query or fragment; no live manifest is known to carry one). Nothing below makes or takes a payment, and a receipt still shows what its signer says, not which model ran.

Added

  • tapeapi init and tapeapi prices convert|check|show (experimental: outside the 2.x compatibility promise, they may change or go away in any 2.x release). init is the operator's wizard from nothing to live, steps 0 to 7 (local trial, circuit, container and activation, the sidecar, the console's service key and delegation, publishing, users, the daily check and renewals), one step per run (--step, --renew): what the operator does, where and who pays (the operator; every paying step says we do not advance it), then the step's check, read-only, through the doctor's diagnose(), connectReport() and the price table files, and the next command. It asks for no key, password or token, never reads .env, uploads nothing, pays nothing and sends no transaction; its first screen says that a receipt proves only what its signer says. From step 3 on, FORMATS has no default: no --formats (or an empty value, or only --yes) is exit 2 naming it; nor has the gateway: without a terminal, steps 3 and 4 without --upstream new-api (or "upstream" in --answers, or a saved answer) are exit 2 naming it; all four formats need --confirm-all-formats (the output says they all go into the manifest on chain, whose history cannot be deleted); on a terminal each format is asked one by one, default No. The .env it writes once (mode 600; an existing one is never read) says FORMATS= exactly the choice and leaves every secret empty; it is written again for a corrected --sidecar-url (or formats, or name) only while it is exactly as the wizard wrote it (its inode, size, mtime and ctime as recorded in tapeapi-init.json, so nothing was filled in), and otherwise the wizard says to correct PUBLIC_URL by hand; steps 4 and 5 compare the formats the manifest declares with the choice. prices convert and prices check run examples/new-api-sidecar/pricing-convert.mjs with the arguments as given (--fetched-as, --quota-per-unit, ...), its output and exit status unchanged, in a checkout only (outside one they print the clone command); prices show prints a price table with its source record (who fetched the price list, the rate, quota_per_unit, refusals and flags, what is not verified), files only. Exit status 0 / 1 / 2 / 3 as the doctor's (prices convert|check: the converter's 0 / 1 / 2); --json, --lang en|zh|both; output redacted as tapeapi connect's. Files: sdk/src/init-cli.js, sdk/src/prices-cli.js (no new export subpath; the ai face is unchanged). tapeapi verify|mcp, connect, receipts and reconcile are unchanged; tapeapi --help lists the two new commands.
  • tapeapi-doctor: in setup mode the manifest-format check carries what is missing as a field (experimental, like the doctor itself). When the sidecar answers in setup mode, the check in --json (and in diagnose()'s report) has setup: { missing, problem, signer }, taken from the sidecar's /tapeapi/v1/health as given (missing the names it lists, problem the configuration error it names or null, signer its signing address or null); tapeapi init reads it in steps 3 and 4 instead of the English detail (which it still reads from a doctor without the field). Only that one key is added: the check's status, its detail, the text report and the exit codes are unchanged (setupFacts in sdk/src/doctor.js).
  • tapeapi connect for MCP tool services (experimental, outside the 2.x compatibility promise). A manifest with no ai field but a valid mcp field (TAPI-20 §3.8: endpoint and toolsSha256) is shown as usable: the endpoint, the number and names of its tools (the manifest's free methods, the ones tapeapi-mcp can offer: it offers no priced method), the first 8 hex digits of toolsSha256, the signer, delegation and activation, and settings made from that field only: Claude Code and Claude Desktop through tapeapi-mcp (stdio: every answer checked on the user's machine, the tools pinned) and the declared Streamable HTTP endpoint direct (marked as not checked). No setting carries a key. Nothing is fetched from the endpoint. Unavailable states and an invalid mcp field (mcp-invalid) are exit 1 with the reason and no settings; --tool also takes claude-desktop; --proxy runs tapeapi-mcp (its JSON-RPC alone on stdout, the card on stderr); --json gains kind: 'ai' | 'mcp' | null (added last, nothing else changes); @tapeapi/sdk/connect gains mcpSnippetsFor, MCP_NOTES and MCP_SNIPPET_TOOLS. An AI service's card, settings and --json (apart from kind) and every unavailable state's text are byte for byte as before. tapeapi init step 6 does not take an MCP tool service for the AI service it sets up.
  • A read whose nodes failed only by the network, and fast, is asked again before it is unavailable (experimental: client behaviour only, no TAP, wire format, status name or exit code changes; outside the 2.x compatibility promise). createRpc and createTapeAPI take rpcRetries (default 2, 0 to 5), rpcRetryMaxMs (default 2000, 0 to 10000) and rpcRetryFastMs (default 2000, 0 to 10000), also per chain in chains[id]; the commands built on them (tapeapi-doctor, tapeapi-verify, tapeapi-mcp, tapeapi connect) use the defaults. The server retries too: createProvider (@tapeapi/server) reads the chain through createRpc (the block number aside, which is not retried: the escrow, channel and session reads of a paid call, the price feeds of USD pricing), and so does the examples' chain reader (examples/_lib/chain.mjs, createChainReader, which the public service's Worker uses for its methods); both now take rpcRetries with the same meaning, passed to every client they build (default the SDK's, 2; rpcRetries: 0 turns it off). A read is sent again to every node, at most rpcRetries more times, when a round of it gets too few answers, every node that failed did so with no answer and by the network (a broken or refused connection, a DNS failure, an HTTP 5xx without a JSON-RPC answer other than the gateway timeouts 504, 522 and 524), the round took less than rpcRetryFastMs from its first request to its last failure, and the nodes that answered agree; the waits before the extra rounds are about 0.2 to 0.4 s and 0.4 to 0.8 s (exponential with jitter) and add up to at most rpcRetryMaxMs. Each attempt needs the same agreement as the first: a strict read (TAP-10 §5.2) still needs every configured node, never two of three. Never retried: a timeout or an abort (a round in which any node timed out is not retried, so a node that keeps timing out costs a failing read one request timeout, rpcTimeoutMs, 8 s by default, as before), a gateway timeout (HTTP 504, 522, 524), a round that took rpcRetryFastMs or longer (a node that resets just before its timeout costs a failing read what it cost before), a disagreement (the single re-ask after one, since 1.x, is unchanged and happens at most once per read), a JSON-RPC error or revert, a refusal (rate limit, method not found, HTTP 4xx), an answer that is too large or not JSON. When the last attempt fails, its error is the one a client without retries gives: the same code, message and data, so unavailable, RPC_UNAVAILABLE and the doctor's exit code 3 read exactly as before. One difference in wording, not in outcome: when every node is down, the read a resolution names in its message can be another one than before (a read that is not retried, such as the pin's eth_blockNumber, fails first while the retried one waits); the status and the exit code are the same. Time: the retries add to a failing read at most rpcRetries x rpcRetryFastMs plus the waits, less than 5.2 s with the defaults, whatever rpcTimeoutMs is. The longest a read can take is therefore what it could take before plus 5.2 s: with the default rpcTimeoutMs of 8 s, 38.0 s instead of 32.8 s (a read that meets a disagreement, its re-ask and broken connections, each request ending just before its timeout), and in theory 54.0 s instead of 48.8 s for a batched eth_call; with the 25 s of the monitor (scripts/monitor.mjs), 106.0 s instead of 100.8 s, and 156.0 s instead of 150.8 s. One read sends at most (2 + rpcRetries) rounds of one request per node, each re-asked at most once after a broken connection as before: 24 requests with three nodes. rpcRetries: 0 (or rpcRetryMaxMs: 0, or rpcRetryFastMs: 0) gives the previous behaviour request for request. rpc.single() clients (the ChannelBus reader, proofs) do not retry; they poll again on their own. On 2026-10-15 one public BNB Chain node answered fetch failed and tapeapi-doctor said undecided (exit 3); the cause of that failure was not recorded, so whether it was two failed connections in a row or a timeout cannot be told. 2.3 retries only failures of the network kind that come back fast: a broken or refused connection like that no longer stops a check, while a timeout (a connect timeout included) is still not retried and still reads undecided.
  • @tapeapi/server/mcp: toolsSha256 helpers and a signed TOOLS_CHANGED hold for createMcpEndpoint (experimental, outside the 2.x compatibility promise; additions only). createMcpEndpoint() also returns toolsSha256(), the SHA-256 of its own tools/list answer exactly as clients compute it (the SDK's mcp.toolsDigest; the vectors of the MCP tools draft, TAPs #16, agree on the SDK, the holder console and the endpoint). New: listedMcpTools(manifest), mcpToolsSha256(manifest) and holdToToolsSha256(methods, { manifest, published }), which refuses every method that is a tool with a signed TOOLS_CHANGED (HTTP 409, error.data exactly { published, current }) while the tools no longer hash to the published value, and passes every other method through; published is the value the holder published, never one computed by the provider. A manifest mcp field (TAPI-20 §3.8) made with them is conformant only when the provider's methods answer with MCP tool results ({ content, ... }): §3.8 and TAPs #16 are written for a signing proxy in front of a real MCP server (createMcpProxy, which already computes and holds its toolsSha256), where each method is a tool, while createMcpEndpoint generates its tools from methods that answer with their own JSON, and tapeapi-mcp verifies such an answer and then does not show it as a tool result (tested). For that reason the public service 11.1013.tape does not declare mcp: its manifest is unchanged and tapeapi connect keeps showing it as not-ai (see the MCP guide). The public Worker (examples/public-api) adds mcp: { endpoint: <PUBLIC_URL>/mcp, toolsSha256 } to its manifest and holds its methods only when the new variable MCP_TOOLS_SHA256 is set (64 hex digits, taken lowercased, surrounding spaces ignored; any other value, such as one with 0x, keeps the whole service in setup mode, and its problem names MCP_TOOLS_SHA256 and the form it needs); the public service does not set it, and without it the manifest and every answer are byte for byte as before.

Changed

  • tapeapi connect: the not-ai reason names both fields. A manifest with neither ai nor mcp is still unavailable with status not-ai and exit 1; the reason now reads "its manifest has no ai field and no MCP tool declaration (mcp, TAPI-20 §3.8): it is neither an AI service nor an MCP tool service" (Chinese: "它的清单没有 ai 字段,也没有 MCP 工具声明……"). Scripts that matched the old sentence should match the status instead.

Fixed

  • tapeapi-mcp: a manifest's mcp field is checked as TAPI-20 §3.8 requires (a correction to conform to the specification's MUST, in a stable command). §3.8 has required, since tapeapi-mcp began checking the field in 0.4.0, that mcp.toolsSha256 be 64 lowercase hex digits and that mcp.endpoint have no query or fragment; tapeapi-mcp accepted capitals (and lowercased them) and did not look for a query or a fragment, which is looser than the specification. It now refuses such a field as unusable, before anything is sent to the service, with the reason "mcp.toolsSha256 must be 64 lowercase hex characters" (was "64 hex characters") or "mcp.endpoint must have no query or fragment". The MCP tools draft (TAPs #16) writes the same rules (§2) and lists both checks as planned for this implementation ("Reject upper case", "Add the missing checks"). Pin files already written are not affected: tapeapi-mcp still pins the digest lowercased, so no pin hash changes. No published mcp manifest is known to be refused by this: #16 records that no live service publishes an mcp member, the holder console and createMcpProxy write the digest in lower case, and the public service does not declare mcp (see Added). tapeapi connect, whose MCP card is new in this release, checks the field the same way from the start (mcp-invalid, exit 1).
  • The escrow deployment scripts (scripts/preflight.mjs, scripts/verify-bscscan.mjs), found in a rehearsal on a local fork of BNB Smart Chain; they matter only to whoever deploys the escrow contract, which is not audited and not deployed. preflight.mjs read the escrow's token from BEM while contracts/script/Deploy.s.sol reads TOKEN, so an environment set up for the deploy script failed with "BEM is not set": it now reads --token / TOKEN, keeps --bem / BEM as a deprecated alias with a warning, fails when both are set and differ, and no longer echoes a value that is not an address. With DEPLOY_ESCROW=true it also checks the token (a new section 3b, read by quorum): it prints keccak256 of the token's code and fails if a token it knows (BEM, or the one Deploy.s.sol pins) has other code, fails if any of the five proxy slots Deploy.s.sol reads is not zero, and fails unless decimals() is between 8 and 18. verify-bscscan.mjs --dry-run no longer asks Etherscan whether the address is verified (it did, before looking at --dry-run): a dry run builds, recompiles, compares with the code from RPC_URL and stops; --check is the way to ask.

[2.2.0] — 2026-10-19

TapeAPI 2.2.0: receipts put to use, against a bill and against the paid period. tapeapi receipts lists, replays and exports the receipts tapeapi-verify --log saved, tapeapi reconcile sets them against a provider's bill row by row, and the doctor's activation check carries the paid period (paidUntil) and the monthly fee as fields. The command-line additions and the doctor's fields are experimental: outside the 2.x compatibility promise, they may change or go away in any 2.x release. Nothing here changes a default: tapeapi --help is 12 lines longer and the doctor's --json has more keys, but with monthlyFee() at 0, as on mainnet today, the doctor's text report is byte for byte what it was and the monitor's report gains no fee line, and an MCP request that does not name revision 2026-07-28 gets exactly the answer it got before. Nothing below makes or takes a payment, and a receipt still shows what its signer says, not which model ran.

Added

  • tapeapi receipts tail|show|export and tapeapi reconcile (experimental: outside the 2.x compatibility promise, they may change or go away in any 2.x release). Both read files on this machine only: no network, no key, nothing uploaded. receipts reads the JSON lines tapeapi-verify --log writes (or bare receipt envelopes): tail lists the last calls with model, tokens, price and the logged verdict, filtered by service (container address, or the name in a manifest snapshot), model and time; show <id> prints one receipt and replays its checks offline (signature and signer, container, method and path, price table; the two hashes when --request / --response give the exact bytes) against a manifest snapshot saved next to the receipts (manifest.json, *.manifest.json) or given with --manifest, taken as given: the line under the verdict says its signer was not checked on chain there, and --json's replay has signerCheckedOnChain: false; without one it decides nothing (exit 3) and fetches nothing; export writes CSV or JSON with stable columns. reconcile --receipts <file|dir> --bill <file> sets the usage and prices the receipts claim against a bill file row by row and lists three kinds of fact, each with its receipt line and bill line: match, missing (on one side only) and mismatch (a field that differs from what the receipt claims); it decides nothing. Rows join by an id column, else by time, model and token counts. --preset new-api names the fields of the JSON new-api's GET /api/log/self returns (data.items: upstream_request_id, model_name, prompt_tokens, completion_tokens, quota, created_at), taken from new-api's source code, not yet checked against a real instance; new-api's web CSV export is not that file (localized headers, a formatted Cost that is not a comparable amount, request_id), does not match the preset (exit 2) and, like other bills, names its columns with --map; amounts are compared only with --amount-scale (new-api: its quota_per_unit). Exit status 0 / 1 / 2 / 3 as the doctor's; every non-zero exit ends with the next command; --json, --lang en|zh|both; output redacted as tapeapi connect's, except the hashes, ids and addresses a receipt carries. Files: sdk/src/receipts-cli.js, sdk/src/reconcile-cli.js (no new export subpath; the ai face is unchanged). tapeapi --help lists the two commands and their exit status (12 more lines); tapeapi doctor|verify|mcp and tapeapi connect are unchanged.
  • tapeapi-doctor: the activation check carries the paid period and the fee as fields (experimental, like the doctor itself: outside the 2.x compatibility promise, they may change or go away in any 2.x release). In --json (and in diagnose()'s report) the check has paidUntil (ISO 8601, UTC), paidUntilSeconds (DomainBinding.containerPaidUntil(container) as read, Unix seconds, compared on chain with the block time) and monthlyFeeWei (monthlyFee() in wei, a decimal string), each null when not read, with the reason in unread (absent when the check is skipped or undecided); a script no longer needs to parse the date out of detail. null does not mean expired: a script reads the check's status first and excludes null before comparing a date (new Date(null) < now is true in JavaScript). The doctor now reads monthlyFee() for an activated name too (one more eth_call); when it is not 0, the text report prints one note: / 提示: line under the check. The check's status, its detail, the status names and the exit codes are unchanged; with monthlyFee() at 0, as on mainnet today, the text report is byte for byte what it was. api.siteStatus() and tapeapi connect read nothing more.
  • MCP: resultType, and ttlMs / cacheScope on list results, for a request of MCP revision 2026-07-28. createMcpServer in @tapeapi/sdk/mcp, and so createMcpEndpoint, createMcpProxy and tapeapi-mcp: a request whose params._meta['io.modelcontextprotocol/protocolVersion'] names revision 2026-07-28 or a later one gets resultType: "complete" on its result, and the result of tools/list, resources/list, resources/templates/list and prompts/list also gets ttlMs: 0 and cacheScope: "public". A request without that key (every request of revisions 2025-11-25, 2025-06-18 and 2025-03-26) gets exactly the answer it got before; initialize and error answers never change. Nothing signed changes: the fields sit beside the result, so receipts, signed envelopes and toolsSha256 are the same in both revisions, and the proxy talks to its upstream as before. The endpoint does not otherwise speak 2026-07-28 (no server/discover, no header checks). The CORS preflight already allows Mcp-Method and Mcp-Name (2.0.0). Checked against the official specification on 2026-10-14, when 2026-07-28 was its latest revision: https://modelcontextprotocol.io/specification/2026-07-28/basic/index (results and resultType) and https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching (ttlMs, cacheScope).
  • The monitor (scripts/monitor.mjs): its implementation-slot check is described in docs/OPERATING.md and tested end to end on the SDK's fake chain, and it notes a monthlyFee() that is not 0. The slot check is not new: since 2.0.0 the monitor reads the ERC-1967 implementation slot of the SiteRegistry and DomainBinding proxies through api.siteStatus and compares it with the expectedImpl list in sdk/src/chains.js; an unlisted implementation is a STORE-CHANGED ALERT and exit code 1, and slots that cannot be read are a warning only. New: docs/OPERATING.md says so; four tests (MON-2.2-1 to MON-2.2-4) run the monitor's own default client over the TAP-10 fake chain with a proxy's implementation swapped (alert, exit 1), with the older listed DomainBinding implementation (quiet), and with eth_getStorageAt failing on every node (warning, never an alert); and the warning for unreadable slots now ends "whether either was changed is undetermined". For an activated service the monitor now also reads monthlyFee() (the doctor's feeWhenLive: 2 more read-only eth_calls per run, one per activated service): a fee that is not 0 (it is 0 on mainnet today; the monitor keeps no state between runs, so "no longer 0" is judged against 0) adds one line under Notes with its monthlyFeeWei, and a fee that cannot be read adds one that says it is undetermined; at 0 the report is byte for byte what it was. In --json an activated service's activation.feeWei and feeText now carry the fee (they were null), and feeUnread appears when its read failed. The status names, the alerts and the exit code are unchanged: the fee line is a note, never an ALERT or a problem.

[2.1.0] — 2026-10-17

TapeAPI 2.1.0: tapeapi connect, one command that shows whether a service can be used, how to set up a client for it, and why not when it cannot. Two entries change a default. The example sidecars' FORMATS (under Changed): set, it limits the formats published and refuses the others; a deployed 2.0.0 new-api sidecar or Worker without it keeps serving its four formats, with a warning. The fix under Fixed: ai.createVerifyingFetch in strict mode, its default, refuses a call to a format the manifest does not declare (strict: false keeps the 2.0 pass-through). Everything else below is experimental: outside the 2.x compatibility promise, it may change or go away in any 2.x release, and the 2.0 default resolution (TAP-10 / TAP-11 §2) is unchanged. Nothing below makes or takes a payment: tapeapi connect pays nothing, the price converter's prices are for display only, and the new-api local demo (chain-demo.mjs) runs on the SDK's fake chain only.

Changed

  • Example sidecars: FORMATS in examples/new-api-sidecar and examples/ai-proxy: unset runs as all with a warning; set explicitly, it limits the formats. Set, only the API formats it names (openai-chat, openai-responses, anthropic-messages, openai-embeddings, comma-separated, or all) are published in the manifest and get receipts. A metered path of a format left out (POST /v1/messages without anthropic-messages, also written loosely) is answered HTTP 400 bad_path with x-tapeapi-sidecar-error: 1, naming the format and FORMATS, and is never forwarded: it can get no receipt, so it is never answered silently. Unmetered paths (/v1/models, count_tokens) pass through as before. An unknown name keeps the sidecar (or the Worker) in setup mode, naming FORMATS; so does a model whose formats lists a format not enabled. Unset (a deployed 2.0.0 sidecar's .env, or the Worker's variables, after the upgrade), the sidecar runs as all, the four formats 2.0.0 published, and says so: a WARNING line in the startup log (FORMATS is not set, so every format is published and signed (...)) and a warning member in /tapeapi/v1/health (the Worker: in health, and once per isolate in its log), naming FORMATS and its values. The setup guides and env.example still ask you to set it: only the formats the upstream really serves (FORMATS=openai-chat for an OpenAI-compatible-only relay), or FORMATS=all. examples/litellm-sidecar gains FORMATS as a new, optional variable (its 2.0.0 server does not read it): unset, every format is published, as in 2.0.0; set, only those are, and a metered path of a format left out is refused as above. Its README, env.example and docker-compose.yml (which now passes FORMATS to the sidecar) say so.
  • @tapeapi/server/ai-proxy: option refuseOtherFormats (experimental, off by default): a metered path of a built-in format that formats leaves out is answered HTTP 400 bad_path (with the sidecar-error header) and not forwarded, instead of passing through without a receipt; { hint } ends the message. The example sidecars set it.
  • Wording fixes in documentation, comments, help text and vector descriptions (no behaviour, hash, signature or encoding changes). docs/guides/container-agents.md (en, zh-CN): the release whose kit resolves the 2.0 way by default is 2.0.0, not 1.9 (never released). The TAP-13 binding rules are in §4 (Response envelope), not §8: the citation is fixed in that guide, in scripts/gen-vectors.mjs, in spec/vectors/verify.py and in the public vector file spec/vectors/container-agent.json, whose content (and SHA-256) therefore changes in two description strings only; no input, expected value or check changes. The comments of sdk/src/agent-*.js, review-*.js and group-public.js, sdk/types/agent.d.ts and the help of tapeapi-verify task say "2.x compatibility promise" instead of "1.x", so tapeapi-verify task --help prints that line differently.

Fixed

  • ai.createVerifyingFetch and tapeapi-verify recognise every built-in format (a fix in the stable SDK that changes a default): a metered path of a format the service's manifest declares no endpoint for, sent under one of its endpoint roots, used to pass through with no report, even in strict mode. Strict, which is createVerifyingFetch's default (strict: true), now refuses it with INVALID_ARGUMENT before sending (data: { format, declared }), as it refuses an endpoint mismatch: such a call can get no receipt, so passing it through silently contradicted strict. Code that relied on the old pass-through passes strict: false: the call is then sent and onReport gets mismatch: true and not verified: format not declared. tapeapi-verify --strict answers HTTP 400 format_not_declared without forwarding; without --strict (its default) it forwards the call and logs it as FAIL, counted among the failed calls of its totals.

Added

  • tapeapi, one command-line entry, and tapeapi connect <name> (experimental; outside the 2.x compatibility promise). New bin tapeapi: tapeapi doctor|verify|mcp … run tapeapi-doctor, tapeapi-verify and tapeapi-mcp as child processes with the arguments as given, so their output and exit status are byte for byte the same (tested); those three commands are unchanged and stay. tapeapi connect <name> resolves an AI service with conform: 'tap10' (the 2.0 default), shows it (container, holder, signer, delegation, activation, formats, the price table, USD for display only) and prints client settings only for the API formats its manifest declares (ai.endpoints[].format): Claude Code only with anthropic-messages, Codex only with openai-responses. A service that is unpaid, not-opened or store-changed is shown as unavailable with the reason (exit 1) and --proxy starts nothing; for a usable service --proxy then runs tapeapi-verify as it is, which resolves the same way (the 2.0 default; connect never passes --no-conform); --json, --lang en|zh|both; exit status 0 / 1 / 2 / 3 as tapeapi-doctor (2 also when --tool / --format names a format the manifest does not declare; 3 when the chain, or the --dev manifest, cannot be read), and every exit other than 0 ends with the next command. It asks for no key or password, uploads nothing and pays nothing. Its output shows as *** the value of a secret-named environment variable of this machine (*KEY, *TOKEN, *SECRET, *SIG, ...; not one that reads as ordinary text: under 8 characters, with a space, or a plain word under 16) and any key-shaped string (sk-..., 0x and 64 hex digits, a long hex run, the token after Bearer); the rest of the manifest is shown as it is. New experimental subpath @tapeapi/sdk/connect (snippetsFor, connectReport, renderConnect). Documented in sdk/README.md and the AI providers guide ("What your users do"). Boundaries: a receipt does not prove which model ran; the settings are not tested against each tool's current release.
  • examples/new-api-sidecar: a price converter and a local demo of the whole path. pricing-convert.mjs (tapeapi-newapi-prices; not part of either package) turns new-api's GET /api/pricing (saved to a file) into the sidecar's models.json, with a source record and --check for drift; the exchange rate has no default and must be confirmed. USD per 1M tokens = model_ratio x 1000000 / quota_per_unit, read from /api/status or given with --quota-per-unit (no default; new-api's own is 500000); create_cache_ratio gives cacheWrite. The prices are new-api's own quota dollars, published as USD for display only, not what a user really pays; tiered and video models are refused with a reason, and model_ratio 37.5 without a billing mode (new-api's placeholder, or a real price) is refused as ambiguous unless it is a default-table price or --accept-ratio-37.5 names it; the refusal says when self-use mode bills it. Only the groups the answer shows (usable_group) count, and who fetched the list (--fetched-as) is recorded. --check compares a hash of the price content; new-api's pricing_version is a build marker, so its change alone is a note, not drift. chain-demo.mjs runs a fake new-api, the converter, a sidecar with FORMATS=openai-chat and a name on the SDK's TAP-10 test chain, then tapeapi-doctor by name in both resolutions (conform: 'tap10', the default, and conform: false), the SDK's checked calls, a tamper check and the converter's drift check; it proves the pieces fit on one machine, nothing about a real new-api, and no real chain.

[2.0.0] — 2026-10-12

TapeAPI 2.0.0: standards by default. With no options, createTapeAPI() resolves a service as TapeOut's official TAPs say (TAP-10 v1.1 §3 to §7 and TAP-11 §2), with one known exception (below); TAP-10 and TAP-11 are Drafts (a merge is not an adoption), and the status names that follow them stay experimental. No wire format or frozen constant of TAPI-20 to TAPI-27 changed: a 2.0 client verifies every 1.x manifest, delegation, signed response and receipt. What to change is in docs/guides/upgrade-2.0.md. 1.x receives security fixes for 6 months after 2.0.0. Bounded agents, US-dollar prices and reviews are new and experimental; the escrow they rely on is not audited and not deployed, so bounded agents and paid calls do not work on mainnet.

Breaking changes

  • createTapeAPI() resolves as TAP-10 and TAP-11 §2 say by default (the conformance mode, conform: 'tap10'). Without any option, every read is pinned to one block (TAP-10 §5.3), the chain id and the holder are read strictly (every node must answer and agree), and the states TAP-10 names are reported as data.status of the error: not-opened, unpaid, store-changed, unavailable, unsupported. conform: false (or null) gives the 1.x resolution back; it is deprecated, kept for all of 2.x, and warns once when the client is made (the warning links the upgrade guide). dev: true also means conform: false. A configuration that is not a TapeOut chain's (a hub, factory or registry of your own, a local chain, a pin other than 'tap10') now fails when the client is made, with a message that says to pass conform: false; nothing falls back silently. allChains stays off by default. tapeapi-mcp, tapeapi-verify and tapeapi-doctor take --no-conform and print which resolution they use; tapeapi-doctor reports a name that is not activated as a failure in the default (a warning with --no-conform). The agent kit resolves this way too (see Changed). Measured on BNB Smart Chain with the default nodes: a cold resolution is about 2 times and a warm one about 4 times the JSON-RPC calls of 1.x. Error codes that change, request counts and the checklist for service providers: docs/guides/upgrade-2.0.md.
  • A resolved manifest is kept for 60 seconds by default, instead of 3,600 (TAP-11 §7.1 says a client SHOULD NOT keep it longer than 60 seconds). A long-lived client reads the manifest about 60 times as often; conform: false keeps 3,600 seconds.
  • A directory label is no longer an input to resolve by default (TAP-10 §3.4): it is refused before anything is read, with INVALID_ARGUMENT and data.status: 'input-error', where 1.x resolved it through the directory (or failed with MANIFEST_INVALID, DELEGATION_INVALID or NOT_FOUND). Pass the name, the container or { circuits, tokenId, chainId } instead; conform: false keeps the 1.x behaviour.
  • A manifest that begins with a byte order mark, or is not valid UTF-8, no longer resolves by default. 1.x resolved it; the 2.0 default refuses it as TAP-11 §3.1 and TAP-10 §16 say, with MANIFEST_INVALID and data.status: 'manifest-invalid'. The holder publishes the manifest again as valid UTF-8 without a byte order mark; until then conform: false is the stop-gap. The upgrade guide lists it under "New failures".
  • CONFORM_PROFILE and svc.conform.profile (only added): '[email protected]+tap11@draft', the TAP texts the conformance mode resolves by (TAP-10 version 1.1, TAP-11 Draft). Absent with conform: false; siteStatus, error data and every existing field are unchanged. The upgrade guide now states the known exception to TAP-11 §2.1 (without allChains, a processor contract#ID string is unsupported, TapeAPI's own name), and the type declarations no longer say that the default's errors are unchanged.
  • How the default follows changes in the official TAPs (wording only; no behaviour, no label changed). TAP-10 and TAP-11 are merged Drafts, so their text can change. A loosening or a new status name becomes the default in a 2.x minor release; a tightening is offered first as an optional new profile and becomes the default in 3.0; status names only grow. data.status, SiteStatus and svc.conform stay experimental in 2.0. allChains stays off and the known exception stays recorded. The upgrade guide has the section "How the default follows changes in the official TAPs", and its promise about status names now says exactly this.
  • Private channels and groups use TAPI-26 v2 and TAPI-27 v2 by default: labels that begin with tape-channel/ and tape-group/ instead of TAP-26/ and TAP-27/. 1.8.1 added version 2 as an option (labels: 'v2'); 2.0 makes it the default, and labels: 'v1' selects version 1 (deprecated in 2.x, not removed in 2.x). The TAPs editors asked for the rename before merging the drafts of private channels (TapeOutProtocol/TAPs#12) and private groups (#20); TAPI-26 and TAPI-27 are Stable (v1), whose labels TAPI-1 §4.1 freezes, so the rename is a version 2 of each. A 2.0 client and a 1.x client that passes no labels do not interoperate (nothing fails open: they time out, or the SDK names the mismatch in data.labels / data.peerLabels); pass labels: 'v1' on the 2.0 side to talk to them. A group keeps its version for life: a 1.x snapshot (v: 1 / v: 2) resumes as a version-1 group, a default group's snapshot is v: 3. api.chain.channelKeys(...).inbox.room is now the version-2 inbox room. Channel records, relays and ChannelBus are unchanged. Vectors are unchanged (version 1's files byte for byte, version 2's from 1.8.1); the default-mode golden trace changes in one value, that inbox room.
  • Specs. TAPI-26 v2 and TAPI-27 v2 are Stable (v2) from 2.0.0 (their Status was Draft before). TAPI-26 and TAPI-27 (Stable (v1), unchanged) note that from 2.0.0 they are no longer the SDK's default and that 1.x keeps security fixes for them for 6 months after 2.0.0. TAPI-1 §4.1 defines Stable (vN) and the TAPI-<n>-vN.md files. No RFC 2119 keyword count changed.
  • TapeAPIError no longer has the deprecated top-level aliases (e.tooLarge, e.rpcCode, e.rpcRevert, e.rpcData, e.agreed, e.disagreed, e.failed, e.groups, e.quorum, e.reason, e.refusals, ...): read e.data.*, where every detail has been since 1.0. The type declarations no longer list them.
  • The channel module reports a mistake in the caller's own options or arguments as INVALID_ARGUMENT, like every other module since 1.0 (createInvite / acceptInvite options, a closed or misused session, relayTransport, busTransport, busReader, fanIn, sealToInbox / openFromInbox options, under either label version). TAPI-26 data that fails (invites, handshake messages, frames, sealed invites, including one of the other label version, and keys and relay lists read from them) stays CHANNEL_INVALID. The group module keeps GROUP_INVALID.
  • The provider directory and the monitor say what a 2.0 default client concludes. The daily directory recheck still runs tapeapi-doctor with conform: false, and its flag (ok, failing, stale, undecided) and the job's exit status mean what they meant; each entry in site/directory/status.json now also has defaultMode, the site status a client in the default mode reads with api.siteStatus (ok, not-opened, unpaid, store-changed, or unavailable when the chain could not be read or the name's cap of 60 RPC requests, shared with the doctor, ran out). The field is only added: it never changes the flag, never fails the job and never removes an entry; providers.json is unchanged. The directory page shows a defaultMode that is not ok on the entry's card, with its reason. scripts/monitor.mjs makes its client with the SDK default (a test locks it) and reports per service the default resolution's outcome (resolved, or the status it refused with); the 14-day delegation problem (exit 1) is unchanged, also when the default mode refuses the service.
  • sdk/src/channel.js, frozen through 1.x, is unfrozen: it takes service itself (the public relayTransport has taken service since 1.0 and no longer translates it) and reads e.data.* instead of the aliases.

Added

  • Prices in US dollars, paid in BEM (experimental; no escrow is deployed, so nothing is charged): the TAP draft "Payment Vouchers for Container Services" (TapeOutProtocol/TAPs#52), TAPI-22 §3.6. Only a manifest with the new top-level member usdPricing is affected; any other manifest answers and pays byte for byte as before (server/test/payment-golden.test.mjs, recorded on the 1.8.1 payment code). New SDK namespace usdPricing (validateUsdPricing, exact convertUsd = min(ceil(U·10^18/R), priceBEM), validateQuote, readReferenceRates over v3 pools at one agreed block, chargeRecord / checkChargeRecord); sig.recoverVoucherSigner and recoverAddress(…, { strictV }) refuse v 0/1 for vouchers while envelopes keep accepting them. Provider (createProvider options usdRate, quoteValidS, quoteStore, new memoryQuoteStore()): quotes honoured by every instance sharing the store, the draft's checks with data.reason, and the signed charge record charge in every priced result. Client (createTapeAPI option usdPricing): quotes only from verified envelopes, the reference check before signing, rate in the voucher, the charge-record check with a halt until a reread and an F2 read, priced calls of one channel one at a time (whatever svc objects or api instances share the payer), and consent to a USD price rise (maxPriceUSD); the provider gives quote requests their own budget (rateLimit.quote) and namespaces memoryQuoteStore by service (put(quote, container), get(rate, container)). Both sides take settlementFacts (F1–F4 from configuration until a settlement TAP exists). Vectors spec/vectors/tapi-22-usd.json, reproduced by verify.py. TAPI-22 §3.5: the first custody asset is BEM (decision of 2026-10-08), with its read-only admission check.
  • createRpc takes rpcTimeoutMs, the name createTapeAPI, its chains and createProvider already use for the timeout of one RPC request. timeoutMs stays; passing both with different values is INVALID_ARGUMENT. The channels guide uses the new name.
  • api.tx.setContributionAll({ circuits, tokenId, bps, escrows }) (experimental): one setContribution per escrow instance, since each instance keeps its own setting. escrows defaults to the instances the client builds for (AUDITED_ESCROWS, empty today, then allowEscrows; not the escrow option, which only setContribution uses); none at all is INVALID_ARGUMENT, never an empty list, and so is escrows: null (the errors say both). The chain.escrow.contributions and tapeapi-doctor --escrows warnings name it.
  • The conformance mode reads the hub's Upgraded logs (TAP-10 §13.8, a conditional SHOULD; experimental). In the conformance mode (the default since 2.0, or conform: 'tap10'), chain.tapeSendKey also asks, at its pinned block and under strict agreement, for every Upgraded log of the hub since its creation block (new constants TAP10_HUB_HISTORY, TAP10_HUB_BOOT, read back on chain). A log naming an implementation that is neither the boot implementation nor one TAP-10 lists for the chain (current or replaced) is hub-changed even when the implementation slot shows the current one again, with data.upgradedLog; it is kept, in sealStatusStore too, while the SDK's list does not accept it (a stored record of another shape is ignored). Only answers that hold the history's anchors count. When the logs cannot be read (the default public nodes do not serve this history) or nodes disagree, the hub is judged by its slot as before and tap10.hub says so (basis: 'slot', upgraded.status: 'unavailable', retried after 300 s); otherwise basis: 'slot+logs'. With conform: false, exactly the same requests are sent as in 1.8.1.
  • @tapeapi/sdk/agent (experimental; checked on local chains only, not usable on mainnet): phase-1 rules for mandates that name funds. Nothing changes by default: without the new option every check is phase 0 exactly as in 1.8, and no type hash, digest or existing vector changed. With { enforcement: 'escrow-channel' }, verifyMandate and verifyTaskThread check such a mandate by static rules (scope-token-cap-mismatch, fee-token-cap-mismatch, scope-duplicate-provider, mode-scope-mismatch, subdelegate-not-allowed; a warning scope-includes-agent, whose static half, new function phase1Warnings, is reported whatever the static problems) instead of phase0-no-funds, then read every capped provider's escrow channel at the check's pinned block, the escrow address too (from the provider's manifest as it stands at that block) (escrow-not-allowed, escrow-other-chain, escrow-token-mismatch, session-missing, session-expiry-mismatch, withdraw-pending by the request's lifetime of 48 h + 7 d, channel-empty, channel-exceeds-cap; with a fee, agent-key-on-fee-channel and fee-channel-short), and report enforcement: 'escrow-channel' with each channel's readings only when all pass. The cap is the console's funding target, not a bound: what the chain bounds is always the channel's balance, every phase-1 result says so with bound: 'channel', and a channel above its cap fails the check. New kit option allowEscrows, new options fundingPath (holder, the default, or container) and feeDue, new function withdrawRequestLive. mandateTypedData and forWallet take the same option and show a phase-1 warning and every amount. New pure function escrowPlan: the holder's unsigned approve, fund and authorizeSession transactions, from the holder's wallet or through the principal container's execute (the TapeOut fee supplied by the caller). It refuses inputs that contradict each other (one escrow given with two tokens, a provider that is the plan's consumer or an escrow of the plan) instead of taking the last one. escrowPlan builds approve and fund for the token the caller names (checked against the scope and the escrow's token()): they do not go through 1.8's tx.fund and its BEM-only escrow check. That is for checking this experimental subpath on local chains; it does not lift the BEM restriction. New vectors: phase1 in spec/vectors/container-agent.json. The escrow is not deployed or audited and pricing in other tokens is not specified, so none of this works on mainnet.
  • @tapeapi/sdk/agent (experimental; local chains only, not usable on mainnet): the holder's plan reads its facts from the chain, with a close-out and a session history. Nothing changes by default. New kit methods: holderPlan reads, at one pinned block, everything escrowPlan needs (the principal's current holder, each provider's escrow from its manifest at that block, the escrow's token, the channel, a live request, the agent key's session) and refuses a holder that is not the current one (holder-not-current); on the container path it also refuses a principal that is not deployed or not opened (principal-not-deployed, principal-not-opened) and finds the TapeOut fee by simulating execute (no fee is written in) under a maxExecuteFee the caller must give (a fee above it is execute-fee-too-high; the plan says the fee times the number of calls in whole coins), refusing when the call still reverts with it (execute-reverts). A key already authorised on a channel until another time is key-already-authorised; one authorised until the mandate's end gets no new authorizeSession, and a plan run again after it was applied never funds back what the key spent: such a channel below its cap is channel-spent, unless the session history holds the first run's record (its new claimedAtPlan, the channel's claimedOf then), in which case only the cap minus what the key has spent since is funded, and a channel above that is channel-exceeds-budget; without that record, a channel at its cap on which anything was settled is channel-spent too. The cap holds per mandate and provider over every channel the session history records: when it names this mandate's key on another channel to the provider (the provider moved its escrow, the circuit changed hands, a second wallet), the plan is funded-elsewhere until that channel is closed out to 0 from its own wallet (one of another wallet never counts as closed), and then funds only what the key has not spent on any of them, and when nothing is left of the cap a fresh channel is budget-exhausted (no lone authorizeSession on an empty channel); a lost history cannot be rebuilt for an escrow the provider no longer names. So holderPlan and closeOut require history ([] when the console has no record; left out is an input error), and the pure functions say in checked.history whether one was given at all. Each record entry carries the pinned block; a plan pinned at or before a record's block is pin-not-after-record (re-run only once the earlier run's transactions are final). With the signed mandate (signed, or its signer), a holder that is not the one who signed it is holder-changed-since-signing, history or not. closeOut (new pure function closeOutPlan): requestWithdraw of each whole channel, then withdraw inside its window. afterPlan (new pure function planApplied): the allowance, channels and sessions read back after the plan, what the key spent since counting toward the cap, and why a channel is short (a failed fund, or a withdrawal that needs nothing). New sessionHistory (one entry per session; a later record only fills a missing claimedAtPlan, never replaces one) and sessionEntry, and the kit's sessionLogs (only the given consumer's logs, up to the kit's pinned block): with a history, a plan refuses a key used for another mandate (key-reused) and a channel another key can still spend on (channel-session-live). A plan refuses a mandate the other funding path already funded (other-path-funded), and on the container path a holder without the native coin for every fee (holder-short-of-fee, from a balance read under strict agreement); the pure escrowPlan needs holder on both paths, and the pure functions take an executeFee only with a maxExecuteFee. closeOut takes the plan's record as history and refuses a consumer that did not fund (consumer-unknown), says when the consumer was not checked, and with fullRequest: true replaces a live request for part of a channel by one for all of it. New constants DEFAULT_FUNDING_PATH (the one default of every funding path option; which path it should be is not decided yet) and FUNDING_PATHS, EXECUTE_FEE_SELECTOR. New vectors: phase1.closeOut and phase1.applied, more phase1.plan cases. The plans were executed on a local copy of the escrow only.
  • @tapeapi/sdk/agent (experimental; local chains only, not usable on mainnet): the agent runtime's spend limiter. Nothing changes by default. New createSpendLimiter({ store }) wraps api.payer({ consumer, sessionKey }) for an agent key: per mandate and provider, no voucher whose cumulative is above the channel's claimedOf at the check that started the work plus the cap is ever signed out (a refused one is released, never returned); that start never moves up; the escrow and consumer of the start are pinned, and a voucher for another escrow, consumer or chain is refused and stops the order; the voucher is checked to be the one counted; the record is written before the voucher leaves, and a key without its record (a restart without the store) never signs. accept, start (the same mandate again keeps its start; a stopped order stays stopped, and a check of another escrow or consumer stops it), recheck (a later check: a live request, or a result that is not an enforced phase-1 check with every channel reading (spend-not-enforced), refuses the step; another escrow, consumer or mandate, a revoked or expired one stops the order), payer, record, stop; pure rules spendDecision, spendRecheck, spendPin, spendRecord, constants SPEND_REFUSALS, SPEND_STOPS. One writer per record is the premise: records carry seq, every write re-reads the record and never overwrites one that changed, a store whose set(key, value, { ifSeq }) compares and sets closes the race between limiters sharing it, and a stop is also kept under a key of its own that no other write can erase. A voucher it lets out carries the unsigned member mandateHash (providers ignore unknown members), and the signed mandate until a voucher to that provider was accepted; a channel the last check read short of the price signs nothing (spend-channel-short). New vectors: phase1.spend, phase1.recheck. examples/agent-service takes an optional spend option that uses it (without it, the example is phase 0 as before; a revocation it receives stops the record); @tapeapi/server is unchanged. Checked end to end on a fake chain and, for the transactions and vouchers, on a local copy of the escrow only.
  • @tapeapi/sdk/agent (experimental; local chains only, not usable on mainnet): a delivery's spend statement and its reconciliation. Nothing changes by default. A phase-1 check already carries each capped channel's claimedAtCheck; a deliver result may now carry spend, the agent's signed statement, per capped provider, of claimedAtStart (claimedOf at the check that started the work), claimedAtDelivery (at its last check before it signed) and spent (their difference). It is covered by the delivery's signature, not by receiptsHash. New pure functions spendStatement (the agent's side: a channel it could not read on the pinned escrow for the pinned consumer is left out, never given as 0) and spendEvidence, constants SPEND_EVIDENCE_PROBLEMS, SPEND_EVIDENCE_WARNINGS. verifyTaskThread(..., { enforcement: 'escrow-channel' }) reconciles a delivery's statement against the applied mandate's readings at the same pinned block and against the receipts that verified, priced from the providers' manifests; verifyEvidence takes { enforcement, scope } for the same. Only a malformed statement, a provider without a cap, or amounts that do not add up are problems (spend-evidence-malformed, spend-provider-out-of-scope, spend-evidence-inconsistent); every mismatch is a warning in evidence.spend.warnings (spend-escrow-changed, spend-above-chain, spend-above-cap, spend-exceeds-receipts, spend-below-receipts, spend-evidence-incomplete), because claimedOf moves only when a provider settles (up to 48 hours later), a hash-only receipt does not sign its method, and prices are read from today's manifest in BEM. Without the option, or for a mandate without funds, spend is ignored exactly as before; with both and no spend member at all, the delivery is warned spend-evidence-absent (never a problem). New vectors: phase1.statement, phase1.evidence. examples/agent-service adds the statement to a spend order's delivery; a phase-0 delivery is unchanged.
  • @tapeapi/sdk/agent (experimental; local chains only, not usable on mainnet): a default per-call price ceiling for the spend limiter. Without maxPrice, the limiter's payer now refuses a call that costs more than 10% of the (mandate, provider) cap, rounded down to the token's smallest unit (new constant SPEND_DEFAULT_CALL_PRICE_PERCENT); where that rounds down to 0 there is no default and every call is refused until one is given (spend-call-price-ceiling-unset). A maxPrice given (one amount, or one per provider: { [provider]: amount }) may be above 10% but never above the cap (spend-call-price-ceiling-above-cap). A price above the ceiling is spend-call-price-above-ceiling, which replaces spend-price-above-max; none of the three stops the order, and each message says how to set the ceiling. An explicit maxPrice of 0 (in any form, or for a provider), an array or an object that is not a plain one is INVALID_ARGUMENT. examples/agent-service passes its spend.maxCallPrice the same way and checks it when the service is made. More phase1.spend vectors.
  • @tapeapi/sdk/agent (experimental; local chains only, not usable on mainnet): an agent runtime can resume its spend orders after a restart, with the agent keys kept sealed. Nothing changes by default: without a key vault nothing is kept and a restart voids every order, as before. New KeyVault interface (seal, open; the operator's: a passphrase, a key of its own, a KMS) and passphraseKeyVault({ passphrase }): scrypt (N 2^17, r 8, p 1, constants KEY_VAULT_SCRYPT, KEY_VAULT_MIN_PASSPHRASE) and XChaCha20-Poly1305 with a random salt and nonce, the blob bound to its context (the chain and the agent key); a blob that does not authenticate throws AGENT_KEY_VAULT and never yields bytes. Pure functions keyVaultKey, keyVaultSeal, keyVaultOpen, keyVaultParse, keyVaultAad, spendKeyContext. createSpendLimiter({ store, vault }) adds keepKey (only the sealed blob is stored), resume (on a new limiter over the same store: the record, the sealed key opened and matched, then a fresh check compared as recheck does; pure rule spendResume, SPEND_RESUME_REFUSALS: spend-state-lost, spend-stopped, spend-key-lost, spend-key-unreadable, spend-key-mismatch, spend-resume-mismatch) and forgetKey; every stop destroys the sealed key. A stopped, revoked or expired order, or one whose provider moved, never resumes, and the ceiling (the channel's claimedOf at the start plus the cap) holds across the restart. examples/agent-service takes spend.vault with a persistent spend.store and gains recover(); its new state.mjs keeps the state in 0600 files and turns persistence on only with TAPEAPI_AGENT_KEY_PASSPHRASE and TAPEAPI_AGENT_STATE_DIR. New vectors: phase1.keyVault, phase1.resume. Whoever has both the passphrase and the files has the keys, so the worst loss stays the channels' balances. resume refuses a missing or stopped record before the vault is asked to open anything; a vault seals at most 4 MiB, and a longer blob is malformed before any key is derived. In the example the whole order record (offer and task text, mandate, receipts, deliverable) is sealed by the same vault, so a record changed on disk is never signed as the agent's own (order-record-unreadable, order-record-corrupt); every method refuses recovering until recover() has run; ctx.resumed tells a task what was paid before the restart; one runtime process per state directory (a lock file, taken over only when its process is gone on this host); and the state directory must never be restored from a backup or a snapshot (a rollback revives orders that were stopped, revoked or delivered).
  • @tapeapi/sdk/review (experimental): evidence-backed reviews between containers, the reference implementation of the draft "Evidence-Backed Reviews between Containers" (TapeOutProtocol/TAPs pull request #54, not merged, no number), which may change. A review record is one EIP-712 type Review in the TAP-11 domain (chainId the reviewer's chain): makeReview, reviewHashOf, reviewTypedData (with what a console should show beside the wallet), signReview (the reviewer's holder or its delegate, the TAP-11 signer of its service), reviewRequestId (tape-review:<reviewer>:<nonce>, a label for the request id of a TAP-13 call, never the caller's identity), subjectOf, textHashOf, evidenceHashOf. createReviewKit(api) verifies records (verifyReview, verifyReviews) with the strict readers of createAgentKit: both containers' identities and holders, EIP-1271 and the site file at one pinned block per chain, the reviewer's delegate and the reviewee's signer as TAP-11 §2 resolves them, another chain through api.forChain; level 1 (a signed answer whose request id names the reviewer, within 90 days) and level 2 (a #47 task: mandate and verdict by the principal's holder, delivery by the agent's signer, the phase-0 rule) reuse the SDK's TAP-13 and #47 code. Every result gives the level, signedBy (holder, delegate or null), counted, the draft's problem names and whether the text is withheld; a chain or node failure throws, never a problem. Carriages: readReviewFile (.well-known/tapeapi-reviews.json, at most 262,144 bytes, a larger declared size is not read, records naming another reviewer ignored; reviewFileBytes) and readReviewMessages (public TapeSend messages with a review member from the reviewer, final only, the block time in place of now, the sending wallet marked when it differs from the current holder; reviewMessageContent refuses a payload over 16,000 bytes). summarizeReviews: only records signed by the current holder or delegate correct, withdraw or conflict; one record per pair; level 1 and level 2 apart; distinct reviewers and holders, never amounts. checkReview(record, facts) replays the draft's vectors (spec/vectors/tap-draft-container-reviews.json, 59 cases and 6 summaries, a byte copy) with given chain facts; verify.py recomputes their hashes and signers independently. tapeapi-verify review <reviewee> (experimental subcommand, not in the main --help) prints the records of the reviewers' site files, the reviewee's inbox and a records file, and the summary.

Changed

  • @tapeapi/sdk/agent (experimental; local chains only, not usable on mainnet): in the spend evidence, spend-above-cap now uses the verifier's own reading (spentNow) when it has one, and the agent's reported spent only otherwise; one more vector, phase1.evidence 35 to 36. The SDK and the independent Python implementation change together.
  • createAgentKit resolves the agent and the providers as TAP-11 §2 says by default (@tapeapi/sdk/agent, experimental). Without a resolve option the kit now resolves them through a conformance-mode client (conform: 'tap10': one pinned block per resolution, strict reads) made from the kit's own client, with the same nodes, quorum, RPC timeout, fetch and other options; it never turns on allChains and reads no other chain, so no request goes to a node the client was not given. A client already in the conformance mode is used as it is. The results change where TAP-10 does: a container that is not opened or not activated, or a site store or payment contract running an implementation TAP-10 does not list, is now agent-unresolvable or provider-unresolvable instead of resolving. A client whose contracts are not the ones TAP-10 lists for its chain (a local or test deployment) keeps its own resolve, as in 1.8. With a resolve option nothing changes. New property kit.resolution: 'tap10', 'client' or 'caller'. tapeapi-verify task builds its kit this way too. To keep the 1.8 default, pass resolve: (container) => api.resolve(container).
  • The /mcp CORS preflight also allows Mcp-Method and Mcp-Name (createMcpEndpoint, createMcpProxy, and so the public service and the proxy examples), so a browser client of a newer MCP revision can send its requests. The headers allowed before are unchanged. The guide on MCP now says which revisions the endpoint speaks.

[1.8.1] — 2026-10-08

Added

  • Private channels and groups: optional version-2 labels (labels: 'v2'). At the request of the TAPs editors (TapeOutProtocol/TAPs PRs #12 and #20), the domain-separation labels of private channels and private groups have a version 2 that begins with tape-channel/ and tape-group/ instead of TAP-26/ and TAP-27/, with the same suffixes. The SDK implements it as an option: pass labels: 'v2' to channel.createInvite, acceptInvite, roomsFor, inboxRoom, sealInvite / sealToInbox and openInvite / openFromInbox, and to group.createGroup, joinGroup, openGroupInvite, groupRoom and checkGroupInvites. The default is unchanged: without the option the SDK uses version 1 exactly as 1.8.0 did, and the version-1 vector files are unchanged. Version 2 is planned to become the default in 2.0.
    • Both sides must use the same version. Invites carry no version (they keep v: 1), and the two versions do not interoperate. A mismatch fails closed: the rooms differ, and an accept, ready, sealed invite or epoch message of the other version is refused with CHANNEL_INVALID / GROUP_INVALID, data.labels and data.peerLabels. The SDK never switches versions on its own; any other labels value is INVALID_ARGUMENT. For a sealed invite the error says only how the wire was sealed: anyone can post to an inbox room, so it is no evidence about the sender.
    • Pending handles, sessions and group handles report labels. SDKs before 1.8.1 ignore the labels option silently and use version 1; an application that passes labels: 'v2' should check pending.labels and session.labels (or group.labels), which those SDKs do not have.
    • The snapshot() of a version-2 group is v: 3 with labels: 'v2' (SDKs up to 1.8.0 refuse it), and resumeGroup takes the version from the snapshot. A v: 1 or v: 2 snapshot that says labels: 'v1' is accepted, as 1.8.0 accepted it; one that says labels: 'v2' is refused.
    • Specifications: spec/TAPI-26-v2.md and spec/TAPI-27-v2.md (Draft). spec/TAPI-26.md and spec/TAPI-27.md stay Stable (v1), with a non-normative note. New vectors spec/vectors/tapi-26-v2-channel.json, tapi-26-v2-identity.json, tapi-27-v2-group.json and tapi-27-v2-group-format2.json, written by the SDK and checked by spec/vectors/verify.py with the version-1 files.

[1.8.0] — 2026-10-06

Changed

  • @tapeapi/sdk/agent (experimental): checks follow more of the draft TAP (TAPs PR #47) and are stricter. The subpath is outside the 1.x compatibility promise; messages, threads and evidence that 1.7.1 accepted may now be refused, and some problem names changed. No type hash, digest or existing vector changed.
    • Input forms are refused, not read leniently (draft §3.7, §7.2, §8). A message received from a counterparty must carry every bytes32 in lower case, every uint256 (nonce, fee, feeCap, a scope cap) as a decimal string (a JSON number or a bigint is now mandate-malformed / message-malformed), and a verdict's reasonHash (missing or null is now message-malformed; 1.7.1 read it as zero). An agent message whose ts or result.exp is negative, or whose receipt lacks the TAP-13 method, params object or 65-byte sig, or whose id is not 1 to 128 code units (TAP-13 §3), is message-malformed. A hash-only evidence receipt whose ok is not a JSON boolean (1.7.1 read it as false), or whose requestHash, bodyHash or ts is not in its form, or whose id is not 1 to 128 code units (the empty id of TAP-13 §8 binding rule 1 is allowed only when ok is false), is receipt-not-hash-only. The builders (mandateTypedData, taskOfferTypedData, the hash and sign* functions) still take a number or a bigint and still default a missing reasonHash; bytes32 input must now be lower case there too. New pure functions: agentMessageProblem, hashOnlyReceiptProblem; normalize* take { wire: true }.
    • One pinned block per verification (draft §4, §6.2; TAP-10 §5.3). Every read of verifyMandate, verifyTaskThread (both passes), verifyEvidence, readRevocations and identityOf is made at one TAP-10 pinned block, by block hash: the principal's identity and holder, EIP-1271 calls, both implementation slots and the revocation file. 1.7.1 read each at latest. The kit checks eth_chainId under strict agreement once; stale-block and wrong-chain are thrown with data.status, never reported as problems.
    • The principal is resolved as TAP-10 §4.3 says. The container is derived by the chain's container opener (1.7.1 asked the hub), and the processor number is found on the chain (snapshot hit confirmed by cpuAt, else a bounded scan), so the on-chain name is always present (1.7.1 gave null outside the snapshot). Every identity failure is now not-tapeout or no-such-token: the problem names not-a-container and wrong-chain are gone (a token() naming another chain is not-tapeout).
    • Resolving the agent or a provider. A site status (not-opened, unpaid, store-changed) from the resolver, or a conformance-mode client's unsupported for an address that is no container of the chain, is now agent-unresolvable / provider-unresolvable (1.7.1 threw it); stale-block, unavailable and wrong-chain are thrown. The kit still resolves through the client's resolve; the guide shows how to pass a conform: 'tap10' client (TAP-11 §2) as the kit's resolve.
    • The two passes share reads, the total goes up. The two passes of verifyTaskThread share one read of the revocation file, the implementation slots and each identity (within one thread the kit's own revocation-file and slot reads (pathCount, fileInfo, read and the slots) are half of what 1.7.1 made); the slots are kept per pinned block for at most 60 s. The total number of reads still rises: resolving the principal adds the pin (eth_blockNumber, eth_getBlockByNumber), one eth_chainId per kit and, outside the snapshot, a cpuAt scan (at most 256 reads per processor looked up, per verification, resumed on the next one).
    • Vectors: spec/vectors/container-agent.json gains inputForms, 66 hand-written cases checked by the SDK and by spec/vectors/verify.py.
  • Payments (experimental, TAPI-22; no escrow is deployed or audited): escrow amounts use the escrow's own token, and approve / fund are built only for allowed escrows. Outside the 1.x compatibility promise.
    • api.tx.approve and api.tx.fund now return a Promise. Both read the escrow's token() and the token's decimals() under strict agreement first, and are built only for an escrow on the new AUDITED_ESCROWS list (empty) or in the new createTapeAPI({ allowEscrows }) option; any other escrow is refused (INVALID_ARGUMENT, data.reason escrow-not-allowed), the configured escrow included. approve approves the escrow's own token instead of defaulting to BEM; its token, and the new fund(provider, amount, { token }), name the token you expect. fund is stricter than approve: it is refused for any escrow that holds a token other than BEM (UNSUPPORTED_PAYMENT_TOKEN, data.reason not-bem), whatever token you name and even for an allowed escrow, because the escrow contract itself takes any admitted token but the manifest's pricing in more than one token is not yet specified, so this SDK does not build funding for a channel it cannot use yet.
    • New client error code UNSUPPORTED_PAYMENT_TOKEN (data.reason: token-unreadable, decimals-unreadable, decimals-out-of-range, not-bem, token-mismatch). Manifest prices are priceBEM, so fund, a priced api.call and an approve without a named token (or for a service) refuse an escrow that holds another token; the escrow contract itself takes any admitted token, and prices in other tokens are not yet specified. Free methods and dev services are not checked.
    • Inside the SDK and the server, every parseUnits / formatUnits call names its decimals. The functions' own default (8, BEM) is unchanged.
  • Escrow contract v3 (experimental, TAPI-22; the candidate for audit): never deployed and not audited, so no live channel, provider or fund is affected. contracts/src/TapeAPIEscrow.sol is the first version that holds one ERC-20 token per instance by design. This is outside the 1.x compatibility promise, and nothing here says the audit will keep this shape.
    • The first constructor argument is the immutable token (it was bem), and token() is the view. The contract never reads the token's decimals; the deploy script requires 8 to 18.
    • The solvency statement reads token.balanceOf(escrow) >= sum of channels + treasuryAccrued (it was ==): tokens sent straight to the escrow stay in it and are never paid out. No code path changed for this; the statement and the invariant test did.
    • The maintenance contribution is pulled, not pushed. settle adds it to the new treasuryAccrued and makes one token call, to the provider; the new claimTreasury() (anyone may call it) pays the accrual to the current treasury and emits TreasuryClaimed. A treasury that cannot receive the token no longer blocks any settlement. Settled keeps its signature; its contribution now means the amount accrued.
    • setTreasury pays the outgoing treasury first (a failed payout is rolled back and ignored, so a frozen treasury never blocks the change; the unclaimed accrual then belongs to the new treasury), and it refuses the escrow itself and its token as treasury (new error BadTreasury(), also in the constructor).
    • One judge for token calls: every token call that does not answer success (a revert, a malformed or short return, a non-bool word) reverts TransferFailed(); the return data copied is bounded to 32 bytes.
    • contracts/script/Deploy.s.sol checks before it sends anything: the canonical USDT-pegged token with its code hash pinned, no proxy token (EIP-1967 and EIP-1822 slots zero), any other token only with ALLOW_OTHER_TOKEN=true, a treasury that is not zero, the token, the TapeOut hub or the container factory; the post-deploy checks read token() and treasuryAccrued(). New tests, among them a fork rehearsal of that script (needs BSC_RPC_URL, skipped without it).
    • Voucher type, typehash, EIP-712 domain and every channel, session, withdrawal and rate rule are unchanged.
  • TAPI-22 (Experimental) revised to specify the escrow that will go to audit. No escrow has ever been deployed, so no channel, provider or fund is affected. The 1.7.1 text of settle and the treasury functions is replaced; an implementation of it would need to change them. Normative changes, in both the English text and its Chinese translation: settle accrues the contribution to treasuryAccrued and MUST NOT transfer it to the treasury (§3.3; the 1.7.1 text required the transfer); claimTreasury() (anyone may call) pays the accrual; setTreasury pays the outgoing treasury first and refuses the escrow and its token (§3.4); voucher amounts are in base units of the instance's token (§3.1); every token call that does not answer success reverts TransferFailed(). The constructor argument and view are named token, the solvency line reads >= (§3.3), and §3.5 and §7 record which planned items the repository contract now has. Counting every MUST (MUST NOT included), each half goes from 40 to 47; MUST NOT alone from 7 to 8. upto settlement by measured usage is stated as not implemented and not specified, and is not part of the audit scope.

Added

  • Experimental, for escrow v3 (not deployed): api.chain.escrow.token, treasuryAccrued, paymentToken (token and decimals read on chain, cached; the label from the new PAYMENT_TOKENS, never the token's name()) and contributions (one provider's contribution across escrow instances, with a warning when they differ); api.tx.claimTreasury and api.tx.wrapNative({ wbnb, amount }) (WBNB deposit(), the wrapper address from the caller, no gas field); formatPaymentAmount; abi.EVENTS / abi.eventTopic (Settled, TreasuryClaimed) and ABI entries for token(), treasuryAccrued(), claimTreasury().
  • tapeapi-doctor --escrows a,b (and diagnose({ escrows })), experimental: notes when your contribution differs between escrow instances. No new check; the exit code is unchanged.
  • scripts/verify-bscscan.mjs takes --token for the escrow's first constructor argument; --bem still works and prints a deprecation notice.

Fixed

  • @tapeapi/sdk/agent (experimental): viaContainer had no way to attach the TapeOut fee, so its transactions revert on BNB Smart Chain mainnet. A read-only test of mainnet at the time of writing found that a TapeOut container's execute(address,uint256,bytes,uint8) needs the fee in the chain's native coin on every call (0.0002 BNB, sent to the treasury, the excess refunded to the sender; a shorter payment reverts with 0xafd49700(paid, required)), and viaContainer in 1.7.0 and 1.7.1 always built the outer transaction with value 0. viaContainer({ from, tx, value }) now takes an optional outer value (a decimal string, a safe integer or a bigint, 0 or more; anything else is INVALID_ARGUMENT), and without one the value stays 0x0 and the summary carries a warning that the call may revert without the fee; with one, the summary says it is the TapeOut fee and goes to TapeOut, not to the recipient. The fee has no getter: the SDK neither reads nor hard-codes it (simulate the transaction and read the revert data). The inner value is still always 0 and a non-zero tx.value is still refused. Types and the container-agents guide are updated.

[1.7.1] — 2026-10-05

Changed

  • @tapeapi/sdk/agent (experimental): revocation in a task thread follows the draft TAP (TAPs PR #47, §7.5-§7.6). The subpath is outside the 1.x compatibility promise; these change what 1.7.0 reports.
    • A revocation, whether a revocation message in the thread (wherever it sits) or the principal's site list found while checking the applied mandate, now only sets the thread's revocation time R, the smallest issued of the revocations that apply. It never changes the state when met. An agent message signed after R is refused (message-after-revocation); a verdict is allowed whatever its issued; only the final checks turn a thread still Offered, Accepted or Active into Cancelled, before Expired, and only once R is not after at. Because R depends on the applied mandate, which depends on the accept, the thread is read twice and the second reading's problems are reported.
    • In 1.7.0 a site list covering the mandate made the thread Cancelled at once with mandate-revoked and every later message out-of-order, even a delivery and verdict signed before the revocation; a revocation message cancelled Offered, Accepted or Active when met, and was accepted silently in Cancelled. Now a thread delivered and accepted before the revocation still reads Settled, by either path, and the same revocation gives the same result as a message or as a site list. mandate-revoked is no longer reported in a thread (verifyMandate on its own still reports it).
    • A revocation found while checking a mandate that the thread refuses (for example nonce-reused) no longer sets a revocation time.
    • tapeapi-verify task prints the new result for such threads, and its revoked: yes, at N (via) line is now revocation: at N (via); agent messages signed after it are refused (or revocation: none): the revocation time refuses later agent messages and does not by itself mean the thread was cancelled. The revoked member of a thread result keeps its shape and now means that revocation time.
  • @tapeapi/sdk/agent (experimental): the principal's revocation file is read as the draft's §6.2 says. A missing file is fileInfo.chunkCount 0 (it was size 0; TAP-10 §7.1); the file is read from the first site store of the chain that has any path for the principal (TAP-11 §2.2 step 3), and only while the site store and the payment contract run implementations TAP-10 lists as accepted (TAP-10 §6.1; otherwise the list is invalid, so every mandate of that principal is revocation-unavailable, as for any unreadable list); a file that begins with a byte order mark is invalid (it was silently stripped). createAgentKit now refuses, when it is created, a chain the SDK does not know or a site store or payment contract with no implementation TAP-10 lists as accepted (INVALID_ARGUMENT, as the conformance mode does), instead of reporting revocation-unavailable on every check.
  • Test vectors: spec/vectors/container-agent.json gains threadRevocation, fifteen abstract thread cases whose results are worked out from the draft, the boundaries included (an accept or a delivery signed exactly at the revocation time is allowed; a revocation time equal to at cancels, one second later does not); the SDK is checked against them on real signed threads and verify.py checks them with an independent model of §7.4-§7.6 (514 to 561 checks).

[1.7.0] — 2026-10-05

Added

  • @tapeapi/sdk/agent (experimental): container agents, phase 0. A new subpath, outside the 1.x compatibility promise (the formats follow the public Ideas TapeOutProtocol/TAPs#40 and #41 and may change with that discussion). Phase 0 has no enforcement: a mandate is a holder's signed statement, every check says enforcement: 'none', a mandate naming any amount is refused, and a payment is a plain transfer verified read-only (TAP-10 §19). It adds four holder-signed EIP-712 types (Mandate, TaskOffer, TaskVerdict, MandateRevocation; domain "TapeAPI", hub as verifyingContract), createAgentKit (verify a mandate, a task thread and an evidence bundle; read revocations; identityOf), createPaymentKit (payment orders, transferToContainer, viaContainer, recipientOf, describeTx, and a 15-step payment check), revocationFileBytes, and forWallet(td, { chainId, hub }), which every typed-data result must pass through before it reaches a wallet (it strips warnings and display, and rejects a payload whose chain or hub is not the one the console expects). A task is the message sequence offer, accept, mandate, deliver, acceptance, revocation. Test vectors: spec/vectors/container-agent.json (the vector set goes from 480 to 514 checks; verify.py agrees with the SDK).
  • validateAgentMember and the manifest's optional agent member (experimental).
  • examples/agent-service/: a runnable agent service and a hiring script (offline demo world with a fixed clock).
  • tapeapi-verify task <thread.json> [--payment <recipient> <index>] [--rpc <url>...]: check a task thread (and, with --payment, the payment) from the command line. The first argument task used to be an error and is now this subcommand; every other invocation behaves as in 1.6 (the output of ten argument shapes is pinned byte for byte).
  • DEFAULT_CONTRIBUTION_BPS = 100 is exported next to MAX_CONTRIBUTION_BPS.

Changed

  • Maintenance contribution: the contract cap is 20% (was 50%), the default stays 1%. MAX_CONTRIBUTION_BPS goes from 5000 to 2000 in the SDK and in contracts/src/TapeAPIEscrow.sol; setContribution refuses more than 2000. The escrow now keeps "never set" apart from "set to 0" (a provider set to 0 contributes nothing; one never set pays the default 1%), and Settled carries the bps that was applied. TAPI-22 says the directory MUST NOT rank or badge services by contribution. The escrow is still not deployed and not audited, so no live channel is affected. Guides, FAQ and BUSINESS.md now say 20%.
  • Revenue share: the basis changes from 2026-10-05. The permanent 10% for @Theairresearch is now 10% of each calendar quarter's revenue after the direct cash costs paid to third parties (rented services and infrastructure, audit fees, gas, related compliance and legal fees; never labour), settled quarterly with public accounts of income, deductions and payout. Payments received before 2026-10-05 are still shared under the earlier promise (10% of any revenue). See BUSINESS.md.

Fixed

  • formatUnits returned a wrong string for a token with 0 decimals (10 showed as .1); the payment summary was the first caller to pass a decimals value read from a chain.

[1.6.0] — 2026-10-04

Added

  • createVerifyingFetch({ requestUsage: true }) and tapeapi-verify --request-usage: the usage of a streamed OpenAI Chat answer can be checked (opt-in; off by default, and only true turns it on). An OpenAI Chat stream carries usage only when the request sets stream_options.include_usage, and the official openai package does not set it (at the time of writing, 7.x). When a streamed request does not, the sidecar asks the upstream for the usage itself and takes the usage chunk out of the client's copy (usageInjected), so no client could compare the receipt's usage with anything (it was listed in unchecked, 1.5's "remaining by design"). With the option, the verifying fetch (or the proxy) sets include_usage: true in the request's bytes before sending: the sidecar then injects and strips nothing, sets no usageInjected, and the usage chunk is part of the signed stream the application receives, so the receipt's usage is compared with it as a whole answer's is. This changes no wire format, digest or vector. It does not prove the upstream's own count: it gives usage the standard a whole answer already has, that the receipt states what the application was handed, so an overstated usage is signed into an answer the application can see (attributable), and a receipt that disagrees with the stream fails (RECEIPT_INVALID; FIXED AI-ASK-1, AI-ASK-END). Details:
    • What changes in the request. Only for a request the sidecar would itself change (a stream OpenAI Chat request whose stream_options.include_usage is not true), one splice in the bytes, every other byte kept: no stream_options gets ,"stream_options":{"include_usage":true} after the last member; an object without include_usage gets ,"include_usage":true inside it; an existing include_usage (false, null, 0, "true", ...) has its value replaced by true; a stream_options that is not an object (null, an array, a string, false) is replaced by {"include_usage":true}. The result is checked before it is used: it must parse to exactly what the sidecar's own rule (prepareUpstream) gives, and the sidecar's rule must have nothing left to change. The order is body, splice, salt, content-length removed, send; requestSha256 is the hash of the bytes finally sent.
    • What is left alone, and what then happens. A compressed body (content-encoding), a Content-Type that is not JSON (content-type), a body that is not UTF-8 (not-utf8) or starts with a byte order mark (not-object), a format with prepareUpstream but no usageMember (no-member), a key that appears twice at the top level or inside stream_options (duplicate-member; a duplicate deeper down is kept as it was), and a splice whose self-check fails (self-check; a body nested deeper than JSON.stringify can go is one). With strict the SDK throws INVALID_ARGUMENT before sending (data: { reason, format }) and tapeapi-verify --strict answers HTTP 400 (usage_request_skipped, with x-should-retry: false) without forwarding; otherwise the request goes as it is, the report says usageRequestSkipped: <reason>, and the usage stays in unchecked. A request that asks for usage itself, is not a stream, or is not an OpenAI Chat request is not touched (no skip reason).
    • Reports. With the option on, every report on a receipt path carries a boolean usageRequested (false: this fetch did not set the member, because the application asked itself, it is not a Chat stream, or the splice was skipped), and possibly usageRequestSkipped; with it off neither field exists. tapeapi-verify --log writes the same fields, the verdict line says usage=asked or usage-request-skipped=<reason>, and the start-up line says (usage requested). If the request asked for the usage and the stream carries none, there is a warning ("the request asked for the usage (requestUsage), but the stream carries none: the upstream did not send it, or something on the way took it out"). If the receipt states a usage the stream lacks, that is a failure as before; if it states none, it passes with usage null.
    • What an application sees. One more chunk before [DONE] with choices: [] and the usage, as when it sets include_usage itself; nothing is taken out on the client, which is why the bytes it holds are the bytes the receipt hashes. Code that reads chunk.choices[0] without a guard throws a TypeError on that chunk; the .stream() helper of the openai package works, and finalChatCompletion().usage has the value. Claude Code (Anthropic Messages) and Codex (Responses) streams carry their usage already and are not touched; Embeddings do not stream.
    • Limits and risks (the reasons it is off). A JSON body sent with a non-JSON Content-Type is skipped by the client while the sidecar, which does not look at Content-Type, still injects: that call's usage is not checkable (strict refuses it locally). An upstream that ignores include_usage leaves a verified receipt with usage null and the warning above. A sidecar that does not inject (the reference has no switch for it; it takes a custom formats table whose Chat adapter has no prepareUpstream), because its upstream rejects stream_options, will see these calls fail with HTTP 400 once clients turn this on. A third-party sidecar that strips the usage chunk although the request asked for it fails under strict. The salt now also reaches the upstream for these requests (the sidecar's re-serialising used to drop it): it is whitespace after the JSON text, which JSON allows, and is not yet measured against the live APIs. Under strict, the official SDKs may wrap the INVALID_ARGUMENT as a connection error and retry it up to maxRetries times (not measured; no request is sent in any retry).
    • Interfaces. New: requestUsage (createVerifyingFetch), usageRequested and usageRequestSkipped on the onReport report, usageMember on AIFormat (['stream_options', 'include_usage'] for openai-chat; a custom adapter without it is never rewritten), UsageRequestSkip, and ai.requestUsageBody (@internal, used by the sidecar and tapeapi-verify). With the option off nothing new runs and the default client path is byte for byte 1.5.0's (GOLDEN AI-1.5, recorded on 1.5.0 before this change). The guide has the three ways to get a stream's usage checked.

Fixed

  • The reference sidecar re-serialised the request it sent upstream (FIXED AI-RESER; every version with usage injection). To ask the upstream for the usage of a streamed Chat request, it sent JSON.stringify of the parsed body, which changed more than that one member: seed: 12345678901234567890 reached the upstream as 12345678901234567000, 1e400 became null, a repeated key was merged, whitespace and escapes were rewritten and the client's salt was dropped, against TAPI-21 §3.5 ("MUST NOT change … in any other way"). It now sets include_usage in the client's bytes with the same function the client uses (above), so every other byte arrives as sent; receipts are unchanged. Where the splice refuses, the sidecar re-serialises as before: a key repeated at the top level or inside stream_options (the repeated keys are merged), a byte order mark, a Content-Type that is not JSON, a custom format without usageMember. This needs @tapeapi/sdk 1.6: @tapeapi/server takes the function from the SDK's root entry, and with an earlier SDK installed it falls back, silently, to re-serialising (the server's dependency range moves to ^1.6.0 with the release).
  • Three smaller fixes around requestUsage and the sidecar (FIXED AI-ASK-CASE, AI-RESER-SDK). A key that differs from stream_options (at the top level) or from include_usage (inside stream_options) only in letter case, after the JSON escapes are decoded, now makes requestUsage skip the request (duplicate-member): a gateway whose JSON decoder ignores case and lets the later member win (new-api and one-api are Go programs) would otherwise read include_usage as false. The sidecar logs one line when @tapeapi/server 1.6 runs with an @tapeapi/sdk older than 1.6 (the request is then re-serialised the way 1.5 did, so the fix above does not apply; the server's dependency range is raised to ^1.6.0 with the release). And a streamed Chat request nested thousands of levels deep, which made the sidecar answer HTTP 500, now gets its HTTP 400 request_too_deep (with x-should-retry: false; setting include_usage yourself sends it on as it is).

Security

  • Release integrity. Every GitHub Action in the workflows is pinned to a commit SHA (with its tag in a comment), and a test keeps it so. From v1.6.0 on, every Release carries a SHA256SUMS file next to the two tarballs, made by scripts/release-checksums.mjs from the uploaded files, and the release notes repeat it. SECURITY.md gains §5, "Verifying a release": how to check, what a checksum on the release page proves and what it does not, and why there is no build attestation (releases are built by hand, not by CI).

Documentation

  • The AI providers guide gets "The usage of a streamed Chat answer: three ways", with what each way guarantees and what it does not. TAPI-21 §3.5 (usage injection) gets one explanatory paragraph in both languages: a client can ask for the usage itself, and the sidecar then changes and strips nothing and sets no usageInjected. It adds no MUST, SHOULD or MAY and changes no frozen constant or vector. The verification page's note on an unchecked usage now says the client can ask for it.

[1.5.0] — 2026-10-03

Added

  • TAP-10 conformance mode: the messaging path (experimental). Under createTapeAPI({ conform: 'tap10' }), api.chain.tapeSendKey resolves the endpoint as TAP-10 §12.2 says and reads the key as §14.4 steps 1–3 say, and api.chain.channelKeys (so groupVerifier too) reads the channel record under the same rules: a fresh TAP-10 pinned block per lookup, strict agreement on every read (every configured node, all equal, at least max(2, min(3, operators)) operators) and on eth_chainId (§5.4), the container from the opener. tapeSendKey also checks the hub at that block (§13.8): an implementation other than the current one TAP-10 lists is hub-changed, a circuit beacon off its implementation is circuits-changed (kept by the client), and the seal status is reported in result.tap10.seal; the key must belong to the resolved container and endpoint (hub-mismatch), be usable (no-key, key-stale) and pass the X25519 checks (bad-key). channelKeys needs both site-store implementations accepted (store-changed) and refuses a record with a byte order mark. Neither judges activation or opening (§12.2): the same unactivated container is unpaid to resolve and has its record and key read as usual. One node down or behind the pin now stops these reads in this mode on BNB Smart Chain's three default operators (strict; Base's four leave one spare, X Layer's two need no more than the quorum), not in the default mode, which is unchanged. Also new: tapesend.endpoint, seal, open, messageId and sendTx take conform: 'tap10', which refuses a chainId above 2^53 − 1 (§12.1); TAP10_SEALS (the beacon, circuit implementation and current hub implementation of each chain, read back on chain 2026-10-02) and TAP10_MAX_CHAIN_ID. Input that may belong to another chain (an address that is no container here, a token() naming another chain, a processor contract that is no processor here) is unsupported, as in resolve, and never cached. A client with conform: 'tap10' now accepts only the hub, the processor factory and the container opener TAP-10 lists for its chain (hub, factory, opener set to anything else are INVALID_ARGUMENT, TAP-10 §2.2), a tightening of the experimental mode of 1.4 for factory and opener. The new option sealStatusStore (a { get, set } store, like channelRecordFloor) keeps the sticky §13.8 statuses across restarts; without it they last as long as the client. The guide has the statuses and codes.
  • TAP-10 conformance mode: the rest of the resolution path (experimental). A container address or processor contract#ID now finds its processor number (TAP-10 §4.3), so it gets its on-chain name and a full activation check (isLive as well as isContainerLive): the SDK ships each chain's processor table (sdk/src/processors-snapshot.js, read-only through the default nodes before each release; on 2026-10-01 (UTC; the snapshot's times are UTC) BNB Smart Chain 1,174, X Layer 263, Base 101 processors, each recorded with its block, count and node operators), uses a hit only after one cpuAt at the pinned block gives back the same address, and otherwise scans cpuAt at the pinned block in pages of 8, at most 256 numbers per resolution, resuming at the next one (unavailable with data.scan until then); only the numbers after the snapshot are scanned once the chain agrees with it, so a cold client does not send a thousand requests to one node. The new option createTapeAPI({ allChains: true }) resolves a container address or processor contract#ID string on every TapeOut chain, each at its own pinned block (TAP-10 §4.1): two chains resolving it is INVALID_ARGUMENT with data.status ambiguous and data.candidates; a chain that cannot be read, or whose site contracts are store-changed, is reported with its own status (and data.chainId) instead of a guess or not-tapeout; results and these errors carry chains, what each chain said (siteStatus(...).chains, svc.conform.chains, data.chains). It sends reads to the nodes of Base and X Layer, which is why it is off unless asked: only true turns it on, any other value is ignored. It applies to resolve under conform: 'tap10' and to siteStatus in any mode, never to the default resolve, and forChain passes it on. With it, all 23 TAP-10 Test Cases that node scripts/tap10-gap.mjs judges offline conform (18 in 1.4). The guide has the rules; resolve and the chain.* reads of the default mode are unchanged (GOLDEN TAP10-0). resolve in this mode now reads what would authorise a signer under strict agreement (TAP-11 §2.2, TAP-10 §5.2): ownerOf, and for a holder that is a contract the EIP-1271 calls (eth_getCode and isValidSignature, for the delegation and for contentSig); its eth_chainId check is the strict one the messaging path makes, before any state is read. Strict needs every configured node to agree, from max(2, min(3, operators)) operators, and a node that does not answer or has not reached the pinned block yet (TAP-10 §1: "no such block" is no answer) counts against that. On the default nodes: BNB Smart Chain (3 operators) now stops with one node down or behind the pin where 1.4 resolved; Base (4 operators) still resolves with one node down, not with two; X Layer (2 operators) is unchanged. With contentSig present and requireContentSig off, a contentSig check that fails this way is the warning CONTENT_SIG_UNCHECKED, as before. Every other read of a resolution, and siteStatus (its chain check included), keep default agreement; a strict read's RPC_UNAVAILABLE now says how many operators it needed. With the messaging path above, this closes the resolve and messaging paths of the mode; the hub's Upgraded log scan (TAP-10 §13.8, a conditional SHOULD) is not done.

Changed

  • TAPI-26 erratum: an invite sent by TapeSend is sealed to the recipient's usable TAP-10 key, read from the hub, never to the recipient's channel key: TAP-10 §14.4 and §15.3 allow a sender no other key, and TAP-10 then governs the message completely. §3.2 said "sealed to B's static key". The durable fallback therefore reaches only a recipient that also holds its TAP-10 key (a TAP-10 client, or a channel with keys: "tapesend/v1"); a TAPI-26 client with channel keys alone (keys: "tape-channel/v1") cannot receive it, and §3.1 already asks other implementations not to derive a TapeSend key. §5 records the correction: no implementation had sent or received such an invite, and no code changed (the SDK never built one). Six section references in §3.1 and §3.2 (both languages) now follow TAP-10 v1.1 (§14.2; §15 and §16; §16). Keyword counts and frozen constants such as the TAP-26/… labels are unchanged.
  • Input without chain information, and siteStatus in any mode. Under conform: 'tap10' without allChains, a processor contract#ID string (0x…#ID) is now INVALID_ARGUMENT with data.status unsupported before any request, because TAP-10 §4.1 resolves it only when exactly one chain does (in 1.4 it was resolved on the client's own chain when that chain had the processor); { circuits, tokenId, chainId? } resolves it on one named chain as before. A container address is still resolved on the client's chain when it is a container of that chain, and is unsupported otherwise; the error now names allChains: true. The 1.4 unsupported for a container or processor contract#ID whose processor number was unknown is gone, along with activation.isLive: null. siteStatus, in any mode (it is the TAP-10 path on a default client too), changes with it: a container address or processor contract#ID gets its name and processor (null in 1.4) and a cold client reads cpuCount and cpuAt to find them; an object form that names its chain ({ circuits, tokenId }, { chainId, container }) gets that chain's not-tapeout where 1.4 said unsupported; on a default client a processor contract#ID string is still resolved on the client's chain, as in 1.4.

Security

Streamed AI receipts: four related fixes to ai.createVerifyingFetch and tapeapi-verify, with matching changes to the offline check (verifyUsageReceipt({ responseBytes }), scanSse, the verification page) and the sidecar. The receipt hash (TAPI-21 §3.5) and its test vectors are unchanged; TAPI-21 §8 gains an informative note on streams that parsers read differently, with no new requirement.

  • A stream cut short could verify (1.0.0–1.4.0). Both clients check a stream where it first ends (its final event, [DONE] or the upstream closing, whichever comes first), but they took the receipt hash, and let the format adapter read the answer, only after processing the whole network chunk that carried the end. Events after the end that came in that same chunk were counted as part of the checked answer, although a client that stops at the end (the official OpenAI SDKs stop at [DONE]) never sees them. So a party on the path (a proxy, a CDN, or the service itself) that moved the receipt comment earlier and inserted a [DONE] could hand the client an OpenAI Chat or Responses stream cut off before its real end that was reported as verified, and that strict mode released, whenever the inserted [DONE] and the rest reached the client in one chunk. tapeapi-verify without --strict checked the receipt against the whole stream when the upstream closed, so it logged such a stream as verified however it was cut. Anthropic Messages streams, which have no sentinel, were not affected by this one. The scanner now keeps the hash, event count and offset where the stream first ended (info.digestAtEnd, info.eventsAtEnd, info.endOffset), the receipt is checked against them, and the adapter reads up to the end only.
  • Strict passes the stream on up to its end and closes it there. Strict used to release the whole chunk that carried the end, so whatever followed the end in it (a comment, an event without data, half an event, a second [DONE]) reached the application, and an event after the end in a later chunk failed the stream. Now the chunk goes on up to the end only, the stream closes and the upstream is cancelled (tapeapi-verify --strict ends the answer there), so what the application receives does not depend on how the bytes were cut, save one byte: when the blank line that ends the stream is a CR at the end of a chunk, the stream ends at that CR and an LF that may follow is not waited for (every client dispatches the event without it, and an upstream that then sent nothing used to hang the stream). Not strict still passes every byte on and reports an event after the end once as a failure (an event after the end of the stream is not covered by its receipt), in the chunk that ended the stream or a later one; before, strict: false reported nothing for it.
  • Two stream shapes that clients parse differently from the receipt rule now fail closed (all versions with streamed receipts). An event the stream closes on before its blank line is discarded by the rule, but the OpenAI SDK dispatches it when the body ends: content appended that way after a verified Chat stream with its [DONE] removed, or after a Responses response.completed, reached the application as verified. And a line that starts with U+FEFF (other than at the very start of the stream) is an unknown field to the rule, but both official SDKs, OpenAI and Anthropic alike, strip a byte order mark from every line and read it as a data line: content could be inserted anywhere in a signed stream of any of the three formats without changing its hash. Strict now ends such a stream with RECEIPT_INVALID (a line led by U+FEFF is not passed on), tapeapi-verify --strict with its error event, and not strict reports each once as a failure. The offline check reports both shapes as problems too (scanSse now returns ambiguous and unfinished; the verification page fails the response check and says why), so a stream that verified offline although clients were shown more than the hash covers no longer does. The reference sidecar no longer signs a stream with such a line before the point where it signs (one after it, past the end, is cut off by strict clients and reported by the others). A few rare honest shapes are refused as well, since clients would read them differently: a stream whose upstream broke off in the middle of an event, after which the sidecar added its receipt line; a stream that starts with two byte order marks; a line that is only a byte order mark; and a data line led by a byte order mark after a CR line end.
  • tapeapi-verify without --strict could log nothing. It checked the receipt only when the upstream closed, so a client that hung up at the end (the openai SDKs stop at [DONE]) while the upstream kept the connection open left no verdict. The receipt is now checked and logged as soon as the stream ends, and when the client hangs up after the end.

verifyUsageReceipt({ responseBytes }) over a whole stream hashes every event in it, as TAPI-21 §3.5 says, so for a stream with events after its end its verdict can differ from the streaming check. Tests replay signed streams of each format, honest and edited, under many cuttings (one event per chunk, whole, byte by byte, seeded random cuts, LF and CRLF), strict and not, and assert that the verdict, and in strict mode the bytes handed on, do not depend on the cutting.

Two more, from an adversarial review of the 1.5.0 candidate:

  • A receipt could switch off its own usage check (every version with AI usage receipts). verifyUsageReceipt, and so ai.createVerifyingFetch, skipped the usage comparison whenever the receipt said usageInjected: true, even when the client held a whole answer whose usage it could read. A receipt signed with the service's own key (which whoever runs the sidecar holds) could therefore claim any usage and matching prices and still verify against the answer it came with. The flag now counts only where the client's copy can lack the usage: a stream, of a format that injects (OpenAI Chat), for a request that did not itself ask for usage. A whole answer is always compared, and a stream whose request already asked, or of a format that never injects, that claims the flag is a problem. The honest sidecar's receipts all still verify, including the one for a stream request answered as JSON. The verification page compared only the hashes of pasted bytes; it now also reads the pasted answer with its format (a new answer check: id, model, usage, completeness), as TAPI-21 §3.5 check 4 asks. The wording of that check now says when the usage cannot be compared, with no new requirement. A verifier with no content type of its own (the page, and verifyUsageReceipt called without stream) took the receipt's own stream on trust, so a whole JSON answer under a receipt re-signed as a stream with the flag was read as a stream without usage and its usage skipped; such a pairing now fails (ai.isWholeJson; on the page, the response check says why). createVerifyingFetch was not affected: it passes stream from the response's content type. Remaining by design: the usage of an injected stream (a streamed OpenAI Chat request that did not set stream_options.include_usage) cannot be checked by anyone, since the client never receives the usage chunk; a client that asks for the usage itself avoids that. Having the verifying fetch ask for it is a candidate for 1.6.
  • Hidden text in MCP tool definitions (every version with the invisible-character check). mcp.invisibleProblems, used by the signing proxy, tapeapi-mcp and the holder console, refused format characters (category Cf) and controls only. Variation selectors (U+FE00–FE0F, U+E0100–E01EF: 256 invisible code points, enough to spell any text) and code points that render blank although they are not Cf passed: every Default_Ignorable_Code_Point outside Cf, assigned or not (U+034F, the Hangul fillers, the Mongolian free variation selectors U+180B–180D and U+180F, U+2065, U+FFF0–FFF8, U+E0080–E00FF, U+E01F0–E0FFF, ...), the braille blank U+2800 and U+1D159. So a description or another pinned field could carry instructions a model reads and the holder approving the digest cannot see. They are now refused, except a single U+FE0E or U+FE0F directly after an emoji character ("⚠️" stays allowed); a battery of real descriptions in a dozen scripts, with emoji, keycaps, NBSP and combining accents, still passes. A tool set already pinned that uses any of them, an ideographic variation selector in Japanese text included, is now refused (INVISIBLE_CHARACTERS) until it is changed and republished.

Fixed

  • AI sidecar: an x-tapeapi-sidecar-error header from the upstream is no longer passed on (a client reads it as "the sidecar answered itself" and reports a transport failure for a signed answer).
  • AI sidecar: a path with an encoded / or backslash (%2F, %5C, any case) is refused with HTTP 400 bad_path before anything is forwarded, since an upstream may decode it into a separator; ai.loosePath reads both as /, so the verifying fetch (and tapeapi-verify) reports the same URLs as a path mismatch (strict refuses them).
  • An id with a lone UTF-16 surrogate has no canonical form: the provider binds it as no id (a signed BAD_REQUEST, where it could fail with HTTP 500), and api.call() refuses it with BAD_REQUEST before sending.
  • JSON-RPC: a single (not batched) answer whose id is not the request's, or is missing or null, now counts as a node that did not answer, as a batch element with the wrong id already did: an id: null answer that is not a node limit (a result, or a revert) is no longer taken as an answer about the chain. A node-limit refusal with id: null stays a refusal.
  • MCP: an upstream isError that is not a boolean is refused before signing by the signing proxy, and tapeapi-mcp refuses such a signed result as not an MCP tool result: what was signed and what was shown could disagree (a signed "true" was shown as a success). Text imitating the provenance line is now labelled in an embedded resource's text and a resource link's name, title and description too; the instructions name structuredContent as data.
  • AI receipts: a usage count of -0 from the upstream is read as 0; the sidecar answered HTTP 500 because canonical JSON has no -0.
  • canonicalJSON refuses the holes of a sparse array (it wrote [1,,2], which is not JSON); the holder console's copy does the same.
  • With clientIpHeader set, the header's address is written one way per address (IPv6 compressed and lowercase, an IPv4-mapped IPv6 address as its IPv4, anything that is not an address "unknown"), so one client cannot take several rate-limit keys by spelling its address differently; the new-api and LiteLLM sidecar examples do the same. Without clientIpHeader nothing changes.

Documentation

  • Calling services for people you do not trust (consume guide): the SDK does not refuse private or internal endpoint URLs, which are valid in a manifest; a server or agent that resolves services for others should pass createTapeAPI({ fetch }) a wrapper that refuses them (an example is given).
  • Upgrade guide: on a chain with two operators (X Layer's default nodes) one operator can hold a pinned read back as far as maxPinAgeS (pin: true) or tap10MaxPinLag (TAP-10 mode) allows, and the client then reads the manifest as it stood then; delegationFloor does not cover it. A third operator's node closes the window.

[1.4.0] — 2026-10-01

Added

  • TAP-10 conformance mode: the resolution path. createTapeAPI({ conform: 'tap10' }) resolves on the TAP-10 v1.1 / TAP-11 §2.2 path (false, null or leaving it out means off, as before), with pin: 'tap10' (also usable alone) and the read-only api.siteStatus(target). The default behaviour is unchanged. In this mode:
    • the whole resolution is pinned to one block by TAP-10 §5.3: each operator's lowest head, the Q-th highest minus 2, stale-block by block distance (BNB Smart Chain 400, Base 150, X Layer 300), no clock; the block hash is then confirmed by every answering node (the SDK keeps its stronger pin by hash);
    • the site store's and the payment contract's implementations are read at that block and fail closed (store-changed);
    • the container is derived through opener.accountOf and checked against the local ERC-6551 derivation, and isOpened and the activation of the name (isLive, isContainerLive; a revert counts as false, TAP-10 §6.3) are read: a name that is not opened or not activated fails with the new code SITE_STATUS (data.status: not-opened or unpaid). Every error in this mode carries data.status, TAP-10's lowercase name;
    • the input forms of TAP-10 §3.4 are accepted (#4246@0, tape://4246.0/, 0x…#ID), an input with no processor number whose activation cannot be told is answered unsupported (the all-chain lookup is planned for 1.5);
    • eth_chainId is checked; only a result or a revert counts as an answer (TAP-10 §1); the holder and the site state are read again after 60 s. Activation is judged by resolve and siteStatus only: channelKeys and the TapeSend key of an unactivated container are still read (TAP-10 §12.2). Today both 11.1013.tape and 12.1013.tape answer unpaid in this mode: neither is activated. Also new: rpc.tap10Block, { answers: 'tap10', strict } on call / ethCall, parseTapeInput, MAX_TOKEN_ID, MAX_PROCESSOR, CONFORM_TTL_MS, tap10MaxPinLag in chains.js. The guide lists what stays for 1.5 and the limits (two-operator chains pin more weakly).
  • The monitor reports each service's activation (TAP-10 §6.3): the paid-until date and days left, or the monthlyFee() read at that moment when the name is not activated. Not activated, and under 14 days left, are warnings; under 3 days or expired is a problem. The exit code is otherwise unchanged (today both of our services are not activated, and the run is still healthy). It adds 6 eth_call requests per run to each node.
  • tapeapi-doctor checks activation (now 14 checks). It reads DomainBinding isLive(name, container) and isContainerLive(container) (a revert counts as false, TAP-10 §6.3). A name that is not activated is a warning, not a failure (a TAP-11 client answers unpaid and does not resolve the service; --strict makes it fail), with the bind hint and the fee monthlyFee() reads at that moment. The guide has a new step 2b. Run against 11.1013.tape and 12.1013.tape today it warns: neither is activated.
  • node scripts/tap10-gap.mjs: a diagnostic that runs TAP-10's own Test Cases through the SDK's name resolution and prints where it conforms and where it does not (offline; of 23 cases that can be judged offline, 12 conform and 11 are known gaps, each with its TAP-10 section). sdk/test/tap10-gap.test.mjs pins the current results (CONFORMS, KNOWN GAP), so the opt-in TAP-10 mode that is planned can flip them. Not part of the public SDK interface.

Changed

  • Name range (erratum, all modes). Names are limited to #ID ≤ 10^18 and processor number ≤ 10^9 (TAP-10 §3.1); up to 1.3, 78 digits were parsed. Such names cannot exist on chain, so a name that used to cost lookups is now refused with no request. The error text for a name that was already refused changed with it.
  • The status of the TAP drafts is stated: the service manifest and delegation was merged as TAP-11 (Draft, TapeOutProtocol/TAPs#8) and seven more drafts are under review (#10, #12, #16, #18, #20, #26, #28). TAPI-20 to TAPI-27 remain TapeAPI's own names and the basis of the 1.x compatibility promise.
  • The specs are renamed TAPI-1 and TAPI-20 to TAPI-27 (they were TAP-1 and TAP-20 to TAP-27). TAP numbers belong to TapeOut's TAP process (TapeOutProtocol/TAPs, TAP-01 §6.1), where editors assign them and the multiples of ten are reserved for core standards; TapeAPI no longer uses any TAP number, and TAP-20 is left free. Parts of these specs are submitted there as TAP drafts: the service manifest (TapeOutProtocol/TAPs#8), signed responses (#10) and private channels (#12). Labels and constants that contain the old names (TAP-26/…, TAP-27/…) are frozen wire constants and do not change: no wire format or signature changes. Conformance check ids are now tapi20.… and so on. Earlier entries below use the new names.

Fixed

  • tapesend: an endpoint built from an address now sits on the recipient's chain. seal, open, messageId and sendTx take an optional toChainId (default: chainId, so existing calls are unchanged); before, the to endpoint of a cross-chain message used the sender's chain, which a TAP-10 client reads as a damaged payload (TAP-10 §15.3, §17). The official message-ID vector #2 (hub on Base, recipient on BNB Smart Chain) reproduces.

[1.3.0] — 2026-09-30

Added

  • Merkle proof mode (experimental, security roadmap 1.2; the light-client idea borrowed from Polkadot). createTapeAPI({ pin: true, proofs: true | 'strict' }) checks cpuAt, isCPU, ownerOf and fileInfo (size, sha256Hash) against EIP-1186 eth_getProof proofs of the pinned block's stateRoot, which nodes of quorum operators must agree on (rpc.confirmedBlock(tag, { stateRoot: true })); the manifest bytes are then checked against the proven hash. A proof may come from any node; every eth_call still goes through the quorum and is compared with the proven value. true warns and keeps the quorum reads; 'strict' refuses with the new codes PROOF_UNAVAILABLE or PROOF_INVALID. It resists nodes that agree on false state, as long as the stateRoot comes from independent operators; it does not resist an upgrade of the contracts (that is real state) or all operators forging a header. RLP and Merkle-Patricia checks are the SDK's own (no new dependency), checked against ethereum/tests trie cases by both the SDK and verify.py; vectors spec/vectors/tapi-20-proof.json; TAPI-20 §3.2 note, §6.5; svc.proofs, the proof namespace. Default unchanged. Which default nodes serve proofs (2026-09-30): Alchemy on BNB Smart Chain, dRPC on Base, none on X Layer at the safe block (add your own node to use 'strict' there). A proven value that contradicts the quorum's answer is refused in both modes (PROOF_INVALID); proofs: true falls back only when no verified proof can be had, and is then detection only. A proof that does not verify moves on to the next node; only a node that refuses the method or serves a bad proof is skipped for 10 minutes, never for a rate limit or a timeout. A confirming node that omits the stateRoot is not counted (nodes of quorum operators must still report the same one). RLP nesting is capped at 64 and proof.* throws only PROOF_INVALID. Not proven: contentSig, EIP-1271 isValidSignature, accountOf (the sentinel derives it locally) and serviceOf. An independent review (77,000 RLP and trie cases, attacks on the whole resolution) found no way to forge a proof.
  • Provider directory at https://tapeapi.fun/directory/. An AI service lists itself by a pull request to site/directory/providers.json once tapeapi-doctor passes; .github/workflows/directory-recheck.yml runs tapeapi-doctor on every entry once a day (read-only, no key, at most 60 JSON-RPC requests per name) and commits site/directory/status.json; an entry that cannot be read that day is undecided and keeps its last verdict. CI checks the format only. A listing only means the automated checks passed: it is not a recommendation, an endorsement or an audit. No ranking, no fees, no automatic removal; TapeAPI lists nobody itself and pays for nothing.

Fixed

  • Resolve on BNB Smart Chain, X Layer and Base, found by testing 1.2.0's security options on all three mainnets:
    • A rate limit is the node not answering, never an answer, whatever its code or wording (Coinbase's -32016 and dRPC's code 15 inside a batch, HTTP 429, "too many requests", "quota"): on Base it used to surface as RPC_DISAGREE. A revert that happens to use those words is still an answer.
    • A pinned read that publicnode or Coinbase answers with "block not found: canonical hash" counts as that node lacking the block (only "not canonical" is still an answer).
    • The pinned block may be older on X Layer (maxPinAgeS 300 -> 600 s; its safe tag moves about every 228 s, measured up to 248 s old) and must be newer on BNB Smart Chain (180 -> 120 s; finalized measured 0 to 2 s old).
    • A request whose connection broke (not a timeout, not an answer) is sent once more after transportRetryMs (default 250 ms): X Layer has two independent operators, so one reset used to fail the read.
    • createTapeAPI({ quiet }) reaches the clients it makes, so a default X Layer client no longer warns.
  • tapeapi-doctor and the AI relay path, found by walking the docs as a new operator:
    • The docs no longer say tapeapi-doctor runs only from a checkout (1.2.0 ships it), and its "next" commands match how it was run: a checkout path, the npx form with the release URL, or the installed command.
    • URL mode probes the address you gave, and warns when the manifest publishes another one (it used to probe the manifest's endpoints, so a pre-publish check could pass against the wrong server).
    • Diagnoses that fit: a page that is not the sidecar (a reverse proxy or wrong port), a self-signed certificate in plain words, and an opened container without a manifest (sidecar, key, delegation, then publish).
    • --lang en|zh prints one language (--json adds detailZh); usage errors are bilingual.
    • Step 0 says npm ci first, and examples/relay-trial prints that command instead of a module-not-found stack.
    • PowerShell forms next to every shell variable example; tapeapi-verify needs its own terminal.
  • The public relay's place is stated in the Public API guide: it is for testing and small-scale use (Cloudflare's free tier, about 100,000 requests a day for all users); apps with always-online users or groups over about 64 members run their own relay at their own cost.
  • Website: the status line reads "Stable 1.x"; every "up to 32" mentions the experimental format 2 (up to 128); the verify page no longer repeats its hash-only notice; docs links have no .html, and the sitemap lists the upgrade guide.
  • Groups, format 2 (experimental), found by testing 1.2.0 across versions and on the public relay:
    • The owner no longer checks every member again at each epoch: it reuses its positive verdicts within verifyReuseS (a new option on createGroup / resumeGroup; 0 checks all) for entries that did not change, and a new member is checked once. Rotating or removing one member of a 128-member group went from 127 checks (about 2,300 RPC requests) to 0; the first epoch after a verdict expires still checks everyone, at most once a day.
    • A format-2 snapshot() is now v: 2, which 1.0.0 to 1.2.0 refuse (GROUP_INVALID) instead of resuming it as a format-1 group and splitting it; the v: 1 format-2 snapshots 1.2.0 wrote still resume.
    • One identity on two devices is no longer silent: open() returns a message signed with our identity that this handle did not seal, with otherDevice: true; group.otherDevice reports it; an "already seen" error carries data.mayBeOtherDevice. One identity is still one device (TAPI-27 §8).
    • "no key for epoch N" carries data.reason (not-yet, expired, snapshot) and data.retryAfterEpoch; an owner resumed from a snapshot no longer reports "fails authentication" for an epoch whose key it does not hold.
    • The groups guide says to check invite.format before joining (a client before 1.2.0 joins a format-2 group without error and then fails on every frame), how to buffer a message that arrives before its epoch, and the owner's cost per epoch.

— 2026-09-30

Added

  • Larger groups (experimental): TAPI-27 §3.8 format 2, up to 128 members in one wire message. Binary roster (104 bytes a member), a marker in the epoch field that every format-1 client refuses (GROUP_INVALID, tested against the released 1.1.0 code), lazy member checks (open().verified, openVerified, verifyMembers, verifyReuseS, at most 86,400 s, counted from the start of the check), channelKeysVerifier (use it, not api.groupVerifier(), for format 2), vectors spec/vectors/tapi-27-group-v2.json. Only positive verdicts are reused; a negative one lasts at most 60 s (VERIFY_NEGATIVE_S), is confirmed by a fresh read and never refuses an epoch. On-demand and background checks share the verifyConcurrency limit. Format 1 stays the default and its bytes are unchanged (joinGroup without format ignores verifyMember, as before); one group never mixes formats. TAPI-27 §8 states what format 2 weakens (a verdict reused for up to a day; senders checked on demand) and what it keeps.
  • A path for AI relays, from nothing to live, with a check at every step.
    • examples/relay-trial/: a one-command local trial (node examples/relay-trial/trial.mjs): the signing sidecar, a fake upstream and a throwaway identity, then verified calls (OpenAI Chat, streaming, Anthropic), the tapeapi-verify proxy and a tamper demo. No key, no circuit, no cost.
    • tapeapi-doctor (experimental, new bin in @tapeapi/sdk): checks an AI service in order (name, circuit, container, manifest file and format, delegation and days left, ai field, prices, endpoints, TLS and sidecar readiness, CORS, a verifiable receipt, receipt lookup) and gives each failure a fix in English and Chinese. The receipt check sends a request with an invalid key, which the gateway refuses and the sidecar still signs, so it normally costs nothing (a gateway that accepts any key spends a few input tokens and 1 output token, 16 on OpenAI Responses); --key-env makes one real, billed call on the operator's own key, sent only to the checked host over https (--allow-http for a loopback sidecar) and shown as *** in every output, echoes included. Exit status 0 / 1 / 2 / 3 (3: the chain or the network could not be reached, retry) for CI; --json, --strict, --offline, and an address mode to check before publishing.
    • The AI providers guide opens with "From zero to live": each step, who pays, the command that checks it and what it should print.
  • Security hardening, learned from Polkadot's shared-security ideas (experimental; every option off or warn-only by default, 1.0 behaviour unchanged). On createTapeAPI:
    • pin: every read of one resolution is pinned (EIP-1898 blockHash) to one block that nodes of quorum operators confirm at the chain's finality tag (finalized on BNB Smart Chain, safe on an L2); a block older than maxPinAgeS is refused as the new client code RPC_STALE. svc.pinned records the block. rpc.confirmedBlock().
    • sentinel ('warn' default, 'strict', 'off'): on the chain's own TapeOut contracts, resolve reads the ERC-1967 implementation slots of the DeWebHub and the SiteRegistry and compares them with the known implementations, and derives the container locally (ERC-6551 CREATE2) to cross-check accountOf. It notices an upgrade; it cannot prevent one. 'warn' reports in svc.warnings and onWarning (default: console.warn once); 'strict' refuses with the new code CONTRACT_UNKNOWN or MANIFEST_INVALID. A failed slot read only warns.
    • requireContentSig: refuse a manifest without a valid holder content signature (TAPI-20 §3.10). Without it, a content signature is only reported (svc.contentSig, warnings), and an error while checking it is the warning CONTENT_SIG_UNCHECKED: it never fails a resolve.
    • delegationFloor: remember the highest delegation expires seen per chain, container, holder and signer, and refuse an older one put back (a rollback of a re-published manifest); a new signer starts its own floor, and api.clearDelegationFloor(error.data) forgets one. A site writer can still put back an older holder-signed delegation with a higher expires.
    • onWarning, clock.
  • TAPI-20 §3.10: optional holder-signed manifest content (contentSig, ManifestContent(address container,bytes32 contentHash) in the delegation domain), so that whoever can write the site cannot silently change ai.baseUrl or prices under a valid delegation when the client requires it. sig.manifestContent* helpers, vectors spec/vectors/tapi-20-content.json (checked by verify.py).
  • security namespace: erc6551Account; ContradictionRecord v1 (contradictionRecord, contradictionsOf, verifyContradiction): evidence anyone can check when providers sign conflicting answers to the same request at the same block (verifyContradiction says valid only when signerOf confirms every signer as the provider's delegated signer, otherwise signaturesConsistent: true; the block-pinned form suits methods whose result is deterministic); withSpotCheck, a wrapper that re-asks an independent provider at a given rate (0, off, by default).

Changed

  • Groups: the TAPI-27 §3.3 step 6 member checks run in parallel (verifyConcurrency, default 8), with the same outcome and errors as before. A 32-member cold start at 280 ms per request: about 45 s before, about 6 s now.
  • Every 42.1013.tape example says it is an example name (it has no manifest, so a command run as written stops with "no file at /.well-known/tapeapi.json") and points to the local trial; tapeapi-verify adds a hint to tapeapi-doctor and the trial when it cannot use a name.
  • ATTEST_DISAGREE errors carry the verified signed envelopes and the request in error.data (envelopes, request), so the disagreement can be kept as evidence.
  • A cold resolve on the chain's own contracts sends the sentinel's two ERC-1967 slot reads to each node alone, in the same round as the batch (never batched, so the eth_call batches stay those of 1.1.0): still 4 rounds, 18 requests instead of 12 on BNB Smart Chain and X Layer (24 on Base, which has four default nodes), cached for 300 s. pin adds one round and 3 requests there (sometimes two rounds on BNB Smart Chain), and two rounds and 8 requests on Base (measured 2026-09-30).
  • A read pinned to a block (an EIP-1898 object or a hex block number, for eth_call, eth_getCode, eth_getStorageAt) that a node answers with "header not found", "unknown block" or -32001 counts as that node not answering, not as a disagreement: a node that lags behind the pinned block no longer makes the read fail with RPC_DISAGREE. The nodes that have the block must still agree, and quorum operators must still answer (otherwise RPC_UNAVAILABLE). This applies to rpc.call / rpc.ethCall with a hex block number too.

[1.1.0] — 2026-09-30

Added

  • One-command signing for LiteLLM Proxy (examples/litellm-sidecar/). A docker-compose package puts the signing sidecar in front of LiteLLM Proxy (the official image ghcr.io/berriai/litellm:v1.103.0, pinned, with PostgreSQL for virtual keys); LiteLLM is unchanged, and users keep their virtual keys and SDKs, pointing only the base URL at the sidecar. The price table's ids are LiteLLM's model_names, which LiteLLM reports back in model. The entry is the new-api package's with LiteLLM's profile (examples/new-api-sidecar/server.mjs gains an optional profile; its defaults are unchanged) and one stream adjustment through createAIProxy's formats option: a Responses stream that ends at data: [DONE] (LiteLLM sends Responses events as data: lines only) gets its receipt before it. smoke.mjs and the tests run without Docker or LiteLLM; e2e.mjs checks a real LiteLLM with the official openai and @anthropic-ai/sdk packages in strict mode. A sidecar in front, not a LiteLLM callback: a callback sees parsed objects, not the bytes a receipt proves.

Fixed

  • RPC batching recovers from a transient network error (sdk/src/rpc.js, FIXED P101-3). One reset connection on a JSON-RPC batch made that node take eth_calls one by one for the client's lifetime. Now only what a node answered about a batch (an HTTP error, a single object, a 200 body that is not JSON) stops batching to it for good; a failure with no answer (the connection reset, the body cut off mid-read) pauses batching to that node until 5 minutes have passed or it has answered 20 calls alone. The calls of the failed batch are still asked again one by one, as before. Performance only: what counts as an answer and every quorum rule are unchanged.
  • A relay room whose storage write fails no longer keeps the frame (examples/cloudflare-worker/relay-room.js, examples/relay-service/relay-core.mjs, FIXED P101-4). relaySend answered with the error but the frame stayed in memory, so the client's retry added it a second time. The frame is now pending until its own write lands: no read hands it out and no long-poll wakes for it; if the write fails, relaySend answers with the error and the frame is taken back out (its index, the frame a full ring had shifted out for it, the per-source 0x03 / 0x04 count), so the retry is the only copy, before and after an eviction. The relay core without storage (the Node relay) is unchanged.
  • The sidecar also strips LiteLLM's injected Chat usage chunk (sdk/src/ai-openai-chat.js, FIXED P101-b). When the sidecar asked the upstream for usage on the client's behalf, it removed only a usage chunk with choices: []; LiteLLM sends it with choices: [{ index: 0, delta: {} }], so the client received a chunk it had not asked for. A usage chunk whose choices carry nothing (an empty delta, no finish_reason, no logprobs, no other member) is now the injected one too; any content, finish_reason or other member keeps a chunk in the stream. examples/litellm-sidecar no longer needs its own Chat adjustment; its Responses adjustment (data: [DONE] as a final line) stays.
  • The monitor reports a signed answer without result instead of crashing (scripts/monitor.mjs, FIXED P101-2). judgeAsync and checkRelayAsync threw a TypeError on it, and the run could end without a report; it is now a FAILED line that says so.

Fixed (docs and website)

  • Docs: every copyable tapeapi-verify 42.1013.tape / tapeapi-mcp 42.1013.tape command (both READMEs, the SDK README, the homepage, the AI providers and MCP guides, the new-api sidecar README) now says that 42.1013.tape is an example name to replace with your service's. The MCP guide's createMcpEndpoint example uses the public service's name, 11.1013.tape. The SDK README and the holder console use 1.2.230.tape, which exists on X Layer, instead of 1.2.344.tape, which does not.
  • Channels guide: node conformance/relay.mjs --url takes the relay's site root (https://relay.tapeapi.fun), not the /tapeapi/v1 address used in an invite's relays[].url.
  • Upgrading to 1.0: the error-code table now lists METHOD_NOT_ALLOWED (a provider's unsigned HTTP 405 for a non-POST request, a transport failure to clients) and NAME_TAKEN (WebMCP, in handle.skipped[].code when registerTool fails). The Experimental list now names MAX_CONTRIBUTION_BPS, RECOMMENDED_CONTRIBUTION_BPS and MAINNET.bem, as tagged in the type declarations.
  • The Chinese "Call a service" guide links to the upgrade guide's error-code table, like the English one.
  • The link from the MCP guide to "Go live from a phone" in the provider guide now works on GitHub as well as on the site: the heading reads "Cloudflare and holder console" (no +), so both produce the same anchor.
  • Homepage and status page: <html lang> and the tab title follow the language switch (on first load and on toggle).
  • The site now serves /favicon.ico (16/32/48 px, generated from favicon.svg) instead of a 404.

Tests

  • Three timing-dependent tests no longer fail on a loaded machine (FIXED P101-1). bus-fuzz, the fallback scenario of bus-privacy and R8-S1 in review-arch ran on the real clock (budgets of 200 ms and 20 s, a 60 ms back-off), so a busy machine or CI runner sometimes failed them. They now run on a virtual clock (sdk/test/helpers/clock.mjs): simulated node latency is the only time that passes, so each asserts what it always did, on the idle machine it was written for; tiny-budget fuzz seeds now replay too. Each also runs once more on a clock going 50 times fast, which failed before the change.

[1.0.0] — 2026-09-29

TapeAPI 1.0.0, the first stable release. Nothing on the wire and nothing in the interfaces of @tapeapi/sdk and @tapeapi/server changed since 1.0.0-rc.5: the specifications change status, not content.

What 1.0 promises

  • Semantic versioning from 1.0.0 on. Code written against the 1.0 documentation keeps working in every 1.x release; a breaking change comes only in 2.0. Additions (a new optional option, a new field, a new error code) may come in any minor release.
  • Stable: every export of @tapeapi/sdk and @tapeapi/server, the command-line tools tapeapi-mcp and tapeapi-verify, and the methods and result shapes of the public services (api.tapeapi.fun, relay.tapeapi.fun), except what is marked otherwise.
  • Experimental (@experimental; may change in a 1.x minor release, each change in this changelog): everything that pays (TAPI-22 payment channels, the escrow, vouchers, api.payer(), maxPrice, the channel builders of api.tx, api.chain.escrow.*), the ServiceDirectory (api.chain.resolve(), api.chain.serviceOf(), directory labels, the directory option, labelToBytes32 and the label helpers of abi), and the whole @tapeapi/sdk/bus-privacy subpath. Reading prices from a manifest and the codes PAYMENT_REQUIRED, BAD_VOUCHER and PRICE_CHANGED are Stable: a free service's client can meet them too.
  • Internal: marked @internal, or not exported.

The full lists, what changed from 0.x and the error-code table are in the upgrade guide: docs/guides/upgrade-1.0.md (https://tapeapi.fun/docs/en/upgrade-1.0).

Specification statuses (TAPI-1 §4.1)

  • TAPI-20, TAPI-21, TAPI-23, TAPI-26 and TAPI-27 are Stable (v1) since 2026-09-29; their Target rows are gone. Every field, encoding, signature domain and error code they define keeps its meaning; a revision may add only optional content and non-normative text; a breaking change is a v2 with its own wire markers, and v1 is not withdrawn earlier than 12 months after v2 becomes Stable.
  • TAPI-20 §3.5 (ServiceDirectory) is Experimental, outside the freeze (TAPI-1 §4.1, freeze rule 3), and so are the places that use it: a label as input to §3.6 step 1, the serviceOf cross-check and verifyDelegation in §3.4. Resolution by name, container or (circuits, tokenId) needs no directory.
  • TAPI-21's error codes, PAYMENT_REQUIRED and BAD_VOUCHER among them, are frozen with TAPI-21, although the payment flow that uses them is in TAPI-22, which is Experimental.
  • Unchanged: TAPI-22 and TAPI-25 Experimental, TAPI-24 Withdrawn, TAPI-1 Draft. No normative text changed. The TAP numbers are still proposals to the TapeKit maintainers (TapeKit#8).
  • The Chinese halves of TAPI-26 and TAPI-27 gain the RFC 2119 keyword sentence their English halves already had (translation only; both halves now count the same keywords).

Website and READMEs

  • The homepage, both READMEs, both package READMEs and the introduction (English and Chinese) say 1.0.0 instead of pre-release; the homepage roadmap marks 1.0 done; the spec index and the READMEs' spec tables show the new statuses. The public-API, MCP and groups guides drop their stale "pre-alpha" label (the no-SLA and no-audit notes stay).

Added

  • The monitor checks the relay's asynchronous delivery (RELAY-1 would now alert within one 30-minute run). scripts/monitor.mjs posts one random 0x03 test frame (64 to 200 bytes) to a random room through relaySend, leaves the room idle for 60 s (ASYNC_WAIT_S, or --async-wait-s=N), then reads it back with a brand-new client (relayRecv from -1, epoch null). It passes only on exactly that one frame, the same bytes, under the epoch relaySend answered; the report says why otherwise ("0 frames, epoch null: the relay lost the room while idle"). Network-layer failures are retried twice and reported as DOWN, apart from a relay that answered wrongly (FAILED). The send goes out first and the other checks run during the wait, so a run takes about 65 s; the workflow's timeout is now 15 minutes. relaySend is free on relay.tapeapi.fun and the frame expires with its room after 15 minutes; the monitor still sends no transactions and holds no keys.

Fixed

  • The public copy keeps git's executable bit. The script that stages the public copy wrote every file 0644, so the CLIs in sdk/bin reached the public repository without their executable bit once; they keep 100755 now, with a test.

From 1.0.0-rc.1 to 1.0.0-rc.5

  • rc.1, the interface freeze: INVALID_ARGUMENT for the caller's own mistakes; fixed TapeAPIError top-level fields (details in e.data, the old names deprecated until 2.0); service, relayClients / busClients, rpcTimeoutMs, name for MCP, clock in Unix seconds; openai-proxy removed; narrower public faces for ai, channel, rpc and group; createVerifyingFetch verifies streams the official SDKs read; provider dev mode only through opts.dev; TAPI-1 §4.1 statuses; the TAPI-20 §6.1 mainnet manifest and TAPI-23 §6 vectors (the Python verifier went from 159 to 249 checks).
  • rc.2: api.resolve() in 4 round trips and 12 requests instead of 7 and 21; the server could serve one voucher twice (fixed); the homepage's fonts are files.
  • rc.3: the website's vendored SDK lives in a content-hashed directory, so a release never mixes old and new modules.
  • rc.4: the last error-code alignments (a server without rpcUrls, and WebMCP's caller-state errors, are INVALID_ARGUMENT); loosely written metered paths are refused instead of passed on unverified; review fixes with no interface change.
  • rc.5: the Cloudflare relay keeps its rooms in Durable Object storage, so idle rooms no longer lose frames.

Upgrading

  • From 0.x: follow the upgrade guide.
  • From 1.0.0-rc.4 or rc.5: nothing to change. From rc.1 to rc.3: code that matched INTERNAL for a server without rpcUrls, or BAD_REQUEST for WebMCP's refresh() and calls after dispose, now sees INVALID_ARGUMENT.

[1.0.0-rc.5] — 2026-09-29

Fixed

  • The Cloudflare relay (relay.tapeapi.fun) no longer loses a room when Cloudflare recycles an idle Durable Object. Seen live on 2026-09-29: a frame posted to a room was read back at once, but every read from +30 s to +400 s found 0 frames and epoch: null, with no redeploy in between. RelayRoom kept its room only in memory, and Cloudflare evicts an idle object within seconds, so a room designed to live 15 minutes lived about ten seconds and asynchronous delivery (group invites, TAPI-26 invites, messages to a peer that was not polling at that moment) mostly failed. The room is now written to the object's SQLite-backed Durable Object storage, one key per frame plus one meta key, and a recreated object restores it with the same epoch and indices, so clients' cursors keep working. relaySend answers only after its frame is stored; a frame a full ring drops is deleted from storage; the Durable Object alarm clears an expired room and its storage (15 minutes idle, 10 for a handshake-only room, as before), and only then does a new room get a new epoch. The relay's methods, parameters and answers are unchanged, and the Node relay (examples/relay-service) still keeps rooms in memory: createRelayCore gained an optional onChange hook and restore(), which it does not use. Privacy: frames (ciphertext), room names, indices and timestamps are now at rest in Cloudflare's Durable Object storage until the room expires; the source of a post (IP address or paying consumer) is still held in memory only and never written. The privacy page says so.

[1.0.0-rc.4] — 2026-09-29

Error codes (the last alignments before 1.0)

  • @tapeapi/server: a provider built without rpcUrls now says INVALID_ARGUMENT on its first chain read, with the fix in the message (rpcUrls: rpcUrlsFor(<chainId>)); it was INTERNAL.
  • WebMCP: refresh() with nothing exposed, refresh() after dispose, and a tool called after dispose are INVALID_ARGUMENT (a mistake in the page's own calls); they were BAD_REQUEST, which reads like the agent's input. A malformed tool input from the agent stays BAD_REQUEST.

Fixes from the release-candidate review (P2 items, observation period): no interface or return shape changes.

Fixed

  • A metered path written loosely is refused, not passed on unverified. /v1//chat/completions, /v1/chat/%63ompletions, /v1//messages and /v1/%6Dessages matched no format, so the reference sidecar forwarded them with no receipt, and createVerifyingFetch and tapeapi-verify --strict let the answer through unverified and unreported; api.openai.com and api.anthropic.com serve these spellings. A path that matches a format only once percent-encoded unreserved characters are decoded, repeated slashes collapsed and a trailing slash dropped is now: 400 bad_path from the sidecar, before anything goes upstream; INVALID_ARGUMENT before sending from a strict createVerifyingFetch (the same e.data as an endpoint mismatch), or onReport with mismatch: true and not verified: path mismatch; 400 from tapeapi-verify --strict, a logged warning without it. Paths no format meters pass through however they are written. (1.0.0-rc.1's "createVerifyingFetch no longer lets anything through unverified" did not hold for these spellings.)
  • createProvider({ dev: true }) alone allows a priced method on a zero-address escrow. The manifest also had to say dev: true (1.0.0-rc.1 said its dev field switches nothing). What dev allows is exactly a zero-address payment.escrow, not a priced manifest with no payment field, which stays invalid; the rc.1 wording "priced methods without an escrow" is corrected in the guide and the declarations.
  • createAIProxy no longer reads the manifest's dev field when checking its endpoints (dead code; the refusal was the same).
  • The refusals of 0.x option names link https://tapeapi.fun/docs/en/upgrade-1.0 (they named a path the npm package does not contain, or nothing); "rpcUrls not configured" says how: createTapeAPI({ rpcUrls: rpcUrlsFor(<chainId>) }).
  • The SDK's own code no longer reads TapeAPIError's deprecated top-level aliases (rpc.js, bus-privacy.js); channel.js keeps its reads until 2.0. Unused imports and a helper removed from tapeapi-verify and the MCP proxy.
  • tapeapi-verify reads a request path that starts with // as a path, not as a host.
  • examples/web2-adapter takes dev mode from the environment (dev: !PROD), not from the manifest.

Docs

  • labelToBytes32 and the abi label helpers are marked @experimental (ServiceDirectory only), as the upgrade guide's list already implied.
  • groups: a rate-limited delivery carries rateLimited / retryAfterS on the failed entry of e.data.deliveries; consume, faq: a script runs wherever @tapeapi/sdk resolves (a clone, or a project with the release package); provide: publishManifest returns { txs, key, size, sha256Hash }; sdk/README lists /ai, /mcp, /bus-privacy and tapeapi-verify; both package READMEs say pre-release, not pre-alpha; README test count.

Tests

  • The browser smoke test loads "My services", the playground, the receipt checker, the holder console and the status page and fails if one throws while loading (RC-1 could not show there); its TAPI-21 envelope checks sign EIP-191, as the vectors have since 0.5.0.

[1.0.0-rc.3] — 2026-09-29

Website

  • The vendored SDK lives in a content-hashed directory, so a release can never mix old and new modules. The 1.0.0-rc.2 fix (Cache-Control: max-age=0 on /playground/vendor/*) did not hold in production: the zone's Browser Cache TTL rewrites Cache-Control on every .js to 4 hours, so a returning visitor could still get a new index.js with a cached old ai.js (a problem since 0.x). npm run build:playground now writes the whole tree to site/playground/vendor/<hash>/, named by the first 10 hex digits of a SHA-256 over every vendored file and path, and removes the old directory. The files themselves are unchanged, byte for byte what sdk/src and @noble hold, and still import each other by relative path, so a module has one URL. The same run regenerates the import maps of the playground, the receipt checker and "My services", the vendor paths in their scripts, and the checker's policy hash. A new test fails unless changing any SDK file changes the directory name. The _headers rule stays as a second line.

[1.0.0-rc.2] — 2026-09-29

SDK

  • api.resolve() takes 4 round trips instead of 7, and 12 HTTP requests instead of 21 (no interface or return shape changes). Against the default BSC nodes, resolve('11.1013.tape') went from a median 2.77 s to 1.79 s on a new client, and from 2.00 s (6 rounds, 18 requests) to 0.68 s (2 rounds, 6 requests) when the same client resolves it again (10 interleaved runs each). How: the second, identical accountOf is gone; reads that do not depend on each other go out together (a name's isCPU and ownerOf with accountOf, and for any target steps 3-5 of TAPI-20 §3.6 in one round), each still checked in its old place with the same error codes; fileInfo still comes before read. createRpc sends concurrent eth_calls to one node as a JSON-RPC batch of at most 3 (all ten default nodes take one; dRPC's free plan refuses more than 3), each call still its own quorum round; a node that does not take a batch is asked call by call from then on, and a batch refused with an HTTP error is never read as answers. resolve keeps cpuAt(n) and accountOf(circuits, tokenId) answers (never errors) for at most 300 s, per chain; the holder, the manifest file and its hash are read on every resolve.
  • Server: one voucher could be served twice. A priced call that committed while the next call with the same cumulative was reading the escrow was missed by both checks. The in-flight reservation is now read before the store, both after the escrow read.

Website

  • The vendored SDK is revalidated on every load (correction after release: this did not take effect; Cloudflare's browser-cache setting forces 4 hours on .js files. Fixed at the root in 1.0.0-rc.3). Its modules import each other without a content stamp (so one module is never loaded under two URLs), and Cloudflare cached them for 4 hours, so a returning visitor could run a new index.js against a cached ai.js right after a release. /playground/vendor/* is now served with Cache-Control: public, max-age=0, must-revalidate (an unchanged file costs one 304).
  • The homepage's fonts are files, not data URIs. The four subsets (same bytes) moved out of site/style.css into site/fonts/, all with font-display: swap, and the homepage preloads Archivo. The render-blocking stylesheet drops from 93 KB to 27 KB (58 KB to 6 KB brotli): on a throttled Slow 4G load the first paint comes at about 550 ms instead of 690 ms, and every face is in by about 680 ms. Once the fonts are in, the page is pixel for pixel what it was. scripts/version-assets.mjs now stamps fonts too (url(...woff2?v=<hash>) in stylesheets and a preload's href), and the publishing step inlines the fonts back into the self-contained DeWEB copy, which still makes no external request.

[1.0.0-rc.1] — 2026-09-29

Breaking changes toward 1.0

The interface freeze review (1.0 plan G1). Every change below can break 0.x code; what to write instead, what 1.0 promises (Stable, Experimental, Internal) and the full error-code table are in docs/guides/upgrade-1.0.md.

  • INVALID_ARGUMENT, a new client code for the caller's own mistakes. A configuration or argument error (createRpc, createTapeAPI without rpcUrls, an unsupported resolve target, payer options, tx builder arguments, callQuorum options, group-delivery carriers, createProvider / createAIProxy / createMcpProxy options) is now INVALID_ARGUMENT, raised before anything is sent. It used to be RPC_UNAVAILABLE, MANIFEST_INVALID, BAD_VOUCHER, ABI_INVALID, BAD_KEY, CHANNEL_INVALID, BAD_REQUEST, QUORUM_FAILED, METHOD_NOT_FOUND or GROUP_DELIVERY, so a retry loop on RPC_UNAVAILABLE could spin on a missing rpcUrls. The codec layers (abi, canon) and the protocol refusals of callQuorum are unchanged. TAPI-21 §3.4 lists it, with GROUP_DELIVERY, BAD_RESPONSE, BUS_PRIVACY, BUS_BUDGET and the MCP error.data.code values.
  • TapeAPIError top-level fields are fixed: name, code, message, data, signed, httpStatus, cause, and ts, block, id, sig, error on a signed provider error. Every other detail is in data (e.data.tooLarge, e.data.rpcCode, e.data.rpcRevert, e.data.agreed, e.data.failed, ...); the old top-level names still read, as deprecated aliases, until 2.0.
  • channel exports: toHex (bare hex, unlike abi.toHex), fromHex, toBase64, fromBase64 and the test hooks _keySchedule, _busMerge, _busKindOf are no longer public; @tapeapi/sdk/channel and the root channel namespace point at a public face (sdk/src/channel-public.js). sig no longer re-exports keccak256, toHex, bytesToHex, hexToBytes (use abi). @tapeapi/sdk/rpc exports createRpc and RPC_BODY_LIMIT only (readJsonBounded, describeUrl, isNodeLimit are internal). abi.FUNCTIONS and abi.SERVICE_TUPLE are @internal.
  • service, not svc: channel.relayTransport({ service }), relay carriers { api, service, payer }, the WebMCP handle's service. svc is refused with a pointer to the upgrade guide.
  • relayClients and busClients, always lists: deliverGroupUpdate({ relayClients: [...], busClients: [...] }), checkGroupInvites({ relayClients: [...] }), clients { api, service, payer? } and { address, sendTx }. relays stays the roster's and the invite's list of relay references { url, container } (createGroup, resumeGroup, channel.createInvite); relay, bus, relays and buses are refused by the delivery functions.
  • Every now is Unix seconds. createGroup, joinGroup and resumeGroup take clock, a function returning Unix seconds; the 0.x now (a function of milliseconds) is refused, and so is a clock returning milliseconds (above 1e11, e.g. Date.now), on the owner's side and the member's: clock must return Unix seconds. The TAPI-27 vectors are unchanged.
  • channelRecordFloor is keyed <chainId>:<container> and shared with the other chains' clients (forChain), so the anti-rollback floor persists on X Layer and Base too; a 0.x entry is migrated on first read.
  • The openai-proxy subpath of @tapeapi/server and createOpenAIProxy are removed: use @tapeapi/server/ai-proxy and createAIProxy.
  • MCP endpoint and proxy: createMcpEndpoint() returns { handleRequest, tools } (was handle); createMcpEndpoint and createMcpProxy take name (was identity: { name }; identity is refused). UPSTREAM_TIMEOUT_MS is AI_UPSTREAM_TIMEOUT_MS in ai-proxy and MCP_UPSTREAM_TIMEOUT_MS in mcp-proxy.
  • ai.createVerifyingFetch no longer lets anything through unverified. Streams read by the official SDKs are verified (they stop reading at [DONE] / message_stop, so the old end-of-stream check never ran). A stream ends at its final event, at [DONE] or when the upstream closes, whichever comes first; in strict mode the chunk in which it ends is released only once a receipt that came before the end verifies, else the iterator throws RECEIPT_INVALID (a [DONE] inserted before response.completed used to let an unverified Responses stream through). Nothing waits for the upstream to close the connection, and with strict: false nothing is held at all; an upstream that breaks off after the end does not fail the call. A custom format that streams must name its final event (stream.final): strict refuses one without it with INVALID_ARGUMENT when the fetch is made, otherwise onReport warns once. A metered request to a host other than the manifest's endpoint (localhost for 127.0.0.1) is refused with INVALID_ARGUMENT in strict mode, reported with mismatch: true otherwise. tapeapi-verify --strict likewise ends the stream in an error event when no receipt before its end verifies, and passes on only whole events. In strict mode a whole answer whose receipt fails is no longer thrown: it becomes an HTTP 502 in the API's error shape (code RECEIPT_INVALID) with x-should-retry: false and x-tapeapi-verify-error: RECEIPT_INVALID, so the official SDKs throw an APIError and do not retry it (a thrown error was wrapped and retried twice by default: three requests, each possibly paid); a caller of the fetch itself checks res.ok. onReport fires as before. Regression tests drive the official openai and @anthropic-ai/sdk packages (root devDependencies only).
  • Provider dev is opts.dev only: createProvider relaxes its payment checks (priced methods without an escrow, the in-memory meter warning) only for dev: true; allowHttp only allows http endpoints; a manifest's own dev field switches nothing, in createProvider and createMcpProxy.
  • rpcTimeoutMs: createTapeAPI, its chains[id] and createProvider take rpcTimeoutMs for the timeout of one RPC request; timeoutMs there is refused (api.call keeps its per-call timeoutMs).
  • Decimal strings out: api.chain.tokenOf() returns tokenId as a decimal string, like every tokenId and processor the SDK returns. The public service's tapeName is unchanged.
  • A narrower frozen surface: the root ai namespace and @tapeapi/sdk/ai are a public face (sdk/src/ai-public.js): the helpers the reference sidecar and the website use stay, marked @internal; amountOf, pricesOf, sseDigestOfPayloads, sentinelOf, rootOf, saltRequestBody and the limit constants are no longer exported there. group.senderKey and group.buildEpoch leave the public group namespace. The whole bus-privacy subpath (options, defaults, stats().privacy) is @experimental.
  • Option interfaces have no index signature: CreateProviderOptions, ChannelSelf, ChannelPeer and WebMCP's paid reject unknown keys at compile time.
  • Types: Group is GroupHandle | OwnerGroup (with GroupSnapshot, Roster); ManifestBase is shared by the two proxies; payment declarations carry @experimental.

Also: api.addresses.factory; PROVIDER_UNAVAILABLE carries data.timedOut / data.aborted for the caller's own timeout or abort; the CLI help lists tapeapi-mcp --dev and the exit status (0, 1, 2; no environment variables).

Website and READMEs

  • Homepage rewritten for the current positioning (site/index.html, site/style.css; 1.0 plan G9). It leads with the signed receipt layer for AI services (the sidecar, what a receipt proves and what it does not: which model ran), then MCP, private channels and groups, and multi-chain. New sections: a start-here path per reader (AI providers, MCP server authors, app developers, circuit holders), the receipt layer with a figure and a "what a receipt proves" table, chains (BNB Chain with payments only there; X Layer and Base read-only), and privacy (what is protected and what is not). "Live now" adds the AI sidecar and new-api package (available, self-hosted only: it handles API keys), tapeapi-verify, the console's AI price tables, one-call group delivery, the spot-check probe and the receipt checker. Paid channels and their gas and amortisation figures move to "Experimental: paid channels" (not deployed); the fee note uses the current wording, and says no call is charged today. Spec statuses follow TAPI-1 §4.1. The examples use rpcUrlsFor(56); a new one plugs createVerifyingFetch into the official OpenAI SDK. Every example was run against the v0.8.0 release package (the AI one against the reference sidecar in examples/ai-proxy, since no outside provider has published a price table yet).
  • README.md and README.zh-CN.md rewritten to the same standard: the value in the first lines, a table of first steps per reader, short examples that run (install from the release package, AI receipts, tapeapi-verify, MCP), a Mermaid diagram of the layers, what a receipt proves, status and commitments, privacy, fees and the specs with their statuses. Contract addresses and repository detail now live in the guides.

Specification statuses toward 1.0 (TAPI-1 §4.1)

  • TAPI-1 §4.1 defines the interim statuses this repository uses until TapeKit adopts a numbered-proposal process: Draft, Stable (v1), Experimental and Withdrawn. Stable (v1) freezes every field, encoding, signature domain and error code; a revision may only add optional content or non-normative text; a breaking change is a new version (v2) with its own wire markers, v1 stays valid beside it and is not withdrawn earlier than 12 months after v2 becomes Stable. When TapeKit assigns numbers or statuses, TapeKit's prevails and the front matter records the mapping. A new optional front-matter row, Target, names the status a Draft intends to reach.
  • Status rows. TAPI-20, TAPI-21, TAPI-23, TAPI-26 and TAPI-27 stay Draft with Target | Stable (v1) at TapeAPI 1.0; TAPI-22 (metered payment and escrow) and TAPI-25 (circuit-verified methods) are Experimental; TAPI-24 (intent RFQ, frozen since 2026-09-21) is Withdrawn. No normative text of TAPI-20 to TAPI-27 changed.

Test vectors

  • TAPI-20 §6.1 mainnet manifest filled in: the live manifest of 11.1013.tape, read only through resolve on the SDK's default BSC nodes (quorum 2 by operator, all three operators identical), every eth_call pinned to block 124552456 by EIP-1898 { blockHash }: size 3414, SHA-256 0xee57f304…3b52c37a, signer, delegation expiry and holder. Recorder scripts/record-mainnet-manifest.mjs, fixture sdk/test/fixtures/mainnet-11-1013-manifest.json, offline replay sdk/test/mainnet-manifest.test.mjs (clock pinned to the block; the table in the spec must quote the fixture). The delegation is renewed before 2026-12-10, so the fixture pins a block's state, not the current manifest.
  • TAPI-23 §6 vectors: spec/vectors/tapi-23-attested.json (from scripts/gen-vectors.mjs), two providers signing with public test keys, real Ethereum values for the example request (block 20000000, USDT totalSupply()), two agreeing cases and three ATTEST_DISAGREE counterexamples, replayed through callQuorum by sdk/test/attested-vectors.test.mjs. §6 now says plainly that no live service offers attestedRead yet (11.1013.tape does not) and what the smallest change would be.
  • spec/vectors/verify.py checks both independently: it re-encodes the mainnet calldata, ABI-decodes the answers, hashes and parses the manifest and recovers the delegation to ownerOf; and it rebuilds each TAPI-23 envelope digest, recovers both signers and decides agreement from §3.4 alone. 159 checks before, 249 now.

[0.8.0] — 2026-09-28

ChannelBus read privacy: contract-wide reads by default, cover rooms as the fallback

  • busPrivacy.busPrivacyReader (new module sdk/src/bus-privacy.js, subpath @tapeapi/sdk/bus-privacy): the options and the reader of channel.busReader, plus mode, cover, contract, setMode(), covers and stats().privacy. It wraps the rpc client busReader reads through, so busReader's own reading code (ranges, union, confirmations, overlap, hold never skip) runs unchanged; sdk/src/channel.js is not touched.
    • mode: 'contract' (the default): no room topic at all (topics: [Wire]); every frame on the bus is downloaded and filtered locally, so a node learns only that the IP reads ChannelBus. It needs no cover pool, so the first poll waits only for its own lookback read. Bounded per poll (contract.maxBytes 8 MiB, maxLogs 10,000, over every node's answer); over it, contract.onExceed: 'cover' (the default) falls back to cover rooms and warns, and 'error' stops the reader with BUS_BUDGET (frames already read are handed over either way). Default because the mainnet ChannelBus carried one log in 500,000 blocks on 2026-09-28: no rooms to draw covers from, and a contract-wide read cost almost nothing.
    • Coming back, with hysteresis. After a fallback the reader returns to 'contract' by itself once it has spent contract.retryMs in 'cover' (30 min, doubled after each relapse soon after a return, at most 24 h) and a pool refresh made after the fallback shows a poll's traffic at or under half the budget; retryMs: null never returns. Covers are drawn once and kept, so the switching shows no new room sets; covers drawn at a fallback come first from rooms seen in quiet contract polls, not from the junk that caused it. stats().privacy.fallback reports it.
    • mode: 'cover': each of your rooms goes out among k rooms (default 8), the others real rooms seen in the bus's last 40,000 blocks of logs (read with queries that name no room), in quiet contract polls, or in cover.pool, in a fresh random order on every request, catch-up reads of rooms added later included. Cover rooms' logs are dropped before the scanner parses them: never decoded, decrypted or kept. Covers are drawn once per room and kept for the reader's life, and across restarts with cover.store, because changing them lets a node intersect requests. Too few covers is never passed off as cover: the reader warns once per change (effectiveK, short), or throws BUS_PRIVACY before sending anything with cover.onShort: 'error'. Its pool scan costs one request per 5,000 blocks (3-6 s each on 48 Club), about 25-50 s before a fresh process's first read; cover.scanBlocks lowers it.
    • mode: 'plain': busReader as before.
    • scanCoverPool and plausibleRoom are exported too. Tests: sdk/test/bus-privacy.test.mjs.
  • No existing default changes. Nothing in the SDK builds a busReader for you (checkGroupInvites reads relays), so no existing call changes behaviour; busReader and busTransport still name your rooms. The guides now point bus readers to busPrivacyReader; mode: 'plain' gives busReader's behaviour through it.
  • Docs: "Read privacy" in docs/guides/channels.md and the Chinese guide (the default and why, the first read, falling back and coming back, what k means, what it helps against and what it does not). TAPI-26 §8 gains a non-normative note, "Node correlation", in both languages; no MUST/SHOULD/MAY changed.

Holder console: AI price tables

  • The console publishes a manifest with an ai field (TAPI-20 §3.9). site/console/lib.js gains aiProblems and normalizeAI, a line-for-line port of the SDK's validateAIField (the page loads no library): the same verdict, the same first message and the same normalised bytes, checked in sdk/test/console-ai.test.mjs on every valid and invalid sample of the SDK tests, the receipt vectors' table, the spec's example, models.example.json and 4,000 random mutations of them. manifestProblems and expectedManifest accept ai in its normalised form (what the sidecar serves); anything else new is still refused.
  • The price table before the wallet asks. Step 5 shows every API format's address and every model's id, aliases and per-currency input, output, cache read, cache write, 1-hour cache write and reasoning prices per 1M tokens, with the spec's defaults marked (aiPriceTable). Hints, never refusals: a price of 0, a price above a per-currency threshold (AI_HUGE), output below input, a cache read above input, endpoints clients ignore, USD for display only, a table near the one-transaction limit. Bilingual, with the page's language switch.
  • Import. Paste or upload a models.json (the new-api sidecar's bare array, converted by modelsToAIField on the step-4 service URL exactly as the sidecar builds its endpoints), an ai field, or a whole manifest (aiFieldOf). A previewed table must equal the served one (aiDiff) or nothing is published.
  • Read back after publishing. Once the putFile transaction is mined, the page reads the manifest back from the chain (length and SHA-256 against the SiteRegistry) and compares it with the bytes sent, price table included (readBackProblems); a lagging node gets a few tries and a mismatch says to check again later.
  • Multi-chain. On X Layer and Base the page states that payments are not open and the prices are for display only.
  • Docs: "Publish the price table with the console" in docs/guides/ai-providers.md (and the Chinese guide); the "console cannot publish ai" notes there and in examples/new-api-sidecar/README.md are gone.

Fixed

  • A page's own modules are content-stamped too. Cloudflare caches .js for 4 hours; pages stamped the scripts they load (console.js?v=…) but not what those scripts import (./lib.js), so after a release a returning visitor could run the new console.js against a stale lib.js and the page would fail to load. scripts/version-assets.mjs now stamps every relative import in site/ modules, leaves first, so any change below a page changes the page's own reference; vendored code is left alone so one module is never loaded under two URLs.

[0.7.0] — 2026-09-28

Privacy hardening

  • Guessable receipt ids. The AI signing sidecar keeps the upstream's answer id (TAPI-21 §3.5), and some upstreams' ids can be guessed: Ollama's OpenAI-compatible API numbers chat ids chatcmpl-0 to chatcmpl-998, so anyone could walk through the free receipt method and read every receipt kept. The sidecar now estimates the randomness of the ids it sees (idEntropyBits, threshold ID_ENTROPY_MIN_BITS = 64) and warns once in its log when they look guessable (a low estimate, or one id seen twice while kept); keeps receipts per (id, requestSha256), so answers that share an id no longer overwrite each other (an id-only lookup still serves the later one); gives the receipt method a budget of its own (receiptRateLimit, default 10 lookups per client IP per minute, refused with the unsigned 429 of TAPI-21 §3.4); states the configurable lifetime (receiptTtlMs) in the method description (unchanged at the default); and, with requireRequestHash (off by default), answers only lookups that also name requestSha256. Worker variables RECEIPT_TTL_S, RECEIPT_LOOKUPS_PER_MIN, RECEIPT_REQUIRE_HASH; the new-api sidecar takes RECEIPT_TTL_S and RECEIPT_REQUIRE_HASH. TAPI-21 §3.5 (both languages): the receipt method MAY take an optional requestSha256 parameter that picks the receipt of that request among answers sharing an id, and a provider whose upstream's ids can be guessed MAY refuse a lookup without it; the manifest entry stays params: { id: "string" }. §8 describes the risk. The receipt shape and the frozen vectors are unchanged.
  • Request salt. createVerifyingFetch (new option salt, default on) and tapeapi-verify (--no-salt to turn it off) append 64 random JSON whitespace characters (128 random bits) after the JSON text of each request body on a receipt path, so the receipt's requestSha256 can no longer be confirmed by hashing guessed short prompts. The parsed request is unchanged: no field is added or changed (user and metadata.user_id stay as written, since gateways route caches by them), no token is added, and prompt caches keyed on the parsed prompt are unaffected. Compressed bodies (Content-Encoding other than identity) and non-JSON bodies are sent as they are; an explicit Content-Length is dropped when the body grows; the receipt is checked over the bytes actually sent; reports carry salted. New exports ai.saltRequestBody and ai.SALT_LENGTH. TAPI-21 §8 and the AI provider guide explain why the cache is unaffected. Acceptance by the live OpenAI and Anthropic APIs is not yet measured (the tests use the reference sidecar).
  • Hash-only MCP receipts and verify links. verifyLink used to put the whole receipt, params and result in clear, into the link, so sharing a link shared the call. It now carries the hash-only form by default (mcp.hashReceipt, receipt v: 2: params and result/error replaced by requestHash = keccak256(canonicalJSON({ method, params })) and bodyHash = keccak256(canonicalJSON(result or error)), the two hashes the TAPI-21 digest is built from, so the signature still verifies). The whole receipt goes into the link only when asked: verifyLink(r, base, { content: true }), toolResultOf({ linkContent: true }), linkContent on createMcpEndpoint and createMcpProxy (LINK_CONTENT=1 in the MCP proxy Worker), tapeapi-mcp --link-content. The provenance note says which form its link carries. The receipt in _meta stays whole. New sig.responseRequestHash, sig.responseBodyHash, sig.responseDigestFromHashes, sig.recoverResponseSignerFromHashes (the same digest, checked against every published TAPI-21 envelope vector). The verification page reads both forms, shows the hashes and says that low-entropy params (an address, a token id, a price pair) can still be guessed from their hash. The reputation design now attaches hash-only receipts. The TAPI-21 envelope and digest are unchanged.
  • Honest labels. The verification page has a "What a receipt does not prove" list in both languages: a receipt does not prove which model or program actually ran; a link with content (MCP receipt v 1) contains the conversation; metadata (the service sees the request and IP, public BSC nodes see the reader's IP and which service is checked) is not hidden. A receipt with content is shown with a note saying so, and the footer says the page can be self-hosted. The help of tapeapi-verify and tapeapi-mcp and the SDK README say the same. A new test (scripts/privacy-copy.test.mjs) keeps this copy free of the words the plan rules out.
  • Log minimisation. All seven wrangler*.toml (the two public services api.tapeapi.fun and relay.tapeapi.fun, the four templates and one more) turn Cloudflare's per-request invocation logs and traces off and redact query strings ([observability.logs] invocation_logs = false, [observability.traces] enabled = false, redact_query_string = true, keys checked against the pinned wrangler 4.141.0); the Workers' own console output stays (on api.tapeapi.fun one line per MCP message with an HMAC caller tag, never the IP). Takes effect on the next deploy. Whether Cloudflare's own request records include the client IP is to be verified.
  • Privacy page (draft). site/privacy/ (Chinese and English) says what the services we run record and what they do not, what Cloudflare, service providers, public RPC nodes and the chain see, and what is still to be verified (client IP in Workers logs, Pages access logs, our plan's retention). Not in the sitemap, not linked, noindex, and not added to any publishing list. scripts/privacy-logs.test.mjs checks the configuration and the page; scripts/privacy-words.mjs holds the ruled-out words both privacy tests use.
  • Session-header switch. The AI signing sidecar takes forwardSessionHeaders (default true: unchanged behaviour); false (FORWARD_SESSION_HEADERS=0 in the Worker and the new-api sidecar) leaves out the clients' session headers (ai.SESSION_HEADERS: x-claude-code-session-id, session-id, thread-id), which let the upstream tie a caller's requests into one session. tapeapi-verify --strip-session-headers does the same on the client side. TAPI-21 §8 (both languages, no new keywords) and the AI provider guide say so. New ai.isSessionHeader.

Group delivery (TAPI-27)

  • One call delivers a group update. A group uses two kinds of room: the epoch message goes to the group room, and each new member's invite to that member's own inbox room. An application that posted only epochWire set up a group nobody could join, and the relay rightly returned 0 frames to the members. New in @tapeapi/sdk: deliverGroupUpdate({ group, update, relay, bus }) posts the invites to every new (or named) member's inbox room and then the epoch message to the group room, over relays (relaySend, optional payer) and / or ChannelBus (the caller's sendTx), and returns every post with its room and the relay's { i, epoch } or its error; any failure throws GROUP_DELIVERY after all posts were tried (or returns ok: false with throwOnError: false), and a relay's per-source limit is marked rateLimited with retryAfterS. checkGroupInvites({ self, identity, relay, cursors }) reads the member's inbox room with one cursor per relay and room that keeps the relay's room epoch (first read after: -1, epoch: null; a stored cursor without an epoch starts over), opens the group invites, and counts what it skips; holder and checkSelf refuse a wallet address or a wrong chainId given for the container.
  • createGroup, addMembers, removeMembers, rotate and resumeGroup now also return epoch and added (the members that need an invite); the owner's handle has epochWire, the latest epoch message, for reposts. Nothing existing changed shape. The JSDoc and types of createGroup, addMembers and inviteFor say which room each part goes to.
  • TAPI-27 §3.5 (both languages): a non-normative note on the two rooms and on cursors. No requirement changed.
  • New guide Group chat (docs/guides/groups.md, docs/guides/zh-CN/groups.md): prerequisites, the owner and member flows, relay or ChannelBus, saving state, a troubleshooting checklist and the limits. New example examples/group-chat/: two throwaway identities over the public relay, with an end-to-end test against a local verified relay.

[0.6.0] — 2026-09-28

Added

  • X Layer and Base, read-only. TapeAPI follows TapeOut to X Layer (chainId 196, area code 2) and Base (chainId 8453, area code 3). @tapeapi/sdk/chains holds the chain registry (processor factory, DeWebHub, SiteRegistry, binding, ERC-6551 registry and account implementation per chain, each read on chain; the recorded answers are in sdk/test/fixtures/chains-onchain.json). Names follow TapeKit kernel/src/name.js: <#ID>.<processor>.tape on BNB Chain, <#ID>.<area>.<processor>.tape elsewhere (1.2.230.tape is #1 of processor 230 on X Layer); new exports parseTapeName, formatTapeName, isNameShaped, CHAINS, chainById, chainByArea, chainByKey.
  • resolve() takes names on any supported chain, { chainId, circuits, tokenId } and { chainId, container }, and every resolved service carries its chainId. createTapeAPI({ chains }) configures nodes per chain; api.forChain(id) and api.chainOfContainer(address) are new. Paid methods of an X Layer or Base service are refused with PAYMENT_REQUIRED ("payments are on BNB Smart Chain only"): payments stay on BNB Chain for now.
  • Default nodes per chain, measured 2026-09-28 and counted by operator: X Layer = OKX (two domains) + dRPC, only two independent operators, so 2-of-2 with no spare (the client warns once); Base = Coinbase, Allnodes, dRPC, Tenderly.
  • tapeapi-mcp and tapeapi-verify accept area-coded names (--rpc-xlayer, --rpc-base); the verification page, playground, "my services" and the console pick the chain from the name (the console has a chain selector and switches the wallet; ChannelBus stays on BNB Chain).
  • examples/new-api-sidecar/: one docker compose up puts the AI signing sidecar in front of an unchanged new-api relay (smoke test and tests included). Guide for AI providers: docs/guides/ai-providers.md (and Chinese).
  • examples/spot-check/: a versioned probe set anyone can run against an AI service; it verifies each receipt and writes raw data only (reported prompt_tokens next to local tokenizer counts), no scores and no rankings.

Changed

  • TAPI-20 multi-chain text (§3.1, §3.2, §3.4, §3.6 step 1 and step 3, §5, §6.2): a service lives on the chain of its circuit; the container, manifest, isCPU and delegation are all read on that chain; the delegation domain is (that chain's chainId, that chain's DeWebHub). The DeWebHub has one address on all three chains, so the chainId alone separates the domains and a delegation for one chain MUST NOT be accepted on another. Identity does not carry across chains. §6.2 adds the digests for chainId 196 and 8453, computed by the SDK and by verify.py independently.
  • Behaviour change: a dotted all-digit string such as 1.2.3 is now an X Layer name, not a directory label. A name with a reserved or unassigned area code is refused. BNB Chain behaviour is otherwise unchanged; receipts are unchanged (the chain follows from the service name, and a wrong chain derives a different container, which fails the check).

[0.5.0] — 2026-09-28

Changed

  • Fee policy (before anything is charged): no mandatory protocol fee. When paid calls settle through the next escrow version (not deployed; only after an independent audit), a default 1% maintenance contribution comes out of the provider's share (the consumer's price does not change); any provider can set it to 0, or up to 50%. The default is a contract constant: the operator has no fee switch and cannot pause. Planned settlement tokens: BEM (primary), BNB, USDT, USDC, ETH, USD1. The permanent 10% of any TapeAPI revenue to @Theairresearch includes this contribution. TAPI-22 §3.4, docs/FEES.md, README, the site and the guides say so; the escrow code in the repository still starts at 0 and changes with the next escrow version.

Security

  • RPC quorum counts operators, not URLs. The default BNB Chain nodes were three dataseeds that all belong to one operator (NodeReal), so "2 of 3 agree" was one operator agreeing with itself. createRpc now counts distinct operators (operatorOf) and refuses a configuration with fewer operators than its quorum; the defaults (RPC_DEFAULTS, rpcUrlsFor(56)) are NodeReal, Alchemy and 48 Club, each checked to serve block-hash-pinned reads.

Added

  • AI signing sidecar @tapeapi/server/ai-proxy (createAIProxy): put it in front of any OpenAI- or Anthropic-compatible API and every call gets a signed usage receipt (service, model, tokens, price, request and response hashes), with the bytes passed through unchanged and no change for clients. Formats: OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, OpenAI Embeddings; streams keep the receipt in an SSE comment the official SDKs ignore, placed before the final event. Example: examples/ai-proxy/.
  • @tapeapi/sdk/ai: verifyUsageReceipt, createVerifyingFetch (plug into the official OpenAI or Anthropic SDK), receipt readers and the price arithmetic (exact, BigInt). The manifest's ai field lists the endpoints and the price table (BEM, BNB, USDT, USDC, ETH or USD).
  • The verification page reads AI usage receipts.
  • Frozen specification of the AI layer: TAPI-20 §3.9 (the ai manifest field: endpoints per format, models with aliases and a price per currency, usage and the amount formula) and TAPI-21 §3.5 (AI usage receipts: envelope, request and stream hashes, delivery, retrieval, client checks, security). Test vectors in sdk/test/fixtures/ai-receipt-vectors.json, checked three ways (the sidecar, the SDK and spec/vectors/verify.py).
  • tapeapi-verify, a local verifying proxy in the SDK release tarball: point Claude Code (ANTHROPIC_BASE_URL) or Codex (OPENAI_BASE_URL) at it and every answer's receipt is checked against the service's on-chain manifest; --strict refuses an answer whose receipt fails. Tested end to end with the real Claude Code and Codex CLIs.
  • The sidecar forwards the coding agents' session headers (so relays keep session affinity and caching), accepts requests up to 32 MiB, waits up to 600 s for a whole answer and ends a stream silent for 300 s; its own errors carry x-tapeapi-sidecar-error: 1.

Fixed

  • spec/vectors/tapi-21-envelope.json was signed over the raw digest instead of the EIP-191 message TAPI-21 §3.3 names; regenerated, and spec/vectors/verify.py now recovers every vector's signer.

[0.4.0] — 2026-09-28

Added

  • Tape out your MCP server. @tapeapi/server/mcp-proxy (createMcpProxy) puts a signing proxy in front of an existing MCP server (Streamable HTTP, JSON or SSE): the server keeps its own host and domain, gains an on-chain identity (a TapeOut circuit's container), its tool definitions are pinned by digest in the on-chain manifest, and every tool result is a signed TAPI-21 envelope with a receipt. A runnable example for Node and Cloudflare Workers is in examples/mcp-proxy/.
  • TAPI-20 §3.8, the optional mcp binding: { endpoint, toolsSha256 }, where toolsSha256 is the SHA-256 of the RFC 8785 canonical JSON of the tools' model-facing fields (sdk mcp.toolsDigest). Clients compare it with the tools/list they receive and refuse a difference.
  • TAPI-21: provider code TOOLS_CHANGED (HTTP 409): a service whose upstream tools no longer match its toolsSha256 refuses every call, signed, until the holder republishes.
  • tapeapi-mcp checks mcp.toolsSha256 against the service's live tools/list, shows the upstream tools exactly as pinned, returns upstream content with a verified receipt, and pins the tool set (a republished set is a change the user must accept).
  • The holder console publishes MCP-bound manifests: it reads tools/list itself, recomputes the digest in the browser, shows every pinned field of every tool before the wallet signs, and refuses invisible or format characters.
  • The MCP guide covers taping out your own server.

Security

  • Adversarial review of the MCP code (MCP-R1 to R7), all fixed with tests: the remote /mcp caps chunked bodies and counts only messages it handles; caller tags in logs are keyed hashes (HMAC) rather than plain hashes of the IP; the proxy serves only the digest-covered fields of each tool; tool text that imitates a TapeAPI provenance line is labelled as the tool's own output and the genuine line comes first.

[0.3.0] — 2026-09-28

Added

  • Remote MCP endpoint https://api.tapeapi.fun/mcp (MCP Streamable HTTP, stateless): the eight public methods as MCP tools for Claude, Cursor and any MCP client, added by URL with nothing to install. Every tool result is the service's signed TAPI-21 envelope and carries a receipt (_meta["fun.tapeapi/receipt"]) and a verification link.
  • Local MCP command tapeapi-mcp in the SDK release tarball (npx -y --package=https://github.com/BruceLanLan/tapeapi/releases/download/v0.3.0/tapeapi-sdk-0.3.0.tgz tapeapi-mcp 11.1013.tape): resolves any TapeOut service on chain, verifies every answer before the model sees it (a tampered answer is discarded), and pins each service's tool definitions and signer, refusing calls after they change on chain until the user accepts (--allow-changed).
  • Receipt verification page https://tapeapi.fun/verify/: open a receipt link or paste a receipt; the page resolves the service on chain in the browser, recovers the signer and says Valid, Invalid, Cannot confirm or Not checked, with every check listed.
  • @tapeapi/sdk/mcp: a transport-free MCP server core (createMcpServer), receipts (receiptOf, verifyLink) and toolResultOf; @tapeapi/server/mcp: createMcpEndpoint({ provider, manifest }) turns any provider into a remote MCP server under the provider's own rate limits.
  • Docs: an MCP guide (English and Chinese); the homepage, README and roadmap list the MCP endpoint.

Changed

  • A signed refusal thrown by api.call now carries its id, sig and error, so it can be receipted like an answer.
  • The relay template (examples/cloudflare-worker/wrangler-relay.toml) no longer names the project's own Worker or domain: a fork deploys it as my-tapeapi-relay with npm run deploy:my-relay. The project's own relay config moved to examples/public-api/wrangler-relay.toml; npm run deploy:relay still deploys it, unchanged.
  • Web2 adapter: a ${NAME} in the config expands only from the env passed to buildMethods (no process.env default; a name missing from it is refused when the service is created), and a hosted policy (HOSTED_POLICY) for running other people's configs: https on 443 only, no IP literals, local or platform hosts, at most 3 hosts, no redirects, a header allow-list, a fixed User-Agent, a streamed 256 KiB body cap and an optional resolver check against private addresses. Responses are capped at 1 MiB under the default policy too.
  • Site: stylesheets and scripts carry a content hash (npm run build:assets), and the logo has a fixed size without its stylesheet.

[0.2.0] — 2026-09-27

Added

  • ChannelBus deployed on BNB Chain at 0x486110c35d9b90a9d6D85c8063A065f9e7b6b707 (code byte-for-byte the tested build); MAINNET.channelBus in the SDK. A test message was read back through public nodes, and the deployment is recorded for a replay test.
  • Public service https://api.tapeapi.fun (11.1013.tape): eight free, signed, block-pinned methods: blockNumber, balance, tokenInfo, tokenBalance, nftOwner, pairPrice, bnbUsd, tapeName (source examples/public-api).
  • Public relay https://relay.tapeapi.fun (12.1013.tape) for TAPI-26 channels.
  • Playground https://tapeapi.fun/playground/: resolve any service by name, see every check, call it, copy the code.
  • Status page https://tapeapi.fun/status/ and a monitor (npm run monitor: a GitHub workflow scheduled about every 30 minutes, which GitHub may delay, that opens an issue when a service is down or a delegation has fewer than 14 days left).
  • Docs site https://tapeapi.fun/docs/ (English and Chinese), generated from docs/guides with npm run build:docs, including a Public API guide.
  • SDK: api.resolve('11.1013.tape') resolves TapeOut names (<#ID>.<processor>.tape) through factory.cpuAt.
  • Holder console: publishes a service's own free method list; bilingual; hardened (CSP, anti-phishing notice).
  • CI runs the JavaScript, Python and Foundry suites on every push.
  • My services https://tapeapi.fun/dashboard/: a read-only dashboard (add service names; health, delegation expiry, links to try or renew; never signs or sends).
  • Homepage: live services, a 30-second quick start and a roadmap; docs/ROADMAP.md, CODE_OF_CONDUCT.md, a rewritten CONTRIBUTING.md and current issue templates; GitHub private vulnerability reporting enabled.
  • Website: social preview (Open Graph / Twitter tags and og.png), a favicon, a 404 page, robots.txt and sitemap.xml.
  • The dashboard's "Renew in console" link prefills the console's processor and circuit numbers and service URL; the console only fills those fields after checking them, and never reads, signs or sends by itself.

Changed

  • Website: new logo and favicon, one header across all pages, redesigned homepage and social preview.
  • callQuorum: a signed revert (TAPI-23 §3.3) counts as disagreement and is never an accepted result; other signed errors stay neutral. TapeOut names must be in canonical form (TapeKit SPEC §2.2): lowercase, no leading zeros.
  • The SDK is installable from each GitHub release: npm install https://github.com/BruceLanLan/tapeapi/releases/download/v0.2.0/tapeapi-sdk-0.2.0.tgz.
  • Default RPC nodes are bnbchain, defibit and ninicoin dataseeds (publicnode timed out on every request on 2026-09-27), with a 3 s per-node timeout on the Workers.
  • The provider template (examples/cloudflare-worker/wrangler.toml) no longer names the project's own Worker or domain.
  • The examples/web2-adapter config calls Coinbase's public price API (spotPrice, cryptoRates) instead of worldtimeapi.org, which no longer answered, and frankfurter at its current api.frankfurter.dev address.

[0.1.0] — 2026-09-26

First public release. Pre-alpha: the free tier runs on TapeOut's deployed contracts; our own contracts have no third-party audit.

Protocol (proposed TAPs, bilingual, English authoritative)

  • TAPI-20 service identity: a service is a circuit, its container is the address, the manifest lives at .well-known/tapeapi.json in the container's site, and the holder authorises a signing key with an EIP-712 delegation checked against the current holder.
  • TAPI-21 signed response envelope with the TAPI-1/resp/v2 digest, bound to the container, the request id, the method and parameters, the outcome and the timestamp; canonical JSON; stable error codes.
  • TAPI-22 metered payment: cumulative vouchers, per-provider escrow channels, session keys, a withdrawal cooldown, zero protocol fee and an optional voluntary contribution.
  • TAPI-23 attested reads of other chains, TAPI-24 intent RFQ (frozen), TAPI-25 circuit-verified methods.
  • TAPI-26 private channels between containers (mutual authentication, forward secrecy, ChaCha20-Poly1305) over relays or ChannelBus; TAPI-27 private groups of up to 32 containers.

SDK (@tapeapi/sdk)

  • Resolution from a container or a circuit, with manifest integrity, delegation and processor checks through a quorum of RPC nodes that must agree.
  • call with envelope verification and price consent; callQuorum across independent providers pinned to one block.
  • Vouchers signed by a wallet or a session key, with meter resynchronisation.
  • Channels and groups; relay, ChannelBus and fan-in transports.
  • A ChannelBus reader that holds rather than skips: it copes with public nodes' history limits, result caps, empty or lagging answers and failures, reads over-full blocks from receipts, and reports every block it cannot read.
  • exposeTapeAPI: WebMCP tools for in-browser AI agents, with per-call and total budgets for paid methods.

Provider runtime (@tapeapi/server)

  • createProvider for Node (listen) and the Fetch API (handleRequest, Cloudflare Workers).
  • Signed answers and errors, voucher checks, per-consumer metering (shared D1 meter on Workers), rate limits.

Contracts

  • ChannelBus: stateless, ownerless event bus for channel frames.
  • TapeAPIEscrow and ServiceDirectory (not deployed).

Tooling

  • Examples: minimal reader, Web2 adapter, DeFi reads, attested cross-chain reads, relay service, Cloudflare Worker, WebMCP demo.
  • A black-box conformance suite for providers and relays.
  • The holder console at tapeapi.fun/console: go live, publish the manifest and deploy ChannelBus from a phone wallet.
  • Test vectors with an independent Python verifier; about 630 JavaScript tests and 169 Foundry tests.

Edit this page on GitHub