Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/DEMO_SCRIPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ infrastructure, not as a general privacy app:
The recorded evidence view proves the contract lifecycle, settlement, and
public audit path.

## 1. Showcase (30s)
## 1.
Showcase (30s)

- Opening: escrow-backed sealed auctions on Stellar
- Mainnet proof card: settled round 1 on real XLM (link to stellar.expert)
Expand Down
2 changes: 1 addition & 1 deletion docs/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Honest boundaries for the current prototype and submission materials. No hidden
fallbacks.

## Network scope
##Network scope

| Proof | Network | What it shows |
| --- | --- | --- |
Expand Down
68 changes: 68 additions & 0 deletions services/agent/README.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions services/agent/scripts/agents-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ async function main() {
auditorPubkey: auditor.publicKey,
revealRound,
attributes: plan.attributes,
usdcSacId: usdcSac,
x402Network: X402_NETWORK as `${string}:${string}`,
log,
});
Expand Down
20 changes: 18 additions & 2 deletions services/agent/src/bidder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +19,7 @@ import {
import {
assertAppraisalSpendAllowed,
assertBidWithinMandate,
assertSufficientBalance,
bidFromAppraisal,
stroopsToUsdc,
verifySessionMandate,
Expand All @@ -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;
Expand Down Expand Up @@ -112,7 +116,19 @@ export async function runBidderAgent(config: BidderAgentConfig): Promise<BidderA

const { bidValue, escrow } = bidFromAppraisal(appraisal.suggestedMaxBid, config.mandate);
assertBidWithinMandate(config.mandate, bidValue, escrow);
log(`appraisal → bid ${stroopsToUsdc(bidValue)} USDC (escrow ${stroopsToUsdc(escrow)})`);

// Pre-flight balance check: refuse to commit if the session account
// doesn't hold enough escrow token to cover the locked escrow.
const readBalance = createSacBalanceReader(
config.rpcUrl,
config.networkPassphrase,
config.usdcSacId,
sessionKp.publicKey(),
);
const balance = await readBalance(sessionKp.publicKey());
assertSufficientBalance(escrow, balance);

log(`appraisal → bid ${stroopsToUsdc(bidValue)} USDC (escrow ${stroopsToUsdc(escrow)}, balance ${stroopsToUsdc(balance)} USDC)`);

const drand = config.drand ?? quicknet();
const nonce = generateNonce();
Expand Down
2 changes: 2 additions & 0 deletions services/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ export {
verifySessionMandate,
assertAppraisalSpendAllowed,
assertBidWithinMandate,
assertSufficientBalance,
bidFromAppraisal,
mandateDigest,
usdcToStroops,
stroopsToUsdc,
MandateError,
MandateCapError,
InsufficientBalanceError,
type SessionMandate,
type SessionMandatePayload,
type CreateMandateParams,
Expand Down
9 changes: 9 additions & 0 deletions services/agent/src/mandate-cap-negative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import { Keypair } from "@stellar/stellar-sdk";
import {
assertAppraisalSpendAllowed,
assertBidWithinMandate,
assertSufficientBalance,
bidFromAppraisal,
createSessionMandate,
InsufficientBalanceError,
MandateCapError,
usdcToStroops,
} from "./mandate.js";
Expand Down Expand Up @@ -51,6 +53,13 @@ test("NEGATIVE: agent rejects explicit bid above mandate maxBid", () => {
);
});

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;
Expand Down
19 changes: 19 additions & 0 deletions services/agent/src/mandate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { Keypair } from "@stellar/stellar-sdk";
import {
assertAppraisalSpendAllowed,
assertBidWithinMandate,
assertSufficientBalance,
bidFromAppraisal,
createSessionMandate,
InsufficientBalanceError,
MandateCapError,
MandateError,
usdcToStroops,
Expand Down Expand Up @@ -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,
);
});
29 changes: 29 additions & 0 deletions services/agent/src/mandate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
Expand Down