From 9087504a13dcf5caa9292ca631b86ee6e22388e2 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Sun, 30 Aug 2026 09:30:32 +0100 Subject: [PATCH] feat: add bidder refund claims and security docs --- SECURITY.md | 23 ++++ docs/operations/health-and-shutdown.md | 28 ++++ docs/threat-model.md | 33 +++++ .../contracts/agent_bidding/src/errors.rs | 6 +- .../contracts/agent_bidding/src/lib.rs | 121 +++++++++++++++++- .../contracts/agent_bidding/src/types.rs | 10 ++ 6 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 SECURITY.md create mode 100644 docs/operations/health-and-shutdown.md create mode 100644 docs/threat-model.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..23ab0eaf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Responsible Disclosure + +Please report suspected vulnerabilities privately instead of opening a public issue with exploit details. + +- Email: security@example.com +- Include affected component, impact, reproduction steps, and any relevant logs or transaction ids. +- Do not access data that is not yours, modify production state, or interrupt network availability while validating a report. +- We acknowledge reports within 3 business days and provide a remediation status update within 10 business days. + +## Severity Targets + +| Severity | Examples | Target | +| --- | --- | --- | +| Critical | Fund loss, private key exposure, remote code execution | Patch or mitigation within 48 hours | +| High | Auth bypass, task tampering, payment reconciliation bypass | Patch within 7 days | +| Medium | Privilege confusion, sensitive metadata leakage | Patch within 30 days | +| Low | Hardening gaps, low-impact information disclosure | Next regular release | + +## Coordinated Release + +Security fixes should include tests, migration notes when state changes, and a short advisory that avoids publishing exploit-ready payloads until users have had time to update. diff --git a/docs/operations/health-and-shutdown.md b/docs/operations/health-and-shutdown.md new file mode 100644 index 00000000..b86e7143 --- /dev/null +++ b/docs/operations/health-and-shutdown.md @@ -0,0 +1,28 @@ +# Health Checks and Graceful Shutdown + +## Probe Contract + +| Probe | Purpose | Expected status | +| --- | --- | --- | +| `GET /health` or `GET /health/live` | Process liveness. Returns without checking dependencies. | `200` while the process can answer HTTP | +| `GET /health/ready` | Readiness for serving traffic. Checks local task and payment stores. | `200` when ready, `500` when local stores fail | +| `GET /health/deep` or `GET /health/dependencies` | Dependency probes for Venice AI and Stellar Horizon. | `200` when all dependencies are reachable, `503` when degraded | + +Readiness should be removed from load balancers before shutdown starts. Liveness should remain successful until the process is ready to exit so supervisors do not hard-kill the server during drain. + +## Shutdown Order + +1. Stop accepting new HTTP and websocket connections. +2. Stop registry sync and recurring background workers. +3. Mark running tasks as failed or interrupted with a durable reason. +4. Mark online agents offline so stale capacity is not advertised. +5. Flush logs, metrics, and reconciliation state. +6. Close task, agent, payment, and queue databases. + +`GRACEFUL_SHUTDOWN_TIMEOUT` bounds the full drain. Production deployments should set the platform termination grace period higher than this value. + +## Operator Checks + +- Confirm `/health/ready` returns non-200 before terminating an instance during rolling deploys. +- Confirm `/health/deep` reports both `venice` and `horizon` as `ok` before enabling traffic. +- Review shutdown logs for each phase when debugging interrupted task execution. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 00000000..e89f03ee --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,33 @@ +# ai-net Threat Model + +## Assets + +- Task prompts, prompt hashes, compressed DAGs, and agent outputs. +- Submitter wallets, payment escrow state, and reconciliation records. +- Agent reputation, bid commitments, reveal data, and refund state. +- Backend API availability and websocket task streams. + +## Trust Boundaries + +| Boundary | Risks | Required Controls | +| --- | --- | --- | +| Wallet to frontend | spoofed accounts, wrong network, replayed signatures | explicit network display, challenge freshness, signature purpose binding | +| Frontend to backend | oversized payloads, auth confusion, stale task reads | schema validation, rate limits, request ids, tenant/task authorization | +| Backend to agents | forged assignments, replayed results, unavailable agents | signed dispatch payloads, idempotency keys, heartbeat expiry | +| Backend to Stellar | stale ledger reads, failed submissions, reconciliation drift | retry budget, event indexing, periodic reconciliation | +| Contracts to indexers | missed lifecycle events, ambiguous terms | typed event payloads, stable glossary terms, replayable indexes | + +## Primary Abuse Cases + +- A bidder loses an auction and cannot independently recover bond state if award execution is delayed. +- A coordinator or indexer misses a task transition because lifecycle events are not explicit. +- A degraded dependency keeps receiving production traffic because readiness does not include dependency probes. +- A shutdown interrupts task dispatch, websocket streams, or reconciliation before state is flushed. + +## Required Mitigations + +- Emit explicit task lifecycle events for on-chain task state changes. +- Provide liveness, readiness, and dependency health probes for deployment platforms. +- Drain HTTP and websocket traffic before closing databases and background workers. +- Keep migration rollbacks deterministic and operator-invokable. +- Document vulnerability reporting, triage targets, and coordinated disclosure expectations. diff --git a/smart-contracts/contracts/agent_bidding/src/errors.rs b/smart-contracts/contracts/agent_bidding/src/errors.rs index 6297fedc..91574b29 100644 --- a/smart-contracts/contracts/agent_bidding/src/errors.rs +++ b/smart-contracts/contracts/agent_bidding/src/errors.rs @@ -5,7 +5,7 @@ //! callers can branch on the numeric code without coupling to a specific SDK //! build. //! -//! The code range used here (`1..=17`) is local to this contract. Codes are +//! The code range used here (`1..=19`) is local to this contract. Codes are //! chosen to read naturally in logs while remaining stable across releases: //! **never renumber an existing variant** once the contract is deployed. @@ -50,4 +50,8 @@ pub enum Error { WinnerNotDetermined = 16, /// The escrow for this auction has already been created. EscrowAlreadyCreated = 17, + /// Winners cannot use the unsuccessful-bidder refund path. + WinnerCannotClaimRefund = 18, + /// The bidder's bond has already been marked as refunded. + RefundAlreadyClaimed = 19, } diff --git a/smart-contracts/contracts/agent_bidding/src/lib.rs b/smart-contracts/contracts/agent_bidding/src/lib.rs index 5ec4d1e0..093ed1c6 100644 --- a/smart-contracts/contracts/agent_bidding/src/lib.rs +++ b/smart-contracts/contracts/agent_bidding/src/lib.rs @@ -57,7 +57,7 @@ mod types; pub use errors::Error; pub use types::{ Auction, AuctionConfig, AuctionCreatedEvent, AuctionPhase, BidRevealedEvent, BidSubmittedEvent, - BidsRevealedEvent, ContractAwardedEvent, DataKey, Escrow, SealedBid, + BidsRevealedEvent, BondRefundClaimedEvent, ContractAwardedEvent, DataKey, Escrow, SealedBid, DEFAULT_BIDDING_DURATION_SECS, MAX_REPUTATION, PRICE_WEIGHT, REPUTATION_WEIGHT, SCORE_SCALE, }; @@ -504,6 +504,60 @@ impl AgentBiddingContract { Ok(()) } + /// Allow a losing bidder to claim their bid bond refund after a winner is selected. + /// + /// This path intentionally excludes the winner because the winning bid proceeds to + /// escrow award. It lets unsuccessful bidders recover independently if `award_contract` + /// is delayed by the creator or an off-chain coordinator. + pub fn claim_bid_refund(env: Env, task_id: Symbol, bidder: Address) -> Result<(), Error> { + bidder.require_auth(); + + let auction: Auction = env + .storage() + .persistent() + .get(&DataKey::Auction(task_id.clone())) + .ok_or(Error::NotFound)?; + + if auction.phase != AuctionPhase::Reveal { + return Err(Error::NotInRevealPhase); + } + + let winner: Address = env + .storage() + .persistent() + .get(&DataKey::Winner(task_id.clone())) + .ok_or(Error::WinnerNotDetermined)?; + if winner == bidder { + return Err(Error::WinnerCannotClaimRefund); + } + + let bid_key = DataKey::Bid(task_id.clone(), bidder.clone()); + let mut bid: SealedBid = env + .storage() + .persistent() + .get(&bid_key) + .ok_or(Error::NotFound)?; + if bid.refunded { + return Err(Error::RefundAlreadyClaimed); + } + + bid.refunded = true; + let bond = bid.bond; + env.storage().persistent().set(&bid_key, &bid); + extend_ttl_for_key(&env, &bid_key); + + env.events().publish( + (symbol_short!("bidding"), symbol_short!("ref_claim")), + BondRefundClaimedEvent { + task_id, + bidder, + bond, + }, + ); + + Ok(()) + } + // ── Award Contract ───────────────────────────────────────────────────── /// Award the contract to the winner determined by `reveal_bids`. @@ -1176,6 +1230,71 @@ mod test { assert!(loser_bid.refunded, "loser's bond should be refunded"); } + #[test] + fn unsuccessful_bidder_can_claim_refund_after_reveal() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "claim_ref"); + + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let winner = Address::generate(&env); + let loser = Address::generate(&env); + let terms = String::from_str(&env, "Terms"); + let winner_salt = BytesN::<32>::from_array(&env, &[51u8; 32]); + let loser_salt = BytesN::<32>::from_array(&env, &[52u8; 32]); + let winner_price = 2_000_000i128; + let loser_price = 5_000_000i128; + let winner_commitment = test_commitment(&env, &winner, winner_price, &terms, &winner_salt); + let loser_commitment = test_commitment(&env, &loser, loser_price, &terms, &loser_salt); + + client.submit_bid(&task_id, &winner, &winner_commitment, &500_000, &80); + client.submit_bid(&task_id, &loser, &loser_commitment, &500_000, &80); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + client.reveal_bid(&task_id, &winner, &winner_price, &terms, &winner_salt); + client.reveal_bid(&task_id, &loser, &loser_price, &terms, &loser_salt); + client.reveal_bids(&task_id); + + client.claim_bid_refund(&task_id, &loser); + + let loser_bid = client.get_bid(&task_id, &loser).unwrap(); + assert!(loser_bid.refunded); + let events = env.events().all(); + assert_eq!( + events.last().unwrap().1, + (symbol_short!("bidding"), symbol_short!("ref_claim")).into_val(&env) + ); + } + + #[test] + fn winner_cannot_claim_unsuccessful_bidder_refund() { + let (env, client) = setup(); + let creator = Address::generate(&env); + let task_id = Symbol::new(&env, "win_ref"); + + create_test_auction(&env, &client, &creator, &task_id, 3600); + + let winner = Address::generate(&env); + let loser = Address::generate(&env); + let terms = String::from_str(&env, "Terms"); + let winner_salt = BytesN::<32>::from_array(&env, &[53u8; 32]); + let loser_salt = BytesN::<32>::from_array(&env, &[54u8; 32]); + let winner_commitment = test_commitment(&env, &winner, 2_000_000, &terms, &winner_salt); + let loser_commitment = test_commitment(&env, &loser, 5_000_000, &terms, &loser_salt); + + client.submit_bid(&task_id, &winner, &winner_commitment, &500_000, &80); + client.submit_bid(&task_id, &loser, &loser_commitment, &500_000, &80); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3601); + client.reveal_bid(&task_id, &winner, &2_000_000, &terms, &winner_salt); + client.reveal_bid(&task_id, &loser, &5_000_000, &terms, &loser_salt); + client.reveal_bids(&task_id); + + let err = client.try_claim_bid_refund(&task_id, &winner); + assert_eq!(err.err(), Some(Ok(Error::WinnerCannotClaimRefund))); + } + #[test] fn award_contract_before_reveal_bids_fails() { let (env, client) = setup(); diff --git a/smart-contracts/contracts/agent_bidding/src/types.rs b/smart-contracts/contracts/agent_bidding/src/types.rs index 3cf0917f..ff646c96 100644 --- a/smart-contracts/contracts/agent_bidding/src/types.rs +++ b/smart-contracts/contracts/agent_bidding/src/types.rs @@ -26,6 +26,7 @@ //! | `submit_bid` | `bid_submitted` | `BidSubmittedEvent` | //! | `reveal_bid` | `bid_revealed` | `BidRevealedEvent` | //! | `reveal_bids` | `bids_revealed` | `BidsRevealedEvent` | +//! | `claim_bid_refund`| `refund_claimed` | `BondRefundClaimedEvent` | //! | `award_contract` | `contract_awarded` | `ContractAwardedEvent` | use soroban_sdk::{contracttype, Address, BytesN, String, Symbol}; @@ -220,6 +221,15 @@ pub struct BidsRevealedEvent { pub winning_price: i128, } +/// Emitted when an unsuccessful bidder claims their bid bond refund. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BondRefundClaimedEvent { + pub task_id: Symbol, + pub bidder: Address, + pub bond: i128, +} + /// Emitted when `award_contract` creates the escrow and refunds bonds. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)]