Skip to content

Security: StargazeBASE/Stargaze

Security

SECURITY.md

Security model and invariants

Attest-pivot status

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. GeofenceVerifier is exercised end-to-end on a Base fork by verifyGeofenceProofOnChain in @stargaze/shared.
  • LIVE (reputation seam)StargazeRegistry.setReputationScore, the ORACLE_ROLE-gated function the reputation-oracle's EvmPublisher writes to publish device / operator reliability scores.
  • PARKED (MPP-era)StargazeEscrow, StargazeStaking, StargazeStakerRewards, PrivacyVaultRegistry, and the registration / crowd-vote / verified-gate paths of StargazeRegistry. 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 $GAZE token layer + slasher economics, the full multi-party trusted-setup ceremony, and the buyer-key tier (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.solPARKED (MPP-era): per-session deposit custody and batch settlement of cumulative EIP-712 vouchers in the settlement token (e.g. USDC on Base).
  • StargazeRegistry.solLIVE (reputation seam) for setReputationScore; PARKED (MPP-era) for provider registration, crowd-vote intent, and the Verified Provider gate (reputation + external IStakeChecker).
  • StargazeStaking.solPARKED (MPP-era) + HARD-FENCED ($GAZE token layer): native $GAZE staking, two-step unstake with cooldown, slash. Implements IStakeChecker.isVerifiedStake.
  • StargazeStakerRewards.solPARKED (MPP-era): pull-based Merkle reward distribution with an on-chain budget invariant and a 90-day sweep.
  • PrivacyVaultRegistry.solPARKED (MPP-era): per-provider Groth16 verifier wiring, auditor key, and buyer-key rotation config.
  • src/verifiers/AggregateSumVerifier.sol, AggregateMeanVerifier.sol, GeofenceVerifier.solLIVE (attest ZK predicate tier): snarkjs-generated Groth16 verifier contracts. GeofenceVerifier is exercised end-to-end on a Base fork by verifyGeofenceProofOnChain.
  • IStakeChecker.solPARKED (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.

Audit posture

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.

Trust model

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_ROLE can 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_ROLE can slash any provider's stake up to its total (active + unbonding) balance. The slash output is always sent to the configured slashSink. 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 GeofenceVerifier is on the attest critical path today.

Invariants

Escrow / voucher settlement (StargazeEscrow)

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.

Provider registry (StargazeRegistry)

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.

Staking + slash (StargazeStaking)

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}.

Staker rewards (StargazeStakerRewards)

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.

Vault registry (PrivacyVaultRegistry)

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}.

Groth16 verifier surface (src/verifiers/)

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.

Constants

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

Known unfinished surfaces

  • Dev vkeys in all three verifiersHARD-FENCED. Generated by vault-circuits/scripts/setup-dev.mjs with hard-coded entropy. Must be re-baked from a real multi-party ceremony before any mainnet deploy; see docs/vault-ceremony.md. This is the live gate for the attest ZK predicate tier hitting Base mainnet.
  • buyer-key tierHARD-FENCED. PrivacyVaultRegistry (itself PARKED) accepts the buyer-key tier and an arbitrary verifier address, but no buyer-key circuit / verifier ships in this repo. The tier is registry-stable ahead of the real circuit; both tier and registry are deferred.
  • $GAZE token addressHARD-FENCED. StargazeStaking.stakeToken and StargazeStakerRewards.rewardToken are settable until first use; they would need to be pointed at the real $GAZE address before staking / reward commits could go live. The whole $GAZE token layer is fenced to the Harden phase.

Reporting

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.

There aren't any published security advisories