diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/verify-standards.ts b/packages/bitbadgesjs-sdk/src/api-indexer/verify-standards.ts index c13d81ac8c..ffd0e20f3d 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/verify-standards.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/verify-standards.ts @@ -993,6 +993,79 @@ function verifyVault(value: any): StandardViolation[] { return violations; } +// ============================================================ +// Agent Vault Validator +// ============================================================ + +function verifyAgentVault(value: any): StandardViolation[] { + const violations: StandardViolation[] = []; + const std = 'Agent Vault'; + const standards: string[] = value?.standards ?? []; + const approvals = getApprovals(value); + const invariants = getInvariants(value); + const isTrue = (v: any) => v === true || v === 'true'; + + // Agent Vaults are Smart Tokens — the base tag must be present too. + if (!standards.includes('Smart Token')) { + violations.push({ standard: std, field: 'standards', message: 'Agent Vault collections MUST also carry the "Smart Token" standard.' }); + } + + // Must have the IBC backing path. + if (!invariants.cosmosCoinBackedPath) { + violations.push({ standard: std, field: 'invariants.cosmosCoinBackedPath', message: 'Agent Vault collections MUST have a cosmosCoinBackedPath defining the IBC backing.' }); + } + + // validTokenIds must be exactly [{1,1}] (kept in lockstep with the consumer + // validator in core/agent-vaults.ts). + const vt = value?.validTokenIds ?? []; + const vtOk = vt.length === 1 && String(vt[0]?.start) === '1' && String(vt[0]?.end) === '1'; + if (!vtOk) { + violations.push({ standard: std, field: 'validTokenIds', message: 'Agent Vault validTokenIds MUST be exactly [{start: 1, end: 1}].' }); + } + + // Must have BOTH a deposit and a withdraw approval — a deposit-only vault is + // a fund trap (you can mint but never release the backing coin). Match by id + // substring, the same heuristic as findDepositApproval/findWithdrawApproval. + const idOf = (a: any) => String(a.approvalId ?? '').toLowerCase(); + const hasDeposit = approvals.some((a: any) => idOf(a).includes('deposit') || (idOf(a).includes('back') && !idOf(a).includes('unback'))); + const hasWithdraw = approvals.some((a: any) => idOf(a).includes('withdraw') || idOf(a).includes('unback')); + if (!hasDeposit) { + violations.push({ standard: std, field: 'collectionApprovals', message: 'Agent Vault MUST have a deposit approval (approvalId containing "deposit" or "back").' }); + } + if (!hasWithdraw) { + violations.push({ standard: std, field: 'collectionApprovals', message: 'Agent Vault MUST have a withdraw approval (approvalId containing "withdraw" or "unback").' }); + } + + // Backing approvals (allowBackedMinting) must be mustPrioritize'd. + const backingApprovals = approvals.filter((a: any) => isTrue(a.approvalCriteria?.allowBackedMinting)); + if (backingApprovals.length === 0) { + violations.push({ standard: std, field: 'collectionApprovals', message: 'Agent Vault MUST have at least one approval with allowBackedMinting: true.' }); + } + for (const ba of backingApprovals) { + if (!isTrue(ba.approvalCriteria?.mustPrioritize)) { + violations.push({ standard: std, field: `collectionApprovals[${ba.approvalId}].mustPrioritize`, message: `Agent Vault backing approval "${ba.approvalId}" MUST have mustPrioritize: true.` }); + } + } + + // Admin kill-switch consistency: any forceful approval (overridesFrom/To with + // a non-Mint source) requires noForcefulPostMintTransfers === false (else the + // chain rejects creation) AND must be admin-scoped — never initiatedBy "All" + // (that would let anyone forcibly seize vault tokens). + const forceful = approvals.filter( + (a: any) => isTrue(a.approvalCriteria?.overridesFromOutgoingApprovals) || isTrue(a.approvalCriteria?.overridesToIncomingApprovals) + ); + if (forceful.length > 0 && isTrue(invariants.noForcefulPostMintTransfers)) { + violations.push({ standard: std, field: 'invariants.noForcefulPostMintTransfers', message: 'Agent Vault has a forceful (override) approval but noForcefulPostMintTransfers is true — the chain will reject this; set it to false.' }); + } + for (const fa of forceful) { + if (String(fa.initiatedByListId ?? '') === 'All') { + violations.push({ standard: std, field: `collectionApprovals[${fa.approvalId}].initiatedByListId`, message: `Agent Vault forceful approval "${fa.approvalId}" MUST be admin-scoped (initiatedByListId cannot be "All").` }); + } + } + + return violations; +} + // ============================================================ // Standard → Validator Map // ============================================================ @@ -1014,7 +1087,8 @@ const STANDARD_VALIDATORS: Record StandardViolation[]> = Auction: verifyAuction, Products: verifyProducts, 'Prediction Market': verifyPredictionMarket, - Vault: verifyVault + Vault: verifyVault, + 'Agent Vault': verifyAgentVault }; // Also match common alternative names @@ -1045,7 +1119,8 @@ const STANDARD_ALIASES: Record = { Products: 'Products', 'Product Catalog': 'Products', 'Prediction Market': 'Prediction Market', - Vault: 'Vault' + Vault: 'Vault', + 'Agent Vault': 'Agent Vault' }; // ============================================================ diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/agent-vaults.spec.ts b/packages/bitbadgesjs-sdk/src/cli/commands/agent-vaults.spec.ts new file mode 100644 index 0000000000..6eb8ec29f3 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/commands/agent-vaults.spec.ts @@ -0,0 +1,50 @@ +/** + * Command-tree shape tests for agent-vaults.ts. Helpers/builders are exercised + * in core/agent-vaults.spec.ts + core/builders/agent-vault.spec.ts; this spec + * guards the CLI surface against accidental flag/subcommand drift. + * + * Note: the `build` alias is wired in cli/index.ts (makeBuildAlias), not in this + * command file, so it is intentionally absent here. + */ + +import { agentVaultsCommand } from './agent-vaults.js'; + +describe('agentVaultsCommand shape', () => { + it('exposes the documented subcommand verbs', () => { + const names = agentVaultsCommand.commands.map((c) => c.name()).sort(); + expect(names).toEqual(['deposit', 'list', 'pay', 'recover', 'show', 'status', 'vote', 'withdraw']); + }); + + it('recover requires --creator, --from, --amount', () => { + const cmd = agentVaultsCommand.commands.find((c) => c.name() === 'recover')!; + const required = (cmd as any).options.filter((o: any) => o.required).map((o: any) => o.long); + for (const f of ['--creator', '--from', '--amount']) expect(required).toContain(f); + }); + + it('deposit + withdraw require --creator and --amount', () => { + for (const verb of ['deposit', 'withdraw']) { + const cmd = agentVaultsCommand.commands.find((c) => c.name() === verb)!; + const required = (cmd as any).options.filter((o: any) => o.required).map((o: any) => o.long); + for (const f of ['--creator', '--amount']) expect(required).toContain(f); + } + }); + + it('pay requires --creator, --amount, --to', () => { + const cmd = agentVaultsCommand.commands.find((c) => c.name() === 'pay')!; + const required = (cmd as any).options.filter((o: any) => o.required).map((o: any) => o.long); + for (const f of ['--creator', '--amount', '--to']) expect(required).toContain(f); + }); + + it('vote requires --creator', () => { + const cmd = agentVaultsCommand.commands.find((c) => c.name() === 'vote')!; + const required = (cmd as any).options.filter((o: any) => o.required).map((o: any) => o.long); + expect(required).toContain('--creator'); + }); + + it('every subcommand takes as the first positional', () => { + for (const verb of ['show', 'status', 'deposit', 'withdraw', 'pay', 'recover', 'vote']) { + const c = agentVaultsCommand.commands.find((cmd) => cmd.name() === verb)!; + expect((c as any)._args[0].name()).toBe('collection-id'); + } + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/agent-vaults.ts b/packages/bitbadgesjs-sdk/src/cli/commands/agent-vaults.ts new file mode 100644 index 0000000000..fc352bbf36 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/commands/agent-vaults.ts @@ -0,0 +1,467 @@ +/** + * `bitbadges-cli agent-vaults` — end-user surface for the Agent Vault standard. + * + * An Agent Vault is a Smart Token (`standards: ['Smart Token','Agent Vault']`) + * whose withdrawal is gated for an autonomous agent: a per-period spend cap, an + * optional time window, and/or a one-time multisig "unlock" vote. The human is + * the manager; the agent holds the vault tokens and withdraws within the gating. + * + * Subcommands: + * list Browse Agent Vault collections + * show Backing address/denom, approval ids, parsed gating + * status Compact status summary + * deposit Emit MsgTransferTokens to fund the vault (mint tokens) + * withdraw Emit MsgTransferTokens to withdraw (burn tokens), gated + * pay Emit {messages:[withdraw, bank send]} — atomic vault→recipient + * vote Emit MsgCastVote toward the multisig withdrawal unlock + * + * Build new via `bb build agent-vault`. + */ + +import { Command } from 'commander'; +import { + addIndexerNetworkOptions as addNetworkFlags, + addIndexerOutputOptions as addOutputFlags, + callIndexer as callApi, + emitIndexerResult as emit, + emitIndexerError as emitError, + type IndexerNetworkFlags as NetworkFlags, + type IndexerOutputFlags as OutputFlags +} from '../utils/indexer-options.js'; +import { requireBb1AddressStrict } from '../utils/address.js'; +import { addDeployOptions, runEmitOrDeploy } from '../utils/deploy-options.js'; +import { normalizeCollection, validateCollectionOrExit } from '../utils/collection-options.js'; +import { + doesCollectionFollowAgentVaultProtocol, + validateAgentVaultCollection, + extractAgentVaultDetails, + buildAgentVaultDepositMsg, + buildAgentVaultWithdrawMsg, + buildAgentVaultPayMsgs, + buildAgentVaultRecoverMsgs, + buildAgentVaultVoteMsg, + type AgentVaultDetails +} from '../../core/agent-vaults.js'; +import { resolveCoin, toBaseUnits } from '../../core/builders/shared.js'; + +async function fetchCollection(collectionId: string, opts: NetworkFlags): Promise { + return normalizeCollection(await callApi('GET', `/collection/${encodeURIComponent(collectionId)}`, opts)); +} + +function validateOrExit(collection: any, ctx: string): void { + validateCollectionOrExit(collection, ctx, validateAgentVaultCollection, 'Agent Vault'); +} + +/** + * Resolve `--amount` into base units of the backing coin (display units by + * default; `--base-units` for a raw integer passthrough). Mirrors the + * smart-tokens helper: the denom is collection-derived + canonical. + */ +function resolveBackingAmount(rawAmount: string, baseUnits: boolean, backingDenom: string): string { + if (baseUnits) { + const a = String(rawAmount).replace(/[_,]/g, ''); + if (!/^\d+$/.test(a)) { + process.stderr.write(`Error: --amount must be a non-negative integer when --base-units is set, got "${rawAmount}"\n`); + process.exit(2); + } + return a; + } + const resolved = resolveCoin(backingDenom); + return toBaseUnits(Number(rawAmount), resolved.decimals); +} + +/** The chain id used by /swap/balances + vote scoping for the current network. */ +function chainIdFor(opts: NetworkFlags): string { + return opts.testnet ? 'bitbadges-2' : 'bitbadges-1'; +} + +/** + * Backing alias' on-chain balance of the backing denom (base units, the vault's + * TVL). Best-effort: returns null if the balance route is unavailable (e.g. a + * local devnet without the swap balance providers). + */ +async function fetchBackingBalance(details: AgentVaultDetails, opts: NetworkFlags): Promise { + try { + const chainId = chainIdFor(opts); + const res = await callApi('POST', '/swap/balances', opts, { chains: { [chainId]: [details.backingAddress] } }); + const rows: any[] = res?.balances?.[chainId]?.[details.backingAddress] ?? []; + const row = rows.find((r: any) => r.denom === details.backingDenom); + return row ? String(row.amount) : '0'; + } catch { + return null; + } +} + +/** Compute the time-window state from the parsed gating + now. */ +function timeWindowState(details: AgentVaultDetails, nowMs: number): { + state: 'always-open' | 'before-unlock' | 'open' | 'expired'; + unlockAt: string | null; + expiresAt: string | null; +} { + const tw = details.gating.timeWindow; + if (!tw) return { state: 'always-open', unlockAt: null, expiresAt: null }; + const start = Number(tw.unlockAt); + const end = Number(tw.expiresAt); + const state = nowMs < start ? 'before-unlock' : nowMs > end ? 'expired' : 'open'; + return { state, unlockAt: tw.unlockAt, expiresAt: tw.expiresAt }; +} + +/** + * Live multisig tally for the withdraw proposal. Returns the configured quorum + * + current weighted yes total + whether quorum is met (mirrors the chain's + * `floor(yesWeight*100/totalWeight) >= quorumThreshold`). Best-effort: an + * un-voted proposal has no vote doc yet (404) → 0 yes, quorum not met. + */ +async function fetchMultisigState(details: AgentVaultDetails, opts: NetworkFlags): Promise<{ + quorumThreshold: string; + totalPossibleWeight: string; + totalYesWeight: string; + yesPercent: number; + quorumMet: boolean; + votesCast: number; + voters: number; +} | null> { + const ms = details.gating.multisig; + if (!ms) return null; + const totalPossibleWeight = ms.voters.reduce((n, v) => n + Number(v.weight || '1'), 0); + let totalYesWeight = 0; + let votesCast = 0; + try { + const res = await callApi('GET', `/vote/${encodeURIComponent(ms.proposalId)}`, opts); + const v = res?.vote; + if (v) { + totalYesWeight = Number(v.totalYesWeight ?? '0'); + votesCast = Array.isArray(v.votes) ? v.votes.length : 0; + } + } catch { + // No vote doc yet (nobody has voted) — leave totals at 0. + } + const yesPercent = totalPossibleWeight > 0 ? Math.floor((totalYesWeight * 100) / totalPossibleWeight) : 0; + return { + quorumThreshold: ms.quorumThreshold, + totalPossibleWeight: String(totalPossibleWeight), + totalYesWeight: String(totalYesWeight), + yesPercent, + quorumMet: yesPercent >= Number(ms.quorumThreshold), + votesCast, + voters: ms.voters.length + }; +} + +// ── agent-vaults (parent) ───────────────────────────────────────────────────── + +export const agentVaultsCommand = new Command('agent-vaults').description( + 'End-user surface for the Agent Vault standard — list / show / status / deposit / withdraw / pay / vote. ' + + 'An Agent Vault is a Smart Token with a gated withdrawal (per-period cap, time window, multisig unlock). Build new via `bb build agent-vault`.' +); + +// ── agent-vaults list ────────────────────────────────────────────────────────── + +addOutputFlags( + addNetworkFlags( + agentVaultsCommand + .command('list') + .description('Browse Agent Vault collections (Smart Tokens carrying the "Agent Vault" standard).') + ) +).action(async (opts: NetworkFlags & OutputFlags) => { + try { + // Agent Vaults are Smart Tokens; browse that category then filter by the + // Agent Vault conformance validator (robust regardless of indexer category). + const res = await callApi('POST', '/browse', opts, { type: 'collections', category: 'smart-token' }); + const all: any[] = res?.collections?.['smart-token'] ?? res?.collections ?? []; + const collections = all.filter((c: any) => doesCollectionFollowAgentVaultProtocol(c)); + const summary = collections.map((c: any) => { + const d = extractAgentVaultDetails(c)!; + return { + collectionId: String(c.collectionId ?? c._docId ?? ''), + backingAddress: d.backingAddress, + backingDenom: d.backingDenom, + gating: d.gating + }; + }); + emit(summary, opts); + } catch (err) { + emitError(err); + } +}); + +// ── agent-vaults show / status ───────────────────────────────────────────────── + +addOutputFlags( + addNetworkFlags( + agentVaultsCommand + .command('show') + .description('Render an Agent Vault — backing address/denom, deposit/withdraw approval ids, and parsed gating.') + .argument('', 'Agent Vault collection ID') + ) +).action(async (collectionId: string, opts: NetworkFlags & OutputFlags) => { + try { + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'agent-vaults show'); + const d = extractAgentVaultDetails(collection)!; + emit( + { + collectionId: String(collectionId), + backingAddress: d.backingAddress, + backingDenom: d.backingDenom, + depositApprovalId: d.depositApproval.approvalId, + withdrawApprovalId: d.withdrawApproval.approvalId, + gating: d.gating, + // Surface the admin kill-switch so depositors can see whether a recovery + // address can claw back + drain the vault (null = no kill-switch). + recovery: d.recovery?.address ?? null, + standards: collection.standards + }, + opts + ); + } catch (err) { + emitError(err); + } +}); + +addOutputFlags( + addNetworkFlags( + agentVaultsCommand + .command('status') + .description( + 'Live status — backing TVL, the per-period cap, time-window state, the current multisig tally, ' + + 'and whether the vault is withdrawable right now.' + ) + .argument('', 'Agent Vault collection ID') + ) +).action(async (collectionId: string, opts: NetworkFlags & OutputFlags) => { + try { + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'agent-vaults status'); + const d = extractAgentVaultDetails(collection)!; + + // Live state (best-effort; each degrades to null/0 if its route is down). + const [backingBalance, multisig] = await Promise.all([ + fetchBackingBalance(d, opts), + fetchMultisigState(d, opts) + ]); + const time = timeWindowState(d, Date.now()); + + // Withdrawable right now = the time window is open AND (no multisig OR + // quorum already reached). The per-period cap bounds the AMOUNT, not + // whether a withdraw is possible at all, so it doesn't gate this flag. + const timeOpen = time.state === 'always-open' || time.state === 'open'; + const multisigOpen = !multisig || multisig.quorumMet; + const withdrawable = timeOpen && multisigOpen; + const status = withdrawable + ? 'withdrawable' + : time.state === 'before-unlock' + ? 'locked-until-unlock' + : time.state === 'expired' + ? 'expired' + : 'locked-pending-multisig'; + + emit( + { + collectionId: String(collectionId), + backingDenom: d.backingDenom, + backingBalance, // base units held by the backing alias (TVL); null if unavailable + cap: d.gating.cap ?? null, + timeWindow: time, + multisig, + recovery: d.recovery?.address ?? null, // kill-switch admin (null = none) + withdrawable, + status + }, + opts + ); + } catch (err) { + emitError(err); + } +}); + +// ── agent-vaults deposit ──────────────────────────────────────────────────────── + +addDeployOptions( + addOutputFlags( + addNetworkFlags( + agentVaultsCommand + .command('deposit') + .description('Emit MsgTransferTokens to fund the vault — mint agent-vault tokens in exchange for the backing coin.') + .argument('', 'Agent Vault collection ID') + .requiredOption('--creator
', 'Caller address (bb1.../0x... auto-normalized) — receives the minted vault tokens') + .requiredOption('--amount ', 'Amount to deposit. Display units of the backing coin; use --base-units for raw base units.') + .option('--base-units', 'Treat --amount as already-in-base-units') + ) + ) +).action( + async (collectionId: string, opts: NetworkFlags & OutputFlags & { creator: string; amount: string; baseUnits?: boolean }) => { + try { + const creator = requireBb1AddressStrict(opts.creator, '--creator'); + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'agent-vaults deposit'); + const details = extractAgentVaultDetails(collection)!; + const amount = resolveBackingAmount(opts.amount, !!opts.baseUnits, details.backingDenom); + const msg = buildAgentVaultDepositMsg({ creator, collectionId: String(collectionId), amount, details }); + await runEmitOrDeploy(msg, opts, { emit: (m) => emit(m, opts), expectedAddress: creator }); + } catch (err) { + emitError(err); + } + } +).addHelpText('after', ` +Examples: + $ bb agent-vaults deposit 42 --creator bb1user...xyz --amount 100 | bb deploy +`); + +// ── agent-vaults withdraw ───────────────────────────────────────────────────────── + +addDeployOptions( + addOutputFlags( + addNetworkFlags( + agentVaultsCommand + .command('withdraw') + .description('Emit MsgTransferTokens to withdraw — burn agent-vault tokens to release the backing coin (subject to the vault gating).') + .argument('', 'Agent Vault collection ID') + .requiredOption('--creator
', 'Caller address (the agent holding the vault tokens)') + .requiredOption('--amount ', 'Amount to withdraw. Display units; use --base-units for raw base units.') + .option('--base-units', 'Treat --amount as already-in-base-units') + ) + ) +).action( + async (collectionId: string, opts: NetworkFlags & OutputFlags & { creator: string; amount: string; baseUnits?: boolean }) => { + try { + const creator = requireBb1AddressStrict(opts.creator, '--creator'); + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'agent-vaults withdraw'); + const details = extractAgentVaultDetails(collection)!; + const amount = resolveBackingAmount(opts.amount, !!opts.baseUnits, details.backingDenom); + const msg = buildAgentVaultWithdrawMsg({ creator, collectionId: String(collectionId), amount, details }); + await runEmitOrDeploy(msg, opts, { emit: (m) => emit(m, opts), expectedAddress: creator }); + } catch (err) { + emitError(err); + } + } +).addHelpText('after', ` +Examples: + $ bb agent-vaults withdraw 42 --creator bb1agent...xyz --amount 5 | bb deploy +`); + +// ── agent-vaults pay ────────────────────────────────────────────────────────────── + +addOutputFlags( + addNetworkFlags( + agentVaultsCommand + .command('pay') + .description( + 'Emit {messages:[MsgTransferTokens(withdraw), bank MsgSend]} — withdraw (gated) then send the released backing coin to a recipient. ' + + 'Atomic only when broadcast as a single tx (bb deploy --browser/--burner); via --with-keyring the two legs run sequentially. Pipe to `bb deploy`.' + ) + .argument('', 'Agent Vault collection ID') + .requiredOption('--creator
', 'Caller address (the agent holding the vault tokens)') + .requiredOption('--amount ', 'Amount to pay. Display units; use --base-units for raw base units.') + .requiredOption('--to
', 'Recipient address (bb1...) of the released backing coin') + .option('--base-units', 'Treat --amount as already-in-base-units') + ) +).action( + async ( + collectionId: string, + opts: NetworkFlags & OutputFlags & { creator: string; amount: string; to: string; baseUnits?: boolean } + ) => { + try { + const creator = requireBb1AddressStrict(opts.creator, '--creator'); + const to = requireBb1AddressStrict(opts.to, '--to'); + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'agent-vaults pay'); + const details = extractAgentVaultDetails(collection)!; + const amount = resolveBackingAmount(opts.amount, !!opts.baseUnits, details.backingDenom); + const messages = buildAgentVaultPayMsgs({ creator, collectionId: String(collectionId), amount, details, to }); + emit({ messages }, opts); + } catch (err) { + emitError(err); + } + } +).addHelpText('after', ` +Examples: + $ bb agent-vaults pay 42 --creator bb1agent...xyz --amount 5 --to bb1vendor...abc | bb deploy +`); + +// ── agent-vaults recover ──────────────────────────────────────────────────────── + +addOutputFlags( + addNetworkFlags( + agentVaultsCommand + .command('recover') + .description( + 'Admin kill-switch — emit {messages:[freeze, exit]} that force-claws-back vault tokens from a holder ' + + '(the agent) to the recovery address and withdraws the backing coin, bypassing the cap/time/multisig gating. ' + + 'Only for vaults built with --recovery. Pipe to `bb deploy`.' + ) + .argument('', 'Agent Vault collection ID') + .requiredOption('--creator
', 'Recovery address (the configured kill-switch admin)') + .requiredOption('--from
', 'Holder to claw back from (typically the agent)') + .requiredOption('--amount ', 'Amount to recover. Display units; use --base-units for raw base units.') + .option('--base-units', 'Treat --amount as already-in-base-units') + ) +).action( + async ( + collectionId: string, + opts: NetworkFlags & OutputFlags & { creator: string; from: string; amount: string; baseUnits?: boolean } + ) => { + try { + const creator = requireBb1AddressStrict(opts.creator, '--creator'); + const from = requireBb1AddressStrict(opts.from, '--from'); + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'agent-vaults recover'); + const details = extractAgentVaultDetails(collection)!; + if (!details.recovery) { + process.stderr.write('Error: this Agent Vault has no admin kill-switch (built without --recovery).\n'); + process.exit(2); + } + if (creator !== details.recovery.address) { + process.stderr.write( + `Error: --creator ${creator} is not the vault's recovery address (${details.recovery.address}). ` + + 'Only the recovery address can invoke the kill-switch; the chain would reject this.\n' + ); + process.exit(2); + } + const amount = resolveBackingAmount(opts.amount, !!opts.baseUnits, details.backingDenom); + const messages = buildAgentVaultRecoverMsgs({ creator, collectionId: String(collectionId), from, amount, details }); + emit({ messages }, opts); + } catch (err) { + emitError(err); + } + } +).addHelpText('after', ` +Examples: + $ bb agent-vaults recover 42 --creator bb1recovery...xyz --from bb1agent...abc --amount 100 | bb deploy +`); + +// ── agent-vaults vote ───────────────────────────────────────────────────────────── + +addDeployOptions( + addOutputFlags( + addNetworkFlags( + agentVaultsCommand + .command('vote') + .description('Emit MsgCastVote toward the vault\'s multisig withdrawal unlock. Only meaningful for vaults built with --signers.') + .argument('', 'Agent Vault collection ID') + .requiredOption('--creator
', 'Voter address (a configured signer)') + .option('--yes-weight ', 'Yes vote as a 0–100 percent of this voter\'s weight (default 100)', '100') + ) + ) +).action( + async (collectionId: string, opts: NetworkFlags & OutputFlags & { creator: string; yesWeight?: string }) => { + try { + const creator = requireBb1AddressStrict(opts.creator, '--creator'); + const collection = await fetchCollection(collectionId, opts); + validateOrExit(collection, 'agent-vaults vote'); + const details = extractAgentVaultDetails(collection)!; + if (!details.gating.multisig) { + process.stderr.write('Error: this Agent Vault has no multisig gating — there is nothing to vote on.\n'); + process.exit(2); + } + const msg = buildAgentVaultVoteMsg({ creator, collectionId: String(collectionId), details, yesWeight: opts.yesWeight }); + await runEmitOrDeploy(msg, opts, { emit: (m) => emit(m, opts), expectedAddress: creator }); + } catch (err) { + emitError(err); + } + } +).addHelpText('after', ` +Examples: + $ bb agent-vaults vote 42 --creator bb1signer...xyz | bb deploy +`); diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/build.spec.ts b/packages/bitbadgesjs-sdk/src/cli/commands/build.spec.ts index 3405b3c739..2fbf4ff26a 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/build.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/build.spec.ts @@ -13,11 +13,12 @@ import { buildProductCatalog } from '../../core/builders/product-catalog.js'; describe('buildCommand shape', () => { it('exposes every documented standard preset', () => { - // The 16 verbs that `bb build --help` advertises. If a verb is added + // The verbs that `bb build --help` advertises. If a verb is added // or removed, this test must be updated in lockstep with --help text. const names = buildCommand.commands.map((c) => c.name()).sort(); expect(names).toEqual([ 'address-list', + 'agent-vault', 'auction', 'bid', 'bounty', @@ -44,7 +45,7 @@ describe('buildCommand shape', () => { // metadata — otherwise an agent calling `bb build vault` with no // flags hits a confusing builder-level error instead of commander's // standard "missing required option" message. - const verbsThatRequireCoinFlag = ['vault', 'smart-token']; + const verbsThatRequireCoinFlag = ['vault', 'agent-vault', 'smart-token']; for (const verb of verbsThatRequireCoinFlag) { const c = buildCommand.commands.find((cmd) => cmd.name() === verb)!; const required = (c.options as any[]).filter((o) => o.required).map((o) => o.long); diff --git a/packages/bitbadgesjs-sdk/src/cli/commands/build.ts b/packages/bitbadgesjs-sdk/src/cli/commands/build.ts index 7a4a7d5f3e..c517dbdb35 100644 --- a/packages/bitbadgesjs-sdk/src/cli/commands/build.ts +++ b/packages/bitbadgesjs-sdk/src/cli/commands/build.ts @@ -447,6 +447,45 @@ sharedOpts( }), opts); }); +sharedOpts( + buildCommand + .command('agent-vault') + .description( + 'Create an IBC-backed Agent Vault — a Smart Token whose withdrawal is gated for an agent ' + + '(per-period cap / time window / multisig unlock). Set --manager to the human. ' + + 'Metadata: pass --uri OR --name + --image + --description.' + ) + .requiredOption('--backing-coin ', 'Backing coin symbol (USDC, BADGE, ATOM, OSMO)') + .option('--symbol ', 'Display symbol (e.g. avUSDC)') + .option('--withdraw-limit ', 'Max withdrawal per --period (display units)') + .option('--period ', 'Reset window for --withdraw-limit: daily | weekly | monthly (default daily)') + .option('--unlock-at ', 'Withdrawals invalid before this epoch-ms') + .option('--expires-at ', 'Withdrawals invalid after this epoch-ms') + .option('--signers ', 'Comma-separated multisig signers (bb1addr or bb1addr:weight) whose votes unlock withdrawals') + .option('--threshold ', 'Required yes-weight to unlock (N in N-of-M); defaults to unanimous') + .option('--recovery
', 'Optional admin kill-switch: a bb1... recovery address that can claw back + fully exit the vault anytime, bypassing all gating') +).action(async (opts) => { + const { buildAgentVault } = await import('../../core/builders/agent-vault.js'); + if (opts.json) { emit(buildAgentVault(readJsonInput(opts.json)), opts); return; } + const signers = typeof opts.signers === 'string' && opts.signers.trim() + ? opts.signers.split(',').map((s: string) => { + const [address, weight] = s.trim().split(':'); + return { address: address.trim(), weight: weight ? Number(weight) : undefined }; + }) + : undefined; + emit(buildAgentVault({ + backingCoin: opts.backingCoin, uri: opts.uri, name: opts.name, symbol: opts.symbol, image: opts.image, + description: opts.description, + withdrawLimit: opts.withdrawLimit ? Number(opts.withdrawLimit) : undefined, + period: opts.period as ('daily' | 'weekly' | 'monthly' | undefined), + unlockAt: opts.unlockAt ? Number(opts.unlockAt) : undefined, + expiresAt: opts.expiresAt ? Number(opts.expiresAt) : undefined, + signers, + threshold: opts.threshold ? Number(opts.threshold) : undefined, + recovery: opts.recovery + }), opts); +}); + sharedOpts( buildCommand .command('subscription') diff --git a/packages/bitbadgesjs-sdk/src/cli/index.ts b/packages/bitbadgesjs-sdk/src/cli/index.ts index e40daa4a8e..1fce769311 100644 --- a/packages/bitbadgesjs-sdk/src/cli/index.ts +++ b/packages/bitbadgesjs-sdk/src/cli/index.ts @@ -193,6 +193,7 @@ import { productsCommand } from './commands/products.js'; import { auctionsCommand } from './commands/auctions.js'; import { predictionMarketsCommand } from './commands/prediction-markets.js'; import { smartTokensCommand } from './commands/smart-tokens.js'; +import { agentVaultsCommand } from './commands/agent-vaults.js'; import { nftsCommand } from './commands/nfts.js'; import { custom2faCommand } from './commands/custom-2fa.js'; @@ -300,6 +301,7 @@ const HELP_GROUPS: { title: string; commands: Command[] }[] = [ auctionsCommand, predictionMarketsCommand, smartTokensCommand, + agentVaultsCommand, nftsCommand, custom2faCommand, dynamicStoresCommand @@ -327,6 +329,7 @@ const STANDARD_BUILD_ALIASES: Record = { 'prediction-markets': 'prediction-market', products: 'product-catalog', 'smart-tokens': 'smart-token', + 'agent-vaults': 'agent-vault', subscriptions: 'subscription' }; const standardsByName = new Map(); diff --git a/packages/bitbadgesjs-sdk/src/cli/integration/agent-vaults.spec.ts b/packages/bitbadgesjs-sdk/src/cli/integration/agent-vaults.spec.ts new file mode 100644 index 0000000000..efc5ad8313 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/cli/integration/agent-vaults.spec.ts @@ -0,0 +1,367 @@ +/** + * Integration: `bb agent-vaults` end-to-end against a live local chain + indexer. + * + * Two vaults are exercised: + * + * A) Capped vault (no multisig) — the everyday agent-spend path: + * 1. alice builds + deploys a USDC-backed Agent Vault with a daily cap. + * 2. show / status / list surface the backing + parsed gating. + * 3. alice deposits 5 USDC → mints 5 vault tokens. + * 4. alice withdraws 2 → releases 2 USDC (within cap). + * 5. alice `pay`s 1 to charlie → {withdraw, bank send}; charlie's USDC grows. + * + * B) Multisig vault — the one-time-unlock path that exercises the MsgCastVote + * fix (camelCase fields; the old snake_case shape crashed the encoder): + * 6. alice deploys a 2-of-2 (alice, charlie) Agent Vault + deposits. + * 7. a withdraw BEFORE quorum is rejected on-chain (gating blocks it). + * 8. alice + charlie cast votes (MsgCastVote) → quorum reached. + * 9. the same withdraw now succeeds (unlock is sticky — resetAfterExecution:false). + * + * Skipped automatically when preflight fails (no local chain/indexer). + */ + +import { preflightIntegration } from './harness/preflight.js'; +import { alice, charlie } from './harness/personas.js'; +import { runCli } from './harness/cli.js'; +import { + deployMsgViaKeyring, + fundPersona, + waitForIndexerCollection, + writeMsgToTmp, + getBankBalance, + pollBalance, + pollTokenAmount +} from './harness/chain.js'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; + +// Local-chain USDC IBC denom — same value used by the smart-tokens spec. +const USDC_DENOM = 'ibc/F082B65C88E4B6D5EF1DB243CDA1D331D002759E938A0F5CD3FFDC5D53B3E349'; + +describe('agent-vaults integration', () => { + let ready = false; + + beforeAll(async () => { ready = (await preflightIntegration()).ok; }, 30000); + + // ─── A) Capped vault (no multisig) ────────────────────────────────────────── + describe('capped vault (deposit / withdraw / pay)', () => { + let collectionId: string | undefined; + + it('build + deploy creates a capped Agent Vault collection', async () => { + if (!ready) return; + const creator = alice(); + const tmp = path.join(os.tmpdir(), `av-build-${crypto.randomBytes(4).toString('hex')}.json`); + runCli( + [ + 'build', 'agent-vault', + '--backing-coin', 'USDC', + '--name', 'Test Agent Vault', + '--image', 'https://example.com/av.png', + '--description', 'A USDC-backed Agent Vault with a daily cap', + '--withdraw-limit', '5', + '--period', 'daily', + '--creator', creator.address, + '--output-file', tmp + ], + { parseJson: false } + ); + expect(fs.existsSync(tmp)).toBe(true); + const tx = await deployMsgViaKeyring(tmp, creator.name); + expect(tx.code).toBe(0); + expect(tx.collectionId).toBeDefined(); + collectionId = tx.collectionId!; + await waitForIndexerCollection(collectionId); + }, 90000); + + it('show returns backing address/denom, approval ids, and parsed cap', () => { + if (!ready || !collectionId) return; + const out = runCli(['agent-vaults', 'show', collectionId, '--local']); + expect(out.json.collectionId).toBe(collectionId); + expect(out.json.backingAddress).toMatch(/^bb1/); + expect(out.json.backingDenom).toBe(USDC_DENOM); + expect(out.json.depositApprovalId).toBe('agent-vault-deposit'); + expect(out.json.withdrawApprovalId).toMatch(/^agent-vault-withdraw-/); + expect(out.json.gating.cap).toEqual({ perPeriodBaseUnits: '5000000', period: 'daily' }); + expect(out.json.standards).toEqual(['Smart Token', 'Agent Vault']); + }, 30000); + + it('status reports withdrawable + correct denom/cap (no time/multisig gating)', () => { + if (!ready || !collectionId) return; + const out = runCli(['agent-vaults', 'status', collectionId, '--local']); + expect(out.json.backingDenom).toBe(USDC_DENOM); + expect(out.json.cap).toEqual({ perPeriodBaseUnits: '5000000', period: 'daily' }); + expect(out.json.timeWindow.state).toBe('always-open'); + expect(out.json.multisig).toBeNull(); + // No time/multisig gating → withdrawable right now. + expect(out.json.withdrawable).toBe(true); + expect(out.json.status).toBe('withdrawable'); + }, 30000); + + it('list runs and returns a (curated) Agent Vault array', () => { + if (!ready || !collectionId) return; + // `list` browses the indexer's CURATED "smart-token" category and filters + // by the Agent Vault validator, so a freshly-created local collection is + // not expected to appear (the browse set is hand-curated in prod). Assert + // the command succeeds and returns a well-shaped array. + const out = runCli(['agent-vaults', 'list', '--local']); + expect(Array.isArray(out.json)).toBe(true); + }, 30000); + + it('alice deposits 5 USDC → mints 5 vault tokens', async () => { + if (!ready || !collectionId) return; + const depositor = alice(); + try { await fundPersona('alice', depositor.address, '10000000', USDC_DENOM); } catch { /* genesis-funded */ } + + const depositMsg = runCli([ + 'agent-vaults', 'deposit', collectionId, + '--creator', depositor.address, '--amount', '5', '--local' + ]); + expect(depositMsg.json.typeUrl).toBe('/tokenization.MsgTransferTokens'); + expect(depositMsg.json.value.transfers[0].prioritizedApprovals[0].approvalId).toBe('agent-vault-deposit'); + + const tx = await deployMsgViaKeyring(writeMsgToTmp(depositMsg.json, 'av-deposit'), depositor.name); + expect(tx.code).toBe(0); + const held = await pollTokenAmount(collectionId, depositor.address, (n) => n >= 5n, { label: 'alice vault tokens' }); + expect(held).toBeGreaterThanOrEqual(5n); + }, 90000); + + it('alice withdraws 2 vault tokens → releases 2 USDC (within cap)', async () => { + if (!ready || !collectionId) return; + const withdrawer = alice(); + const withdrawMsg = runCli([ + 'agent-vaults', 'withdraw', collectionId, + '--creator', withdrawer.address, '--amount', '2', '--local' + ]); + expect(withdrawMsg.json.value.transfers[0].prioritizedApprovals[0].approvalId).toMatch(/^agent-vault-withdraw-/); + const tx = await deployMsgViaKeyring(writeMsgToTmp(withdrawMsg.json, 'av-withdraw'), withdrawer.name); + expect(tx.code).toBe(0); + }, 90000); + + it('alice pays 1 USDC to charlie via the gated withdraw + bank send', async () => { + if (!ready || !collectionId) return; + const payer = alice(); + const recipient = charlie(); + const before = getBankBalance(recipient.address, USDC_DENOM); + + const payMsg = runCli([ + 'agent-vaults', 'pay', collectionId, + '--creator', payer.address, '--amount', '1', '--to', recipient.address, '--local' + ]); + // pay emits a 2-msg envelope: [gated withdraw, bank send]. + expect(Array.isArray(payMsg.json.messages)).toBe(true); + expect(payMsg.json.messages).toHaveLength(2); + expect(payMsg.json.messages[0].typeUrl).toBe('/tokenization.MsgTransferTokens'); + expect(payMsg.json.messages[1].typeUrl).toBe('/cosmos.bank.v1beta1.MsgSend'); + + const tx = await deployMsgViaKeyring(writeMsgToTmp(payMsg.json, 'av-pay'), payer.name); + expect(tx.code).toBe(0); + for (const sub of tx.additionalTxs) expect(sub.code).toBe(0); + + // charlie receives 1 USDC (1e6 base units). + const after = await pollBalance(recipient.address, USDC_DENOM, (n) => n >= before + 1_000_000n, { + label: 'charlie USDC after pay' + }); + expect(after - before).toBeGreaterThanOrEqual(1_000_000n); + }, 120000); + + it('conformance throw — show on a non-Agent-Vault collection exits non-zero', () => { + if (!ready) return; + const out = runCli(['agent-vaults', 'show', '1', '--local'], { throwOnError: false, parseJson: false }); + expect(out.exitCode).not.toBe(0); + expect(out.stderr + out.stdout).toMatch(/not.*found|not.*valid|Agent Vault/i); + }, 30000); + }); + + // ─── B) Multisig vault (one-time unlock — exercises the MsgCastVote fix) ───── + describe('multisig vault (vote-gated withdraw)', () => { + let collectionId: string | undefined; + + it('build + deploy a 2-of-2 (alice, charlie) Agent Vault and fund it', async () => { + if (!ready) return; + const manager = alice(); + const tmp = path.join(os.tmpdir(), `av-ms-build-${crypto.randomBytes(4).toString('hex')}.json`); + // Unique alpha symbol per run → unique proposalId → a fresh indexer + // VoteDoc. (The indexer keys VoteDocs by the bare proposalId, so reusing + // identical vault params across runs would otherwise read a prior run's + // already-passed vote. Symbols must be digit-free per the chain regex.) + const sym = 'avms' + Array.from({ length: 6 }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join(''); + runCli( + [ + 'build', 'agent-vault', + '--backing-coin', 'USDC', + '--symbol', sym, + '--name', 'Multisig Agent Vault', + '--image', 'https://example.com/av-ms.png', + '--description', 'A 2-of-2 vote-gated Agent Vault', + '--withdraw-limit', '10', + '--signers', `${alice().address},${charlie().address}`, + '--threshold', '2', + '--creator', manager.address, + '--output-file', tmp + ], + { parseJson: false } + ); + const tx = await deployMsgViaKeyring(tmp, manager.name); + expect(tx.code).toBe(0); + collectionId = tx.collectionId!; + await waitForIndexerCollection(collectionId); + + // status surfaces the 2-voter multisig and reports it locked (no votes yet). + const status = runCli(['agent-vaults', 'status', collectionId, '--local']); + expect(status.json.multisig).not.toBeNull(); + expect(status.json.multisig.voters).toBe(2); + expect(status.json.multisig.quorumThreshold).toBe('100'); // 2-of-2 → 100% + expect(status.json.multisig.quorumMet).toBe(false); + expect(status.json.withdrawable).toBe(false); + expect(status.json.status).toBe('locked-pending-multisig'); + + // alice deposits 6 USDC so there are tokens to withdraw later. + try { await fundPersona('alice', manager.address, '10000000', USDC_DENOM); } catch { /* ok */ } + const dep = runCli(['agent-vaults', 'deposit', collectionId, '--creator', manager.address, '--amount', '6', '--local']); + const depTx = await deployMsgViaKeyring(writeMsgToTmp(dep.json, 'av-ms-deposit'), manager.name); + expect(depTx.code).toBe(0); + await pollTokenAmount(collectionId, manager.address, (n) => n >= 6n, { label: 'alice multisig vault tokens' }); + }, 150000); + + it('withdraw BEFORE quorum is rejected on-chain', async () => { + if (!ready || !collectionId) return; + const w = runCli(['agent-vaults', 'withdraw', collectionId, '--creator', alice().address, '--amount', '2', '--local']); + // The msg builds fine; the chain must reject it because the multisig is unmet. + let threw = false; + let code = 0; + try { + const tx = await deployMsgViaKeyring(writeMsgToTmp(w.json, 'av-ms-early-withdraw'), alice().name); + code = tx.code; + } catch { + threw = true; // chain-binary non-zero exit also counts as "rejected" + } + expect(threw || code !== 0).toBe(true); + }, 90000); + + it('alice + charlie cast votes (MsgCastVote) → quorum reached', async () => { + if (!ready || !collectionId) return; + for (const voter of [alice(), charlie()]) { + const voteMsg = runCli(['agent-vaults', 'vote', collectionId, '--creator', voter.address, '--local']); + expect(voteMsg.json.typeUrl).toBe('/tokenization.MsgCastVote'); + // Regression guard: the emitted value MUST be camelCase, or the encoder crashes. + expect(voteMsg.json.value.collectionId).toBe(collectionId); + expect(voteMsg.json.value.yesWeight).toBe('100'); + expect(voteMsg.json.value.collection_id).toBeUndefined(); + const tx = await deployMsgViaKeyring(writeMsgToTmp(voteMsg.json, 'av-vote'), voter.name); + expect(tx.code).toBe(0); + } + // status now reflects quorum reached → withdrawable. Poll: the indexer + // needs a moment to process the MsgCastVote txs into its VoteModel after + // they commit on-chain. + let quorumMet = false; + for (let i = 0; i < 15 && !quorumMet; i++) { + const s = runCli(['agent-vaults', 'status', collectionId!, '--local']); + quorumMet = s.json.multisig?.quorumMet === true; + if (quorumMet) { + expect(s.json.withdrawable).toBe(true); + expect(s.json.status).toBe('withdrawable'); + } else { + await new Promise((r) => setTimeout(r, 1500)); + } + } + expect(quorumMet).toBe(true); + }, 150000); + + it('withdraw AFTER quorum succeeds (sticky one-time unlock)', async () => { + if (!ready || !collectionId) return; + const w = runCli(['agent-vaults', 'withdraw', collectionId, '--creator', alice().address, '--amount', '2', '--local']); + const tx = await deployMsgViaKeyring(writeMsgToTmp(w.json, 'av-ms-withdraw'), alice().name); + expect(tx.code).toBe(0); + }, 90000); + }); + + // ─── C) Admin kill-switch — recovery fully drains a gated vault ────────────── + // The vault is time-locked far in the future so the agent's normal withdraw is + // blocked; the recovery address (charlie) must still be able to claw back + + // exit, proving the kill-switch bypasses the gating. + describe('admin kill-switch (recovery drain bypasses gating)', () => { + let collectionId: string | undefined; + const FAR_FUTURE = '4102444800000'; // 2100-01-01, well past any test run + + it('build + deploy a time-locked vault with a recovery kill-switch; agent deposits', async () => { + if (!ready) return; + const manager = alice(); + const recovery = charlie(); + const sym = 'avks' + Array.from({ length: 6 }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join(''); + const tmp = path.join(os.tmpdir(), `av-ks-build-${crypto.randomBytes(4).toString('hex')}.json`); + runCli( + [ + 'build', 'agent-vault', + '--backing-coin', 'USDC', + '--symbol', sym, + '--name', 'Kill-switch Agent Vault', + '--image', 'https://example.com/av-ks.png', + '--description', 'Time-locked vault with a recovery kill-switch', + '--unlock-at', FAR_FUTURE, + '--recovery', recovery.address, + '--creator', manager.address, + '--output-file', tmp + ], + { parseJson: false } + ); + const tx = await deployMsgViaKeyring(tmp, manager.name); + expect(tx.code).toBe(0); + collectionId = tx.collectionId!; + await waitForIndexerCollection(collectionId); + + try { await fundPersona('alice', manager.address, '10000000', USDC_DENOM); } catch { /* ok */ } + const dep = runCli(['agent-vaults', 'deposit', collectionId, '--creator', manager.address, '--amount', '5', '--local']); + const depTx = await deployMsgViaKeyring(writeMsgToTmp(dep.json, 'av-ks-deposit'), manager.name); + expect(depTx.code).toBe(0); + await pollTokenAmount(collectionId, manager.address, (n) => n >= 5n, { label: 'alice kill-switch vault tokens' }); + + // Transparency: show/status must surface the kill-switch recovery address. + const shown = runCli(['agent-vaults', 'show', collectionId, '--local']); + expect(shown.json.recovery).toBe(recovery.address); + const stat = runCli(['agent-vaults', 'status', collectionId, '--local']); + expect(stat.json.recovery).toBe(recovery.address); + }, 150000); + + it("agent's normal withdraw is blocked (time-locked)", async () => { + if (!ready || !collectionId) return; + const w = runCli(['agent-vaults', 'withdraw', collectionId, '--creator', alice().address, '--amount', '2', '--local']); + let rejected = false; + try { + const tx = await deployMsgViaKeyring(writeMsgToTmp(w.json, 'av-ks-blocked'), alice().name); + rejected = tx.code !== 0; + } catch { + rejected = true; + } + expect(rejected).toBe(true); + }, 90000); + + it('recovery (charlie) claws back + fully exits, bypassing the time lock', async () => { + if (!ready || !collectionId) return; + const recovery = charlie(); + const before = getBankBalance(recovery.address, USDC_DENOM); + + const rec = runCli([ + 'agent-vaults', 'recover', collectionId, + '--creator', recovery.address, '--from', alice().address, '--amount', '5', '--local' + ]); + expect(Array.isArray(rec.json.messages)).toBe(true); + expect(rec.json.messages).toHaveLength(2); + expect(rec.json.messages[0].value.transfers[0].prioritizedApprovals[0].approvalId).toBe('agent-vault-emergency-freeze'); + expect(rec.json.messages[1].value.transfers[0].prioritizedApprovals[0].approvalId).toBe('agent-vault-emergency-exit'); + + const tx = await deployMsgViaKeyring(writeMsgToTmp(rec.json, 'av-ks-recover'), recovery.name); + expect(tx.code).toBe(0); + for (const sub of tx.additionalTxs) expect(sub.code).toBe(0); + + // charlie received the 5 USDC, and alice's vault tokens were clawed to zero. + const after = await pollBalance(recovery.address, USDC_DENOM, (n) => n >= before + 5_000_000n, { + label: 'charlie USDC after recovery drain' + }); + expect(after - before).toBeGreaterThanOrEqual(5_000_000n); + const aliceLeft = await pollTokenAmount(collectionId, alice().address, (n) => n === 0n, { label: 'alice tokens after clawback' }); + expect(aliceLeft).toBe(0n); + }, 150000); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/keyring-command.spec.ts b/packages/bitbadgesjs-sdk/src/cli/utils/keyring-command.spec.ts index 1f98a58852..fb67771119 100644 --- a/packages/bitbadgesjs-sdk/src/cli/utils/keyring-command.spec.ts +++ b/packages/bitbadgesjs-sdk/src/cli/utils/keyring-command.spec.ts @@ -296,6 +296,30 @@ describe('buildKeyringMultiCommand', () => { expect(result.commandLine.trim().endsWith('--yes')).toBe(true); }); + it('maps a cosmos bank MsgSend to `tx bank send` (agent-vaults pay envelope)', () => { + const bankSendMsg = { + typeUrl: '/cosmos.bank.v1beta1.MsgSend', + value: { + fromAddress: 'bb1agent', + toAddress: 'bb1vendor', + amount: [{ denom: 'ibc/ABC', amount: '1000000' }] + } + }; + const result = buildKeyringMultiCommand({ + messages: [transferMsg, bankSendMsg], + from: 'alice', + network: 'local', + binary: 'bitbadgeschaind', + keyringBackend: 'test', + gas: 'auto', + gasAdjustment: '1.3' + }); + // Withdraw leg → tokenization; send leg → the bank module (NOT tokenization). + expect(result.commandLine).toMatch(/bitbadgeschaind tx tokenization transfer-tokens \/[^ \n]+\.json/); + expect(result.commandLine).toContain('bitbadgeschaind tx bank send bb1agent bb1vendor 1000000ibc/ABC'); + expect(result.commandLine).not.toContain('tx tokenization bank'); + }); + it('writes only the inner value of JSON-arg msgs (not the typeUrl wrapper)', () => { const result = buildKeyringMultiCommand({ messages: [voteMsg, transferMsg], diff --git a/packages/bitbadgesjs-sdk/src/cli/utils/keyring-command.ts b/packages/bitbadgesjs-sdk/src/cli/utils/keyring-command.ts index 23bd5811ee..e89292290e 100644 --- a/packages/bitbadgesjs-sdk/src/cli/utils/keyring-command.ts +++ b/packages/bitbadgesjs-sdk/src/cli/utils/keyring-command.ts @@ -100,11 +100,13 @@ export interface KeyringCommandResult { export function buildKeyringCommand(opts: KeyringCommandOptions): KeyringCommandResult { const jsonArgSubcommand = TYPE_URL_TO_SUBCOMMAND[opts.msg.typeUrl]; const positionalBuilder = POSITIONAL_BUILDERS[opts.msg.typeUrl]; + const fullTxBuilder = FULL_TX_BUILDERS[opts.msg.typeUrl]; - if (!jsonArgSubcommand && !positionalBuilder) { + if (!jsonArgSubcommand && !positionalBuilder && !fullTxBuilder) { const supported = [ ...Object.keys(TYPE_URL_TO_SUBCOMMAND), - ...Object.keys(POSITIONAL_BUILDERS) + ...Object.keys(POSITIONAL_BUILDERS), + ...Object.keys(FULL_TX_BUILDERS) ].sort().join('\n - '); throw new Error( `--with-keyring does not support typeUrl "${opts.msg.typeUrl}" — no chain-binary subcommand mapping.\n` + @@ -145,8 +147,13 @@ export function buildKeyringCommand(opts: KeyringCommandOptions): KeyringCommand if (!msgFilePath) msgFilePath = p; return p; }; - const parts = positionalBuilder!(value, writeJson); - head = `${opts.binary} tx tokenization ${parts.join(' ')}`; + const builder = positionalBuilder ?? fullTxBuilder!; + const parts = builder(value, writeJson); + // POSITIONAL_BUILDERS live under `tx tokenization`; FULL_TX_BUILDERS own + // their module segment (e.g. `bank send`) and slot directly after `tx`. + head = opts.msg.typeUrl in POSITIONAL_BUILDERS + ? `${opts.binary} tx tokenization ${parts.join(' ')}` + : `${opts.binary} tx ${parts.join(' ')}`; subcommand = parts[0]; } @@ -284,6 +291,28 @@ const POSITIONAL_BUILDERS: Record = { } }; +/** + * Full-`tx`-line builders for msgs that live OUTSIDE the `tx tokenization` + * module. Unlike POSITIONAL_BUILDERS (appended after a hardcoded + * `tx tokenization`), these OWN the module segment, so they return the args + * after `${binary} tx` (e.g. `['bank', 'send', ...]` → `tx bank send ...`). + * + * Needed by multi-msg flows that mix a tokenization msg with a native cosmos + * msg — e.g. `bb agent-vaults pay` emits `[MsgTransferTokens(withdraw), + * bank MsgSend]`. Note these chain as SEPARATE sequential txs (see the + * atomicity caveat above); use `--browser`/`--burner` for a single atomic tx. + */ +const FULL_TX_BUILDERS: Record = { + '/cosmos.bank.v1beta1.MsgSend': (v) => { + // Use: tx bank send [from_key_or_address] [to_address] [amount] + const from = String(v.fromAddress ?? v.from_address ?? ''); + const to = String(v.toAddress ?? v.to_address ?? ''); + const coins = (v.amount as Array<{ denom: string; amount: string }> | undefined) ?? []; + const amountArg = coins.map((c) => `${String(c.amount)}${String(c.denom)}`).join(','); + return ['bank', 'send', shellQuote(from), shellQuote(to), shellQuote(amountArg)]; + } +}; + /** Conservative shell-quote for positional args. Wraps in single quotes if any non-safe char is present. */ function shellQuote(s: string): string { if (s === '') return "''"; @@ -329,6 +358,7 @@ export function buildKeyringMultiCommand(opts: KeyringMultiCommandOptions): Keyr const m = opts.messages[i]; const jsonArgSubcommand = TYPE_URL_TO_SUBCOMMAND[m.typeUrl]; const positionalBuilder = POSITIONAL_BUILDERS[m.typeUrl]; + const fullTxBuilder = FULL_TX_BUILDERS[m.typeUrl]; let head: string; if (jsonArgSubcommand) { @@ -351,8 +381,21 @@ export function buildKeyringMultiCommand(opts: KeyringMultiCommandOptions): Keyr }; const parts = positionalBuilder((m.value ?? {}) as Record, writeJson); head = `${opts.binary} tx tokenization ${parts.join(' ')}`; + } else if (fullTxBuilder) { + const writeJson = (data: unknown): string => { + const p = path.join(os.tmpdir(), `bb-msg-${crypto.randomBytes(4).toString('hex')}.json`); + fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + msgFilePaths.push(p); + return p; + }; + const parts = fullTxBuilder((m.value ?? {}) as Record, writeJson); + head = `${opts.binary} tx ${parts.join(' ')}`; } else { - const supported = [...Object.keys(TYPE_URL_TO_SUBCOMMAND), ...Object.keys(POSITIONAL_BUILDERS)].sort(); + const supported = [ + ...Object.keys(TYPE_URL_TO_SUBCOMMAND), + ...Object.keys(POSITIONAL_BUILDERS), + ...Object.keys(FULL_TX_BUILDERS) + ].sort(); throw new Error( `--with-keyring: message[${i}] typeUrl "${m.typeUrl}" has no chain-binary subcommand mapping.\n` + `Supported:\n - ${supported.join('\n - ')}` diff --git a/packages/bitbadgesjs-sdk/src/core/agent-vaults.spec.ts b/packages/bitbadgesjs-sdk/src/core/agent-vaults.spec.ts new file mode 100644 index 0000000000..5a037dbfbd --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/core/agent-vaults.spec.ts @@ -0,0 +1,169 @@ +/** + * Tests for agent-vaults.ts — validator, extractor, and lifecycle msg builders. + * + * Round-trips through buildAgentVault so builder output passes its own + * detector, and asserts the deposit/withdraw/pay/vote msg shapes. + */ +import { + validateAgentVaultCollection, + doesCollectionFollowAgentVaultProtocol, + extractAgentVaultDetails, + buildAgentVaultDepositMsg, + buildAgentVaultWithdrawMsg, + buildAgentVaultPayMsgs, + buildAgentVaultRecoverMsgs, + buildAgentVaultVoteMsg +} from './agent-vaults.js'; +import { buildAgentVault } from './builders/agent-vault.js'; +import { buildSmartToken } from './builders/smart-token.js'; + +const META = { name: 'Agent Vault', image: 'https://example.com/i.png', description: 'agent vault' }; + +/** The builder emits MsgCreateCollection.value, which is iCollectionDoc-shaped. */ +const asCollection = (m: any): any => m.value ?? m; + +describe('validateAgentVaultCollection', () => { + it('accepts default builder output', () => { + const c = asCollection(buildAgentVault({ backingCoin: 'USDC', ...META })); + const r = validateAgentVaultCollection(c); + expect(r.valid).toBe(true); + expect(r.errors).toEqual([]); + }); + + it('rejects a plain Smart Token (no "Agent Vault" standard)', () => { + const c = asCollection(buildSmartToken({ backingCoin: 'USDC', ...META })); + expect(doesCollectionFollowAgentVaultProtocol(c)).toBe(false); + expect(validateAgentVaultCollection(c).errors).toContain('Missing "Agent Vault" standard'); + }); + + it('rejects a collection without the IBC backing path', () => { + const c = asCollection(buildAgentVault({ backingCoin: 'USDC', ...META })); + delete c.invariants.cosmosCoinBackedPath; + expect(doesCollectionFollowAgentVaultProtocol(c)).toBe(false); + }); +}); + +describe('extractAgentVaultDetails', () => { + it('extracts approvals + parses gating', () => { + const c = asCollection( + buildAgentVault({ + backingCoin: 'USDC', + withdrawLimit: 5, + period: 'daily', + unlockAt: 1700000000000, + expiresAt: 1800000000000, + signers: [{ address: 'bb1aaa' }, { address: 'bb1bbb' }], + threshold: 2, + ...META + }) + ); + const d = extractAgentVaultDetails(c)!; + expect(d).not.toBeNull(); + expect(d.depositApproval.approvalId).toContain('deposit'); + expect(d.withdrawApproval.approvalId).toContain('withdraw'); + expect(d.gating.cap).toEqual({ perPeriodBaseUnits: '5000000', period: 'daily' }); + expect(d.gating.timeWindow).toEqual({ unlockAt: '1700000000000', expiresAt: '1800000000000' }); + expect(d.gating.multisig?.voters).toHaveLength(2); + }); + + it('returns empty gating for an ungated vault', () => { + const c = asCollection(buildAgentVault({ backingCoin: 'USDC', ...META })); + const d = extractAgentVaultDetails(c)!; + expect(d.gating).toEqual({}); + }); + + it('exposes the kill-switch recovery approvals only when built with --recovery', () => { + const without = extractAgentVaultDetails(asCollection(buildAgentVault({ backingCoin: 'USDC', ...META })))!; + expect(without.recovery).toBeUndefined(); + + const withRec = extractAgentVaultDetails(asCollection(buildAgentVault({ backingCoin: 'USDC', recovery: 'bb1recovery', ...META })))!; + expect(withRec.recovery?.address).toBe('bb1recovery'); + expect(withRec.recovery?.freezeApproval.approvalId).toBe('agent-vault-emergency-freeze'); + expect(withRec.recovery?.exitApproval.approvalId).toBe('agent-vault-emergency-exit'); + }); +}); + +describe('buildAgentVaultRecoverMsgs (admin kill-switch)', () => { + it('emits [freeze (holder→recovery), exit (recovery→backing)]', () => { + const c = asCollection(buildAgentVault({ backingCoin: 'USDC', recovery: 'bb1recovery', ...META })); + const details = extractAgentVaultDetails(c)!; + const [freeze, exit] = buildAgentVaultRecoverMsgs({ + creator: 'bb1recovery', + collectionId: '42', + from: 'bb1agent', + amount: '1000000', + details + }); + const f = (freeze.value as any).transfers[0]; + expect(f.from).toBe('bb1agent'); + expect(f.toAddresses).toEqual(['bb1recovery']); + expect(f.prioritizedApprovals[0].approvalId).toBe('agent-vault-emergency-freeze'); + const e = (exit.value as any).transfers[0]; + expect(e.from).toBe('bb1recovery'); + expect(e.toAddresses).toEqual([details.backingAddress]); + expect(e.prioritizedApprovals[0].approvalId).toBe('agent-vault-emergency-exit'); + }); + + it('throws for a vault with no kill-switch', () => { + const c = asCollection(buildAgentVault({ backingCoin: 'USDC', ...META })); + const details = extractAgentVaultDetails(c)!; + expect(() => + buildAgentVaultRecoverMsgs({ creator: 'bb1r', collectionId: '42', from: 'bb1agent', amount: '1', details }) + ).toThrow(/no admin kill-switch/); + }); +}); + +describe('buildAgentVaultVoteMsg', () => { + it('throws (does not fall back to a constant) when the vault has no multisig proposal', () => { + const details = extractAgentVaultDetails(asCollection(buildAgentVault({ backingCoin: 'USDC', ...META })))!; + expect(() => buildAgentVaultVoteMsg({ creator: 'bb1signer', collectionId: '42', details })).toThrow(/no multisig proposal/); + }); +}); + +describe('lifecycle msg builders', () => { + const c = asCollection(buildAgentVault({ backingCoin: 'USDC', withdrawLimit: 5, signers: [{ address: 'bb1signer' }], ...META })); + const details = extractAgentVaultDetails(c)!; + const args = { creator: 'bb1agent', collectionId: '42', amount: '1000000', details }; + + it('deposit: from backing → creator, prioritizes deposit approval', () => { + const m = buildAgentVaultDepositMsg(args); + expect(m.typeUrl).toBe('/tokenization.MsgTransferTokens'); + const t = (m.value as any).transfers[0]; + expect(t.from).toBe(details.backingAddress); + expect(t.toAddresses).toEqual(['bb1agent']); + expect(t.prioritizedApprovals[0].approvalId).toBe(details.depositApproval.approvalId); + }); + + it('withdraw: from creator → backing, prioritizes withdraw approval', () => { + const m = buildAgentVaultWithdrawMsg(args); + const t = (m.value as any).transfers[0]; + expect(t.from).toBe('bb1agent'); + expect(t.toAddresses).toEqual([details.backingAddress]); + expect(t.prioritizedApprovals[0].approvalId).toBe(details.withdrawApproval.approvalId); + }); + + it('pay: [gated withdraw, bank send] in the released backing denom', () => { + const [withdraw, send] = buildAgentVaultPayMsgs({ ...args, to: 'bb1vendor' }); + expect(withdraw.typeUrl).toBe('/tokenization.MsgTransferTokens'); + expect(send.typeUrl).toBe('/cosmos.bank.v1beta1.MsgSend'); + expect((send.value as any).toAddress).toBe('bb1vendor'); + expect((send.value as any).amount).toEqual([{ denom: details.backingDenom, amount: '1000000' }]); + }); + + it('vote: MsgCastVote on the withdraw approval proposal (camelCase, matching the MsgCastVote encoder)', () => { + const m = buildAgentVaultVoteMsg({ creator: 'bb1signer', collectionId: '42', details }); + expect(m.typeUrl).toBe('/tokenization.MsgCastVote'); + const v = m.value as any; + expect(v.creator).toBe('bb1signer'); + expect(v.collectionId).toBe('42'); + expect(v.approvalLevel).toBe('collection'); + expect(v.approverAddress).toBe(''); + expect(v.approvalId).toBe(details.withdrawApproval.approvalId); + expect(v.proposalId).toBe(details.gating.multisig?.proposalId); + expect(v.yesWeight).toBe('100'); + // Guard against a snake_case regression — the `new MsgCastVote(v)` encoder + // in `bb deploy` reads camelCase only and would drop these. + expect(v.collection_id).toBeUndefined(); + expect(v.yes_weight).toBeUndefined(); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/core/agent-vaults.ts b/packages/bitbadgesjs-sdk/src/core/agent-vaults.ts new file mode 100644 index 0000000000..f429dbf361 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/core/agent-vaults.ts @@ -0,0 +1,326 @@ +/** + * Agent Vault helpers — consumer-side validator + extractor + lifecycle msg + * builders (deposit / withdraw / pay / vote). + * + * An Agent Vault is a Smart Token (`standards: ['Smart Token','Agent Vault']`) + * with a gated withdrawal approval: a per-period spend cap, an optional time + * window, and/or a one-time multisig "unlock" vote. The human is the manager; + * the agent holds the vault tokens and withdraws within the gating. + * + * Source of truth for the shape is `core/builders/agent-vault.ts`. Deposit/ + * withdraw detection reuses the Smart Token substring matchers. + */ + +import type { iCollectionApproval } from '@/interfaces/types/approvals.js'; +import type { iCollectionDoc } from '@/api-indexer/docs-types/interfaces.js'; +import { findDepositApproval, findWithdrawApproval } from './smart-tokens.js'; +import { + AGENT_VAULT_EMERGENCY_FREEZE_APPROVAL_ID, + AGENT_VAULT_EMERGENCY_EXIT_APPROVAL_ID +} from './builders/agent-vault.js'; + +const AV_MAX_UINT64 = '18446744073709551615'; + +export interface AgentVaultGating { + /** Per-period withdrawal cap (base units of the backing coin) + the period. */ + cap?: { perPeriodBaseUnits: string; period: string }; + /** Restricted withdraw window (epoch-ms strings). */ + timeWindow?: { unlockAt: string; expiresAt: string }; + /** Multisig unlock challenge. */ + multisig?: { + proposalId: string; + quorumThreshold: string; + voters: { address: string; weight: string }[]; + }; +} + +export interface AgentVaultDetails { + /** Backing-address bb1... alias derived from the IBC denom. */ + backingAddress: string; + /** Full IBC denom string the vault wraps (`ibc/...` or `ubadge`). */ + backingDenom: string; + depositApproval: iCollectionApproval; + withdrawApproval: iCollectionApproval; + /** Gating parsed from the withdraw approval's criteria. */ + gating: AgentVaultGating; + /** Admin kill-switch (present only when the vault was built with a recovery address). */ + recovery?: { + /** bb1... recovery address the kill-switch is scoped to. */ + address: string; + freezeApproval: iCollectionApproval; + exitApproval: iCollectionApproval; + }; +} + +export interface AgentVaultValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export const validateAgentVaultCollection = ( + collection: Readonly> +): AgentVaultValidationResult => { + const errors: string[] = []; + const warnings: string[] = []; + + if (!collection.standards?.includes('Smart Token')) { + errors.push('Missing "Smart Token" standard'); + } + if (!collection.standards?.includes('Agent Vault')) { + errors.push('Missing "Agent Vault" standard'); + } + const backed = (collection.invariants as any)?.cosmosCoinBackedPath; + if (!backed) { + errors.push('Missing invariants.cosmosCoinBackedPath — Agent Vaults require an IBC backing path'); + } + const vt = collection.validTokenIds; + if (!vt || vt.length !== 1 || BigInt(vt[0].start) !== 1n || BigInt(vt[0].end) !== 1n) { + errors.push('validTokenIds must be exactly [{start: 1, end: 1}]'); + } + const approvals = collection.collectionApprovals ?? []; + if (!findDepositApproval(approvals)) { + errors.push('Missing deposit approval (approvalId must contain "deposit" or "back")'); + } + if (!findWithdrawApproval(approvals)) { + errors.push('Missing withdraw approval (approvalId must contain "withdraw" or "unback")'); + } + + return { valid: errors.length === 0, errors, warnings }; +}; + +export const doesCollectionFollowAgentVaultProtocol = ( + collection: Readonly> +): boolean => { + return validateAgentVaultCollection(collection).valid; +}; + +/** Parse the gating out of the withdraw approval's criteria + transferTimes. */ +function parseGating(withdrawApproval: iCollectionApproval): AgentVaultGating { + const gating: AgentVaultGating = {}; + const crit: any = withdrawApproval.approvalCriteria; + if (crit?.approvalAmounts) { + gating.cap = { + perPeriodBaseUnits: String(crit.approvalAmounts.perInitiatedByAddressApprovalAmount ?? '0'), + period: String(crit.approvalAmounts.amountTrackerId ?? '').replace('withdrawal-', '') + }; + } + const tt: any = (withdrawApproval.transferTimes ?? [])[0]; + if (tt && !(String(tt.start) === '1' && String(tt.end) === AV_MAX_UINT64)) { + gating.timeWindow = { unlockAt: String(tt.start), expiresAt: String(tt.end) }; + } + const vc: any = crit?.votingChallenges?.[0]; + if (vc) { + gating.multisig = { + proposalId: String(vc.proposalId ?? ''), + quorumThreshold: String(vc.quorumThreshold ?? '0'), + voters: (vc.voters ?? []).map((v: any) => ({ address: String(v.address), weight: String(v.weight) })) + }; + } + return gating; +} + +/** Extract backing metadata + approvals + gating. Returns null on shape mismatch. */ +export function extractAgentVaultDetails( + collection: Readonly> +): AgentVaultDetails | null { + const backed = (collection.invariants as any)?.cosmosCoinBackedPath; + if (!backed) return null; + const approvals = collection.collectionApprovals ?? []; + const depositApproval = findDepositApproval(approvals); + const withdrawApproval = findWithdrawApproval(approvals); + if (!depositApproval || !withdrawApproval) return null; + + const backingAddress = String(backed?.address ?? '') || String(depositApproval.fromListId ?? ''); + const backingDenom = String(backed?.conversion?.sideA?.denom ?? ''); + + // Optional admin kill-switch — present only when both emergency approvals exist. + const freezeApproval = approvals.find((a) => a.approvalId === AGENT_VAULT_EMERGENCY_FREEZE_APPROVAL_ID); + const exitApproval = approvals.find((a) => a.approvalId === AGENT_VAULT_EMERGENCY_EXIT_APPROVAL_ID); + const recovery = + freezeApproval && exitApproval + ? { address: String(freezeApproval.toListId ?? ''), freezeApproval, exitApproval } + : undefined; + + return { + backingAddress, + backingDenom, + depositApproval, + withdrawApproval, + gating: parseGating(withdrawApproval), + recovery + }; +} + +// ── Msg builders ─────────────────────────────────────────────────────────── + +export interface AgentVaultTransferMsg { + typeUrl: '/tokenization.MsgTransferTokens'; + value: Record; +} +export interface AgentVaultVoteMsg { + typeUrl: '/tokenization.MsgCastVote'; + value: Record; +} +export interface BankSendMsg { + typeUrl: '/cosmos.bank.v1beta1.MsgSend'; + value: Record; +} + +function transfer( + creator: string, + collectionId: string, + from: string, + to: string, + amount: string, + approval: iCollectionApproval +): AgentVaultTransferMsg { + return { + typeUrl: '/tokenization.MsgTransferTokens', + value: { + creator, + collectionId: String(collectionId), + transfers: [ + { + from, + toAddresses: [to], + balances: [ + { + amount: String(amount), + tokenIds: [{ start: '1', end: '1' }], + ownershipTimes: [{ start: '1', end: AV_MAX_UINT64 }] + } + ], + prioritizedApprovals: [ + { + approvalId: approval.approvalId, + approvalLevel: 'collection', + approverAddress: '', + version: String(approval.version ?? '0') + } + ], + onlyCheckPrioritizedCollectionApprovals: true, + onlyCheckPrioritizedOutgoingApprovals: false, + onlyCheckPrioritizedIncomingApprovals: false, + memo: '' + } + ] + } + }; +} + +export interface AgentVaultLifecycleArgs { + creator: string; + collectionId: string; + amount: string; + details: AgentVaultDetails; +} + +/** Deposit: fund the backing alias → mint agent-vault tokens to the caller. */ +export function buildAgentVaultDepositMsg(args: AgentVaultLifecycleArgs): AgentVaultTransferMsg { + const { creator, collectionId, amount, details } = args; + return transfer(creator, collectionId, details.backingAddress, creator, amount, details.depositApproval); +} + +/** Withdraw: burn agent-vault tokens → release backing coin to the caller (gated). */ +export function buildAgentVaultWithdrawMsg(args: AgentVaultLifecycleArgs): AgentVaultTransferMsg { + const { creator, collectionId, amount, details } = args; + return transfer(creator, collectionId, creator, details.backingAddress, amount, details.withdrawApproval); +} + +/** + * Recover (admin kill-switch): the recovery address forcibly claws back vault + * tokens from a holder (the agent) and then unbacks them to the backing coin, + * bypassing the cap / time window / multisig. Emits `[freeze, exit]`: + * 1. freeze — `from` (holder) → recovery, prioritizing the forceful freeze + * approval (overrides the holder's outgoing + recovery's incoming). + * 2. exit — recovery → backing alias, prioritizing the ungated exit lane; + * releases the backing coin to recovery. + * `creator` is the recovery address. Throws if the vault has no kill-switch. + * Atomicity is path-dependent (same caveat as `pay`). + */ +export function buildAgentVaultRecoverMsgs(args: { + creator: string; + collectionId: string; + /** Holder to claw back from (the agent). */ + from: string; + amount: string; + details: AgentVaultDetails; +}): [AgentVaultTransferMsg, AgentVaultTransferMsg] { + const { creator, collectionId, from, amount, details } = args; + if (!details.recovery) { + throw new Error('This Agent Vault has no admin kill-switch (built without a recovery address).'); + } + const freeze = transfer(creator, collectionId, from, creator, amount, details.recovery.freezeApproval); + const exit = transfer(creator, collectionId, creator, details.backingAddress, amount, details.recovery.exitApproval); + return [freeze, exit]; +} + +/** + * Pay: withdraw (gated unback) then bank-send the released backing coin to a + * recipient. Emits `[withdraw, MsgSend]`. The recipient is never named in any + * approval; the gating constrains the spend rate, not the destination. + * + * Atomicity is path-dependent: broadcast as a SINGLE tx (the `bb deploy` + * --browser/--burner signing-client paths) the send never executes if the + * gated withdraw fails. The `--with-keyring` path chains them as two + * sequential txs (the chain binary's tx subcommands take one msg each), so a + * post-withdraw send failure leaves the agent holding the withdrawn coin — + * it can re-run just the send. + */ +export function buildAgentVaultPayMsgs( + args: AgentVaultLifecycleArgs & { to: string } +): [AgentVaultTransferMsg, BankSendMsg] { + const { creator, collectionId, amount, details, to } = args; + const withdraw = buildAgentVaultWithdrawMsg({ creator, collectionId, amount, details }); + const send: BankSendMsg = { + typeUrl: '/cosmos.bank.v1beta1.MsgSend', + value: { + fromAddress: creator, + toAddress: to, + amount: [{ denom: details.backingDenom, amount: String(amount) }] + } + }; + return [withdraw, send]; +} + +/** + * Vote: cast a weighted yes toward the withdraw approval's multisig unlock. + * Mirrors the SDK's MsgCastVote shape — camelCase fields, exactly as the + * `MsgCastVote` wrapper class (transactions/.../msgCastVote.ts) and the + * deposit/withdraw transfer builders above expect. (Earlier this emitted + * snake_case, which the `new MsgCastVote(v)` encoder in `bb deploy` drops, + * leaving every field but `creator` undefined and crashing on broadcast.) + * `yesWeight` is a 0–100 percent of this voter's assigned weight (default 100). + */ +export function buildAgentVaultVoteMsg(args: { + creator: string; + collectionId: string; + details: AgentVaultDetails; + yesWeight?: string; +}): AgentVaultVoteMsg { + const { creator, collectionId, details, yesWeight = '100' } = args; + // Resolve the real on-chain proposalId (a per-vault hash). Do NOT fall back to + // a constant — the bare prefix is never an actual proposalId, so a vote cast + // against it would silently miss the real proposal and never advance quorum. + const proposalId = + details.gating.multisig?.proposalId ?? + details.withdrawApproval.approvalCriteria?.votingChallenges?.[0]?.proposalId; + if (!proposalId) { + throw new Error( + 'Cannot cast vote: this Agent Vault has no multisig proposal (no votingChallenge on the withdraw approval).' + ); + } + return { + typeUrl: '/tokenization.MsgCastVote', + value: { + creator, + collectionId: String(collectionId), + approvalLevel: 'collection', + approverAddress: '', + approvalId: details.withdrawApproval.approvalId, + proposalId, + yesWeight: String(yesWeight) + } + }; +} diff --git a/packages/bitbadgesjs-sdk/src/core/builders/agent-vault.spec.ts b/packages/bitbadgesjs-sdk/src/core/builders/agent-vault.spec.ts new file mode 100644 index 0000000000..cdd6579e9e --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/core/builders/agent-vault.spec.ts @@ -0,0 +1,188 @@ +/** + * Tests for the Agent Vault builder. + * + * Verifies the standards tag, deposit/withdraw approvals, the gating + * compilation (per-period cap → approvalAmounts, time window → transferTimes, + * multisig → votingChallenges), determinism, and standards-compliance. + */ +import { verifyStandardsCompliance } from '../../api-indexer/verify-standards.js'; +import { buildAgentVault, AGENT_VAULT_DEPOSIT_APPROVAL_ID, AGENT_VAULT_WITHDRAW_PROPOSAL_PREFIX } from './agent-vault.js'; + +const val = (msg: any) => msg.value; +const META = { name: 'Agent Vault', description: 'An agent budget vault.', image: 'ipfs://test-image' }; + +function withdrawApproval(msg: any): any { + return val(msg).collectionApprovals.find((a: any) => a.approvalId.includes('withdraw')); +} + +describe('buildAgentVault', () => { + test('carries the Smart Token + Agent Vault standards', () => { + const r = val(buildAgentVault({ backingCoin: 'USDC', ...META })); + expect(r.standards).toEqual(['Smart Token', 'Agent Vault']); + }); + + test('has a deposit approval and a prefixed withdraw approval', () => { + const r = val(buildAgentVault({ backingCoin: 'USDC', ...META })); + const ids = r.collectionApprovals.map((a: any) => a.approvalId); + expect(ids).toContain(AGENT_VAULT_DEPOSIT_APPROVAL_ID); + expect(ids.some((id: string) => id.startsWith('agent-vault-withdraw-'))).toBe(true); + }); + + test('ungated by default — withdraw approval has no cap/time/multisig', () => { + const w = withdrawApproval(buildAgentVault({ backingCoin: 'USDC', ...META })); + expect(w.approvalCriteria.approvalAmounts).toBeUndefined(); + expect(w.approvalCriteria.votingChallenges).toBeUndefined(); + // FOREVER transfer window + expect(w.transferTimes).toEqual([{ start: '1', end: '18446744073709551615' }]); + }); + + test('withdrawLimit + period → approvalAmounts (per-initiator, periodic reset)', () => { + const w = withdrawApproval(buildAgentVault({ backingCoin: 'USDC', withdrawLimit: 5, period: 'weekly', ...META })); + const aa = w.approvalCriteria.approvalAmounts; + expect(aa.perInitiatedByAddressApprovalAmount).toBe('5000000'); // 5 USDC @ 6dp + expect(aa.amountTrackerId).toBe('withdrawal-weekly'); + expect(aa.resetTimeIntervals.intervalLength).toBe('604800000'); + }); + + test('unlockAt/expiresAt → restricted transferTimes window', () => { + const w = withdrawApproval(buildAgentVault({ backingCoin: 'USDC', unlockAt: 1700000000000, expiresAt: 1800000000000, ...META })); + expect(w.transferTimes).toEqual([{ start: '1700000000000', end: '1800000000000' }]); + }); + + test('signers + threshold → votingChallenges (N-of-M → percentage quorum)', () => { + const w = withdrawApproval( + buildAgentVault({ + backingCoin: 'USDC', + signers: [{ address: 'bb1aaa' }, { address: 'bb1bbb' }, { address: 'bb1ccc' }], + threshold: 2, + ...META + }) + ); + const vc = w.approvalCriteria.votingChallenges[0]; + // proposalId is hashed (unique per vault), prefixed for readability. + expect(vc.proposalId.startsWith(AGENT_VAULT_WITHDRAW_PROPOSAL_PREFIX + '-')).toBe(true); + // 2 of 3 equal-weight voters → floor(2/3 * 100) = 66 + expect(vc.quorumThreshold).toBe('66'); + expect(vc.voters).toHaveLength(3); + expect(vc.voters[0]).toEqual({ address: 'bb1aaa', weight: '1' }); + expect(vc.resetAfterExecution).toBe(false); + }); + + test('throws when --threshold exceeds the total signer weight (e.g. 5-of-3)', () => { + expect(() => + buildAgentVault({ + backingCoin: 'USDC', + signers: [{ address: 'bb1aaa' }, { address: 'bb1bbb' }, { address: 'bb1ccc' }], + threshold: 5, + ...META + }) + ).toThrow(/threshold must be between 1 and the total signer weight/); + }); + + test('throws when --threshold is below 1', () => { + expect(() => + buildAgentVault({ backingCoin: 'USDC', signers: [{ address: 'bb1aaa' }], threshold: 0, ...META }) + ).toThrow(/threshold must be between 1 and the total signer weight/); + }); + + test('throws on duplicate signer addresses', () => { + expect(() => + buildAgentVault({ backingCoin: 'USDC', signers: [{ address: 'bb1aaa' }, { address: 'bb1aaa' }], ...META }) + ).toThrow(/duplicate addresses/); + }); + + test('throws when --unlock-at is not before --expires-at', () => { + expect(() => + buildAgentVault({ backingCoin: 'USDC', unlockAt: 2000, expiresAt: 1000, ...META }) + ).toThrow(/must be before/); + }); + + test('threshold defaults to unanimous (100%)', () => { + const w = withdrawApproval( + buildAgentVault({ backingCoin: 'USDC', signers: [{ address: 'bb1aaa' }, { address: 'bb1bbb' }], ...META }) + ); + expect(w.approvalCriteria.votingChallenges[0].quorumThreshold).toBe('100'); + }); + + test('deterministic — identical params produce byte-identical msg', () => { + const p = { backingCoin: 'USDC', withdrawLimit: 5, period: 'daily' as const, ...META }; + expect(buildAgentVault(p)).toEqual(buildAgentVault(p)); + }); + + test('distinct vaults get distinct proposalIds (no indexer VoteDoc collision)', () => { + // The indexer keys VoteDocs by the bare proposalId, so two multisig vaults + // with different params MUST NOT share one. (Identical params intentionally + // collide — same as the withdraw approvalId — which is the replay case.) + const a = withdrawApproval(buildAgentVault({ backingCoin: 'USDC', signers: [{ address: 'bb1aaa' }], ...META })); + const b = withdrawApproval(buildAgentVault({ backingCoin: 'BADGE', signers: [{ address: 'bb1aaa' }], ...META })); + const pidA = a.approvalCriteria.votingChallenges[0].proposalId; + const pidB = b.approvalCriteria.votingChallenges[0].proposalId; + expect(pidA).not.toBe(pidB); + }); + + test('passes standards-compliance verification', () => { + const msg = buildAgentVault({ backingCoin: 'USDC', withdrawLimit: 5, period: 'daily', ...META }); + const vr = verifyStandardsCompliance({ messages: [msg] }); + expect({ valid: vr.valid, violations: vr.violations }).toEqual({ valid: true, violations: [] }); + }); + + test('no kill-switch by default — forceful transfers locked, no emergency approvals', () => { + const r = val(buildAgentVault({ backingCoin: 'USDC', ...META })); + expect(r.invariants.noForcefulPostMintTransfers).toBe(true); + const ids = r.collectionApprovals.map((a: any) => a.approvalId); + expect(ids).not.toContain('agent-vault-emergency-freeze'); + expect(ids).not.toContain('agent-vault-emergency-exit'); + }); + + test('--recovery bakes the freeze + exit approvals and unlocks forceful transfers', () => { + const r = val(buildAgentVault({ backingCoin: 'USDC', recovery: 'bb1recovery', ...META })); + // The freeze is forceful, which the chain only allows when the invariant is off. + expect(r.invariants.noForcefulPostMintTransfers).toBe(false); + + const freeze = r.collectionApprovals.find((a: any) => a.approvalId === 'agent-vault-emergency-freeze'); + const exit = r.collectionApprovals.find((a: any) => a.approvalId === 'agent-vault-emergency-exit'); + expect(freeze).toBeDefined(); + expect(exit).toBeDefined(); + + // Freeze: recovery-scoped forceful clawback; never from the backing alias. + expect(freeze.toListId).toBe('bb1recovery'); + expect(freeze.initiatedByListId).toBe('bb1recovery'); + expect(freeze.fromListId.startsWith('!Mint')).toBe(true); + expect(freeze.approvalCriteria.overridesFromOutgoingApprovals).toBe(true); + expect(freeze.approvalCriteria.overridesToIncomingApprovals).toBe(true); + + // Exit: recovery-only, ungated (no cap/time/multisig), unbacks to recovery. + expect(exit.fromListId).toBe('bb1recovery'); + expect(exit.initiatedByListId).toBe('bb1recovery'); + expect(exit.approvalCriteria.allowBackedMinting).toBe(true); + expect(exit.approvalCriteria.approvalAmounts).toBeUndefined(); + expect(exit.approvalCriteria.votingChallenges).toBeUndefined(); + }); + + test('a kill-switch vault still passes standards-compliance verification', () => { + const msg = buildAgentVault({ backingCoin: 'USDC', withdrawLimit: 5, recovery: 'bb1recovery', ...META }); + const vr = verifyStandardsCompliance({ messages: [msg] }); + expect({ valid: vr.valid, violations: vr.violations }).toEqual({ valid: true, violations: [] }); + }); + + test('verifyAgentVault rejects a deposit-only vault (fund trap)', () => { + const msg = buildAgentVault({ backingCoin: 'USDC', ...META }); + // Drop the withdraw approval — depositors could mint but never get coins back. + msg.value.collectionApprovals = msg.value.collectionApprovals.filter( + (a: any) => !a.approvalId.includes('withdraw') + ); + const vr = verifyStandardsCompliance({ messages: [msg] }); + expect(vr.valid).toBe(false); + expect(JSON.stringify(vr.violations)).toMatch(/withdraw approval/i); + }); + + test('verifyAgentVault rejects a forceful approval that anyone can initiate', () => { + const msg = buildAgentVault({ backingCoin: 'USDC', recovery: 'bb1recovery', ...META }); + // Open the kill-switch freeze to "All" — anyone could seize vault tokens. + const freeze = msg.value.collectionApprovals.find((a: any) => a.approvalId === 'agent-vault-emergency-freeze'); + freeze.initiatedByListId = 'All'; + const vr = verifyStandardsCompliance({ messages: [msg] }); + expect(vr.valid).toBe(false); + expect(JSON.stringify(vr.violations)).toMatch(/admin-scoped/i); + }); +}); diff --git a/packages/bitbadgesjs-sdk/src/core/builders/agent-vault.ts b/packages/bitbadgesjs-sdk/src/core/builders/agent-vault.ts new file mode 100644 index 0000000000..d8a93d0f16 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/core/builders/agent-vault.ts @@ -0,0 +1,316 @@ +/** + * Agent Vault builder — a Smart Token whose withdrawal approval is gated for an + * autonomous agent: a per-period spend cap, an optional time window, and an + * optional multisig "unlock" vote. The human is the collection manager + * (`--manager`); the agent holds the vault tokens and withdraws within these + * guardrails. + * + * Distinct from the `Vault` standard (`./vault.ts`): an Agent Vault carries + * `standards: ['Smart Token', 'Agent Vault']` and the richer gating below. + * Ported from the cosmos-mcp reference implementation. + * + * @module core/builders/agent-vault + */ +import { + FOREVER, + MAX_UINT64, + resolveCoin, + toBaseUnits, + buildMsg, + buildAliasPath, + sanitizeCosmosPathName, + ibcBackedInvariants, + generateAliasAddressForIBCBackedDenom, + frozenPermissions, + tokenMetadataEntry, + metadataFromFlat, + MetadataMissingError, + approvalMetadata, + stableHashId +} from './shared.js'; + +export type AgentVaultPeriod = 'daily' | 'weekly' | 'monthly'; + +export interface AgentVaultSigner { + address: string; + /** Voting weight (default 1). */ + weight?: number; +} + +export interface AgentVaultParams { + backingCoin: string; // USDC, BADGE, ATOM, OSMO + /** Pre-hosted collection metadata URI. If provided, name/image/description are ignored. */ + uri?: string; + name?: string; + symbol?: string; + image?: string; + description?: string; + /** Max the agent may withdraw per `period` (display units of the backing coin). 0/undefined = uncapped. */ + withdrawLimit?: number; + /** Reset window for `withdrawLimit`. Default 'daily'. */ + period?: AgentVaultPeriod; + /** Withdrawals invalid before this epoch-ms. */ + unlockAt?: number; + /** Withdrawals invalid after this epoch-ms. */ + expiresAt?: number; + /** Multisig signers whose weighted "yes" votes unlock withdrawals (one-time). */ + signers?: AgentVaultSigner[]; + /** Required yes-weight to unlock (N in N-of-M when weights are 1). Defaults to unanimous. Requires `signers`. */ + threshold?: number; + /** + * Optional admin kill-switch (off by default). A bb1... recovery address the + * human controls. When set, two baked approvals let recovery FULLY exit the + * vault at any time, bypassing the cap / time window / multisig: + * 1. a forceful "freeze" — recovery claws back vault tokens from any holder + * (the agent) to itself, and + * 2. an ungated recovery-only withdraw lane — recovery unbacks to the coin. + * This necessarily enables forceful post-mint transfers (the freeze), so it + * flips `noForcefulPostMintTransfers` to false; without it the vault stays + * fully locked. The recovery address must be known at build time (approvals + * are frozen at creation). + */ + recovery?: string; +} + +/** Stable IDs, matched by `extractAgentVaultDetails` (substring "deposit"/"withdraw"). */ +export const AGENT_VAULT_DEPOSIT_APPROVAL_ID = 'agent-vault-deposit'; +export const AGENT_VAULT_WITHDRAW_APPROVAL_PREFIX = 'agent-vault-withdraw'; +/** + * Prefix for the multisig withdraw proposal id. The actual proposalId is + * `stableHashId(this, withdrawSeed)` so it is UNIQUE per vault — the indexer + * keys VoteDocs by the bare proposalId (handleMsgCastVote `_docId = proposalId`), + * so a constant would make two multisig vaults collide on one vote doc. Matches + * the deterministic-proposalId convention in `bounty.ts`. + */ +export const AGENT_VAULT_WITHDRAW_PROPOSAL_PREFIX = 'agent-vault-withdraw-vote'; +/** Admin kill-switch approval ids (present only when `recovery` is set). */ +export const AGENT_VAULT_EMERGENCY_FREEZE_APPROVAL_ID = 'agent-vault-emergency-freeze'; +export const AGENT_VAULT_EMERGENCY_EXIT_APPROVAL_ID = 'agent-vault-emergency-exit'; + +const PERIOD_MS: Record = { + daily: 86_400_000, + weekly: 604_800_000, + monthly: 2_592_000_000 // 30d +}; + +/** Next midnight UTC (ms) — deterministic within a day; mirrors `vault.ts`. */ +function nextMidnight(): number { + const now = new Date(); + const m = new Date(now); + m.setUTCHours(0, 0, 0, 0); + if (m.getTime() <= now.getTime()) m.setUTCDate(m.getUTCDate() + 1); + return m.getTime(); +} + +export function buildAgentVault(params: AgentVaultParams): any { + const coin = resolveCoin(params.backingCoin); + const backingAddr = generateAliasAddressForIBCBackedDenom(coin.denom); + const symbol = sanitizeCosmosPathName(params.symbol || 'av' + coin.symbol, 'symbol'); + const period = params.period ?? 'daily'; + + // Single deterministic seed shared by the withdraw approvalId AND the multisig + // proposalId, so both are stable across replays yet unique per distinct vault + // (the proposalId MUST be unique — the indexer keys VoteDocs by it). + const withdrawSeed = { + backing: coin.denom, + symbol, + withdrawLimit: params.withdrawLimit || 0, + period, + unlockAt: params.unlockAt || 0, + expiresAt: params.expiresAt || 0, + signers: (params.signers ?? []).map((s) => `${s.address}:${s.weight ?? 1}`).join(','), + threshold: params.threshold || 0 + }; + + // Withdrawal gating → approvalCriteria. No overridesFrom/To: the holder (agent) + // must own the tokens to withdraw and the gating below caps the rate. (An + // optional manager kill-switch adds its own forceful approval separately.) + const withdrawCriteria: any = { mustPrioritize: true, allowBackedMinting: true }; + + // Amount cap → per-initiator running tally, reset every `period`. + if (params.withdrawLimit) { + withdrawCriteria.approvalAmounts = { + overallApprovalAmount: '0', + perToAddressApprovalAmount: '0', + perFromAddressApprovalAmount: '0', + perInitiatedByAddressApprovalAmount: toBaseUnits(params.withdrawLimit, coin.decimals), + amountTrackerId: `withdrawal-${period}`, + resetTimeIntervals: { + startTime: String(nextMidnight()), + intervalLength: String(PERIOD_MS[period]) + } + }; + } + + // Multisig → one-time voting-challenge unlock. + if (params.signers && params.signers.length) { + const voters = params.signers.map((s) => ({ address: s.address, weight: String(s.weight ?? 1) })); + // Reject duplicate signer addresses — they inflate totalWeight (so the + // quorum % is computed against a total the chain won't actually tally), + // which can make quorum unreachable. + const addrs = voters.map((v) => v.address); + if (new Set(addrs).size !== addrs.length) { + throw new Error('agent-vault: --signers contains duplicate addresses; each signer must be listed once.'); + } + const totalWeight = voters.reduce((n, v) => n + Number(v.weight), 0); + const threshold = params.threshold ?? totalWeight; // default: unanimous + // Chain semantics (verified against x/tokenization/keeper/msg_server_cast_vote.go): + // `quorumThreshold` is a PERCENTAGE (0–100) of total voter weight, and the + // pass check is `floor(yesWeight*100/total) >= quorumThreshold` (GTE, integer + // division). We map a required yes-weight `threshold` → floor(threshold / + // totalWeight * 100). Because the chain ALSO floors, this is exact for any + // total weight ≤ 100 (the floored percentages are strictly increasing, so + // `threshold-1` weight always lands below the bar and N-of-M never passes + // with fewer than N). Guard the input range — a threshold above the total + // weight (e.g. a typo'd 5-of-3) or < 1 would otherwise silently clamp to + // unanimous / 1%, hiding a misconfiguration. + if (threshold < 1 || threshold > totalWeight) { + throw new Error( + `agent-vault: --threshold must be between 1 and the total signer weight (${totalWeight}), got ${threshold}.` + ); + } + const quorumPct = Math.max(1, Math.min(100, Math.floor((threshold / totalWeight) * 100))); + withdrawCriteria.votingChallenges = [ + { + proposalId: stableHashId(AGENT_VAULT_WITHDRAW_PROPOSAL_PREFIX, withdrawSeed), + quorumThreshold: String(quorumPct), + voters, + uri: '', + customData: '', + resetAfterExecution: false, // one-time unlock — never re-arms + delayAfterQuorum: '0' + } + ]; + } + + // Time window → restrict the withdraw approval's transferTimes. + if (params.unlockAt && params.expiresAt && params.unlockAt >= params.expiresAt) { + throw new Error( + `agent-vault: --unlock-at (${params.unlockAt}) must be before --expires-at (${params.expiresAt}); ` + + 'otherwise withdrawals are permanently locked.' + ); + } + const withdrawTransferTimes = + params.unlockAt || params.expiresAt + ? [{ start: String(params.unlockAt ?? 1), end: String(params.expiresAt ?? MAX_UINT64) }] + : FOREVER; + + const collectionApprovals: any[] = [ + // Deposit: anyone funds the backing alias → mints agent-vault tokens to the funder. + { + fromListId: backingAddr, + toListId: `!${backingAddr}`, + initiatedByListId: 'All', + approvalId: AGENT_VAULT_DEPOSIT_APPROVAL_ID, + ...approvalMetadata('Deposit', 'Open deposit — fund the vault to mint agent-vault tokens.'), + transferTimes: FOREVER, + tokenIds: FOREVER, + ownershipTimes: FOREVER, + version: '0', + approvalCriteria: { mustPrioritize: true, allowBackedMinting: true } + }, + // Withdrawal: burn agent-vault tokens → release backing coin, within the gating. + { + fromListId: '!Mint', + toListId: backingAddr, + initiatedByListId: 'All', + // Deterministic suffix (not random) for replayable, diff-able builds. + // The `agent-vault-withdraw-` prefix is load-bearing for detection. + approvalId: stableHashId(AGENT_VAULT_WITHDRAW_APPROVAL_PREFIX, withdrawSeed), + ...approvalMetadata( + 'Withdrawal', + "Burn agent-vault tokens to withdraw backing coins, within the vault's gating (cap / time window / multisig)." + ), + transferTimes: withdrawTransferTimes, + tokenIds: FOREVER, + ownershipTimes: FOREVER, + version: '0', + approvalCriteria: withdrawCriteria + } + ]; + + // Optional admin kill-switch — two approvals that let `recovery` fully exit + // the vault at any time, bypassing all gating. Recovery-scoped, so only the + // recovery address can invoke them. Requires forceful transfers (the freeze), + // hence the invariant flip below. + if (params.recovery) { + collectionApprovals.push( + // Freeze: recovery forcibly claws back vault tokens from any holder (the + // agent) to itself. Excludes the backing alias from `from` — forceful + // transfers FROM a reserved protocol address are globally disallowed + // (ticket 0436). overrides bypass the agent's outgoing + recovery's + // incoming approvals so no agent cooperation is needed. + { + fromListId: `!Mint:${backingAddr}`, + toListId: params.recovery, + initiatedByListId: params.recovery, + approvalId: AGENT_VAULT_EMERGENCY_FREEZE_APPROVAL_ID, + ...approvalMetadata('Emergency Freeze', 'Recovery address claws back vault tokens from any holder.'), + transferTimes: FOREVER, + tokenIds: FOREVER, + ownershipTimes: FOREVER, + version: '0', + approvalCriteria: { + overridesFromOutgoingApprovals: true, + overridesToIncomingApprovals: true + } + }, + // Exit: recovery unbacks its (clawed-back) tokens → releases the backing + // coin to recovery. Recovery-initiated outgoing, so no overrides needed; + // NO cap / time window / multisig — this is the ungated emergency exit. + { + fromListId: params.recovery, + toListId: backingAddr, + initiatedByListId: params.recovery, + approvalId: AGENT_VAULT_EMERGENCY_EXIT_APPROVAL_ID, + ...approvalMetadata('Emergency Exit', 'Recovery address withdraws the backing coin, bypassing the vault gating.'), + transferTimes: FOREVER, + tokenIds: FOREVER, + ownershipTimes: FOREVER, + version: '0', + approvalCriteria: { mustPrioritize: true, allowBackedMinting: true } + } + ); + } + + const invariants = { + ...ibcBackedInvariants(coin.denom), + disablePoolCreation: true, + // An Agent Vault is a wallet-like Smart Token holding an agent's funds, so + // forceful post-mint transfers are locked by default — combined with + // frozenPermissions() below, nothing can move the agent's tokens out of + // band. The opt-in kill-switch deliberately bakes a forceful "freeze" + // approval, which the chain only permits when this invariant is false, so + // we unlock it precisely (and only) when a recovery lane is configured. + noForcefulPostMintTransfers: !params.recovery + }; + + const collectionSource = metadataFromFlat({ + uri: params.uri, + name: params.name, + description: params.description, + image: params.image + }); + if (!collectionSource) { + throw new MetadataMissingError('agent-vault collectionMetadata', ['name', 'image', 'description']); + } + const aliasPath = buildAliasPath({ + denom: 'u' + symbol.toLowerCase(), + symbol, + decimals: coin.decimals, + pathMetadata: collectionSource, + unitMetadata: collectionSource + }); + + return buildMsg({ + collectionApprovals, + standards: ['Smart Token', 'Agent Vault'], + invariants, + aliasPathsToAdd: [aliasPath], + collectionMetadata: collectionSource, + tokenMetadata: [tokenMetadataEntry(FOREVER, collectionSource, 'agent-vault token')], + // Fully frozen — the manager cannot edit/revoke the withdraw approval + // post-deposit and trap depositor funds (matches `vault.ts`). + collectionPermissions: frozenPermissions() + }); +} diff --git a/packages/bitbadgesjs-sdk/src/core/builders/index.ts b/packages/bitbadgesjs-sdk/src/core/builders/index.ts index c5aec06cdf..b8de883a06 100644 --- a/packages/bitbadgesjs-sdk/src/core/builders/index.ts +++ b/packages/bitbadgesjs-sdk/src/core/builders/index.ts @@ -10,6 +10,17 @@ // ── Collection builders ────────────────────────────────────────────────────── export { buildVault, type VaultParams } from './vault.js'; +export { + buildAgentVault, + type AgentVaultParams, + type AgentVaultSigner, + type AgentVaultPeriod, + AGENT_VAULT_DEPOSIT_APPROVAL_ID, + AGENT_VAULT_WITHDRAW_APPROVAL_PREFIX, + AGENT_VAULT_WITHDRAW_PROPOSAL_PREFIX, + AGENT_VAULT_EMERGENCY_FREEZE_APPROVAL_ID, + AGENT_VAULT_EMERGENCY_EXIT_APPROVAL_ID +} from './agent-vault.js'; export { buildSubscription, type SubscriptionParams, type SubscriptionPayout } from './subscription.js'; export { buildBounty, type BountyParams } from './bounty.js'; export { buildPaymentRequest, type PaymentRequestParams } from './payment-request.js'; diff --git a/packages/bitbadgesjs-sdk/src/core/index.ts b/packages/bitbadgesjs-sdk/src/core/index.ts index 3f5a6d5a37..f50b01d9c5 100644 --- a/packages/bitbadgesjs-sdk/src/core/index.ts +++ b/packages/bitbadgesjs-sdk/src/core/index.ts @@ -31,6 +31,7 @@ export * from './bounties.js'; export * from './payment-requests.js'; export * from './auctions.js'; export * from './smart-tokens.js'; +export * from './agent-vaults.js'; export * from './credit-tokens.js'; // NOTE: interpret.js was moved to ../api-indexer/interpret.js to avoid a circular import // (interpretCollection depends on the BitBadgesCollection runtime class, whose module graph