refactor(services): unified approveService + TEE commitment-root storage#119
Merged
Merged
Conversation
Collapse five `approveServiceWith*` entrypoints into one
`approveService(ApprovalParams)`. Move TEE commitment storage from a
per-operator dynamic array to a single keccak256 root + full data
emitted as `TeeCommitmentsRecorded`. BLS stays opt-in via zero pubkey.
RFQ paths (`createServiceFromQuotes`, `extendServiceFromQuotes`) are
unchanged — they already used the resource-commitment hash pattern.
WHY THIS CHANGE
The matrix of approval entrypoints was strict superset growth: each new
optional capability (commitments → BLS → BLS+commitments → TEE) doubled
the surface and left the protocol with a 5-selector explosion that any
new feature would extend further. Every variant duplicated the auth
gates inline, making it easy to drop a check on one path and ship.
The TEE commitment work also stored the full struct array per-operator
on activation — a per-(request, operator) cap of 8 commits × 3 storage
slots × 20K cold SSTORE ≈ 480K gas per operator just to copy commits
forward. With multi-operator services that gas scales linearly in
operator count and burns the activator's tx alone, since "last operator
pays for everyone" is the default activation pattern. The audit flagged
this as a HIGH operator-economics concern.
ARCHITECTURE
Single entrypoint:
approveService(ApprovalParams { requestId, securityCommitments,
blsPubkey, blsPopSignature,
teeCommitments }) external
Empty / zero fields are no-ops. The contract dispatches:
- securityCommitments empty + requirements present → auto-fill
protocol-default TNT commitment (only when that's the lone
requirement) or revert
- blsPubkey == [0,0,0,0] → operator skips BLS
- teeCommitments empty → operator skips TEE binding
Order of operations (fail-fast, validate-before-write):
1. _requireApprovingOperator (auth gate, no SSTORE)
2. _validateSecurityCommitments OR auto-fill default TNT
3. _validateTeeCommitments + compute keccak root
4. BLS proof-of-possession verify (if registering)
5. SSTORE security commits / TEE root / BLS pubkey
6. Mark approved, emit, manager-hook, activate-if-threshold
TEE storage shape:
mapping(uint64 => mapping(address => bytes32)) _requestTeeCommitmentRoot
mapping(uint64 => mapping(address => bytes32)) _serviceTeeCommitmentRoot
Activation: O(operators) — one bytes32 SSTORE per operator that
supplied a non-empty TEE array. Down from O(operators × commits × 3
slots). Pre-refactor 3-operators × 8-commits activator-gas was 2.89M;
post-refactor expected well under 1M (gas test enforces < 1.5M).
Slashing pattern: contract stores root only; slasher / provisioning
oracle supplies the original commitment array as a witness, verifies
`keccak256(abi.encode(witness)) == getTeeCommitmentRoot(serviceId, op)`
before treating the witness as authoritative. Same pattern already
used by `_serviceResourceCommitmentHash` for RFQ resource commits.
RFQ PATHS UNCHANGED
`createServiceFromQuotes` and `extendServiceFromQuotes` keep the
signed-quote acceptance flow exactly as it was. Quotes carry resource
commitments (already hash-stored) and security commitments. TEE is NOT
in the EIP-712 quote shape — TEE commitments require a request-derived
nonce that doesn't exist until the request is created, so they can't
be pre-signed in a quote. Manual approval remains the only path that
binds TEE commitments today; if RFQ + TEE is wanted later, extend the
QuoteDetails type at that point.
BLS IS OPT-IN
The protocol must accept any operator. Operators that don't register a
BLS pubkey can still approve services and run workloads — they simply
cannot participate in aggregated job-result signing on services that
choose to use BLS aggregation. The `JobsAggregation` path reverts with
`OperatorBlsPubkeyNotRegistered` only when an operator who didn't
register tries to participate in a BLS aggregate. No exclusion at
approval, no penalty otherwise.
API IMPACT
- TangleServicesFacet selectors: 10 → 6
Removed: approveService(uint64,uint8), approveServiceWithCommitments,
approveServiceWithBls, approveServiceWithCommitmentsAndBls,
approveServiceWithTeeCommitments, getTeeCommitment
Kept: approveService(ApprovalParams), rejectService,
getOperatorBlsPubkey, blsPopMessage,
getTeeCommitmentRoot (replaces getTeeCommitment),
teeNonceFor
- ITangleServices interface trimmed to match.
TEST SURFACE
Replaced TeeCommitmentApprovalTest.t.sol (16 cases) and
TeeCommitmentHardenTest.t.sol (7 cases) with one tight
ServicesApprovalTest.t.sol (~14 cases). Every test names a specific
failure mode; no compiler-bug theater. Coverage:
Happy paths
- single-operator + single TEE commit, root persists
- minimal approval, no optional fields
- mixed TEE / non-TEE operators, roots independent
- slasher witness verification (honest matches, tampered rejects)
Adversarial — TEE validation
- DirectTdx rejected; Unset enum sentinel rejected
- zero expectedMeasurement rejected
- cross-request replay rejected (nonce request-derived)
- past expiry rejected; >MAX_TTL rejected; at-cap accepted
- TooManyTeeCommitments (cap = 8) rejected
Adversarial — auth ordering
- unauthorized caller fails BEFORE storage write
- double approval rejected
Activation gas measurement
- 3 ops × 8 commits gas, asserted < 1.5M (was 2.89M pre-refactor)
CALL-SITE MIGRATION
~50 call sites in test/ and script/ migrated to the unified entrypoint
via `_approve / _approveWithCommitments / _approveWithBls` helpers
hoisted into BlueprintDefinitionHelper (visible in BaseTest,
TestHarness, and InvariantFuzz). One inline ApprovalParams construction
in three scripts where the helper isn't reachable.
DOES NOT TOUCH
- fix/slashing-correctness branch / Slashing.sol / SlashingLib.sol
- RFQ flows (QuotesCreate, QuotesExtend, TangleQuotesFacet)
- BLS aggregation path (JobsAggregation.sol)
- Heartbeat, payments, blueprint registry, MBSM, beacon, oracles
drewstone
added a commit
that referenced
this pull request
May 5, 2026
Regen via `cargo xtask gen-bindings` against the post-PR-#119 contracts: the unified `approveService(Types.ApprovalParams)` entrypoint, dropped `approveServiceWith*` matrix, and root-storage TEE commitments (`getTeeCommitmentRoot` view, `TeeCommitmentsRecorded` event with full array payload). Bump tnt-core-bindings 0.10.9 → 0.11.0 (BREAKING — selector list shrank 10 → 6, struct types changed, multiple methods removed). Tag `bindings-v0.11.0` triggers the crates.io publish workflow.
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.
Summary
One coherent architectural move that solves three problems left from PR #117:
approveService,approveServiceWithCommitments,approveServiceWithBls,approveServiceWithCommitmentsAndBls,approveServiceWithTeeCommitmentscollapse into a singleapproveService(ApprovalParams). Selectors onTangleServicesFacetgo from 10 → 6.TeeAttestationCommitment[](3 slots × N commits per operator, up to 480K gas/operator at cap) to a single keccak256 root + full data emitted asTeeCommitmentsRecorded. Activation cost drops from O(operators × commits × 3 slots) to O(operators).keccak256(abi.encode(witness)) == getTeeCommitmentRoot(serviceId, operator)before treating it as authoritative. Same pattern already used by_serviceResourceCommitmentHashfor RFQ resource commits.BLS stays opt-in (zero pubkey = operator skips BLS, no penalty). RFQ paths (
createServiceFromQuotes,extendServiceFromQuotes) are unchanged.Why each change is correct
Unified
approveService(ApprovalParams)The problem. Five entrypoints, each duplicating the auth gates inline, each adding selector overhead. Adding any new optional field (e.g., a future per-job pricing override) doubles the matrix again. Reviewing the matrix is hard because each variant slightly reorders auth/validate/write.
The fix. One entrypoint,
ApprovalParamsstruct with optional fields. Empty/zero = opt-out:securityCommitments == []blsPubkey == [0,0,0,0]teeCommitments == []Order of operations (fail-fast, validate-before-write):
Unauthorized callers never reach an SSTORE — they revert at step 1 with
Unauthorized. Misshapen TEE / BLS / commitment payloads revert at steps 2–4 before writing.TEE commitment-root storage
The problem. Audit on PR #117 flagged this as a HIGH operator-economics concern: with
MAX_TEE_COMMITMENTS_PER_OPERATOR = 8and N operators, the activator pays N × 8 × 3 cold SSTOREs (~480K gas/operator). At 10 operators × 8 commits, activation cost approaches 5M gas. At 20+, the activation tx exceeds the block gas limit and the service stalls in pending state forever.The fix. Store one
bytes32per(requestId, operator)and per(serviceId, operator)instead of a dynamic array. The root iskeccak256(abi.encode(commitments)). The full commitment array is emitted inTeeCommitmentsRecordedfor indexers; slashers reconstruct from event logs and verify keccak match.Activation gas at 3 operators × 8 commits drops from 2,886,434 gas pre-refactor → expected well under 1M (test asserts <1.5M as a generous ceiling).
This is the same shape
_serviceResourceCommitmentHashalready uses for RFQ resource commits. It's the standard pattern in modern restaking systems (EigenLayer, Symbiotic) for slashing-safe storage of attestable data.BLS confirmed opt-in
JobsAggregation.solreverts withOperatorBlsPubkeyNotRegisteredonly when an operator who didn't register a BLS pubkey is asked to participate in a BLS aggregate. The protocol accepts any operator at approval time. The PR doesn't change this — it just makes it explicit in the unified entrypoint thatblsPubkey == [0,0,0,0]is the documented opt-out.RFQ unchanged
createServiceFromQuotes/extendServiceFromQuoteskeep the signed-quote acceptance flow exactly as it was. The EIP-712QuoteDetailstypehash carriessecurityCommitmentsandresourceCommitmentsbut NOTteeCommitments— TEE commitments require a request-derived nonce that doesn't exist until the request is created, so they can't be pre-signed in a quote. If RFQ + TEE is wanted later, extendQuoteDetailsat that point.What this solves
TeeAttestationCommitment[]per(service, op)(variable, expensive)bytes32per(service, op)(fixed)Test plan
forge fmtclean across all changed filesTeeCommitmentApprovalTest.t.sol(16 tests) andTeeCommitmentHardenTest.t.sol(7 tests) deletedtest/tangle/ServicesApprovalTest.t.sol(14 tests, every one named after a specific failure mode) covers happy paths + adversarial validation + auth ordering + slasher witness verification + activation-gas regression assertiontest/andscript/migrated through_approve/_approveWithCommitments/_approveWithBlshelpers inBlueprintDefinitionHelperforge builddid not complete on this machine — repeated solc OOM during the 341-file via_ir compile. Same infrastructure issue that's been failing CI onmainsince PR fix(security): pre-mainnet hardening across deploy, MBSM, beacon, oracles, quotes, BLS #115. Pushed for CI to run the canonical build on dedicated runners.forge build+forge test --match-contract ServicesApprovalTestand the full existing suite for regressionsAudit asks
This PR is intentionally scoped: contract surface refactor + storage shape change. Not bundled with the slashing branch, not bundled with operator-count caps. Looking for the auditor agent to confirm:
TeeAttestationCommitment[]storage is read (would silently return zero arrays after refactor).ApprovalParams(empty field = no-op) don't introduce a bypass — e.g., supplying emptysecurityCommitmentswhen requirements exist must still revert (handled by_isOnlyDefaultTntRequirementcheck)._persistTeeCommitmentscorrectly skips operators with zero root (no-op rather than overwriting an existing service root).Does NOT touch
fix/slashing-correctnessbranch (will rebase cleanly after this lands)Slashing.sol/SlashingLib.solQuotesCreate,QuotesExtend,TangleQuotesFacet)JobsAggregation.sol(BLS aggregation path)