Stargaze pivoted to a privacy-first attestation layer for Physical AI on
Base (see the top-level README.md). The on-chain surface this document
covers straddles three tiers:
- LIVE (attest path) — the three Groth16 verifier contracts under
src/verifiers/(GeofenceVerifier,AggregateSumVerifier,AggregateMeanVerifier) that back the corridor-compliance ZK predicate tier.GeofenceVerifieris exercised end-to-end on a Base fork byverifyGeofenceProofOnChainin@stargaze/shared. - LIVE (reputation seam) —
StargazeRegistry.setReputationScore, theORACLE_ROLE-gated function the reputation-oracle'sEvmPublisherwrites to publish device / operator reliability scores. - PARKED (MPP-era) —
StargazeEscrow,StargazeStaking,StargazeStakerRewards,PrivacyVaultRegistry, and the registration / crowd-vote / verified-gate paths ofStargazeRegistry. These contracts remain in-tree and tested; the invariants below still hold against them. They are not on the attest critical path and further work is pending a design-partner validating the wedge. - HARD-FENCED — the
$GAZEtoken layer + slasher economics, the full multi-party trusted-setup ceremony, and thebuyer-keytier (registry-stable today, no circuit yet). Deferred to the Harden phase per the project overview.
Scope of this file: the on-chain Solidity contracts in
packages/contracts-evm (live + parked). Out of scope: off-chain
services (backend, reputation oracle, frontend). The EVM indexer-side
seam (parseAttestedLog / decodeStargazeAttestation /
watchStargazeAttestations / backfillStargazeAttestations in
@stargaze/shared/attest/watch, exercised by the @stargaze/indexer
tests) is built and tested; this file does not cover it.
In scope (packages/contracts-evm/src/):
StargazeEscrow.sol— PARKED (MPP-era): per-session deposit custody and batch settlement of cumulative EIP-712 vouchers in the settlement token (e.g. USDC on Base).StargazeRegistry.sol— LIVE (reputation seam) forsetReputationScore; PARKED (MPP-era) for provider registration, crowd-vote intent, and the Verified Provider gate (reputation + externalIStakeChecker).StargazeStaking.sol— PARKED (MPP-era) + HARD-FENCED ($GAZEtoken layer): native$GAZEstaking, two-step unstake with cooldown, slash. ImplementsIStakeChecker.isVerifiedStake.StargazeStakerRewards.sol— PARKED (MPP-era): pull-based Merkle reward distribution with an on-chain budget invariant and a 90-day sweep.PrivacyVaultRegistry.sol— PARKED (MPP-era): per-provider Groth16 verifier wiring, auditor key, and buyer-key rotation config.src/verifiers/AggregateSumVerifier.sol,AggregateMeanVerifier.sol,GeofenceVerifier.sol— LIVE (attest ZK predicate tier): snarkjs-generated Groth16 verifier contracts.GeofenceVerifieris exercised end-to-end on a Base fork byverifyGeofenceProofOnChain.IStakeChecker.sol— PARKED (MPP-era): the gate interface the registry consults.
The verifier contracts currently embed dev verifying keys produced by
packages/vault-circuits/scripts/setup-dev.mjs. The real multi-party
trusted-setup ceremony per
docs/vault-ceremony.md and the matching
verifier regeneration per
docs/vault-verifier-deployment.md
are HARD-FENCED to the Harden phase — no mainnet broadcasts of these
verifiers until the ceremony is run.
This is a single-party build. There is no external audit before launch —
no Trail of Bits, no Certora engagement. The safety net is the Foundry
fuzz/invariant suite in packages/contracts-evm/test/, run on every change
through make check (which runs forge build && forge test). The CI profile
([profile.ci] in foundry.toml) raises fuzz runs to 1000 and invariant runs
to 256 × depth 64. Each invariant below is indexed by the Foundry test name(s)
that exercise it so reviewers can map a property to its proof.
Authorisation is OpenZeppelin AccessControl role-based, per contract.
Status tags below mirror the Attest-pivot status section above (LIVE = on the attest critical path; PARKED = MPP-era, in-tree but shelved; HARD-FENCED = deferred to the Harden phase).
| Principal | Role / authentication | Powers |
|---|---|---|
| Escrow admin (PARKED) | DEFAULT_ADMIN_ROLE on StargazeEscrow (set in constructor) |
setRoutingFeeSink, grant/revoke ROUTER_ROLE |
| Payment router (PARKED) | ROUTER_ROLE on StargazeEscrow |
settle — the only caller that can batch-settle a session |
| Agent wallet (PARKED) | EVM keypair; address recorded in Session.agentWallet |
Signs EIP-712 vouchers; opens a session (anyone with their token approval can call openSession) |
| Registry admin (PARKED) | DEFAULT_ADMIN_ROLE on StargazeRegistry |
setStakeChecker |
| Reputation oracle (LIVE) | ORACLE_ROLE on StargazeRegistry |
setReputationScore |
| Provider owner (PARKED) | msg.sender recorded as Provider.owner at register |
updateMeta, plus owner-gated vault config |
| Staking admin (PARKED + HARD-FENCED) | DEFAULT_ADMIN_ROLE on StargazeStaking |
setStakeToken (only while empty), setThresholds, setUnstakeCooldown, setSlashSink |
| Slasher (PARKED + HARD-FENCED) | SLASHER_ROLE on StargazeStaking |
slash |
| Stake owner (PARKED + HARD-FENCED) | The provider's owner (via the registry) |
stake, requestUnstake, claimUnstake |
| Rewards admin (PARKED) | REWARDS_ADMIN_ROLE on StargazeStakerRewards |
commitRewardsRoot, sweepEpoch |
| Rewards token admin (PARKED + HARD-FENCED) | DEFAULT_ADMIN_ROLE on StargazeStakerRewards |
setRewardToken (only before first commit) |
| Vault admin (PARKED) | DEFAULT_ADMIN_ROLE on PrivacyVaultRegistry |
deactivate |
Failure modes outside this trust model that the on-chain code does not defend against:
- (PARKED) A compromised
ROUTER_ROLEcan settle any session for which it holds a valid voucher batch, and chooses when to submit. Vouchers are EIP-712-signed by the agent so the router cannot forge spend, but it can pick which vouchers in a session to settle. Mitigation is operational. - (PARKED + HARD-FENCED) A compromised
SLASHER_ROLEcan slash any provider's stake up to its total (active + unbonding) balance. The slash output is always sent to the configuredslashSink. Mitigation is operational (multisig). - (LIVE attest path) A compromised verifier-contract deployment
(wrong embedded vkey) would accept arbitrary proofs. Mitigation is the
ceremony + regeneration flow and re-running the vector tests; see the
deployment doc. This is the real live concern — the
GeofenceVerifieris on the attest critical path today.
PARKED (MPP-era). All E* invariants hold against the in-tree escrow,
but StargazeEscrow is not on the attest critical path — the attest
pivot leaves payments to the consumer side and to ecosystems like x402.
E1. Refund accounting identity. Across a settle, the deposit fully
decomposes: deposit == totalSpend + routingFee + refundToAgent, with
routingFee = totalSpend * 200 / 10_000. No tokens are created or
stranded. Verified by StargazeEscrowFuzz.t.sol::testFuzz_RefundAccountingIdentity
over fuzzed deposits and cumulative amounts, and exercised end-to-end by
StargazeEscrow.t.sol::test_OpenAndSettleSingleVoucher.
E2. Voucher replay guard. Each settled voucher's EIP-712 digest is
recorded in consumedVouchers; a second settle of the same digest reverts
with VoucherReused. Verified by
StargazeEscrowFuzz.t.sol::testFuzz_VoucherReplayBlocked.
E3. Cumulative monotonicity per provider. lastCumulative[sessionId][provider]
only increases; every voucher asserts cumulativeAmount > lastCumulative,
reverting NonMonotonic otherwise. The per-voucher payout is the delta
cumulativeAmount - prev. Enforced in settle; the monotone delta logic
is covered across the fuzz suite (the refund-identity and spending-limit
fuzz tests settle real ascending vouchers).
E4. Signature integrity. settle recovers the signer with
ECDSA.recover(_hashTypedDataV4(structHash), signature) and requires
signer == session.agentWallet, reverting BadSignature otherwise. The
struct hash binds sessionId, agentWallet, provider,
cumulativeAmount, nonce, and expiry via VOUCHER_TYPEHASH. Verified
by StargazeEscrowFuzz.t.sol::testFuzz_BadSignerRejected.
E5. Domain separation. The EIP-712 domain is EIP712("Stargaze", "1"),
so chainId and the escrow's own address (verifyingContract) are bound
into every digest. A voucher signed for another chain, another contract,
or another protocol's domain cannot replay here. The off-chain schema is
mirrored in packages/shared/src/mpp/voucher.ts and must stay byte-aligned
with VOUCHER_TYPEHASH.
E6. Spending-limit cap. Running totalSpend is checked against
session.spendingLimit inside the settle loop, reverting
SpendingLimitExceeded. The limit is in turn constrained at
openSession to <= deposit. Verified by
StargazeEscrowFuzz.t.sol::testFuzz_SpendingLimitCap and the
openSession revert path it leans on.
E7. Routing fee rate. ROUTING_FEE_BPS = 200 (2%), BPS_DENOMINATOR = 10_000. The fee is computed once over totalSpend and transferred to
routingFeeSink; settle reverts RoutingSinkUnset if the sink is
address(0). Solidity 0.8 checked arithmetic guards overflow.
E8. Settle idempotency. A session with settled == true reverts
AlreadySettled on a second settle. Unknown session ids revert
UnknownSession. (StargazeEscrow settles once per session; there is no
separate closeSession.)
E9. Per-voucher expiry. A voucher with non-zero expiry past
block.timestamp reverts VoucherExpired, dropping the whole batch.
E10. Open-session uniqueness. openSession reverts AlreadyOpen if the
sessionId already has a recorded agentWallet, and reverts
SpendingLimitExceeded if spendingLimit > deposit. The deposit is pulled
via SafeERC20.safeTransferFrom under nonReentrant.
E11. Custody is contract-held, not externally signable. All payouts,
the routing fee, and the refund are safeTransfered by the escrow itself
under nonReentrant; no external key signs an outflow from the escrow
balance.
Mixed. R3 (setReputationScore) is the LIVE reputation seam the
reputation-oracle's EvmPublisher writes to publish device / operator
reliability scores. R1, R2, R4, R5 cover PARKED (MPP-era) provider
registration, owner-gated metadata, crowd-vote intent, and the Verified
Provider gate; they hold against the in-tree contract but are not on the
attest critical path.
R1. Registration is one-shot per id. register reverts AlreadyRegistered
if the providerId is taken, and seeds a neutral reputation of 500.
Verified by StargazeRegistry.t.sol::{test_Register_HappyPath, test_Register_RevertsOnDuplicate}.
R2. Owner-gated metadata. updateMeta requires msg.sender == Provider.owner and a registered provider, reverting NotProviderOwner
or NotRegistered. Verified by
StargazeRegistry.t.sol::{test_UpdateMeta_OnlyOwner, test_UpdateMeta_RevertsWhenNotRegistered}.
R3. Oracle-gated scoring with range clamp. setReputationScore is
ORACLE_ROLE-only, requires a registered provider, and reverts
ScoreOutOfRange for score > MAX_REPUTATION (1000). Verified by
StargazeRegistry.t.sol::{test_SetReputationScore_OnlyOracle, test_SetReputationScore_RevertsOutOfRange, test_SetReputationScore_RevertsWhenNotRegistered, testFuzz_ReputationScoreRoundTrip}.
R4. Crowd-vote records intent only. castReputationVote emits
ReputationVoted for a registered provider and moves no value or score —
the aggregation is off-chain. Verified by
StargazeRegistry.t.sol::{test_CastReputationVote_HappyPath, test_CastReputationVote_RevertsWhenNotRegistered}.
R5. Verified gate is reputation AND stake. isVerified is true only if
the provider is registered, reputationScore >= VERIFIED_SCORE (800), a
non-zero stakeChecker is set, and stakeChecker.isVerifiedStake returns
true. setStakeChecker is admin-only. Verified by
StargazeRegistry.t.sol::{test_IsVerified_RequiresCheckerAndScore, test_IsVerified_RequiresRegistered, test_SetStakeChecker_OnlyAdmin} and,
on the staking side, by
StargazeStaking.t.sol::test_Registry_IsVerified_RequiresStakeAndScore.
PARKED (MPP-era) + HARD-FENCED ($GAZE token layer + slasher
economics). All S* invariants hold against the in-tree contract, but the
staking surface is shelved and the $GAZE token + slashing economics
are fenced to the Harden phase.
S1. Active stake accounting. stakedOf[providerId] and totalStaked only
move through stake (+), requestUnstake (− to unbonding),
claimUnstake (− on payout), and slash (−). All paths are
onlyProviderOwner (except slash, which is SLASHER_ROLE). A round-trip
conserves balance. Verified by
StargazeStaking.t.sol::testFuzz_StakeUnstakeRoundTrip and the
test_Stake_* happy/revert cases (BelowMinStake, ZeroAmount,
NotProviderOwner, NotRegistered).
S2. Min-stake floor. stake reverts BelowMinStake if the resulting
balance is under minStake; a partial requestUnstake must leave either
zero or at least minStake. Verified by
StargazeStaking.t.sol::{test_Stake_RevertsBelowMin, test_RequestUnstake_PartialMustLeaveMinOrZero}.
S3. Cooldown enforcement. requestUnstake removes stake from the active
balance immediately (so the Verified gate drops at once) and sets
unlockAt = block.timestamp + unstakeCooldown; a fresh request resets the
timer. claimUnstake reverts StillLocked before unlockAt and
NothingUnbonding when the queue is empty. Verified by
StargazeStaking.t.sol::{test_RequestUnstake_DropsGateImmediately, test_ClaimUnstake_RevertsWhileLocked, test_ClaimUnstake_AfterCooldown, test_ClaimUnstake_RevertsWhenNothingPending}.
S4. Slash cap and ordering. slash reverts InsufficientStake if amount > active + unbonding, drains the active balance first and then the
unbonding queue, and sends the seized amount to slashSink (reverting
SlashSinkUnset if unset). A provider cannot dodge a slash by queueing an
unstake. Verified by
StargazeStaking.t.sol::{test_Slash_FromActive, test_Slash_DrainsActiveThenUnbonding, test_Slash_RevertsOverTotal, test_Slash_RevertsWhenNotSlasher, testFuzz_SlashNeverExceedsStake}.
S5. Stake-token binding. setStakeToken is admin-only and reverts
StakeTokenLocked whenever totalStaked != 0, so the accounting can never
straddle two tokens. This is what lets the contract deploy before the
Clanker v4 launch finalises the $GAZE address. Verified by
StargazeStaking.t.sol::{test_SetStakeToken_LockedWhenStaked, test_SetStakeToken_AllowedWhenEmpty}.
S6. Verified-stake gate. isVerifiedStake(providerId) returns
stakedOf[providerId] >= verifiedStakeThreshold — only the active balance
counts, not the unbonding queue. setThresholds is admin-only. Verified by
StargazeStaking.t.sol::{test_BelowThreshold_NotVerifiedStake, test_SetThresholds_OnlyAdmin}.
PARKED (MPP-era). W* invariants hold against the in-tree contract, but the staker-rewards surface is shelved with the rest of the MPP-era trust layer.
W1. Budget invariant. commitRewardsRoot requires the contract to already
hold totalCommittedUnclaimed + allocated tokens, reverting Underfunded
otherwise — a buggy or over-eager root can never commit more than is
funded. totalCommittedUnclaimed decreases on every claim and on sweep.
Verified by StargazeStakerRewards.t.sol::{test_Commit_RevertsUnderfunded, test_BudgetInvariant_SecondEpochNeedsMoreFunding}.
W2. Commit guards. commitRewardsRoot is REWARDS_ADMIN_ROLE-only and
reverts EpochExists (duplicate), ZeroRoot, ZeroAllocation, or
RewardTokenUnset. Verified by
StargazeStakerRewards.t.sol::{test_Commit_HappyPath, test_Commit_RevertsOnDuplicateEpoch, test_Commit_RevertsOnZeroRootOrAlloc, test_Commit_RevertsWhenNotAdmin}.
W3. Merkle claim correctness. claim is permissionless, verifies the leaf
keccak256(bytes.concat(keccak256(abi.encode(account, amount)))) against
the epoch root with OpenZeppelin MerkleProof.verify (sorted-pair), pays
account, and marks hasClaimed. It reverts AlreadyClaimed,
InvalidProof (wrong amount or bad proof), UnknownEpoch, or
AlreadySwept. Funds always go to account regardless of caller. Verified
by StargazeStakerRewards.t.sol::{test_Claim_HappyPath, test_Claim_RevertsOnDoubleClaim, test_Claim_RevertsOnWrongAmount, test_Claim_RevertsOnBadProof, test_Claim_RevertsOnUnknownEpoch, test_Claim_RevertsAfterSweep}.
W4. Sweep after grace. sweepEpoch returns the unclaimed remainder to a
target after SWEEP_GRACE (90 days), is REWARDS_ADMIN_ROLE-only, and
reverts GraceNotElapsed early or AlreadySwept on a repeat. Sweeping
decrements totalCommittedUnclaimed by the remainder and blocks subsequent
claims for that epoch. Verified by
StargazeStakerRewards.t.sol::{test_Sweep_RevertsBeforeGrace, test_Sweep_AfterGraceReturnsRemainder, test_Sweep_RevertsOnDoubleSweep}.
W5. Reward-token binding. setRewardToken is admin-only and reverts
RewardTokenLocked once any epoch is committed (totalCommittedUnclaimed != 0), so distribution can never straddle two tokens. Verified by
StargazeStakerRewards.t.sol::test_SetRewardToken_LockedAfterCommit.
PARKED (MPP-era). V* invariants hold against the in-tree contract,
but PrivacyVaultRegistry is not on the attest critical path — the
attest pivot uses EAS as the registry (see @stargaze/shared/attest),
not a bespoke per-provider verifier-wiring registry.
V1. Owner-gated configure. configure, setAuditorKey, and
setBuyerKeyRotationCid all require msg.sender == Provider.owner (read
from StargazeRegistry) and a registered provider, reverting
NotProviderOwner / NotRegistered. Verified by
PrivacyVaultRegistry.t.sol::{test_Configure_RevertsOnAttacker, test_Configure_RevertsWhenProviderNotRegistered, test_SetAuditorKey_RevertsOnAttacker, test_SetBuyerKeyRotationCid_RevertsOnAttacker}.
V2. Tier whitelist. configure reverts UnknownTier unless tier is one
of keccak256("open" | "zk-aggregate" | "confidential" | "buyer-key").
Verified by PrivacyVaultRegistry.t.sol::{test_Configure_HappyPath_OpenTier, test_Configure_HappyPath_AllFourTiers, test_Configure_RevertsOnUnknownTier}.
V3. Re-configure preserves rotation state. A second configure overwrites
tier, onChainVerifier, arweaveCid, and re-activates the vault, but
preserves auditorKey and buyerKeyRotationCid, so a provider can rotate
their verifier address without re-running auditor / key-rotation setup.
Verified by
PrivacyVaultRegistry.t.sol::test_Configure_PreservesAuditorAndRotationCid.
V4. Admin-gated deactivation. deactivate is DEFAULT_ADMIN_ROLE-only
(not the provider owner) and reverts NotConfigured if already inactive.
setAuditorKey and setBuyerKeyRotationCid revert NotConfigured once a
vault is inactive; it is brought back online by a fresh owner-gated
configure. Verified by
PrivacyVaultRegistry.t.sol::{test_Deactivate_HappyPath, test_Deactivate_OnlyAdmin, test_Deactivate_RevertsWhenAlreadyInactive, test_SetAuditorKey_RevertsWhenNotActive, test_SetBuyerKeyRotationCid_RevertsWhenNotActive}.
LIVE (attest ZK predicate tier). All three verifiers back the attest
path; GeofenceVerifier is exercised end-to-end on a Base fork by
verifyGeofenceProofOnChain in @stargaze/shared. The dev-vkey caveat
in P3 still applies — the ceremony is HARD-FENCED to the Harden
phase, so no mainnet broadcasts of these verifiers today.
P1. Proof acceptance is exact. Each verifier exposes
verifyProof(uint[2] a, uint[2][2] b, uint[2] c, uint[N] pubSignals) —
the shape snarkjs.groth16.exportSolidityCallData emits. A known-good
proof verifies; a tampered public signal or a tampered proof point is
rejected. Verified by
Verifiers.t.sol::{test_AggregateSumVerifier_AcceptsKnownGoodProof, test_AggregateSumVerifier_RejectsTamperedPublicSignal, test_AggregateSumVerifier_RejectsTamperedProof, test_AggregateMeanVerifier_AcceptsKnownGoodProof, test_AggregateMeanVerifier_RejectsTamperedPublicSignal, test_GeofenceVerifier_AcceptsKnownGoodProof, test_GeofenceVerifier_RejectsExpandedBox}. The known-good vectors live in
packages/contracts-evm/test/vectors/<circuit>.json.
P2. Registry wiring is just an address. PrivacyVaultRegistry stores the
verifier as onChainVerifier (an address); the marketplace looks it up
and calls verifyProof directly. There is no on-chain proof-record /
replay PDA — the verifier is a pure view pairing check. Verified by
Verifiers.t.sol::test_BuyerCanLookUpAndCallVerifier.
P3. Dev vkeys today. All three verifiers embed dev verifying keys (header
banner in each generated .sol). Re-bake from a real ceremony before any
production deployment, and regenerate the matching test vectors in the same
change — see docs/vault-verifier-deployment.md.
| Constant | Value | Source | Risk if wrong |
|---|---|---|---|
ROUTING_FEE_BPS |
200 (2%) |
StargazeEscrow.sol |
Immutable — redeploy required to change |
BPS_DENOMINATOR |
10_000 |
StargazeEscrow.sol |
Fixed bps denominator |
VOUCHER_TYPEHASH |
EIP-712 type of Voucher(...) |
StargazeEscrow.sol |
Must match packages/shared/src/mpp/voucher.ts field order |
VERIFIED_SCORE |
800 |
StargazeRegistry.sol |
Verified reputation threshold |
MAX_REPUTATION |
1000 |
StargazeRegistry.sol |
Reputation clamp ceiling |
SWEEP_GRACE |
90 days |
StargazeStakerRewards.sol |
Shorter window returns funds sooner |
minStake / verifiedStakeThreshold / unstakeCooldown |
constructor args | StargazeStaking.sol |
Admin-settable post-deploy |
| Tier constants | keccak256("open" | "zk-aggregate" | "confidential" | "buyer-key") |
PrivacyVaultRegistry.sol |
Off-chain tier strings must hash to these |
- Dev vkeys in all three verifiers — HARD-FENCED. Generated by
vault-circuits/scripts/setup-dev.mjswith hard-coded entropy. Must be re-baked from a real multi-party ceremony before any mainnet deploy; seedocs/vault-ceremony.md. This is the live gate for the attest ZK predicate tier hitting Base mainnet. buyer-keytier — HARD-FENCED.PrivacyVaultRegistry(itself PARKED) accepts thebuyer-keytier and an arbitrary verifier address, but nobuyer-keycircuit / verifier ships in this repo. The tier is registry-stable ahead of the real circuit; both tier and registry are deferred.$GAZEtoken address — HARD-FENCED.StargazeStaking.stakeTokenandStargazeStakerRewards.rewardTokenare settable until first use; they would need to be pointed at the real$GAZEaddress before staking / reward commits could go live. The whole$GAZEtoken layer is fenced to the Harden phase.
Report vulnerabilities privately to the operator. Do not open public GitHub issues for security bugs. This is a single-party build with no external audit or bug-bounty programme before launch; the disclosure channel is direct contact with the operator.