Skip to content

feat(cli): Agent Vault standard + agent-vaults command [experimental] - #254

Draft
trevormil wants to merge 6 commits into
mainfrom
feat/agent-vaults
Draft

feat(cli): Agent Vault standard + agent-vaults command [experimental]#254
trevormil wants to merge 6 commits into
mainfrom
feat/agent-vaults

Conversation

@trevormil

Copy link
Copy Markdown
Collaborator

Summary (experimental / draft)

Adds an Agent Vault standard + a first-class agent-vaults CLI command. An
Agent Vault is a Smart Token (standards: ['Smart Token', 'Agent Vault'])
whose withdrawal approval is gated for an autonomous agent:

  • per-period spend cap (withdrawLimit + daily|weekly|monthly) → approvalCriteria.approvalAmounts (per-initiator tally + reset interval)
  • time window (unlockAt/expiresAt) → the withdraw approval's transferTimes
  • one-time multisig unlock (signers + threshold) → approvalCriteria.votingChallenges (resetAfterExecution: false)

The human is the collection manager (--manager); the agent holds the vault
tokens and withdraws within the guardrails. Ported from a reference
implementation built on the Meridian devnet.

Distinct from the existing simpler Vault standard (daily cap + 2FA +
emergency-recovery). Additive — nothing about Vault/buildVault changes.
Name is intentionally separate from the display-only AI Agent Vault tag.

What's added

  • core/builders/agent-vault.tsbuildAgentVault() (mirrors buildVault/buildSmartToken).
  • core/agent-vaults.ts — validator/detector/extractor + deposit/withdraw/pay/vote msg builders.
  • api-indexer/verify-standards.tsverifyAgentVault + registry/alias.
  • cli/commands/agent-vaults.tslist / show / status / deposit / withdraw / pay / vote (emit → bb deploy).
  • cli/commands/build.tsbb build agent-vault preset; cli/index.ts registers command + build alias.

Test plan

  • 23 new specs (builder gating, detector, msg shapes, command surface).
  • Full suite green (3087 tests); tsc --noEmit clean.
  • Manual: bb build agent-vault … | bb explain emits the expected standards + gated approvals.
  • ⏳ Not yet run: live bb … | bb simulate against a devnet — flagged for review.

⚠️ Review notes

  • N-of-M → quorum %: SDK quorumThreshold is a percent of total voter weight; mapped via floor(threshold/totalWeight*100). Confirm the intended convention.
  • Admin kill-switch (manager drain/revoke vs emergency-recovery) intentionally deferred.

🤖 Generated with Claude Code

Trevor Miller and others added 2 commits June 4, 2026 15:24
Introduces "Agent Vault" — a Smart Token whose withdrawal approval is gated
for an autonomous agent: a per-period spend cap, an optional time window, and
an optional one-time multisig "unlock" vote. The human is the collection
manager; the agent holds the vault tokens and withdraws within the guardrails.
Ported from a reference implementation built on the Meridian devnet.

Distinct from the existing simpler `Vault` standard (daily cap + 2FA +
emergency-recovery) — carries `standards: ['Smart Token', 'Agent Vault']`.

- core/builders/agent-vault.ts — buildAgentVault(): gating → approvalAmounts
  (per-period cap), transferTimes (time window), votingChallenges (multisig).
- core/agent-vaults.ts — validator/detector + deposit/withdraw/pay/vote msg builders.
- api-indexer/verify-standards.ts — verifyAgentVault + registry/alias entry.
- cli/commands/agent-vaults.ts — list/show/status/deposit/withdraw/pay/vote.
- cli/commands/build.ts — `bb build agent-vault` preset.
- cli/index.ts — register command + build alias.

Tests: 23 new specs (builder gating, detector, msg shapes, command surface);
full suite green (3087 tests); tsc clean.

REVIEW NOTE: the N-of-M → quorumThreshold(percent) mapping uses
floor(threshold/totalWeight*100); confirm this is the intended convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… transfers

Three review fixes on the experimental Agent Vault standard:

- buildAgentVaultVoteMsg emitted MsgCastVote in snake_case, but the
  `new MsgCastVote(v)` encoder behind `bb deploy` reads camelCase only —
  every field but `creator` landed undefined and `toProto()` crashed on
  `collectionId.toString()`. Switched to camelCase (matching the deposit/
  withdraw transfer builders and the wrapper class). Verified end-to-end:
  the value now encodes cleanly through encodeMsgFromJson; the old shape
  throws. Spec updated to assert camelCase + guard the regression.

- Multisig quorum: confirmed against the chain handler that quorumThreshold
  is a percent checked with floor()+GTE, so the floor() mapping is exact for
  total weight <= 100 (kept). Added a guard throwing when --threshold is
  outside [1, totalWeight] instead of silently clamping a typo'd N-of-M to
  unanimous/1%. Resolves the PR's open quorum question.

- Locked noForcefulPostMintTransfers on the vault invariants (matches
  buildSmartToken). An Agent Vault is a wallet-like token holding an agent's
  funds; with frozenPermissions this guarantees no approval can forcibly
  move the vault tokens, and clears the `bb check` CRITICAL flag.

Full unit suite green (3089); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@trevormil

Copy link
Copy Markdown
Collaborator Author

Review fixes pushed (0dc48cf)

Reviewed against vault.ts / smart-token.ts / smart-tokens.ts, the chain proto + msg_server_cast_vote.go, the bb deploy encode path, and the frontend vault form. Three fixes:

🔴 vote was broken — snake_case MsgCastVote (now camelCase)

buildAgentVaultVoteMsg emitted collection_id/approval_id/yes_weight, but the new MsgCastVote(v) encoder behind bb deploy (encodeMsgFromJsonmsgCastVote.ts) reads camelCase only. Every field but creator landed undefined and toProto() crashed on collectionId.toString(). Verified end-to-end against encodeMsgFromJson:

  • new value encodes cleanly → collectionId:42, approvalId:…, proposalId:…, yesWeight:100
  • old snake_case throws Cannot read properties of undefined (reading 'toString')

Spec updated to assert camelCase + guard the regression.

🟠 Quorum mapping — confirmed + guarded

Confirmed against msg_server_cast_vote.go:200-221: quorumThreshold is a percent of total weight, checked floor(yesWeight*100/total) >= quorumThreshold. Since the chain also floors, the existing floor(threshold/total*100) map is exact for total weight ≤ 100 (kept; 2-of-3 → 66 still correct). Added a guard that throws when --threshold is outside [1, totalWeight] rather than silently clamping a typo'd 5-of-3 to unanimous. Resolves the PR's open quorum question.

🟠 Locked forceful transfers

Set noForcefulPostMintTransfers: true on the invariants (matches buildSmartToken). An Agent Vault is a wallet-like token holding an agent's funds; with frozenPermissions() this guarantees no approval can forcibly move vault tokens, and clears the bb check CRITICAL flag.

Status: full unit suite green (3089), tsc clean. Built CLI smoke-tested: bb build agent-vault emits correct standards/cap/window/quorum/invariants; bb check parity with smart-token. ⏳ Still pending: live deposit/withdraw/pay/vote | bb deploy against a devnet (running now).

Follow-ups (not blocking, noted from review)

  • agent-vaults status hardcodes status:'active' — doesn't report real balance/remaining-cap/vote-tally.
  • Naming: agent-vault-* approvals render under the generic Smart Token FE layout (substring match), not the dedicated Vault UI (which keys on exact vault-deposit / vault-withdraw- prefixes). A dedicated FE surface is the larger follow-up.

…k MsgSend

- Add cli/integration/agent-vaults.spec.ts — full lifecycle against a live
  local chain + indexer: build/deploy/show/status/list, deposit→withdraw→pay
  on a capped vault, and the multisig path (pre-quorum withdraw rejected →
  alice+charlie cast votes → post-quorum withdraw succeeds). 12/12 green.
  This exercises the camelCase MsgCastVote fix on-chain.

- deploy --with-keyring: `bb agent-vaults pay` emits a 2-msg envelope whose
  send leg is /cosmos.bank.v1beta1.MsgSend — outside `tx tokenization`. The
  keyring command builder only mapped tokenization verbs, so pay failed to
  deploy. Added a FULL_TX_BUILDERS category (owns its module segment) wiring
  MsgSend → `tx bank send`, in both the single- and multi-msg builders, with
  unit coverage.

- Clarified buildAgentVaultPayMsgs docstring: atomicity holds only on the
  single-tx browser/burner paths; --with-keyring chains two sequential txs.

Full unit suite green (3090); tsc clean; agent-vaults e2e 12/12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@trevormil

Copy link
Copy Markdown
Collaborator Author

✅ E2E verified against a live local chain + indexer (5dafc06)

Added cli/integration/agent-vaults.spec.ts mirroring the smart-tokens integration harness. 12/12 green against a running devnet:

Capped vault: build → deploy → show/status/listdeposit (mints vault tokens) → withdraw (within cap) → pay (gated withdraw + bank send to a recipient; charlie's USDC balance verified to increase) → conformance throw.

Multisig vault (2-of-2): deploy + deposit → pre-quorum withdraw is rejected on-chain (voting challenge threshold not met: got 0%, need 100%) → alice + charlie vote (MsgCastVote, code 0) → post-quorum withdraw succeeds (sticky one-time unlock). This exercises the camelCase MsgCastVote fix on real chain state.

One more consistency fix found via e2e

bb agent-vaults pay emits a 2-msg envelope whose send leg is /cosmos.bank.v1beta1.MsgSend — outside tx tokenization. bb deploy --with-keyring only mapped tokenization verbs, so pay couldn't deploy. Added a FULL_TX_BUILDERS category to the keyring command builder (owns its module segment) mapping MsgSend → tx bank send, in both single- and multi-msg paths, with unit coverage. Also clarified the pay atomicity docstring (single-tx only on browser/burner; --with-keyring chains two sequential txs).

Acceptance status

  • ✅ Quorum convention confirmed (percent + GTE, floor exact ≤100 weight) + range guard added
  • ✅ Live deploy/lifecycle e2e run (supersedes the bb … | bb simulate item — txs actually broadcast, code 0)
  • ⏳ Admin kill-switch — still intentionally deferred (open decision)
  • ⏳ Merge — left for your approval

Full unit suite green (3090), tsc clean.

trevormil and others added 2 commits June 6, 2026 12:41
…ue proposalId

status:
- Replace the hardcoded `status:'active'` with live on-chain state: backing
  TVL (best-effort /swap/balances), the per-period cap, time-window state
  (always-open|before-unlock|open|expired), the multisig tally (quorum %,
  weighted yes, quorumMet) read from /vote, and a derived `withdrawable`
  boolean + status string (withdrawable|locked-until-unlock|expired|
  locked-pending-multisig). Each live fetch degrades gracefully.

proposalId uniqueness (bug fix):
- The multisig withdraw proposalId was a constant, but the indexer keys
  VoteDocs by the bare proposalId (handleMsgCastVote `_docId = proposalId`),
  so two multisig vaults would collide on one vote doc. Now hashed per vault
  params via stableHashId (matches bounty.ts), sharing one withdrawSeed with
  the withdraw approvalId. Renamed the constant → AGENT_VAULT_WITHDRAW_PROPOSAL_PREFIX.
  NOTE: a deeper fix belongs in the indexer (key votes by collectionId+proposalId)
  — affects all votingChallenge standards; documented for follow-up.

e2e: agent-vaults.spec.ts asserts the real status (capped vault withdrawable;
multisig vault locked-pending-multisig → withdrawable after quorum). 12/12 green.
Unit suite green (3091); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n recovery

Adds an optional admin kill-switch, off by default. `bb build agent-vault
--recovery <addr>` bakes two approvals that let the human/recovery address
FULLY exit the vault at any time, bypassing the cap / time-window / multisig:
  1. agent-vault-emergency-freeze — forceful clawback of vault tokens from any
     holder (the agent) to recovery (overridesFrom/To; from excludes the
     backing alias per the reserved-protocol-address rule).
  2. agent-vault-emergency-exit — an ungated, recovery-only withdraw lane that
     unbacks to the backing coin.

- noForcefulPostMintTransfers is now conditional: true (fully locked) when no
  recovery is configured, false only when the kill-switch needs the forceful
  freeze. (Reverts the earlier unconditional lock, which both blocked any
  kill-switch and was redundant — frozenPermissions already downgrades the
  bb check finding to info.)
- New `bb agent-vaults recover <id> --creator <recovery> --from <holder>
  --amount <n>` emits {messages:[freeze, exit]} → `bb deploy`.
- extractAgentVaultDetails surfaces the recovery approvals; buildAgentVaultRecoverMsgs
  builds the pair (throws if the vault has no kill-switch).

Tests: builder (approvals + invariant flip + standards-compliance), core
(extractor + recover msgs), CLI shape. e2e: a time-locked vault blocks the
agent's withdraw, then recovery claws back + drains to the coin, bypassing the
lock (charlie +5 USDC, alice tokens → 0). 15/15 e2e green; unit 3098; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@trevormil

Copy link
Copy Markdown
Collaborator Author

Real status, admin kill-switch, + a proposalId fix (e1bb795)

status is now live state

bb agent-vaults status replaces the hardcoded 'active' with: backing TVL (best-effort /swap/balances), the per-period cap, time-window state (always-open|before-unlock|open|expired), the live multisig tally from /vote (quorum %, weighted yes, quorumMet), and a derived withdrawable + status string. e2e asserts the capped vault as withdrawable and the multisig vault transitioning locked-pending-multisig → withdrawable across the vote.

Admin kill-switch (opt-in --recovery <addr>)

Per the design call, this is the full-drain model: two baked approvals let the human/recovery address claw back vault tokens from the agent and fully exit to the backing coin at any time, bypassing the cap/time/multisig:

  • agent-vault-emergency-freeze — forceful clawback (holder→recovery), from excludes the backing alias (ticket-0436 reserved-address rule).
  • agent-vault-emergency-exit — ungated, recovery-only unback lane.

Off by default. noForcefulPostMintTransfers is now conditionaltrue (fully locked) without recovery, false only when the kill-switch needs the forceful freeze. This reverts the earlier unconditional lock, which both blocked any kill-switch and was redundant (frozen permissions already downgrade the bb check finding to info). New bb agent-vaults recover command emits {messages:[freeze, exit]}. e2e proves it: a time-locked vault blocks the agent's withdraw, then recovery drains it (+5 USDC to recovery, agent tokens → 0).

This resolves the deferred "admin kill-switch" acceptance item, and mirrors buildVault's --emergency-recovery (opt-in) but stronger (ungated exit, so a stuck multisig can't trap funds).

Bug fixed: multisig proposalId collision

The withdraw proposalId was a constant, but the indexer keys VoteDocs by the bare proposalId (handleMsgCastVote: _docId = proposalId), so two multisig vaults would collide on one vote doc. Now hashed per-vault via stableHashId (matches bounty.ts).

⚠️ Indexer follow-up (separate repo): the real fix is for the indexer to key votes by (collectionId, proposalId) — the chain already scopes by collectionId (ConstructVotingTrackerKey), but the indexer doesn't. This affects all votingChallenge standards (bounty/PM too). I can file/fix that next if you want.

Status

Unit suite 3098 green; tsc clean; e2e 15/15 against a live local chain+indexer (deposit/withdraw/pay/vote/recover + status transitions + kill-switch drain). Still awaiting your merge approval.

…transparency

Adversarial review follow-ups:

- vote: stop falling back to a constant proposalId. The on-chain proposalId is
  a per-vault hash, so the bare prefix is never real — a fallback vote would
  silently miss the proposal and never advance quorum. Now throws if no
  multisig proposal can be resolved.
- verifyAgentVault: brought to parity with the consumer validator — now also
  requires both a deposit AND a withdraw approval (rejects deposit-only fund
  traps), validTokenIds == [{1,1}], and kill-switch consistency (any forceful
  approval must be admin-scoped, never initiatedBy "All", and requires
  noForcefulPostMintTransfers=false).
- recover: --creator not matching the vault's recovery address is now a hard
  error (exit 2), not a warning — it would always be rejected on-chain.
- builder guards: throw on duplicate --signers (inflated quorum weight) and on
  --unlock-at >= --expires-at (permanently-locked window).
- transparency: `show` and `status` now surface the kill-switch `recovery`
  address so depositors can see whether an admin can claw back + drain.
- docs: corrected `pay` description (atomicity is single-tx only).

Tests: +5 unit (guards, verifier negatives, vote throw) → 3103 green; e2e adds
show/status recovery assertions → 15/15. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@trevormil

Copy link
Copy Markdown
Collaborator Author

Hardening pass (c930d8c) — adversarial review follow-ups

Ran a fresh adversarial review of the full feature. Fixes:

  • vote no longer falls back to a constant proposalId. The real proposalId is a per-vault hash, so the bare prefix is never on-chain — a fallback vote would silently miss the proposal and never reach quorum. Now throws if it can't resolve a multisig proposal.
  • verifyAgentVault brought to parity with the consumer validator — it was materially weaker. Now also requires both a deposit AND a withdraw approval (rejects deposit-only fund traps), validTokenIds == [{1,1}], and kill-switch consistency (forceful approvals must be admin-scoped — never initiatedBy: All — and require noForcefulPostMintTransfers: false).
  • recover mismatch is now a hard error (exit 2), not a warning — a non-recovery --creator would always be chain-rejected.
  • Builder guards: throw on duplicate --signers (inflated quorum weight → unreachable) and on --unlock-at >= --expires-at (permanently-locked window).
  • Kill-switch transparency: show and status now surface the recovery address so depositors can see whether an admin can claw back + drain (the mitigation for "open deposit + powerful recovery").
  • Corrected the pay description (atomic only as a single tx).

Reviewed-and-OK (not changed): quorum floor() mapping + range guard, proposalId hashing, status field reads, deposit/withdraw detection vs the emergency ids, the !Mint:<alias> freeze source, the conditional noForcefulPostMintTransfers.

Tests: unit 3103 green (+5: guards, verifier negatives, vote-throw); e2e 15/15 (adds show/status recovery assertions). tsc clean.

Net state of PR #254: review + 5 rounds of fixes, full e2e against a live chain+indexer. The one thing I'd still call out as a separate-repo follow-up: the indexer keys VoteDocs by bare proposalId — it should be (collectionId, proposalId) — which affects all votingChallenge standards (bounty/PM too). Happy to take that next. Otherwise this is ready for your merge call.

@trevormil

Copy link
Copy Markdown
Collaborator Author

Follow-up: the indexer-side vote-keying bug this surfaced is now fixed in bitbadges-indexer PR #185 — VoteDocs are keyed by a composite id (collectionId+approverAddress+approvalLevel+approvalId+proposalId) instead of the bare proposalId, with readers switched to field queries (no migration). That removes the cross-collection collision for all votingChallenge standards. The SDK-side per-vault proposalId hashing here stays as defense-in-depth. Re-ran this PR's agent-vaults e2e against the patched indexer: 15/15 green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant