diff --git a/HACKATHON.md b/HACKATHON.md index ab68a9d..86e8115 100644 --- a/HACKATHON.md +++ b/HACKATHON.md @@ -1,119 +1,78 @@ -# Maiat Dojo — BNB Hackathon Submission +# AgentShack — The Smart Money Era -> **Tokenized Agent Commerce + Reputation-Gated Allocation on BNB Chain.** -> Chain: BSC Testnet (chain 97), Mainnet ready. -> Target: Long-term Resource-Based Economy (RBE) for AI agents. +A BSC Testnet marketplace for discovering, inspecting and hiring curated BNB Agent Studio agents across all four main-track categories. ---- +- Track: Main Track — Build the BNB Agent Studio Marketplace. +- Repository: https://github.com/jh1nresh/agentshack +- Public marketplace: https://www.agentshack.io/market +- Homepage: https://www.agentshack.io +- Network: BSC Testnet, chain ID 97. No mainnet hiring is offered. +- Rules: https://www.bnbchain.org/en/hackathons/smart-money-era +- Registration: https://forms.gle/9g9XPNFwnYaHAz9L8 +- Official form build deadline: September 9, 2026, 12:00 UTC (20:00 Taiwan). +- Judging: September 9–23, 2026. -## One-liner +## Four categories, with explicit service limits -**Agents buy skills like ERC-20 tokens, gated by reputation not just money.** -Every `swap()` checks reputation before transferring USDC. Day-1 floors are 0, -but the gate exists so reputation can gradually replace money as the primary -resource allocation mechanism — the RBE seed. +| Category | Agent / ERC-8004 ID | Available service | Limitation | +| --- | --- | --- | --- | +| Rebalancing | Studio Desk Rebalancer / 1880 | Reads PancakeSwap V3 pool and position data; returns a bounded rebalance proposal | No best-range, fee-return or continuous management guarantee | +| Grid Trading | Studio Desk Grid / 1881 | One bounded step on a fixed Pancake pair, with inventory and minimum-output limits | No profitability guarantee or claim of a measured trading track record | +| Yield Optimisation | Studio Desk Bounded Yield / 1882 | Bounded deposit/withdrawal at one verified Venus venue | Not a highest-APR search or cross-venue optimiser | +| Health Factor Monitoring | Studio Desk Risk Protection / 1883 | Venus liquidity/shortfall reads and a bounded repayment proposal | Liquidity/shortfall is not presented as a computed health-factor metric | ---- +The marketplace lists third-party Studio Desk services. The descriptions above do not claim that AgentShack built those agents, that an agent card proves execution, or that receiving a proposal proves a DeFi transaction occurred. -## What's on-chain (BSC Testnet — chain 97) +## User journey -| Contract | Address | -|---|---| -| SkillRegistry | [`0x104579420Ab86579631E8452EE553A75Fc257690`](https://testnet.bscscan.com/address/0x104579420Ab86579631E8452EE553A75Fc257690) | -| SwapRouter | [`0x2844515814b44Ab23d5001571e2E1C726295536a`](https://testnet.bscscan.com/address/0x2844515814b44Ab23d5001571e2E1C726295536a) | -| ReputationHub | [`0x6c6b8b4a72A291d95eC461FEc29cd764bbfcC159`](https://testnet.bscscan.com/address/0x6c6b8b4a72A291d95eC461FEc29cd764bbfcC159) | -| USDC (test) | [`0x2F808cc071D7B54d23a7647d79d7EF6E2C830d31`](https://testnet.bscscan.com/address/0x2F808cc071D7B54d23a7647d79d7EF6E2C830d31) | +1. Open the homepage and select one of the four categories. +2. Search/select an agent, inspect its service, fee and limitations. +3. Open its setup page and connect a BSC Testnet wallet. +4. Request a signed quote. The server checks its canonical hash, signer, chain, agent, commerce contract, policy, fee token, selector and fee cap. +5. Approve the five wallet transactions: create job, register policy, set budget, approve exact fee, fund. +6. Request delivery. Retrying after confirmed funding only repeats the delivery notification in the current page session. +7. Inspect the seller response and transaction links. Record delivery and actual execution evidence separately. -### Demo skills registered +The wallet needs testnet BNB for gas and the seller's quoted test $U token. The four currently listed fees total 0.0042 test $U, excluding gas. The app does not acquire tokens or send a private key to a server. -| Slug | skillId | RUN_TOKEN | -|---|---|---| -| echo-test | `0x8ba5917d62055f9100acf59259c285ec2d0825d3fb72d3dc7dc0ac9a564b6f91` | `0x05E472c503E3e0651723E9A1638BD54E9873a426` | -| web-scraper | `0x155a797494737fd1f2521b7378a906951020479d96261ff2a1b89c996936d255` | `0x689b4183b76aA2518E39f0C2ce9BF849F5f6E4c2` | -| price-oracle | `0xe84a466f61fd3ef7b48426e340721de4a6859b63f9d22100f241f38815209150` | `0x3BB93aADA4e149FeCa5c1e2390Fe256582E5C8C2` | +## Data and security -### E2E on-chain proof (2026-04-18) +The server reads `getAgentWallet` and `tokenURI` directly from the fixed BSC Testnet ERC-8004 registry. For these curated data-URI identities, it verifies the chain, wallet, identity metadata and exact A2A/HTTP endpoints before fetching the agent card from `desk.rouma.online`. An unavailable indexer is not an activation dependency. Unknown identity, changed endpoint, malformed metadata or failed RPC reads disable activation. -- swap tx: [`0xa7fab38797227a122c3f837458dfd6ed70b0ecf51798d6d01cde7d8de3d09d29`](https://testnet.bscscan.com/tx/0xa7fab38797227a122c3f837458dfd6ed70b0ecf51798d6d01cde7d8de3d09d29) -- settle tx: [`0xa754b56944d7473dd992dacc3d5645ad98b7fb7fc6b2395ebe8a3ddffc287913`](https://testnet.bscscan.com/tx/0xa754b56944d7473dd992dacc3d5645ad98b7fb7fc6b2395ebe8a3ddffc287913) +Seller quotes use v1 flat JSON terms with lexicographically sorted keys, keccak256, and an EIP-191 signature over the raw hash. All four real signed responses are retained as public regression fixtures in `src/__tests__/fixtures/bnb-seller-quotes.json`. ---- +External agent-card and seller-response bodies have a 100 KB streaming limit. Curated URLs are server-owned, redirects are rejected, and requests time out. No caller-supplied URL is accepted. -## Architecture +Identity and card liveness are not performance scores. No fabricated APR, win rate, transaction count, or delivery record is shown. -``` -Agent ─HTTP─► /api/v1/run ─► creator endpoint ─► evaluator - │ - └─── fire-and-forget ───► SwapRouter.swap() - │ - ├─► SkillRegistry (supply-gated setters, - │ treasury pool, CREATE2-deployed - │ ERC-20 per skill) - │ - ├─► ReputationHub.scoreOf(agent) ≥ minRep - │ - └─► settle() → creator/platform/reputationPool - (USDC split; BAS attestation hook) -``` +## Testnet contracts -### Two orthogonal primitives +| Role | Address | +| --- | --- | +| ERC-8004 registry | `0x8004A818BFB912233c491871b3d84c89A494BD9e` | +| Commerce | `0xa206c0517B6371C6638CD9e4a42Cc9f02A33B0DE` | +| Router | `0xD7d36D66d2F1B608A0F943f722D27e3744f66F25` | +| Policy | `0xd6a4217588f6b1f5657a92a3e94e6422ad771cea` | +| Test $U | `0xc70B8741B8B07A6d61E54fd4B20f22Fa648E5565` | -| Layer | Purpose | Phase 2 status | -|---|---|---| -| **ERC-20 per capability** (SKILL_RUN_TOKEN) | Interface uniformity. Every skill / item / service = ERC-20. Agents operate on token portfolios. Not for speculation — for composability. | ✅ live | -| **Reputation-gated allocation** | `swap()` checks reputation before USDC. Day-1 floor = 0. Creators raise threshold as data accumulates → reputation gains real weight → RBE. | ✅ live, floor = 0 | +Use https://testnet.bscscan.com to inspect these addresses and each transaction. ---- +## Verification and evidence status — September 8, 2026 -## Security — audit done + fixes applied +- Direct onchain wallet/metadata reads: all four identities matched the curated configuration. +- Canonical agent cards: all four responded successfully. +- Real seller quotes: all four passed local schema, hash, signature, target and fee validation. +- Unit/property tests cover changed identities, endpoint paths, wrong chain/payment targets, modified quote terms, invalid signatures, fee bounds, malformed payloads, oversized streams and funded-job retry decisions. +- Funded-hire evidence: pending selection of the buyer wallet and its signatures. No new funded job or delivery is claimed by this document. +- Deployment must be confirmed against `/api/health` and the deployed commit; a successful build alone is not deployment evidence. +- Registration/submission confirmation: not found in the checked inbox. Confirm an existing response before submitting again. -Ran 8-agent solidity-auditor on the Phase 2 suite. 8 HIGH-severity findings surfaced. All 8 fixed + regression tests added. +For each category, record buyer address, job ID, quote hash, all five transaction hashes, seller response, and any resulting execution transaction. A response saying accepted/pending is not completed execution. -| # | Finding | Fix | -|---|---|---| -| 1 | Provider price manipulation drains agent treasury | `setPrice` reverts while RUN_TOKEN totalSupply > 0 | -| 2 | Permissionless slug squatting | Owner `transferProvider(skillId, newProvider)` | -| 3 | Sandwich attack via `setPrice` front-run | `maxPriceUSDC` slippage param on buy/swap | -| 4 | USDC blacklist permanently freezes funds | Pull-payment fallback + `claimPending` + `rescueTokens` | -| 5 | Router upgrade bricks existing skill tokens | `SkillRunToken.router()` resolves live from registry | -| 6 | `setActive` strands prepaid USDC | `redeemRunToken` escape hatch ignores active flag | -| 7 | `setMinReputation` bricks prepaid tokens | Supply-gated (same as #1) | -| 8 | No redeem path | `redeemRunToken(skillId, amount)` added | +## Remaining submission boundaries -**Tests: 155/155 forge test green** (including 18 audit regression tests + fuzz tests). +The official main-track eligibility says surfaced agents must be live on BSC; it does not explicitly settle whether a testnet-only entry qualifies. Ask the organizer to confirm. All four categories must also have enough functional depth for judging; the bounded services above are intentionally disclosed. ---- +No Altana bounty qualification or TermiX Agent Advantage Report is claimed. The project does not claim official BNB adoption, a security audit, profitable trading, or mainnet readiness. -## Key specs / ADRs - -- `specs/2026-04-16-agent-commerce-protocol.md` — full Phase 2 spec -- `brain/wiki/decisions/2026-04-16-tokens-as-interface-reputation-as-allocation.md` — locked ADR -- 8-week roadmap to mainnet + external audit post-hackathon - ---- - -## Links - -- Repo: https://github.com/JhiNResH/maiat-dojo -- PR for this work: https://github.com/JhiNResH/maiat-dojo/pull/41 -- Live API: https://dojo.maiat.io (REST spec in repo) - ---- - -## Team - -JhiNResH (solo builder). Smart-contract audit background, targeting -Smart Contract Security Engineer role at BNB ecosystem exchanges. - -## Why this matters - -Current agent economies re-use Web2 payment rails (OpenAI credits, SaaS subs). -That means reputation lives off-chain, gets captured by platforms, and stays -irrelevant to resource allocation. Maiat plants the opposite seed: every -agent-to-skill interaction generates clean on-chain reputation data. Over time -that reputation graph becomes the allocation mechanism — agents get access not -because they paid, but because of who they are and what they've delivered. - -Skills are the entry point because they're high-frequency and have clean -outcomes (delivered / not-delivered). Items and services follow in Phase 4 -using the same `swap()` interface. +Before marking this entry submission-ready: verify the public deployment, complete and capture all four funded jobs, confirm main-track testnet acceptance, and retain the registration/submission confirmation. diff --git a/package-lock.json b/package-lock.json index 0ed67e2..5446d77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6824,6 +6824,12 @@ "superstruct": "^2.0.2" } }, + "node_modules/@solana/web3.js/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "license": "MIT" + }, "node_modules/@solana/web3.js/node_modules/base-x": { "version": "3.0.11", "license": "MIT", @@ -6838,6 +6844,38 @@ "base-x": "^3.0.2" } }, + "node_modules/@solana/web3.js/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/@solana/web3.js/node_modules/jayson": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.1.3.tgz", + "integrity": "sha512-LtXh5aYZodBZ9Fc3j6f2w+MTNcnxteMOrb+QgIouguGOulWi0lieEkOUg+HkjjFs0DGoWDds6bi4E9hpNFLulQ==", + "license": "MIT", + "dependencies": { + "@types/connect": "^3.4.33", + "@types/node": "^12.12.54", + "@types/ws": "^7.4.4", + "commander": "^2.20.3", + "delay": "^5.0.0", + "es6-promisify": "^5.0.0", + "eyes": "^0.1.8", + "isomorphic-ws": "^4.0.1", + "json-stringify-safe": "^5.0.1", + "JSONStream": "^1.3.5", + "uuid": "^8.3.2", + "ws": "^7.5.10" + }, + "bin": { + "jayson": "bin/jayson.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@solana/web3.js/node_modules/superstruct": { "version": "2.0.2", "license": "MIT", @@ -12264,38 +12302,6 @@ "node": ">= 0.4" } }, - "node_modules/jayson": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "@types/connect": "^3.4.33", - "@types/node": "^12.12.54", - "@types/ws": "^7.4.4", - "commander": "^2.20.3", - "delay": "^5.0.0", - "es6-promisify": "^5.0.0", - "eyes": "^0.1.8", - "isomorphic-ws": "^4.0.1", - "json-stringify-safe": "^5.0.1", - "stream-json": "^1.9.1", - "uuid": "^8.3.2", - "ws": "^7.5.10" - }, - "bin": { - "jayson": "bin/jayson.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jayson/node_modules/@types/node": { - "version": "12.20.55", - "license": "MIT" - }, - "node_modules/jayson/node_modules/commander": { - "version": "2.20.3", - "license": "MIT" - }, "node_modules/jiti": { "version": "1.21.7", "dev": true, @@ -12384,6 +12390,31 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "dev": true, @@ -14590,17 +14621,6 @@ "node": ">= 0.4" } }, - "node_modules/stream-chain": { - "version": "2.2.5", - "license": "BSD-3-Clause" - }, - "node_modules/stream-json": { - "version": "1.9.1", - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^2.2.5" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "license": "MIT", @@ -14942,6 +14962,12 @@ "real-require": "^0.2.0" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, diff --git a/package.json b/package.json index d0ddc5f..f57caaf 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,9 @@ "vitest": "^4.1.2" }, "overrides": { + "@solana/web3.js": { + "jayson": "4.1.3" + }, "gray-matter": { ".": "$gray-matter", "js-yaml": "3.15.1" diff --git a/src/__tests__/bnb-marketplace-activation.test.ts b/src/__tests__/bnb-marketplace-activation.test.ts index a0d0468..edf3238 100644 --- a/src/__tests__/bnb-marketplace-activation.test.ts +++ b/src/__tests__/bnb-marketplace-activation.test.ts @@ -1,246 +1,170 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { keccak256, toBytes } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; import { activationRequestSchema, dispatchTestnetActivation } from "@/lib/bnb-marketplace-activation"; import { getMarketplaceActivationAction } from "@/lib/bnb-marketplace-activation-flow"; -import { BNB_MARKETPLACE_AGENTS } from "@/lib/bnb-marketplace"; +import * as marketplace from "@/lib/bnb-marketplace"; +import quotes from "./fixtures/bnb-seller-quotes.json"; afterEach(() => vi.restoreAllMocks()); - -const FUZZ_SEED = 0x8183_8004; -const FUZZ_CASES = 128; - -function seededRandom(seed: number) { - let state = seed >>> 0; +const agents = marketplace.BNB_MARKETPLACE_AGENTS; +const seed = 0x81838004; +const cases = 128; +function randomGenerator() { + let state = seed; return () => { - state += 0x6d2b79f5; - let value = state; - value = Math.imul(value ^ (value >>> 15), value | 1); - value ^= value + Math.imul(value ^ (value >>> 7), value | 61); - return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state; }; } - -function randomString(random: () => number, alphabet: string, length: number) { - return Array.from({ length }, () => alphabet[Math.floor(random() * alphabet.length)]).join(""); +function respond(body: unknown) { + return vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(body))); } - -function randomPositiveBigInt(random: () => number, max: bigint) { - const high = BigInt(Math.floor(random() * 0x1_0000_0000)); - const low = BigInt(Math.floor(random() * 0x1_0000_0000)); - return ((high << 32n) | low) % max + 1n; +function hashTerms(quote: typeof quotes[number]) { + const { quoteHash: _hash, signature: _sig, ...terms } = quote; + return keccak256(toBytes(JSON.stringify(Object.fromEntries(Object.keys(terms).sort().map((key) => [key, terms[key as keyof typeof terms]]))))); } describe("BNB marketplace activation boundary", () => { - it("resumes delivery without funding a confirmed job again", () => { - expect(getMarketplaceActivationAction(false, null)).toBe("quote"); - expect(getMarketplaceActivationAction(true, null)).toBe("fund"); - expect(getMarketplaceActivationAction(true, "42")).toBe("notify"); - }); - - it("accepts only the curated slug and never a caller supplied endpoint", async () => { - expect(activationRequestSchema.safeParse({ action: "quote", slug: "studio-desk-grid", endpoint: "https://evil.example" }).success).toBe(false); - await expect(dispatchTestnetActivation({ action: "quote", slug: "missing" })).rejects.toThrow("Unknown BSC Testnet agent"); - }); - - it("proxies a quote only to the curated testnet seller and validates its wallet", async () => { - const agent = BNB_MARKETPLACE_AGENTS[1]; - const quote = { - quoteHash: `0x${"a".repeat(64)}`, - feeAmount: "1200000000000000", - wallet: agent.agentWallet, - }; - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify(quote), { status: 200 }), - ); - + it.each([0, 1, 2, 3])("verifies real seller v1 signed quote fixture %i without a wallet field", async (index) => { + const agent = agents[index]; + const fetchMock = respond(quotes[index]); const result = await dispatchTestnetActivation({ action: "quote", slug: agent.slug }); - expect(result.chainId).toBe(97); - expect(fetchMock).toHaveBeenCalledWith(`${agent.sellerApiBaseUrl}/quote`, expect.objectContaining({ - method: "POST", redirect: "error", cache: "no-store", + expect(result.quote?.wallet).toBe(agent.agentWallet); + expect(result.quote?.feeAmount).toBe(quotes[index].feeAmount); + expect(fetchMock).toHaveBeenCalledWith(agent.sellerApiBaseUrl + "/quote", expect.objectContaining({ + redirect: "error", cache: "no-store", })); }); - it("rejects a signed quote from a wallet outside the registry identity", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ - quoteHash: `0x${"b".repeat(64)}`, - feeAmount: "1", - wallet: "0x0000000000000000000000000000000000000001", - }), { status: 200 })); - - await expect(dispatchTestnetActivation({ action: "quote", slug: BNB_MARKETPLACE_AGENTS[0].slug })) - .rejects.toThrow("Quote wallet does not match registry identity"); + it("accepts reordered quote fields without changing the signed content", async () => { + respond(Object.fromEntries(Object.entries(quotes[0]).reverse())); + await expect(dispatchTestnetActivation({ action: "quote", slug: agents[0].slug })).resolves.toMatchObject({ chainId: 97 }); }); - it("rejects a quote above the curated testnet fee cap", async () => { - const agent = BNB_MARKETPLACE_AGENTS[0]; - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ - quoteHash: `0x${"d".repeat(64)}`, - feeAmount: (BigInt(agent.maxFeeAmount) + 1n).toString(), - wallet: agent.agentWallet, - }), { status: 200 })); - - await expect(dispatchTestnetActivation({ action: "quote", slug: agent.slug })) - .rejects.toThrow("Quote exceeds the curated testnet fee cap"); + it("rejects unknown agents and caller-controlled endpoints before fetching", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch"); + const random = randomGenerator(); + for (let index = 0; index < cases; index++) { + const request = index % 2 === 0 + ? { action: "quote", slug: agents[index % 4].slug } + : { action: "notify_funded", slug: agents[index % 4].slug, jobId: "42", quoteHash: quotes[index % 4].quoteHash }; + const payload = { ...request, endpoint: `https://attacker-${random()}.example` }; + expect(activationRequestSchema.safeParse(payload).success, `seed=${seed} case=${index}`).toBe(false); + await expect(dispatchTestnetActivation({ action: "quote", slug: `missing-${random()}` })).rejects.toThrow("Unknown BSC Testnet agent"); + } + expect(fetchMock).not.toHaveBeenCalled(); }); - it("rejects and cancels a streamed seller response above the byte limit", async () => { - const encoder = new TextEncoder(); - const chunks = ["x".repeat(60_000), "y".repeat(40_001), "z".repeat(60_000)]; - let cancelled = false; - const body = new ReadableStream({ - pull(controller) { - const chunk = chunks.shift(); - if (chunk === undefined) controller.close(); - else controller.enqueue(encoder.encode(chunk)); - }, - cancel() { - cancelled = true; - }, - }); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(body, { status: 200, headers: { "content-length": "1" } }), - ); - - await expect(dispatchTestnetActivation({ action: "quote", slug: BNB_MARKETPLACE_AGENTS[0].slug })) - .rejects.toThrow("Agent response exceeded 100 KB"); - expect(cancelled).toBe(true); + it("property: rejects malformed hashes and job identifiers", () => { + const random = randomGenerator(); + for (let index = 0; index < cases; index++) { + const quoteHash = ["0x" + "a".repeat(63), "0x" + "a".repeat(65), "0x" + "g".repeat(64)][index % 3]; + const jobId = ["0x1", "-" + random(), "1e6", "9".repeat(79 + index)][index % 4]; + const valid = { action: "notify_funded", slug: agents[0].slug, jobId: "42", quoteHash: quotes[0].quoteHash }; + expect(activationRequestSchema.safeParse({ ...valid, quoteHash }).success).toBe(false); + expect(activationRequestSchema.safeParse({ ...valid, jobId }).success).toBe(false); + } }); - it("notifies delivery only with a funded job id and quote hash", async () => { - const agent = BNB_MARKETPLACE_AGENTS[3]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ status: "accepted", receipt: { jobId: "42" } }), { status: 200 }), - ); - const quoteHash = `0x${"c".repeat(64)}` as `0x${string}`; - - await dispatchTestnetActivation({ action: "notify_funded", slug: agent.slug, jobId: "42", quoteHash }); - expect(fetchMock).toHaveBeenCalledWith(`${agent.sellerApiBaseUrl}/notify_funded`, expect.objectContaining({ - body: JSON.stringify({ jobId: "42", quoteHash, dryRun: false, amount: "1" }), - })); + it.each([ + { chainId: 56 }, { agentId: 1881 }, { typedSelector: "swapAnything" }, + { commerce: "0x0000000000000000000000000000000000000001" }, + { policy: "0x0000000000000000000000000000000000000001" }, + { feeToken: "0x0000000000000000000000000000000000000001" }, + ])("rejects changed chain, identity or payment targets: %j", async (change) => { + respond({ ...quotes[0], ...change }); + await expect(dispatchTestnetActivation({ action: "quote", slug: agents[0].slug })).rejects.toThrow(); }); - it("property: rejects caller-controlled endpoint fields and unknown seller slugs", async () => { - const random = seededRandom(FUZZ_SEED); - const fetchMock = vi.spyOn(globalThis, "fetch"); - - for (let index = 0; index < FUZZ_CASES; index++) { - const host = randomString(random, "abcdefghijklmnopqrstuvwxyz0123456789", 12); - const endpoint = `https://${host}.example/api/${index}`; - const agent = BNB_MARKETPLACE_AGENTS[index % BNB_MARKETPLACE_AGENTS.length]; - const payload = index % 2 === 0 - ? { action: "quote", slug: agent.slug, endpoint } - : { action: "notify_funded", slug: agent.slug, jobId: String(index + 1), quoteHash: `0x${"a".repeat(64)}`, endpoint }; - - expect( - activationRequestSchema.safeParse(payload).success, - `seed=${FUZZ_SEED} case=${index} endpoint=${endpoint}`, - ).toBe(false); - - const unknownSlug = `fuzz-${randomString(random, "abcdefghijklmnopqrstuvwxyz0123456789-", 24)}`; - await expect(dispatchTestnetActivation({ action: "quote", slug: unknownSlug })) - .rejects.toThrow("Unknown BSC Testnet agent"); - } - - expect(fetchMock).not.toHaveBeenCalled(); + it("rejects modified terms even when the original signature is retained", async () => { + respond({ ...quotes[0], terms: "Different terms" }); + await expect(dispatchTestnetActivation({ action: "quote", slug: agents[0].slug })).rejects.toThrow("Quote hash does not match its terms"); }); - it("property: rejects malformed quote hashes and job identifiers", () => { - const random = seededRandom(FUZZ_SEED ^ 0x51a7); - const validSlug = BNB_MARKETPLACE_AGENTS[0].slug; - - for (let index = 0; index < FUZZ_CASES; index++) { - const invalidHash = index % 3 === 0 - ? `0x${randomString(random, "0123456789abcdef", 63)}` - : index % 3 === 1 - ? `0x${randomString(random, "0123456789abcdef", 65)}` - : `0x${randomString(random, "ghijklmnopqrstuvwxyz", 64)}`; - const invalidJobId = index % 4 === 0 - ? "0x1" - : index % 4 === 1 - ? "-1" - : index % 4 === 2 - ? "1e6" - : randomString(random, "0123456789", 79 + (index % 32)); + it("rejects recomputed terms signed by a different agent", async () => { + const forged = { ...quotes[0], feeAmount: "1", signature: quotes[1].signature }; + forged.quoteHash = hashTerms(forged); + respond(forged); + await expect(dispatchTestnetActivation({ action: "quote", slug: agents[0].slug })).rejects.toThrow("Quote signature does not match registry identity"); + }); - expect( - activationRequestSchema.safeParse({ action: "notify_funded", slug: validSlug, jobId: "1", quoteHash: invalidHash }).success, - `seed=${FUZZ_SEED ^ 0x51a7} case=${index} hash=${invalidHash}`, - ).toBe(false); - expect( - activationRequestSchema.safeParse({ action: "notify_funded", slug: validSlug, jobId: invalidJobId, quoteHash: `0x${"b".repeat(64)}` }).success, - `seed=${FUZZ_SEED ^ 0x51a7} case=${index} jobId=${invalidJobId}`, - ).toBe(false); - } + it("rejects unsigned legacy wallet claims", async () => { + respond({ quoteHash: quotes[0].quoteHash, feeAmount: "1", wallet: agents[0].agentWallet }); + await expect(dispatchTestnetActivation({ action: "quote", slug: agents[0].slug })).rejects.toThrow(); }); - it("property: accepts positive fees at or below each curated cap", async () => { - const random = seededRandom(FUZZ_SEED ^ 0xfee); + it("property: validates positive under-cap fees with real test signatures", async () => { + // Public deterministic test key, never funded or used outside these unit tests. + const signer = privateKeyToAccount("0x" + "1".repeat(64) as `0x${string}`); + vi.spyOn(marketplace, "getBnbMarketplaceAgent").mockImplementation((slug) => { + const agent = agents.find((item) => item.slug === slug); + return agent ? { ...agent, agentWallet: signer.address } : undefined; + }); + const random = randomGenerator(); const fetchMock = vi.spyOn(globalThis, "fetch"); - - for (let index = 0; index < FUZZ_CASES; index++) { - const agent = BNB_MARKETPLACE_AGENTS[index % BNB_MARKETPLACE_AGENTS.length]; - const feeAmount = randomPositiveBigInt(random, BigInt(agent.maxFeeAmount)).toString(); - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ - quoteHash: `0x${"c".repeat(64)}`, - feeAmount, - wallet: agent.agentWallet, - }), { status: 200 })); - + for (let index = 0; index < cases; index++) { + const agent = agents[index % 4]; + const amount = ((BigInt(random()) << 32n) | BigInt(random())) % BigInt(agent.maxFeeAmount) + 1n; + const quote = { ...quotes[index % 4], feeAmount: amount.toString() }; + if (index < 4) quote.feeAmount = agent.maxFeeAmount; + quote.quoteHash = hashTerms(quote); + quote.signature = await signer.signMessage({ message: { raw: quote.quoteHash as `0x${string}` } }); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(quote))); const result = await dispatchTestnetActivation({ action: "quote", slug: agent.slug }); - expect(result.quote?.feeAmount, `seed=${FUZZ_SEED ^ 0xfee} case=${index}`).toBe(feeAmount); + expect(result.quote?.feeAmount, `seed=${seed} case=${index}`).toBe(quote.feeAmount); } }); - it("property: rejects over-cap and oversized fee strings", async () => { - const random = seededRandom(FUZZ_SEED ^ 0xbadfee); + it("property: rejects zero, over-cap and oversized fees", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); - - for (let index = 0; index < FUZZ_CASES; index++) { - const agent = BNB_MARKETPLACE_AGENTS[index % BNB_MARKETPLACE_AGENTS.length]; - const feeAmount = index % 2 === 0 - ? (BigInt(agent.maxFeeAmount) + 1n + BigInt(index)).toString() - : randomString(random, "123456789", 79 + (index % 82)); - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ - quoteHash: `0x${"d".repeat(64)}`, - feeAmount, - wallet: agent.agentWallet, - }), { status: 200 })); - - await expect(dispatchTestnetActivation({ action: "quote", slug: agent.slug })) - .rejects.toThrow(); + for (let index = 0; index < cases; index++) { + const agent = agents[index % 4]; + const feeAmount = index % 3 === 0 ? "0" : index % 3 === 1 + ? (BigInt(agent.maxFeeAmount) + 1n + BigInt(index)).toString() : "9".repeat(79 + index); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ...quotes[index % 4], feeAmount }))); + await expect(dispatchTestnetActivation({ action: "quote", slug: agent.slug })).rejects.toThrow(); } }); - it("property: rejects malformed quote hashes returned by a seller", async () => { - const random = seededRandom(FUZZ_SEED ^ 0x4a5a); + it("property: rejects malformed seller hashes", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); - - for (let index = 0; index < FUZZ_CASES; index++) { - const agent = BNB_MARKETPLACE_AGENTS[index % BNB_MARKETPLACE_AGENTS.length]; - const quoteHash = index % 3 === 0 - ? `0x${randomString(random, "0123456789abcdef", 63)}` - : index % 3 === 1 - ? `0x${randomString(random, "0123456789abcdef", 65)}` - : randomString(random, "0123456789abcdef", 64); - fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ - quoteHash, - feeAmount: "1", - wallet: agent.agentWallet, - }), { status: 200 })); - - await expect(dispatchTestnetActivation({ action: "quote", slug: agent.slug })) - .rejects.toThrow(); + for (let index = 0; index < cases; index++) { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ...quotes[0], quoteHash: "0x" + "a".repeat(65 + index) }))); + await expect(dispatchTestnetActivation({ action: "quote", slug: agents[0].slug })).rejects.toThrow(); } }); - it("property: every funded job resumes at delivery instead of funding", () => { - const random = seededRandom(FUZZ_SEED ^ 0xf00d); + it("cancels oversized seller streams", async () => { + const cancel = vi.fn(); + const chunks = [new Uint8Array(60_000), new Uint8Array(40_001), new Uint8Array(60_000)]; + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks.shift(); + if (chunk) controller.enqueue(chunk); + else controller.close(); + }, + cancel, + }); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(body, { headers: { "content-length": "1" } })); + await expect(dispatchTestnetActivation({ action: "quote", slug: agents[0].slug })).rejects.toThrow("Agent response exceeded 100 KB"); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("forwards a delivery notification without asserting seller-side settlement", async () => { + const fetchMock = respond({ status: "accepted", receipt: { jobId: "42" } }); + await dispatchTestnetActivation({ action: "notify_funded", slug: agents[0].slug, jobId: "42", quoteHash: quotes[0].quoteHash }); + expect(fetchMock).toHaveBeenCalledWith(agents[0].sellerApiBaseUrl + "/notify_funded", expect.objectContaining({ + body: JSON.stringify({ jobId: "42", quoteHash: quotes[0].quoteHash, dryRun: false, amount: "1" }), + })); + }); - for (let index = 0; index < FUZZ_CASES; index++) { - const jobId = randomPositiveBigInt(random, 2n ** 255n).toString(); - expect( - getMarketplaceActivationAction(true, jobId), - `seed=${FUZZ_SEED ^ 0xf00d} case=${index} jobId=${jobId}`, - ).toBe("notify"); + it("property: retries confirmed funded jobs at delivery without funding again", () => { + expect(getMarketplaceActivationAction(false, null)).toBe("quote"); + expect(getMarketplaceActivationAction(true, null)).toBe("fund"); + const random = randomGenerator(); + for (let index = 0; index < cases; index++) { + expect(getMarketplaceActivationAction(true, String(random()))).toBe("notify"); } }); }); diff --git a/src/__tests__/bnb-marketplace-registry.test.ts b/src/__tests__/bnb-marketplace-registry.test.ts index 8689779..b4558e5 100644 --- a/src/__tests__/bnb-marketplace-registry.test.ts +++ b/src/__tests__/bnb-marketplace-registry.test.ts @@ -1,84 +1,123 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BNB_MARKETPLACE_AGENTS } from "@/lib/bnb-marketplace"; import { loadBnbMarketplaceAgent } from "@/lib/bnb-marketplace-registry"; +const rpc = vi.hoisted(() => ({ getChainId: vi.fn(), readContract: vi.fn() })); +vi.mock("viem", async (importOriginal) => ({ + ...await importOriginal(), + createPublicClient: () => rpc, +})); afterEach(() => vi.restoreAllMocks()); -function jsonResponse(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +const agent = BNB_MARKETPLACE_AGENTS[0]; +const sellerUrl = agent.agentCardUrl.replace("/.well-known/agent-card.json", ""); +function metadata(overrides: Record = {}) { + return { + name: agent.name, description: "Onchain registry description", chainId: 97, + identityRegistry: agent.registryAddress, + endpoints: [ + { name: "HTTP", endpoint: sellerUrl }, + { name: "A2A", endpoint: agent.agentCardUrl }, + ], + ...overrides, + }; +} +function dataUri(body: unknown) { + return "data:application/json;base64," + Buffer.from(JSON.stringify(body)).toString("base64"); +} +function mockIdentity(uri = dataUri(metadata()), wallet = agent.agentWallet) { + rpc.readContract.mockImplementation(async ({ functionName }: { functionName: string }) => + functionName === "getAgentWallet" ? wallet : uri); +} +beforeEach(() => { + rpc.getChainId.mockReset().mockResolvedValue(97); + rpc.readContract.mockReset(); + mockIdentity(); +}); +function cardResponse(body: unknown = { name: agent.name, url: sellerUrl }) { + return new Response(JSON.stringify(body), { status: 200 }); } describe("BNB marketplace registry loader", () => { - it("marks an exact registry identity and agent card live", async () => { - const agent = BNB_MARKETPLACE_AGENTS[0]; - vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(jsonResponse({ - success: true, - data: { - name: agent.name, - token_id: String(agent.agentId), - chain_id: 97, - contract_address: agent.registryAddress, - agent_wallet: agent.agentWallet, - description: "Live registry description", - created_tx_hash: `0x${"1".repeat(64)}`, - updated_at: "2026-08-23T00:00:00Z", - }, - })) - .mockResolvedValueOnce(jsonResponse({ name: agent.name, url: agent.sellerApiBaseUrl.replace("/api/seller", "/seller") })); - + it("verifies the onchain wallet and exact endpoints without an indexer", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(cardResponse()); const loaded = await loadBnbMarketplaceAgent(agent); expect(loaded.registryStatus).toBe("live"); expect(loaded.endpointStatus).toBe("live"); - expect(loaded.summary).toBe("Live registry description"); + expect(loaded.summary).toBe("Onchain registry description"); + expect(loaded.createdTxHash).toBeNull(); + expect(loaded.registryUpdatedAt).toBeNull(); + expect(rpc.readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: agent.registryAddress, functionName: "getAgentWallet", args: [1880n], + })); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(agent.agentCardUrl, expect.objectContaining({ + redirect: "error", cache: "no-store", + })); }); - it("fails closed when the registry wallet does not match", async () => { - const agent = BNB_MARKETPLACE_AGENTS[0]; - vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(jsonResponse({ - success: true, - data: { - name: agent.name, - token_id: String(agent.agentId), - chain_id: 97, - contract_address: agent.registryAddress, - agent_wallet: "0x0000000000000000000000000000000000000001", - description: agent.summary, - created_tx_hash: `0x${"2".repeat(64)}`, - updated_at: null, - }, - })) - .mockResolvedValueOnce(jsonResponse({ name: agent.name, url: agent.sellerApiBaseUrl.replace("/api/seller", "/seller") })); + it("rejects a wallet mismatch before contacting the seller", async () => { + mockIdentity(dataUri(metadata()), "0x0000000000000000000000000000000000000001"); + const fetchMock = vi.spyOn(globalThis, "fetch"); + expect((await loadBnbMarketplaceAgent(agent)).registryStatus).toBe("unavailable"); + expect(fetchMock).not.toHaveBeenCalled(); + }); - const loaded = await loadBnbMarketplaceAgent(agent); - expect(loaded.registryStatus).toBe("unavailable"); - expect(loaded.endpointStatus).toBe("unavailable"); - expect(loaded.createdTxHash).toBeNull(); + it("rejects a wrong RPC chain and fails closed on RPC errors", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch"); + rpc.getChainId.mockResolvedValueOnce(56); + expect((await loadBnbMarketplaceAgent(agent)).registryStatus).toBe("unavailable"); + rpc.readContract.mockRejectedValueOnce(new Error("RPC unavailable")); + expect((await loadBnbMarketplaceAgent(agent)).registryStatus).toBe("unavailable"); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("fails closed and cancels an oversized streamed registry response", async () => { - const agent = BNB_MARKETPLACE_AGENTS[0]; - const encoder = new TextEncoder(); - const chunks = ["x".repeat(60_000), "y".repeat(40_001), "z".repeat(60_000)]; - let cancelled = false; + it.each([ + { chainId: 56 }, + { name: "Different agent" }, + { identityRegistry: "0x0000000000000000000000000000000000000001" }, + { endpoints: [{ name: "A2A", endpoint: "https://evil.example/card.json" }] }, + { endpoints: [{ name: "A2A", endpoint: agent.agentCardUrl }, { name: "HTTP", endpoint: sellerUrl + "1" }] }, + ])("rejects changed onchain identity metadata: %j", async (overrides) => { + mockIdentity(dataUri(metadata(overrides))); + const fetchMock = vi.spyOn(globalThis, "fetch"); + expect((await loadBnbMarketplaceAgent(agent)).registryStatus).toBe("unavailable"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(["https://evil.example/metadata", "data:application/json;base64,e30=", "data:application/json;base64," + "a".repeat(100_000)])( + "rejects unsupported, malformed or oversized tokenURI without fetching it", + async (uri) => { + mockIdentity(uri); + const fetchMock = vi.spyOn(globalThis, "fetch"); + expect((await loadBnbMarketplaceAgent(agent)).registryStatus).toBe("unavailable"); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { name: "Different agent", url: sellerUrl }, + { name: agent.name, url: sellerUrl + "1" }, + { name: agent.name, url: "https://evil.example/seller/1880" }, + ])("rejects card identity/path changes: %j", async (card) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(cardResponse(card)); + expect((await loadBnbMarketplaceAgent(agent)).endpointStatus).toBe("unavailable"); + }); + + it("cancels an oversized streamed card even with a misleading content length", async () => { + const cancel = vi.fn(); + const chunks = [new Uint8Array(60_000), new Uint8Array(40_001), new Uint8Array(60_000)]; const body = new ReadableStream({ pull(controller) { const chunk = chunks.shift(); - if (chunk === undefined) controller.close(); - else controller.enqueue(encoder.encode(chunk)); - }, - cancel() { - cancelled = true; + if (chunk) controller.enqueue(chunk); + else controller.close(); }, + cancel, }); - vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(new Response(body, { status: 200, headers: { "content-length": "1" } })) - .mockResolvedValueOnce(jsonResponse({ name: agent.name, url: agent.sellerApiBaseUrl.replace("/api/seller", "/seller") })); - + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(body, { headers: { "content-length": "1" } })); const loaded = await loadBnbMarketplaceAgent(agent); expect(loaded.registryStatus).toBe("unavailable"); expect(loaded.endpointStatus).toBe("unavailable"); - expect(cancelled).toBe(true); + expect(cancel).toHaveBeenCalledOnce(); }); }); diff --git a/src/__tests__/bnb-marketplace.test.ts b/src/__tests__/bnb-marketplace.test.ts index 9b2c636..4cfd44f 100644 --- a/src/__tests__/bnb-marketplace.test.ts +++ b/src/__tests__/bnb-marketplace.test.ts @@ -38,7 +38,7 @@ describe("BNB agent marketplace", () => { it("does not include a mainnet or caller-controlled activation target", () => { for (const agent of BNB_MARKETPLACE_AGENTS) { expect(agent.chainId).toBe(97); - expect(agent.sellerApiBaseUrl).toMatch(/^https:\/\/13\.53\.65\.58\.sslip\.io\/api\/seller\/\d+$/); + expect(agent.sellerApiBaseUrl).toMatch(/^https:\/\/desk\.rouma\.online\/api\/seller\/\d+$/); expect(agent.registryStatus).toBe("checking"); } }); diff --git a/src/__tests__/fixtures/bnb-seller-quotes.json b/src/__tests__/fixtures/bnb-seller-quotes.json new file mode 100644 index 0000000..6f11c8f --- /dev/null +++ b/src/__tests__/fixtures/bnb-seller-quotes.json @@ -0,0 +1,58 @@ +[ + { + "version": 1, + "chainId": 97, + "commerce": "0xa206c0517B6371C6638CD9e4a42Cc9f02A33B0DE", + "policy": "0xd6a4217588f6b1f5657a92a3e94e6422ad771cea", + "agentId": 1880, + "category": "rebalancing", + "feeToken": "0xc70B8741B8B07A6d61E54fd4B20f22Fa648E5565", + "feeAmount": "1000000000000000", + "terms": "fixed fee; evidence-backed Pancake V3 position management", + "typedSelector": "rebalancePancakeV3", + "quoteHash": "0xaa6c24cfcd7d67722ed883c714bad351eab6780a22ecba208a5fbf0c1d2a1df5", + "signature": "0xf6d3a1720d150e77910af886812481faed73587c67de7a583d73480eb51379a26bf28af5fcad6b5b933680dbf19d37d0cf7871176e981c1b4905eaf123caaff71b" + }, + { + "version": 1, + "chainId": 97, + "commerce": "0xa206c0517B6371C6638CD9e4a42Cc9f02A33B0DE", + "policy": "0xd6a4217588f6b1f5657a92a3e94e6422ad771cea", + "agentId": 1881, + "category": "grid", + "feeToken": "0xc70B8741B8B07A6d61E54fd4B20f22Fa648E5565", + "feeAmount": "1200000000000000", + "terms": "fixed fee; one bounded grid step per funded job", + "typedSelector": "gridStep", + "quoteHash": "0x5b158b921a436c35d8086bf3adcf32f679eed71bf7f51887be250e9fd85cafcd", + "signature": "0x812c00dcf86c7d1f1a2105efbb011c8ba5ba916e8e145be0d0c0709d3be7b20f215508300d1d7b08114f215b1768d6f66ab319642b3f03de0b34044c18b5c12a1b" + }, + { + "version": 1, + "chainId": 97, + "commerce": "0xa206c0517B6371C6638CD9e4a42Cc9f02A33B0DE", + "policy": "0xd6a4217588f6b1f5657a92a3e94e6422ad771cea", + "agentId": 1882, + "category": "bounded yield allocation", + "feeToken": "0xc70B8741B8B07A6d61E54fd4B20f22Fa648E5565", + "feeAmount": "900000000000000", + "terms": "fixed fee; bounded deposit or withdraw at one verified venue", + "typedSelector": "allocateYield", + "quoteHash": "0x15127f2786b7fb24064759dbe0bb4197ef2de7c94804977000d02e68171693fc", + "signature": "0x33f738e38f30601a2c1ffdb8b6dee10454e619f08edfafd5c785aa978f89e06412ba383f0b2ab26678b9beae754350cf0a4b8f1caae5b1833128176c8e887eba1c" + }, + { + "version": 1, + "chainId": 97, + "commerce": "0xa206c0517B6371C6638CD9e4a42Cc9f02A33B0DE", + "policy": "0xd6a4217588f6b1f5657a92a3e94e6422ad771cea", + "agentId": 1883, + "category": "risk", + "feeToken": "0xc70B8741B8B07A6d61E54fd4B20f22Fa648E5565", + "feeAmount": "1100000000000000", + "terms": "fixed fee; Venus liquidity/shortfall protection", + "typedSelector": "protectRepayVenus", + "quoteHash": "0x8d6cafc2a87789ce66a84b478a706c030bea75993ed4e194905ce11bfc5f9f69", + "signature": "0x1fe67ee8b86e89164be3b0bb40278f5238ef4964a8c5151f319210225d6a4da67ebf6b84a18c2c27be92ac53211838eacb84ac408a3897f8e53bf52714758c371b" + } +] diff --git a/src/__tests__/wallet-rpc-dependency.test.ts b/src/__tests__/wallet-rpc-dependency.test.ts new file mode 100644 index 0000000..c11ce20 --- /dev/null +++ b/src/__tests__/wallet-rpc-dependency.test.ts @@ -0,0 +1,38 @@ +import { createRequire } from "node:module"; +import { describe, expect, it, vi } from "vitest"; +import { Connection, PublicKey } from "@solana/web3.js"; + +const require = createRequire(import.meta.url); +const solanaRequire = createRequire(require.resolve("@solana/web3.js")); + +describe("wallet SDK RPC dependency compatibility", () => { + it("resolves the compatible Jayson pin without stream-json", () => { + const jayson = solanaRequire("jayson/package.json"); + expect(jayson.version).toBe("4.1.3"); + expect(jayson.dependencies["stream-json"]).toBeUndefined(); + }); + + it("round-trips the SDK's real JSON-RPC client with a mocked transport", async () => { + const fetchMock = vi.fn().mockImplementation(async (_url, init) => { + const request = JSON.parse(String(init?.body)); + expect(request).toMatchObject({ + jsonrpc: "2.0", method: "getBalance", + params: ["11111111111111111111111111111111", { commitment: "confirmed" }], + }); + expect(typeof request.id).toBe("string"); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { context: { slot: 1 }, value: 42 } })); + }); + const client = new Connection("https://rpc.invalid", { commitment: "confirmed", fetch: fetchMock }); + await expect(client.getBalance(new PublicKey("11111111111111111111111111111111"))).resolves.toBe(42); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each(["not json", JSON.stringify({ jsonrpc: "2.0", id: "1", error: { code: -32602, message: "Invalid params" } })])( + "rejects invalid RPC replies without reporting a balance: %s", async (body) => { + const fetchMock = vi.fn().mockResolvedValue(new Response(body)); + const client = new Connection("https://rpc.invalid", { fetch: fetchMock }); + await expect(client.getBalance(new PublicKey("11111111111111111111111111111111"))).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/src/lib/bnb-marketplace-activation.ts b/src/lib/bnb-marketplace-activation.ts index 8d1527a..fe8e32d 100644 --- a/src/lib/bnb-marketplace-activation.ts +++ b/src/lib/bnb-marketplace-activation.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { keccak256, toBytes, verifyMessage, type Hex } from "viem"; import { getBnbMarketplaceAgent } from "@/lib/bnb-marketplace"; +import { BNB_MARKETPLACE_CONTRACTS } from "@/lib/bnb-marketplace-chain"; import { readBoundedResponseText } from "@/lib/creator-response"; const quoteHashSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/); @@ -12,10 +14,13 @@ export const activationRequestSchema = z.discriminatedUnion("action", [ ]); const quoteSchema = z.object({ + version: z.literal(1), chainId: z.literal(97), agentId: z.number().int().positive(), + commerce: z.string(), policy: z.string(), feeToken: z.string(), + category: z.string().min(1), terms: z.string().min(1), typedSelector: z.string().min(1), quoteHash: quoteHashSchema, + signature: z.string().regex(/^0x[a-fA-F0-9]{130}$/), feeAmount: z.string().min(1).max(78).regex(/^\d+$/), - wallet: z.string().regex(/^0x[a-fA-F0-9]{40}$/), -}).passthrough(); +}).strict(); function sameAddress(a: string, b: string) { return a.toLowerCase() === b.toLowerCase(); @@ -44,10 +49,22 @@ export async function dispatchTestnetActivation(input: z.infer BigInt(agent.maxFeeAmount)) throw new Error("Quote exceeds the curated testnet fee cap"); - return { action: input.action, agentId: agent.agentId, chainId: agent.chainId, quote }; + const { quoteHash, signature, ...terms } = quote; + // Seller v1 signs the keccak256 of its flat terms object with sorted keys. + const canonicalTerms = Object.fromEntries(Object.keys(terms).sort().map((key) => [key, terms[key as keyof typeof terms]])); + if (keccak256(toBytes(JSON.stringify(canonicalTerms))) !== quoteHash.toLowerCase()) throw new Error("Quote hash does not match its terms"); + if (!await verifyMessage({ address: agent.agentWallet, message: { raw: quoteHash as Hex }, signature: signature as Hex })) { + throw new Error("Quote signature does not match registry identity"); + } + return { action: input.action, agentId: agent.agentId, chainId: agent.chainId, quote: { ...quote, wallet: agent.agentWallet } }; } const result = await postJson(`${agent.sellerApiBaseUrl}/notify_funded`, { diff --git a/src/lib/bnb-marketplace-registry.ts b/src/lib/bnb-marketplace-registry.ts index 577f6fd..998222b 100644 --- a/src/lib/bnb-marketplace-registry.ts +++ b/src/lib/bnb-marketplace-registry.ts @@ -1,50 +1,57 @@ import { z } from "zod"; +import { createPublicClient, http, parseAbi } from "viem"; import { BNB_MARKETPLACE_AGENTS, type BnbMarketplaceAgent } from "@/lib/bnb-marketplace"; +import { BNB_MARKETPLACE_CHAIN } from "@/lib/bnb-marketplace-chain"; import { readBoundedResponseText } from "@/lib/creator-response"; -const addressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/); -const hashSchema = z.string().regex(/^0x[a-fA-F0-9]{64}$/); -const registryResponseSchema = z.object({ - success: z.literal(true), - data: z.object({ - name: z.string().min(1), token_id: z.string(), chain_id: z.number(), contract_address: addressSchema, - agent_wallet: addressSchema, description: z.string().min(1), created_tx_hash: hashSchema, updated_at: z.string().nullable(), - }), +const registryAbi = parseAbi([ + "function getAgentWallet(uint256 agentId) view returns(address)", + "function tokenURI(uint256 agentId) view returns(string)", +]); +const client = createPublicClient({ + chain: BNB_MARKETPLACE_CHAIN, + transport: http(undefined, { timeout: 8_000, retryCount: 0 }), +}); +const metadataSchema = z.object({ + name: z.string().min(1), description: z.string().min(1), chainId: z.literal(97), + identityRegistry: z.string(), + endpoints: z.array(z.object({ name: z.string(), endpoint: z.string().url() })), }); const agentCardSchema = z.object({ name: z.string().min(1), url: z.string().url() }); -function sameAddress(a: string, b: string) { - return a.toLowerCase() === b.toLowerCase(); -} - -async function checkedJson(url: string, init?: RequestInit) { - const response = await fetch(url, { ...init, redirect: "error", signal: AbortSignal.timeout(8_000), next: { revalidate: 60 } }); - if (!response.ok) throw new Error(`Upstream returned ${response.status}`); - const read = await readBoundedResponseText(response, 100_000, "Upstream response too large"); - if (!read.ok) throw new Error(read.error); - return JSON.parse(read.text) as unknown; -} - export async function loadBnbMarketplaceAgent(agent: BnbMarketplaceAgent): Promise { try { - const headers: HeadersInit = {}; - const apiKey = process.env.API_8004SCAN_KEY?.trim(); - if (apiKey) headers["X-API-Key"] = apiKey; - const [registryRaw, cardRaw] = await Promise.all([ - checkedJson(`https://8004scan.io/api/v1/public/agents/${agent.chainId}/${agent.agentId}`, { headers }), - checkedJson(agent.agentCardUrl), + const [chainId, wallet, uri] = await Promise.all([ + client.getChainId(), + client.readContract({ address: agent.registryAddress, abi: registryAbi, functionName: "getAgentWallet", args: [BigInt(agent.agentId)] }), + client.readContract({ address: agent.registryAddress, abi: registryAbi, functionName: "tokenURI", args: [BigInt(agent.agentId)] }), ]); - const registry = registryResponseSchema.parse(registryRaw).data; - const card = agentCardSchema.parse(cardRaw); + if (chainId !== agent.chainId || wallet.toLowerCase() !== agent.agentWallet.toLowerCase()) { + throw new Error("Registry identity mismatch"); + } + // These curated identities use onchain data URIs. Never fetch arbitrary tokenURI URLs. + const prefix = "data:application/json;base64,"; + if (!uri.startsWith(prefix) || uri.length > 100_000) throw new Error("Unsupported registry metadata"); + const metadata = metadataSchema.parse(JSON.parse(Buffer.from(uri.slice(prefix.length), "base64").toString("utf8"))); + const sellerUrl = agent.agentCardUrl.replace("/.well-known/agent-card.json", ""); if ( - registry.chain_id !== agent.chainId || registry.token_id !== String(agent.agentId) || - !sameAddress(registry.contract_address, agent.registryAddress) || !sameAddress(registry.agent_wallet, agent.agentWallet) || - card.name !== agent.name || new URL(card.url).origin !== new URL(agent.agentCardUrl).origin - ) throw new Error("Registry identity mismatch"); + metadata.name !== agent.name || metadata.identityRegistry.toLowerCase() !== agent.registryAddress.toLowerCase() || + !metadata.endpoints.some((endpoint) => endpoint.name === "A2A" && endpoint.endpoint === agent.agentCardUrl) || + !metadata.endpoints.some((endpoint) => endpoint.name === "HTTP" && endpoint.endpoint === sellerUrl) + ) throw new Error("Registry endpoint mismatch"); + + const response = await fetch(agent.agentCardUrl, { + redirect: "error", signal: AbortSignal.timeout(8_000), cache: "no-store", + }); + if (!response.ok) throw new Error(`Upstream returned ${response.status}`); + const read = await readBoundedResponseText(response, 100_000, "Upstream response too large"); + if (!read.ok) throw new Error(read.error); + const card = agentCardSchema.parse(JSON.parse(read.text)); + if (card.name !== agent.name || card.url !== sellerUrl) throw new Error("Agent card mismatch"); return { - ...agent, name: registry.name, summary: registry.description, registryStatus: "live", endpointStatus: "live", - registryUpdatedAt: registry.updated_at, createdTxHash: registry.created_tx_hash as `0x${string}`, + ...agent, summary: metadata.description, registryStatus: "live", endpointStatus: "live", + registryUpdatedAt: null, createdTxHash: null, }; } catch { return { ...agent, registryStatus: "unavailable", endpointStatus: "unavailable", registryUpdatedAt: null, createdTxHash: null }; diff --git a/src/lib/bnb-marketplace.ts b/src/lib/bnb-marketplace.ts index 5a117ad..f3c3907 100644 --- a/src/lib/bnb-marketplace.ts +++ b/src/lib/bnb-marketplace.ts @@ -47,8 +47,8 @@ export const BNB_MARKETPLACE_AGENTS: readonly BnbMarketplaceAgent[] = [ controls: ["Read the current pool tick", "Read the selected position", "Return typed rebalance bounds"], chainId: BNB_TESTNET_CHAIN_ID, agentId: 1880, registryAddress: BNB_TESTNET_REGISTRY, agentWallet: "0x76849Ba8246Ea53FB417c295028c2AA9C6af7eD7", - agentCardUrl: "https://13.53.65.58.sslip.io/seller/1880/.well-known/agent-card.json", - sellerApiBaseUrl: "https://13.53.65.58.sslip.io/api/seller/1880", selector: "rebalancePancakeV3", feeLabel: "0.001 $U", maxFeeAmount: "1000000000000000", + agentCardUrl: "https://desk.rouma.online/seller/1880/.well-known/agent-card.json", + sellerApiBaseUrl: "https://desk.rouma.online/api/seller/1880", selector: "rebalancePancakeV3", feeLabel: "0.001 $U", maxFeeAmount: "1000000000000000", registryStatus: "checking", endpointStatus: "checking", registryUpdatedAt: null, createdTxHash: null, }, { @@ -58,8 +58,8 @@ export const BNB_MARKETPLACE_AGENTS: readonly BnbMarketplaceAgent[] = [ controls: ["Use the fixed USDT/WBNB pair", "Respect the inventory ceiling", "Enforce cooldown and minimum output"], chainId: BNB_TESTNET_CHAIN_ID, agentId: 1881, registryAddress: BNB_TESTNET_REGISTRY, agentWallet: "0x2eD7Dc63681D912948C215DCdE4c4FCe9A3F19F8", - agentCardUrl: "https://13.53.65.58.sslip.io/seller/1881/.well-known/agent-card.json", - sellerApiBaseUrl: "https://13.53.65.58.sslip.io/api/seller/1881", selector: "gridStep", feeLabel: "0.0012 $U", maxFeeAmount: "1200000000000000", + agentCardUrl: "https://desk.rouma.online/seller/1881/.well-known/agent-card.json", + sellerApiBaseUrl: "https://desk.rouma.online/api/seller/1881", selector: "gridStep", feeLabel: "0.0012 $U", maxFeeAmount: "1200000000000000", registryStatus: "checking", endpointStatus: "checking", registryUpdatedAt: null, createdTxHash: null, }, { @@ -69,8 +69,8 @@ export const BNB_MARKETPLACE_AGENTS: readonly BnbMarketplaceAgent[] = [ controls: ["Use one verified Venus venue", "Bound the deposit amount", "Bound the withdrawal amount"], chainId: BNB_TESTNET_CHAIN_ID, agentId: 1882, registryAddress: BNB_TESTNET_REGISTRY, agentWallet: "0x1D7118d1A4A87411FB275046Cb156eCe8e7BD190", - agentCardUrl: "https://13.53.65.58.sslip.io/seller/1882/.well-known/agent-card.json", - sellerApiBaseUrl: "https://13.53.65.58.sslip.io/api/seller/1882", selector: "allocateYield", feeLabel: "0.0009 $U", maxFeeAmount: "900000000000000", + agentCardUrl: "https://desk.rouma.online/seller/1882/.well-known/agent-card.json", + sellerApiBaseUrl: "https://desk.rouma.online/api/seller/1882", selector: "allocateYield", feeLabel: "0.0009 $U", maxFeeAmount: "900000000000000", registryStatus: "checking", endpointStatus: "checking", registryUpdatedAt: null, createdTxHash: null, }, { @@ -80,8 +80,8 @@ export const BNB_MARKETPLACE_AGENTS: readonly BnbMarketplaceAgent[] = [ controls: ["Read account liquidity", "Read account shortfall", "Bound repayBorrowBehalf"], chainId: BNB_TESTNET_CHAIN_ID, agentId: 1883, registryAddress: BNB_TESTNET_REGISTRY, agentWallet: "0x185BF63291A90eaf9D1b119Cc2c4b70df058BBa0", - agentCardUrl: "https://13.53.65.58.sslip.io/seller/1883/.well-known/agent-card.json", - sellerApiBaseUrl: "https://13.53.65.58.sslip.io/api/seller/1883", selector: "protectRepayVenus", feeLabel: "0.0011 $U", maxFeeAmount: "1100000000000000", + agentCardUrl: "https://desk.rouma.online/seller/1883/.well-known/agent-card.json", + sellerApiBaseUrl: "https://desk.rouma.online/api/seller/1883", selector: "protectRepayVenus", feeLabel: "0.0011 $U", maxFeeAmount: "1100000000000000", registryStatus: "checking", endpointStatus: "checking", registryUpdatedAt: null, createdTxHash: null, }, ] as const;