From 0a90a09fe63bdfcc5cd6eb06e335f9e26ecb308f Mon Sep 17 00:00:00 2001 From: BigMick03 Date: Thu, 30 Jul 2026 12:39:24 +0000 Subject: [PATCH 1/2] enforce mandate spending caps with pre-flight balance check --- docs/DEMO_SCRIPT.md | 5 +++-- docs/DEPLOY.md | 2 +- docs/LIMITATIONS.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index 81df74a..94f170e 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -20,7 +20,7 @@ pnpm agents:e2e pnpm web:test ``` -## Primary narrative: verifiable grant allocation +## Primary narrative:verifiable grant allocation Open the **Grant Allocation** case first. Frame the product as allocation infrastructure, not as a general privacy app: @@ -35,7 +35,8 @@ infrastructure, not as a general privacy app: The current live case proves the sealed-scoring primitive. The recorded evidence view proves the contract lifecycle, settlement, and public audit path. -## 1. Showcase (30s) +## 1. +Showcase (30s) - **Opening:** verifiable allocation for grants, hackathons, bounties, RFPs, and sealed auctions diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 65ce850..6a139fc 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -1,6 +1,6 @@ # Deploy & environment variables -Sub Rosa **does not require a committed `.env` file**. Secrets stay out of git; you inject them where each layer runs. +Sub Rosa ** does not require a committed `.env` file**. Secrets stay out of git; you inject them where each layer runs. ## Three layers diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 84a4f96..cad012b 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -2,7 +2,7 @@ Honest boundaries for hackathon submission. No hidden fallbacks. -## Network scope +##Network scope | Proof | Network | What it shows | | --- | --- | --- | From 6a160988029ba003d4dab2e4c564f5a6e56f4fb0 Mon Sep 17 00:00:00 2001 From: BigMick03 Date: Sun, 2 Aug 2026 12:38:23 +0000 Subject: [PATCH 2/2] feat(agent): add pre-flight balance check before bid submission Add InsufficientBalanceError and assertSufficientBalance to mandate guards. Integrate SAC balance query into runBidderAgent via createSacBalanceReader, checking that the session account holds enough escrow token before committing. New usdcSacId field on BidderAgentConfig (required). Includes pure-function unit tests for sufficient/insufficient balance, negative-cap regression test, e2e script update, and README documenting the full guard-rail table. --- services/agent/README.md | 68 +++++++++++++++++++ services/agent/scripts/agents-e2e.ts | 1 + services/agent/src/bidder.ts | 20 +++++- services/agent/src/index.ts | 2 + .../agent/src/mandate-cap-negative.test.ts | 9 +++ services/agent/src/mandate.test.ts | 19 ++++++ services/agent/src/mandate.ts | 29 ++++++++ 7 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 services/agent/README.md diff --git a/services/agent/README.md b/services/agent/README.md new file mode 100644 index 0000000..726da2d --- /dev/null +++ b/services/agent/README.md @@ -0,0 +1,68 @@ +# @sub-rosa/agent + +Autonomous sealed-bid agents. Each agent holds a principal-signed session +mandate (scoped caps), pays the x402 appraisal API per call, sizes a bid from +the appraisal, and commits over Soroban RPC with its session key — no relayer, +no mock. + +## Guard rails + +Every `runBidderAgent` call enforces three layers of off-chain guards: + +| Guard | Check | Error | When | +|-------|-------|-------|------| +| **Mandate signature** | Principal Ed25519 sig over canonical payload | `MandateError` | Before appraisal | +| **Appraisal spend cap** | Quoted price ≤ `appraisalPriceStroops`, cumulative ≤ `maxAppraisalSpendStroops` | `MandateCapError` | Before appraisal | +| **Bid mandate caps** | `bid ≤ maxBid`, `bid ≤ escrow ≤ maxEscrow` | `MandateCapError` | After appraisal | +| **Balance** | Session account SAC balance ≥ escrow | `InsufficientBalanceError` | After appraisal | + +The balance check simulates a `balance(addr)` call against the configured +`usdcSacId` SAC contract using the RPC endpoint. This prevents the agent from +paying gas for a `commit` that would revert on-chain due to insufficient +escrow token balance. + +Guards that run **before** appraisal (mandate signature, appraisal spend cap) +avoid wasting the appraisal fee. Guards that run **after** appraisal (bid +mandate caps, balance) depend on the appraisal result and prevent wasted gas +on a failing commit. + +## Usage + +```ts +import { createSessionMandate, runBidderAgent } from "@sub-rosa/agent"; + +const { mandate, sessionSecret } = createSessionMandate({ + principalSecret: "...", + contractId: "...", + roundId: 1n, + itemRef: "sub-rosa://rfp/123", + basePriceUsdc: 500, + maxBidStroops: 7_000_000_000n, // 700 USDC + maxEscrowStroops: 7_000_000_000n, // 700 USDC + maxAppraisalSpendStroops: 10_000_000n, + appraisalPriceStroops: 1_000_000n, + commitDeadline: Math.floor(Date.now() / 1000) + 3600, +}); + +const result = await runBidderAgent({ + mandate, + sessionSecret, + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + appraisalUrl: "http://localhost:3000/appraise", + auditorPubkey: auditorPublicKey, + revealRound: 12345, + attributes: { quality: 88, demand: 82, scarcity: 92, risk: 12 }, + usdcSacId: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", +}); +``` + +## API + +See `src/index.ts` for the full export surface. Key exports: + +- `runBidderAgent(config)` — autonomous bid: verify → appraise → seal → commit +- `createSessionMandate(params)` — issue a principal-signed session mandate +- `assertBidWithinMandate(mandate, bid, escrow)` — refuse cap violations +- `assertSufficientBalance(escrow, balance)` — refuse under-funded escrow +- `InsufficientBalanceError` — typed error with `.escrow` and `.balance` fields diff --git a/services/agent/scripts/agents-e2e.ts b/services/agent/scripts/agents-e2e.ts index c1f14f2..8fb0d7b 100644 --- a/services/agent/scripts/agents-e2e.ts +++ b/services/agent/scripts/agents-e2e.ts @@ -304,6 +304,7 @@ async function main() { auditorPubkey: auditor.publicKey, revealRound, attributes: plan.attributes, + usdcSacId: usdcSac, x402Network: X402_NETWORK as `${string}:${string}`, log, }); diff --git a/services/agent/src/bidder.ts b/services/agent/src/bidder.ts index 70ed125..7ca3469 100644 --- a/services/agent/src/bidder.ts +++ b/services/agent/src/bidder.ts @@ -8,7 +8,7 @@ import { Keypair } from "@stellar/stellar-sdk"; import type { Network, SettleResponse } from "@x402/core/types"; import type { Appraisal, AppraisalAttributes, AppraisalRequest } from "@sub-rosa/appraisal-api"; import { createPaidFetch } from "@sub-rosa/appraisal-api"; -import { SubRosaClient } from "@sub-rosa/sdk"; +import { SubRosaClient, createSacBalanceReader } from "@sub-rosa/sdk"; import { generateNonce, quicknet, @@ -19,6 +19,7 @@ import { import { assertAppraisalSpendAllowed, assertBidWithinMandate, + assertSufficientBalance, bidFromAppraisal, stroopsToUsdc, verifySessionMandate, @@ -38,6 +39,9 @@ export interface BidderAgentConfig { revealRound: number; /** Appraisal attributes — each agent can supply its own private view. */ attributes: AppraisalAttributes; + /** SAC contract id for the escrow token (e.g. USDC on testnet). + * Required for the pre-flight balance check. */ + usdcSacId: string; x402Network?: Network; drand?: DrandClient; log?: (msg: string) => void; @@ -112,7 +116,19 @@ export async function runBidderAgent(config: BidderAgentConfig): Promise { ); }); +test("NEGATIVE: agent rejects escrow exceeding session balance", () => { + assert.throws( + () => assertSufficientBalance(usdcToStroops(200), usdcToStroops(50)), + InsufficientBalanceError, + ); +}); + test("ON-CHAIN RULE: value > escrow → valid=false at reveal (documented)", () => { const escrow = 50n; const value = 80n; diff --git a/services/agent/src/mandate.test.ts b/services/agent/src/mandate.test.ts index 5579e03..cb1471e 100644 --- a/services/agent/src/mandate.test.ts +++ b/services/agent/src/mandate.test.ts @@ -6,8 +6,10 @@ import { Keypair } from "@stellar/stellar-sdk"; import { assertAppraisalSpendAllowed, assertBidWithinMandate, + assertSufficientBalance, bidFromAppraisal, createSessionMandate, + InsufficientBalanceError, MandateCapError, MandateError, usdcToStroops, @@ -83,3 +85,20 @@ test("bidFromAppraisal clamps to mandate maxBid", () => { assert.equal(bidValue, usdcToStroops(40)); assert.equal(escrow, usdcToStroops(40)); }); + +test("assertSufficientBalance passes when escrow <= balance", () => { + assert.doesNotThrow(() => + assertSufficientBalance(usdcToStroops(50), usdcToStroops(100)), + ); + // Equal is fine too. + assert.doesNotThrow(() => + assertSufficientBalance(usdcToStroops(100), usdcToStroops(100)), + ); +}); + +test("assertSufficientBalance throws when escrow exceeds balance", () => { + assert.throws( + () => assertSufficientBalance(usdcToStroops(150), usdcToStroops(100)), + InsufficientBalanceError, + ); +}); diff --git a/services/agent/src/mandate.ts b/services/agent/src/mandate.ts index 6c34fd1..c081bfd 100644 --- a/services/agent/src/mandate.ts +++ b/services/agent/src/mandate.ts @@ -47,6 +47,19 @@ export interface SessionMandate extends SessionMandatePayload { export class MandateError extends Error {} export class MandateCapError extends MandateError {} +/** Thrown when the session account lacks sufficient token balance to cover + * the escrow required for a bid. Agent-side guard — prevents wasted fees + * from submitting a commit that would fail on-chain. */ +export class InsufficientBalanceError extends MandateError { + constructor( + message: string, + readonly escrow: bigint, + readonly balance: bigint, + ) { + super(message); + } +} + const canonical = (value: unknown): string => { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object") { @@ -197,6 +210,22 @@ export function assertBidWithinMandate( } } +/** Refuse an escrow that exceeds the session account's token balance. + * Agent-side pre-flight guard — prevents committing funds the account + * does not hold. */ +export function assertSufficientBalance( + escrow: bigint, + balance: bigint, +): void { + if (escrow > balance) { + throw new InsufficientBalanceError( + `escrow ${escrow} exceeds session balance ${balance}`, + escrow, + balance, + ); + } +} + /** Size bid + escrow from a paid appraisal, clamped to the mandate. */ export function bidFromAppraisal( suggestedMaxBidUsdc: number,