feat(usage-meter): link usage records to verified attestations - #84
Merged
joelpeace48-cell merged 1 commit intoAug 24, 2026
Merged
joelpeace48-cell merged 1 commit into
joelpeace48-cell merged 1 commit into
Conversation
A usage record is only as trustworthy as its link back to the evidence behind it. Metering could happen with no attestation and attestations could exist with nothing metered against them, so an invoice could not be traced to what justified it — which is the entire pitch. Every record now carries the attestation it was priced against, and that reference is verified against audit-registry at write time rather than taken from the caller. Verification checks the attestation exists, is not superseded, and belongs to the claimed payer; the registry's own verify_attestation is the single predicate, so the two contracts cannot drift on what "valid" means. The interface into audit-registry is declared locally as a #[contractclient] trait rather than by depending on that crate. Depending on the contract crate would pull its #[contractimpl]-generated WASM exports (initialize, version) into this binary and collide at link time with this contract's own exports of the same names. Double-metering is rejected: an attestation id is burned on first use and a second record against it fails with AlreadyMetered. Only a *verified* attestation is burned — an id that failed verification has metered nothing, so marking it would let a typo or a race against the registry write permanently block the real attestation. Unattested usage is configurable per payer and defaults to the strict option. An unset policy is Reject, so a payer is only ever billed for usage nothing vouches for after explicitly opting in. That switch is authorized by the payer rather than the admin — it decides whether the payer can be billed on trust, so it is not the admin's call. Opted-in records are flagged attested: false, have the unverified reference dropped so they do not read as sourced, and accumulate against a separate total so downstream pricing can treat them differently without re-deriving which was which. Cost of the cross-contract call, measured rather than assumed (soroban-sdk 27.0.6, attested write vs unattested write, same storage writes otherwise): unattested (no call) cpu=186,318 mem=77,223 attested (one call) cpu=256,825 mem=97,795 verification cpu= 70,507 mem=20,572 About 0.07% of the 100,000,000-instruction transaction budget, so the per-record call is affordable and the batch-root alternative the issue raises is not needed at this cost. The measurement lives in a test so it stays honest across SDK upgrades. Authorization follows the pattern from FinesseStudioLab#74: the payer's signature is bound to the attestation id and the unit count with require_auth_for_args, so an intermediate cannot take a signature given for one metering and spend it on a larger one. usage-meter is the caller on this edge and verify_attestation is an unauthorized read, so no allowlist is needed here. Also replaces the Symbol admin placeholder with a real Address, adds typed errors, checked arithmetic on the unit totals (a wrapped total silently zeroes an invoice rather than failing it), TTL extension on every persistent write, and #[contractevent] types for the observable state changes. Tests: 13. The two that are about authorization do not use mock_all_auths. fmt, clippy -D warnings, cargo test --workspace and the wasm32v1-none release build all pass.
4 tasks
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #70
Summary
A usage record is only as trustworthy as its link back to the evidence behind it. Metering could happen with no attestation and attestations could exist with nothing metered against them, so an invoice could not be traced to what justified it — which is the entire pitch.
Every record now carries the attestation it was priced against, verified against
audit-registryat write time rather than taken from the caller. Verification checks the attestation exists, is not superseded, and belongs to the claimed payer; the registry's ownverify_attestationis the single predicate, so the two contracts cannot drift on what "valid" means.Interface into audit-registry
Declared locally as a
#[contractclient]trait rather than by depending on that crate. Depending on the contract crate would pull its#[contractimpl]-generated WASM exports (initialize,version, …) into this binary and collide at link time with this contract's own exports of the same names. It also means this PR needs no changes when #83 merges, in either order.Double-metering
An attestation id is burned on first use; a second record against it fails with
AlreadyMetered.Only a verified attestation is burned. An id that failed verification has metered nothing, so marking it would let a typo — or a race against the registry write — permanently block the real attestation that later occupies that id. There is a test for exactly that sequence: fail, then attest, then succeed.
Unattested-usage policy
Configurable per payer, defaulting to the strict option. An unset policy is
Reject, rather than defaulting to lenient and relying on someone to tighten it — a payer is only ever billed for usage nothing vouches for after explicitly opting in.The switch is authorized by the payer, not the admin. It decides whether the payer can be billed on trust, so it is not the admin's call to make; there is a test asserting the admin's signature is insufficient.
Opted-in records are flagged
attested: false, have the unverified reference dropped so they do not read as sourced, and accumulate against a separate total (unattested_unitsvsattested_units) so downstream pricing can treat them differently without re-deriving which was which.Cross-contract call cost, measured
The issue asks for this to be quantified rather than assumed. Measured on soroban-sdk 27.0.6, comparing an attested write (one cross-contract call) against an unattested one (no call), same contract, same storage writes otherwise:
About 0.07% of the 100,000,000-instruction transaction budget. The per-record call is affordable and the batch-root alternative the issue raises is not needed at this cost. The measurement lives in a test (
test_cross_contract_verification_cost_is_measured, run with--nocapture) so it stays honest across SDK upgrades; the assertion is direction-and-magnitude rather than an exact figure, so an SDK bump does not fail CI on a number.Authorization
Follows the pattern in #74 / #83: the payer's signature is bound to the attestation id and the unit count with
require_auth_for_args, so an intermediate cannot take a signature given for one metering and spend it on a larger one.No allowlist is needed on this edge —
usage-meteris the caller here, andverify_attestationis an unauthorized read by design. Whenpayment-routerlater calls into this contract, that edge will need one.Also in this change
The scaffold's
Symboladmin placeholder becomes a realAddress, plus typed errors, checked arithmetic on the unit totals (a wrapped total silently zeroes an invoice rather than failing it), TTL extension on every persistent write, and#[contractevent]types for the observable state changes —events().publishis deprecated in SDK 27 and CI runs with-D warnings.Tests
mock_all_auths, since it would approve everyrequire_authin the transaction and make them pass regardless. The rest use aStubRegistryimplementing onlyverify_attestation, which keeps this suite about the link — pass, fail, replay — rather than about attestation semantics that belong to the registry's own tests.Migration note
initialize(admin: Symbol)becomesinitialize(admin: Address).pingis gone;versionreturns 2. A deployment must callset_registrybefore any usage can be recorded —record_usagefails withRegistryNotSetrather than silently recording everything as unattested.Conflicts
One of three PRs. File sets are disjoint by crate: this one touches
usage-meter/only, #83 touchesaudit-registry/plusdocs/, #73's touchespayment-router/only. All six merge orders were tested locally againstmain— clean in every order, withfmt,clippy -D warnings,cargo test --workspace(45 tests) and thewasm32v1-nonerelease build passing on the combined result.