diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c32237b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## 0.2.0 - 2026-02-27 + +### Added + +- Generic subgraph command family: + - `ag subgraph list` + - `ag subgraph check --source core-base|gbm-base [--raw]` + - `ag subgraph query --source ...` +- Baazaar subgraph wrappers: + - `ag baazaar listing get --kind erc721|erc1155 --id [--verify-onchain]` + - `ag baazaar listing active --kind erc721|erc1155 [--first] [--skip]` + - `ag baazaar listing mine --kind erc721|erc1155 --seller <0x...> [--first] [--skip]` +- GBM subgraph wrappers: + - `ag auction get --id [--verify-onchain]` + - `ag auction active [--first] [--skip] [--at-time ]` + - `ag auction mine --seller <0x...> [--first] [--skip]` + - `ag auction bids --auction-id [--first] [--skip]` + - `ag auction bids-mine --bidder <0x...> [--first] [--skip]` +- Optional `--raw` GraphQL payload passthrough while preserving typed projections. +- New docs under `docs/subgraph/` for endpoint policy and query matrix. + +### Security and policy + +- Strict endpoint allowlist by default for canonical sources only. +- Custom endpoint override requires both `--subgraph-url` and `--allow-untrusted-subgraph`. +- Non-HTTPS custom subgraph endpoints are rejected. + +### Notes + +- Existing `onchain`, `tx`, mapped write aliases, and `baazaar read` onchain call behavior are preserved. diff --git a/README.md b/README.md index 699cb40..204514c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ For read-only automation: npm run ag -- bootstrap --mode agent --profile prod --chain base --signer readonly --json ``` -## Command surface (current) +## Command surface (v0.2.0) - `bootstrap` - `profile list|show|use|export` @@ -43,6 +43,9 @@ npm run ag -- bootstrap --mode agent --profile prod --chain base --signer readon - `tx send|status|resume|watch` - `batch run --file plan.yaml` - `onchain call|send` +- `subgraph list|check|query` +- `baazaar listing get|active|mine` (subgraph-first read wrappers) +- `auction get|active|mine|bids|bids-mine` (subgraph-first read wrappers) - ` read` (routes to generic onchain call for that domain) Planned domain namespaces are stubbed for parity tracking: @@ -52,6 +55,71 @@ Planned domain namespaces are stubbed for parity tracking: Many Base-era write flows are already executable as mapped aliases in those namespaces (internally routed through `onchain send`). Example: `ag lending create --abi-file ./abis/GotchiLendingFacet.json --address 0x... --args-json '[...]' --json` +## Subgraph sources and endpoint policy + +Canonical source aliases: + +- `core-base` -> `https://api.goldsky.com/api/public/project_cmh3flagm0001r4p25foufjtt/subgraphs/aavegotchi-core-base/prod/gn` +- `gbm-base` -> `https://api.goldsky.com/api/public/project_cmh3flagm0001r4p25foufjtt/subgraphs/aavegotchi-gbm-baazaar-base/prod/gn` + +Default policy is strict allowlist: + +- Non-canonical subgraph URLs are blocked by default (`SUBGRAPH_ENDPOINT_BLOCKED`) +- Override is explicit and per-command only: pass both `--subgraph-url ` and `--allow-untrusted-subgraph` +- Non-HTTPS custom URLs are rejected + +Auth: + +- Public Goldsky endpoints work without auth +- If `GOLDSKY_API_KEY` is set, CLI injects `Authorization: Bearer ` +- Override env var name per command with `--auth-env-var ` + +## Subgraph command examples + +List configured canonical sources: + +```bash +npm run ag -- subgraph list --json +``` + +Check source reachability/introspection: + +```bash +npm run ag -- subgraph check --source core-base --json +``` + +Run custom GraphQL query: + +```bash +npm run ag -- subgraph query \ + --source gbm-base \ + --query 'query($first:Int!){ auctions(first:$first){ id } }' \ + --variables-json '{"first":5}' \ + --json +``` + +Baazaar wrappers: + +```bash +npm run ag -- baazaar listing active --kind erc721 --first 20 --skip 0 --json +npm run ag -- baazaar listing mine --kind erc1155 --seller 0x... --json +npm run ag -- baazaar listing get --kind erc721 --id 123 --verify-onchain --json +``` + +GBM wrappers: + +```bash +npm run ag -- auction active --first 20 --json +npm run ag -- auction bids --auction-id 123 --json +npm run ag -- auction get --id 123 --verify-onchain --json +``` + +Raw GraphQL passthrough (typed projection remains included): + +```bash +npm run ag -- auction active --first 5 --raw --json +``` + ## Signer backends - `readonly` (read-only mode) @@ -106,6 +174,8 @@ All successful/error responses use a stable envelope: - Method inventory: [`docs/parity/base-method-inventory.md`](docs/parity/base-method-inventory.md) - Command mapping: [`docs/parity/base-command-matrix.md`](docs/parity/base-command-matrix.md) +- Subgraph endpoints/policy: [`docs/subgraph/endpoints-and-policy.md`](docs/subgraph/endpoints-and-policy.md) +- Subgraph query matrix: [`docs/subgraph/query-matrix.md`](docs/subgraph/query-matrix.md) Raffle/ticket flows are intentionally excluded for Base-era scope. diff --git a/docs/subgraph/endpoints-and-policy.md b/docs/subgraph/endpoints-and-policy.md new file mode 100644 index 0000000..de50a93 --- /dev/null +++ b/docs/subgraph/endpoints-and-policy.md @@ -0,0 +1,41 @@ +# Subgraph Endpoints and Policy + +## Canonical aliases + +- `core-base` + - `https://api.goldsky.com/api/public/project_cmh3flagm0001r4p25foufjtt/subgraphs/aavegotchi-core-base/prod/gn` +- `gbm-base` + - `https://api.goldsky.com/api/public/project_cmh3flagm0001r4p25foufjtt/subgraphs/aavegotchi-gbm-baazaar-base/prod/gn` + +## Security model (v0.2.0) + +- Default: strict allowlist for canonical endpoints only. +- Custom endpoint requires both: + - `--subgraph-url ` + - `--allow-untrusted-subgraph` +- Custom non-HTTPS endpoint is rejected. + +## Auth behavior + +- Endpoint works without auth by default. +- CLI injects bearer auth only when env var exists. + - Default env var: `GOLDSKY_API_KEY` + - Per-command override: `--auth-env-var ` + +## Core flags + +- `--timeout-ms ` +- `--raw` +- `--subgraph-url --allow-untrusted-subgraph` +- `--auth-env-var ` + +## Error codes + +- `SUBGRAPH_SOURCE_UNKNOWN` +- `SUBGRAPH_ENDPOINT_BLOCKED` +- `SUBGRAPH_TIMEOUT` +- `SUBGRAPH_HTTP_ERROR` +- `SUBGRAPH_GRAPHQL_ERROR` +- `SUBGRAPH_INVALID_RESPONSE` +- `SUBGRAPH_VERIFY_MISMATCH` +- `INVALID_VARIABLES_JSON` diff --git a/docs/subgraph/query-matrix.md b/docs/subgraph/query-matrix.md new file mode 100644 index 0000000..0cecf52 --- /dev/null +++ b/docs/subgraph/query-matrix.md @@ -0,0 +1,78 @@ +# Subgraph Query Matrix + +## Generic commands + +- `ag subgraph list` + - No network call. Returns canonical alias definitions. + +- `ag subgraph check --source core-base|gbm-base [--raw]` + - Runs introspection query. + - Output includes sorted query field names. + +- `ag subgraph query --source (--query | --query-file ) [--variables-json ] [--raw] [--timeout-ms ] [--auth-env-var ] [--subgraph-url --allow-untrusted-subgraph]` + +## Baazaar wrappers (`core-base`) + +- `ag baazaar listing get --kind erc721 --id [--verify-onchain] [--raw]` + - Query: `erc721Listing(id: $id)` + - Verify path: compares to `getERC721Listing` on Base Aavegotchi diamond. + +- `ag baazaar listing get --kind erc1155 --id [--verify-onchain] [--raw]` + - Query: `erc1155Listing(id: $id)` + - Verify path: compares to `getERC1155Listing` on Base Aavegotchi diamond. + +- `ag baazaar listing active --kind erc721 [--first ] [--skip ] [--raw]` + - Filter: `{ cancelled: false, timePurchased: "0" }` + +- `ag baazaar listing active --kind erc1155 [--first ] [--skip ] [--raw]` + - Filter: `{ cancelled: false, sold: false }` + +- `ag baazaar listing mine --kind erc721 --seller <0x...> [--first ] [--skip ] [--raw]` + - Filter: `{ seller: $seller }` (`Bytes`, lowercase) + +- `ag baazaar listing mine --kind erc1155 --seller <0x...> [--first ] [--skip ] [--raw]` + - Filter: `{ seller: $seller }` (`Bytes`, lowercase) + +## GBM wrappers (`gbm-base`) + +- `ag auction get --id [--verify-onchain] [--raw]` + - Query: `auction(id: $id)` + - Verify path compares to `getAuctionHighestBid`, `getContractAddress`, `getTokenId`, `getAuctionStartTime`, `getAuctionEndTime`. + +- `ag auction active [--first ] [--skip ] [--at-time ] [--raw]` + - Filter: `{ claimed: false, cancelled: false, startsAt_lte: $now, endsAt_gt: $now }` + +- `ag auction mine --seller <0x...> [--first ] [--skip ] [--raw]` + - Filter: `{ seller: $seller }` (`Bytes`, lowercase) + +- `ag auction bids --auction-id [--first ] [--skip ] [--raw]` + - Filter: `{ auction: $auctionId }` + +- `ag auction bids-mine --bidder <0x...> [--first ] [--skip ] [--raw]` + - Filter: `{ bidder: $bidder }` (`Bytes`, lowercase) + +## Pagination + +- `--first` default `20`, min `1`, max `200` +- `--skip` default `0`, min `0`, max `100000` +- No auto-pagination in v0.2.0 + +## Output contract + +All commands return envelope: + +- `schemaVersion` +- `command` +- `status` +- `data` +- `meta` + +Typed response payload includes: + +- `source` +- `endpoint` +- `queryName` +- `pagination` where applicable +- normalized entity fields + +`--raw` adds `raw` with complete GraphQL payload while preserving typed projection. diff --git a/package-lock.json b/package-lock.json index 31694b4..7576fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@aavegotchi/aavegotchi-cli", - "version": "0.1.0", + "name": "aavegotchi-cli", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@aavegotchi/aavegotchi-cli", - "version": "0.1.0", + "name": "aavegotchi-cli", + "version": "0.2.0", "license": "MIT", "dependencies": { "better-sqlite3": "^11.8.1", diff --git a/package.json b/package.json index 34201e4..a87e2ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aavegotchi-cli", - "version": "0.1.0", + "version": "0.2.0", "description": "Agent-first CLI for automating Aavegotchi app and onchain workflows", "license": "MIT", "repository": { @@ -11,7 +11,8 @@ "main": "dist/index.js", "files": [ "dist", - "README.md" + "README.md", + "CHANGELOG.md" ], "bin": { "ag": "dist/index.js", diff --git a/src/command-runner.test.ts b/src/command-runner.test.ts new file mode 100644 index 0000000..1ac8c83 --- /dev/null +++ b/src/command-runner.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CommandContext } from "./types"; + +const { + findMappedFunctionMock, + runMappedDomainCommandMock, + runAuctionSubgraphCommandMock, + runBaazaarListingSubgraphCommandMock, +} = vi.hoisted(() => ({ + findMappedFunctionMock: vi.fn(), + runMappedDomainCommandMock: vi.fn(), + runAuctionSubgraphCommandMock: vi.fn(), + runBaazaarListingSubgraphCommandMock: vi.fn(), +})); + +vi.mock("./commands/mapped", () => ({ + findMappedFunction: findMappedFunctionMock, + runMappedDomainCommand: runMappedDomainCommandMock, +})); + +vi.mock("./commands/auction-subgraph", () => ({ + runAuctionSubgraphCommand: runAuctionSubgraphCommandMock, +})); + +vi.mock("./commands/baazaar-subgraph", () => ({ + runBaazaarListingSubgraphCommand: runBaazaarListingSubgraphCommandMock, +})); + +import { executeCommand } from "./command-runner"; + +function createCtx(path: string[]): CommandContext { + return { + commandPath: path, + args: { positionals: path, flags: {} }, + globals: { mode: "agent", json: true, yes: true }, + }; +} + +describe("command runner routing", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("routes auction active to subgraph wrapper before mapped fallback", async () => { + findMappedFunctionMock.mockReturnValue("unexpectedMapping"); + runAuctionSubgraphCommandMock.mockResolvedValue({ auctions: [] }); + + const result = await executeCommand(createCtx(["auction", "active"])); + + expect(result.commandName).toBe("auction active"); + expect(result.data).toEqual({ auctions: [] }); + expect(runAuctionSubgraphCommandMock).toHaveBeenCalledTimes(1); + expect(runMappedDomainCommandMock).not.toHaveBeenCalled(); + }); + + it("routes baazaar listing get to subgraph wrapper", async () => { + runBaazaarListingSubgraphCommandMock.mockResolvedValue({ listing: null }); + + const result = await executeCommand(createCtx(["baazaar", "listing", "get"])); + + expect(result.commandName).toBe("baazaar listing get"); + expect(result.data).toEqual({ listing: null }); + expect(runBaazaarListingSubgraphCommandMock).toHaveBeenCalledTimes(1); + }); + + it("keeps mapped writes working for auction buy-now", async () => { + findMappedFunctionMock.mockReturnValue("buyNow"); + runMappedDomainCommandMock.mockResolvedValue({ mappedMethod: "buyNow" }); + + const result = await executeCommand(createCtx(["auction", "buy-now"])); + + expect(result.commandName).toBe("auction buy-now"); + expect(result.data).toEqual({ mappedMethod: "buyNow" }); + expect(runAuctionSubgraphCommandMock).not.toHaveBeenCalled(); + expect(runMappedDomainCommandMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/command-runner.ts b/src/command-runner.ts index e4e867f..010d9c7 100644 --- a/src/command-runner.ts +++ b/src/command-runner.ts @@ -4,6 +4,8 @@ import { runBatchRunCommand } from "./commands/batch"; import { runBootstrapCommand } from "./commands/bootstrap"; import { findMappedFunction, runMappedDomainCommand } from "./commands/mapped"; import { runOnchainCallCommand, runOnchainSendCommand } from "./commands/onchain"; +import { runAuctionSubgraphCommand } from "./commands/auction-subgraph"; +import { runBaazaarListingSubgraphCommand } from "./commands/baazaar-subgraph"; import { runPolicyListCommand, runPolicyShowCommand, @@ -23,6 +25,7 @@ import { runSignerKeychainRemoveCommand, } from "./commands/signer"; import { isDomainStubRoot, runDomainStubCommand } from "./commands/stubs"; +import { runSubgraphCheckCommand, runSubgraphListCommand, runSubgraphQueryCommand } from "./commands/subgraph"; import { runTxResumeCommand, runTxSendCommand, runTxStatusCommand, runTxWatchCommand } from "./commands/tx"; export interface CommandExecutionResult { @@ -196,6 +199,48 @@ export async function executeCommand(ctx: CommandContext): Promise ({ + executeSubgraphQueryMock: vi.fn(), + runRpcPreflightMock: vi.fn(), +})); + +vi.mock("../subgraph/client", () => ({ + executeSubgraphQuery: executeSubgraphQueryMock, +})); + +vi.mock("../rpc", () => ({ + runRpcPreflight: runRpcPreflightMock, +})); + +import { + runAuctionActiveSubgraphCommand, + runAuctionBidsMineSubgraphCommand, + runAuctionGetSubgraphCommand, + runAuctionMineSubgraphCommand, +} from "./auction-subgraph"; + +function createContext(positionals: string[], flags: Record): CommandContext { + return { + commandPath: positionals, + args: { positionals, flags }, + globals: { + mode: "agent", + json: true, + yes: true, + }, + }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("auction subgraph commands", () => { + it("fetches active auctions with pagination and explicit time", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com/gbm", + queryName: "auction.active", + data: { + auctions: [ + { + id: "1", + contractAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + tokenId: "2", + quantity: "1", + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + highestBid: "99", + totalBids: "2", + startsAt: "1700000", + endsAt: "1701000", + claimed: false, + cancelled: false, + }, + ], + }, + }); + + const result = await runAuctionActiveSubgraphCommand( + createContext(["auction", "active"], { + first: "7", + skip: "3", + "at-time": "1700500", + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "gbm-base", + queryName: "auction.active", + variables: { + now: "1700500", + first: 7, + skip: 3, + }, + }), + ); + + expect(result).toMatchObject({ + atTime: "1700500", + pagination: { first: 7, skip: 3 }, + auctions: [ + { + id: "1", + contractAddress: "0xa99c4b08201f2913db8d28e71d020c4298f29dbf", + }, + ], + }); + }); + + it("filters mine query by lowercased seller", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com/gbm", + queryName: "auction.mine", + data: { + auctions: [], + }, + }); + + await runAuctionMineSubgraphCommand( + createContext(["auction", "mine"], { + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryName: "auction.mine", + variables: expect.objectContaining({ + seller: "0xab59ca4a16925b0a4bac5026c94beb20a29df479", + }), + }), + ); + }); + + it("filters bids-mine query by lowercased bidder", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com/gbm", + queryName: "auction.bids-mine", + data: { + bids: [], + }, + }); + + await runAuctionBidsMineSubgraphCommand( + createContext(["auction", "bids-mine"], { + bidder: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryName: "auction.bids-mine", + variables: expect.objectContaining({ + bidder: "0xab59ca4a16925b0a4bac5026c94beb20a29df479", + }), + }), + ); + }); + + it("includes raw payload when requested", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com/gbm", + queryName: "auction.get", + data: { + auction: { + id: "1", + contractAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + tokenId: "2", + quantity: "1", + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + highestBid: "99", + totalBids: "2", + startsAt: "1700000", + endsAt: "1701000", + claimed: false, + cancelled: false, + }, + }, + raw: { + data: { + auction: { id: "1" }, + }, + }, + }); + + const result = await runAuctionGetSubgraphCommand( + createContext(["auction", "get"], { + id: "1", + raw: true, + }), + ); + + expect(result).toMatchObject({ + raw: { + data: { + auction: { id: "1" }, + }, + }, + }); + }); + + it("throws verify mismatch when onchain snapshot differs", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com/gbm", + queryName: "auction.get", + data: { + auction: { + id: "5", + contractAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + tokenId: "200", + quantity: "1", + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + highestBid: "300", + totalBids: "8", + startsAt: "1700000", + endsAt: "1701000", + claimed: false, + cancelled: false, + }, + }, + }); + + const readContract = vi.fn(async (input: { functionName: string }) => { + switch (input.functionName) { + case "getAuctionHighestBid": + return 301n; + case "getContractAddress": + return "0xa99c4b08201f2913db8d28e71d020c4298f29dbf"; + case "getTokenId": + return 200n; + case "getAuctionStartTime": + return 1700000n; + case "getAuctionEndTime": + return 1701000n; + default: + return 0n; + } + }); + + runRpcPreflightMock.mockResolvedValueOnce({ + chainId: 8453, + client: { + readContract, + }, + }); + + await expect( + runAuctionGetSubgraphCommand( + createContext(["auction", "get"], { + id: "5", + "verify-onchain": true, + }), + ), + ).rejects.toMatchObject({ + code: "SUBGRAPH_VERIFY_MISMATCH", + }); + }); +}); diff --git a/src/commands/auction-subgraph.ts b/src/commands/auction-subgraph.ts new file mode 100644 index 0000000..8e40c21 --- /dev/null +++ b/src/commands/auction-subgraph.ts @@ -0,0 +1,494 @@ +import { parseAbi } from "viem"; + +import { getFlagBoolean, getFlagString } from "../args"; +import { resolveChain, resolveRpcUrl } from "../chains"; +import { getProfileOrThrow, loadConfig } from "../config"; +import { CliError } from "../errors"; +import { runRpcPreflight } from "../rpc"; +import { + normalizeGbmAuction, + normalizeGbmAuctions, + normalizeGbmBids, + toLowercaseAddress, +} from "../subgraph/normalize"; +import { + GBM_ACTIVE_AUCTIONS_QUERY, + GBM_AUCTION_BY_ID_QUERY, + GBM_BIDS_BY_AUCTION_QUERY, + GBM_BIDS_BY_BIDDER_QUERY, + GBM_MINE_AUCTIONS_QUERY, +} from "../subgraph/queries"; +import { BASE_GBM_DIAMOND } from "../subgraph/sources"; +import { executeSubgraphQuery } from "../subgraph/client"; +import { CommandContext, JsonValue } from "../types"; + +const DEFAULT_FIRST = 20; +const MAX_FIRST = 200; +const DEFAULT_SKIP = 0; +const MAX_SKIP = 100000; + +const GBM_VERIFY_ABI = parseAbi([ + "function getAuctionHighestBid(uint256 _auctionId) view returns (uint256)", + "function getContractAddress(uint256 _auctionId) view returns (address)", + "function getTokenId(uint256 _auctionId) view returns (uint256)", + "function getAuctionStartTime(uint256 _auctionId) view returns (uint256)", + "function getAuctionEndTime(uint256 _auctionId) view returns (uint256)", +]); + +function parseRawFlag(ctx: CommandContext): boolean { + return getFlagBoolean(ctx.args.flags, "raw"); +} + +function parseTimeoutMs(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + + const timeout = Number(value); + if (!Number.isInteger(timeout) || timeout <= 0) { + throw new CliError("INVALID_ARGUMENT", "--timeout-ms must be a positive integer.", 2, { + value, + }); + } + + return timeout; +} + +function parseBoundedIntFlag( + value: string | undefined, + flagName: string, + fallback: number, + min: number, + max: number, +): number { + if (!value) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new CliError("INVALID_ARGUMENT", `${flagName} must be an integer between ${min} and ${max}.`, 2, { + value, + }); + } + + return parsed; +} + +function parsePagination(ctx: CommandContext): { first: number; skip: number } { + const first = parseBoundedIntFlag(getFlagString(ctx.args.flags, "first"), "--first", DEFAULT_FIRST, 1, MAX_FIRST); + const skip = parseBoundedIntFlag(getFlagString(ctx.args.flags, "skip"), "--skip", DEFAULT_SKIP, 0, MAX_SKIP); + + return { + first, + skip, + }; +} + +function parseAddress(value: string | undefined, flagName: string): `0x${string}` { + if (!value || !/^0x[a-fA-F0-9]{40}$/.test(value)) { + throw new CliError("INVALID_ARGUMENT", `${flagName} must be an EVM address.`, 2, { + value, + }); + } + + return toLowercaseAddress(value); +} + +function parseAuctionId(value: string | undefined, flagName: string): string { + if (!value) { + throw new CliError("MISSING_ARGUMENT", `${flagName} is required.`, 2); + } + + if (!/^\d+$/.test(value)) { + throw new CliError("INVALID_ARGUMENT", `${flagName} must be an unsigned integer string.`, 2, { + value, + }); + } + + return value; +} + +function parseActiveTime(ctx: CommandContext): string { + const atTime = getFlagString(ctx.args.flags, "at-time"); + if (!atTime) { + return Math.floor(Date.now() / 1000).toString(); + } + + if (!/^\d+$/.test(atTime)) { + throw new CliError("INVALID_ARGUMENT", "--at-time must be a unix timestamp (seconds).", 2, { + value: atTime, + }); + } + + return atTime; +} + +function parseCommonSubgraphOptions(ctx: CommandContext): { + source: "gbm-base"; + timeoutMs?: number; + authEnvVar?: string; + subgraphUrl?: string; + allowUntrustedSubgraph?: boolean; +} { + const subgraphUrl = getFlagString(ctx.args.flags, "subgraph-url"); + const allowUntrustedSubgraph = getFlagBoolean(ctx.args.flags, "allow-untrusted-subgraph"); + + if (allowUntrustedSubgraph && !subgraphUrl) { + throw new CliError("INVALID_ARGUMENT", "--allow-untrusted-subgraph requires --subgraph-url.", 2); + } + + return { + source: "gbm-base", + timeoutMs: parseTimeoutMs(getFlagString(ctx.args.flags, "timeout-ms")), + authEnvVar: getFlagString(ctx.args.flags, "auth-env-var"), + subgraphUrl, + allowUntrustedSubgraph, + }; +} + +function parseVerifyOnchainFlag(ctx: CommandContext): boolean { + return getFlagBoolean(ctx.args.flags, "verify-onchain"); +} + +function resolveReadRpcUrl(ctx: CommandContext): string { + const explicitRpc = getFlagString(ctx.args.flags, "rpc-url"); + if (explicitRpc) { + return explicitRpc; + } + + const profileName = getFlagString(ctx.args.flags, "profile") || ctx.globals.profile; + if (profileName) { + const config = loadConfig(); + const profile = getProfileOrThrow(config, profileName); + return profile.rpcUrl; + } + + return resolveRpcUrl(resolveChain("base"), undefined); +} + +function createMismatchDiff( + keys: string[], + subgraph: Record, + onchain: Record, +): Record { + const diff: Record = {}; + + for (const key of keys) { + if (subgraph[key] !== onchain[key]) { + diff[key] = { + subgraph: subgraph[key], + onchain: onchain[key], + }; + } + } + + return diff; +} + +function withNormalizeContext( + response: { source: string; endpoint: string; queryName: string }, + normalize: () => T, +): T { + try { + return normalize(); + } catch (error) { + if (error instanceof CliError && error.code === "SUBGRAPH_INVALID_RESPONSE") { + const extraDetails = + error.details && typeof error.details === "object" ? (error.details as Record) : {}; + + throw new CliError(error.code, error.message, error.exitCode, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + ...extraDetails, + }); + } + + throw error; + } +} + +async function verifyAuctionOnchain(ctx: CommandContext, auction: ReturnType): Promise { + const chain = resolveChain("base"); + const rpcUrl = resolveReadRpcUrl(ctx); + const preflight = await runRpcPreflight(chain, rpcUrl); + const auctionId = BigInt(auction.id); + + const [highestBid, contractAddress, tokenId, startsAt, endsAt] = await Promise.all([ + preflight.client.readContract({ + address: BASE_GBM_DIAMOND, + abi: GBM_VERIFY_ABI, + functionName: "getAuctionHighestBid", + args: [auctionId], + }), + preflight.client.readContract({ + address: BASE_GBM_DIAMOND, + abi: GBM_VERIFY_ABI, + functionName: "getContractAddress", + args: [auctionId], + }), + preflight.client.readContract({ + address: BASE_GBM_DIAMOND, + abi: GBM_VERIFY_ABI, + functionName: "getTokenId", + args: [auctionId], + }), + preflight.client.readContract({ + address: BASE_GBM_DIAMOND, + abi: GBM_VERIFY_ABI, + functionName: "getAuctionStartTime", + args: [auctionId], + }), + preflight.client.readContract({ + address: BASE_GBM_DIAMOND, + abi: GBM_VERIFY_ABI, + functionName: "getAuctionEndTime", + args: [auctionId], + }), + ]); + + const onchainProjection: Record = { + highestBid: (highestBid as bigint).toString(), + contractAddress: toLowercaseAddress(contractAddress as `0x${string}`), + tokenId: (tokenId as bigint).toString(), + startsAt: (startsAt as bigint).toString(), + endsAt: (endsAt as bigint).toString(), + }; + + const subgraphProjection: Record = { + highestBid: auction.highestBid, + contractAddress: auction.contractAddress, + tokenId: auction.tokenId, + startsAt: auction.startsAt, + endsAt: auction.endsAt, + }; + + const diff = createMismatchDiff( + ["highestBid", "contractAddress", "tokenId", "startsAt", "endsAt"], + subgraphProjection, + onchainProjection, + ); + + if (Object.keys(diff).length > 0) { + throw new CliError("SUBGRAPH_VERIFY_MISMATCH", "Subgraph auction does not match onchain snapshot.", 2, { + source: "gbm-base", + endpoint: "onchain-verify", + queryName: "auction.get", + auctionId: auction.id, + rpcUrl, + contractAddress: BASE_GBM_DIAMOND, + diff, + }); + } + + return { + verified: true, + rpcUrl, + chainId: preflight.chainId, + contractAddress: BASE_GBM_DIAMOND, + }; +} + +export async function runAuctionGetSubgraphCommand(ctx: CommandContext): Promise { + const id = parseAuctionId(getFlagString(ctx.args.flags, "id"), "--id"); + const raw = parseRawFlag(ctx); + const verifyOnchain = parseVerifyOnchainFlag(ctx); + const common = parseCommonSubgraphOptions(ctx); + + const response = await executeSubgraphQuery<{ auction: unknown | null }>({ + ...common, + queryName: "auction.get", + query: GBM_AUCTION_BY_ID_QUERY, + variables: { id }, + raw, + }); + + const auction = response.data.auction + ? withNormalizeContext(response, () => normalizeGbmAuction(response.data.auction)) + : null; + const verification = verifyOnchain && auction ? await verifyAuctionOnchain(ctx, auction) : undefined; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + auction, + ...(verification ? { verification } : {}), + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runAuctionActiveSubgraphCommand(ctx: CommandContext): Promise { + const pagination = parsePagination(ctx); + const now = parseActiveTime(ctx); + const raw = parseRawFlag(ctx); + const common = parseCommonSubgraphOptions(ctx); + + const response = await executeSubgraphQuery<{ auctions: unknown }>({ + ...common, + queryName: "auction.active", + query: GBM_ACTIVE_AUCTIONS_QUERY, + variables: { + now, + ...pagination, + }, + raw, + }); + + if (!Array.isArray(response.data.auctions)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Expected auctions to be an array.", 2, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + }); + } + const auctions = response.data.auctions; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + atTime: now, + pagination, + auctions: withNormalizeContext(response, () => normalizeGbmAuctions(auctions)), + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runAuctionMineSubgraphCommand(ctx: CommandContext): Promise { + const seller = parseAddress(getFlagString(ctx.args.flags, "seller"), "--seller"); + const pagination = parsePagination(ctx); + const raw = parseRawFlag(ctx); + const common = parseCommonSubgraphOptions(ctx); + + const response = await executeSubgraphQuery<{ auctions: unknown }>({ + ...common, + queryName: "auction.mine", + query: GBM_MINE_AUCTIONS_QUERY, + variables: { + seller, + ...pagination, + }, + raw, + }); + + if (!Array.isArray(response.data.auctions)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Expected auctions to be an array.", 2, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + }); + } + const auctions = response.data.auctions; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + seller, + pagination, + auctions: withNormalizeContext(response, () => normalizeGbmAuctions(auctions)), + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runAuctionBidsSubgraphCommand(ctx: CommandContext): Promise { + const auctionId = parseAuctionId(getFlagString(ctx.args.flags, "auction-id"), "--auction-id"); + const pagination = parsePagination(ctx); + const raw = parseRawFlag(ctx); + const common = parseCommonSubgraphOptions(ctx); + + const response = await executeSubgraphQuery<{ bids: unknown }>({ + ...common, + queryName: "auction.bids", + query: GBM_BIDS_BY_AUCTION_QUERY, + variables: { + auctionId, + ...pagination, + }, + raw, + }); + + if (!Array.isArray(response.data.bids)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Expected bids to be an array.", 2, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + }); + } + const bids = response.data.bids; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + auctionId, + pagination, + bids: withNormalizeContext(response, () => normalizeGbmBids(bids)), + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runAuctionBidsMineSubgraphCommand(ctx: CommandContext): Promise { + const bidder = parseAddress(getFlagString(ctx.args.flags, "bidder"), "--bidder"); + const pagination = parsePagination(ctx); + const raw = parseRawFlag(ctx); + const common = parseCommonSubgraphOptions(ctx); + + const response = await executeSubgraphQuery<{ bids: unknown }>({ + ...common, + queryName: "auction.bids-mine", + query: GBM_BIDS_BY_BIDDER_QUERY, + variables: { + bidder, + ...pagination, + }, + raw, + }); + + if (!Array.isArray(response.data.bids)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Expected bids to be an array.", 2, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + }); + } + const bids = response.data.bids; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + bidder, + pagination, + bids: withNormalizeContext(response, () => normalizeGbmBids(bids)), + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runAuctionSubgraphCommand(ctx: CommandContext): Promise { + const action = ctx.commandPath[1]; + + if (action === "get") { + return runAuctionGetSubgraphCommand(ctx); + } + + if (action === "active") { + return runAuctionActiveSubgraphCommand(ctx); + } + + if (action === "mine") { + return runAuctionMineSubgraphCommand(ctx); + } + + if (action === "bids") { + return runAuctionBidsSubgraphCommand(ctx); + } + + if (action === "bids-mine") { + return runAuctionBidsMineSubgraphCommand(ctx); + } + + throw new CliError("UNKNOWN_COMMAND", `Unknown command '${ctx.commandPath.join(" ")}'.`, 2); +} diff --git a/src/commands/baazaar-subgraph.test.ts b/src/commands/baazaar-subgraph.test.ts new file mode 100644 index 0000000..594ca7d --- /dev/null +++ b/src/commands/baazaar-subgraph.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CommandContext } from "../types"; + +const { executeSubgraphQueryMock, runRpcPreflightMock } = vi.hoisted(() => ({ + executeSubgraphQueryMock: vi.fn(), + runRpcPreflightMock: vi.fn(), +})); + +vi.mock("../subgraph/client", () => ({ + executeSubgraphQuery: executeSubgraphQueryMock, +})); + +vi.mock("../rpc", () => ({ + runRpcPreflight: runRpcPreflightMock, +})); + +import { + runBaazaarListingActiveSubgraphCommand, + runBaazaarListingGetSubgraphCommand, + runBaazaarListingMineSubgraphCommand, +} from "./baazaar-subgraph"; + +function createContext(positionals: string[], flags: Record): CommandContext { + return { + commandPath: positionals, + args: { positionals, flags }, + globals: { + mode: "agent", + json: true, + yes: true, + }, + }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("baazaar subgraph commands", () => { + it("gets erc721 listing and normalizes fields", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "core-base", + endpoint: "https://example.com/core", + queryName: "baazaar.listing.get.erc721", + data: { + erc721Listing: { + id: "10", + category: "3", + erc721TokenAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + tokenId: "99", + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + priceInWei: "100", + cancelled: false, + timeCreated: "1700000", + timePurchased: "0", + }, + }, + }); + + const result = await runBaazaarListingGetSubgraphCommand( + createContext(["baazaar", "listing", "get"], { + kind: "erc721", + id: "10", + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "core-base", + queryName: "baazaar.listing.get.erc721", + variables: { id: "10" }, + }), + ); + + expect(result).toMatchObject({ + source: "core-base", + endpoint: "https://example.com/core", + listingKind: "erc721", + listing: { + id: "10", + erc721TokenAddress: "0xa99c4b08201f2913db8d28e71d020c4298f29dbf", + seller: "0xab59ca4a16925b0a4bac5026c94beb20a29df479", + }, + }); + }); + + it("returns active erc1155 listings with pagination", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "core-base", + endpoint: "https://example.com/core", + queryName: "baazaar.listing.active.erc1155", + data: { + erc1155Listings: [ + { + id: "11", + category: "4", + erc1155TokenAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + erc1155TypeId: "44", + quantity: "2", + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + priceInWei: "123", + cancelled: false, + sold: false, + timeCreated: "1700001", + }, + ], + }, + }); + + const result = await runBaazaarListingActiveSubgraphCommand( + createContext(["baazaar", "listing", "active"], { + kind: "erc1155", + first: "5", + skip: "10", + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryName: "baazaar.listing.active.erc1155", + variables: { first: 5, skip: 10 }, + }), + ); + + expect(result).toMatchObject({ + listingKind: "erc1155", + pagination: { first: 5, skip: 10 }, + listings: [ + { + id: "11", + erc1155TypeId: "44", + }, + ], + }); + }); + + it("filters mine query with lowercased seller address", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "core-base", + endpoint: "https://example.com/core", + queryName: "baazaar.listing.mine.erc721", + data: { + erc721Listings: [], + }, + }); + + await runBaazaarListingMineSubgraphCommand( + createContext(["baazaar", "listing", "mine"], { + kind: "erc721", + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryName: "baazaar.listing.mine.erc721", + variables: expect.objectContaining({ + seller: "0xab59ca4a16925b0a4bac5026c94beb20a29df479", + }), + }), + ); + }); + + it("includes raw payload when requested", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "core-base", + endpoint: "https://example.com/core", + queryName: "baazaar.listing.get.erc1155", + data: { + erc1155Listing: { + id: "1", + category: "4", + erc1155TokenAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + erc1155TypeId: "44", + quantity: "1", + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + priceInWei: "1", + cancelled: false, + sold: false, + timeCreated: "1", + }, + }, + raw: { + data: { + erc1155Listing: { id: "1" }, + }, + }, + }); + + const result = await runBaazaarListingGetSubgraphCommand( + createContext(["baazaar", "listing", "get"], { + kind: "erc1155", + id: "1", + raw: true, + }), + ); + + expect(result).toMatchObject({ + raw: { + data: { + erc1155Listing: { id: "1" }, + }, + }, + }); + }); + + it("throws verify mismatch when onchain snapshot differs", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "core-base", + endpoint: "https://example.com/core", + queryName: "baazaar.listing.get.erc721", + data: { + erc721Listing: { + id: "10", + category: "3", + erc721TokenAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + tokenId: "99", + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + priceInWei: "100", + cancelled: false, + timeCreated: "1700000", + timePurchased: "0", + }, + }, + }); + + runRpcPreflightMock.mockResolvedValueOnce({ + chainId: 8453, + client: { + readContract: vi.fn(async () => ({ + listingId: 10n, + seller: "0xab59ca4a16925b0a4bac5026c94beb20a29df479", + erc721TokenAddress: "0xa99c4b08201f2913db8d28e71d020c4298f29dbf", + erc721TokenId: 99n, + category: 3n, + priceInWei: 200n, + timeCreated: 1700000n, + timePurchased: 0n, + cancelled: false, + })), + }, + }); + + await expect( + runBaazaarListingGetSubgraphCommand( + createContext(["baazaar", "listing", "get"], { + kind: "erc721", + id: "10", + "verify-onchain": true, + }), + ), + ).rejects.toMatchObject({ + code: "SUBGRAPH_VERIFY_MISMATCH", + }); + }); +}); diff --git a/src/commands/baazaar-subgraph.ts b/src/commands/baazaar-subgraph.ts new file mode 100644 index 0000000..cac5aa0 --- /dev/null +++ b/src/commands/baazaar-subgraph.ts @@ -0,0 +1,570 @@ +import { parseAbi } from "viem"; + +import { getFlagBoolean, getFlagString } from "../args"; +import { resolveChain, resolveRpcUrl } from "../chains"; +import { getProfileOrThrow, loadConfig } from "../config"; +import { CliError } from "../errors"; +import { runRpcPreflight } from "../rpc"; +import { + normalizeBaazaarErc1155Listing, + normalizeBaazaarErc1155Listings, + normalizeBaazaarErc721Listing, + normalizeBaazaarErc721Listings, + toLowercaseAddress, +} from "../subgraph/normalize"; +import { + BAAZAAR_ACTIVE_ERC1155_QUERY, + BAAZAAR_ACTIVE_ERC721_QUERY, + BAAZAAR_ERC1155_LISTING_BY_ID_QUERY, + BAAZAAR_ERC721_LISTING_BY_ID_QUERY, + BAAZAAR_MINE_ERC1155_QUERY, + BAAZAAR_MINE_ERC721_QUERY, +} from "../subgraph/queries"; +import { BASE_AAVEGOTCHI_DIAMOND } from "../subgraph/sources"; +import { executeSubgraphQuery } from "../subgraph/client"; +import { CommandContext, JsonValue } from "../types"; + +const DEFAULT_FIRST = 20; +const MAX_FIRST = 200; +const DEFAULT_SKIP = 0; +const MAX_SKIP = 100000; + +const BAAZAAR_VERIFY_ABI = parseAbi([ + "function getERC721Listing(uint256 _listingId) view returns ((uint256 listingId,address seller,address erc721TokenAddress,uint256 erc721TokenId,uint256 category,uint256 priceInWei,uint256 timeCreated,uint256 timePurchased,bool cancelled,uint16[2] principalSplit,address affiliate,uint32 whitelistId))", + "function getERC1155Listing(uint256 _listingId) view returns ((uint256 listingId,address seller,address erc1155TokenAddress,uint256 erc1155TypeId,uint256 category,uint256 quantity,uint256 priceInWei,uint256 timeCreated,uint256 timeLastPurchased,uint256 sourceListingId,bool sold,bool cancelled,uint16[2] principalSplit,address affiliate,uint32 whitelistId))", +]); + +type ListingKind = "erc721" | "erc1155"; + +function parseKind(value: string | undefined): ListingKind { + if (!value) { + throw new CliError("MISSING_ARGUMENT", "--kind is required (erc721|erc1155).", 2); + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "erc721" || normalized === "erc1155") { + return normalized; + } + + throw new CliError("INVALID_ARGUMENT", "--kind must be one of: erc721, erc1155.", 2, { + value, + }); +} + +function parseRawFlag(ctx: CommandContext): boolean { + return getFlagBoolean(ctx.args.flags, "raw"); +} + +function parseTimeoutMs(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + + const timeout = Number(value); + if (!Number.isInteger(timeout) || timeout <= 0) { + throw new CliError("INVALID_ARGUMENT", "--timeout-ms must be a positive integer.", 2, { + value, + }); + } + + return timeout; +} + +function parseListingId(value: string | undefined, flagName: string): string { + if (!value) { + throw new CliError("MISSING_ARGUMENT", `${flagName} is required.`, 2); + } + + if (!/^\d+$/.test(value)) { + throw new CliError("INVALID_ARGUMENT", `${flagName} must be an unsigned integer string.`, 2, { + value, + }); + } + + return value; +} + +function parseAddress(value: string | undefined, flagName: string): `0x${string}` { + if (!value || !/^0x[a-fA-F0-9]{40}$/.test(value)) { + throw new CliError("INVALID_ARGUMENT", `${flagName} must be an EVM address.`, 2, { + value, + }); + } + + return toLowercaseAddress(value); +} + +function parseBoundedIntFlag( + value: string | undefined, + flagName: string, + fallback: number, + min: number, + max: number, +): number { + if (!value) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new CliError("INVALID_ARGUMENT", `${flagName} must be an integer between ${min} and ${max}.`, 2, { + value, + }); + } + + return parsed; +} + +function parsePagination(ctx: CommandContext): { first: number; skip: number } { + const first = parseBoundedIntFlag(getFlagString(ctx.args.flags, "first"), "--first", DEFAULT_FIRST, 1, MAX_FIRST); + const skip = parseBoundedIntFlag(getFlagString(ctx.args.flags, "skip"), "--skip", DEFAULT_SKIP, 0, MAX_SKIP); + + return { + first, + skip, + }; +} + +function parseCommonSubgraphOptions(ctx: CommandContext): { + source: "core-base"; + timeoutMs?: number; + authEnvVar?: string; + subgraphUrl?: string; + allowUntrustedSubgraph?: boolean; +} { + const subgraphUrl = getFlagString(ctx.args.flags, "subgraph-url"); + const allowUntrustedSubgraph = getFlagBoolean(ctx.args.flags, "allow-untrusted-subgraph"); + + if (allowUntrustedSubgraph && !subgraphUrl) { + throw new CliError("INVALID_ARGUMENT", "--allow-untrusted-subgraph requires --subgraph-url.", 2); + } + + return { + source: "core-base", + timeoutMs: parseTimeoutMs(getFlagString(ctx.args.flags, "timeout-ms")), + authEnvVar: getFlagString(ctx.args.flags, "auth-env-var"), + subgraphUrl, + allowUntrustedSubgraph, + }; +} + +function parseVerifyOnchainFlag(ctx: CommandContext): boolean { + return getFlagBoolean(ctx.args.flags, "verify-onchain"); +} + +function resolveReadRpcUrl(ctx: CommandContext): string { + const explicitRpc = getFlagString(ctx.args.flags, "rpc-url"); + if (explicitRpc) { + return explicitRpc; + } + + const profileName = getFlagString(ctx.args.flags, "profile") || ctx.globals.profile; + if (profileName) { + const config = loadConfig(); + const profile = getProfileOrThrow(config, profileName); + return profile.rpcUrl; + } + + return resolveRpcUrl(resolveChain("base"), undefined); +} + +function createMismatchDiff( + keys: string[], + subgraph: Record, + onchain: Record, +): Record { + const diff: Record = {}; + + for (const key of keys) { + if (subgraph[key] !== onchain[key]) { + diff[key] = { + subgraph: subgraph[key], + onchain: onchain[key], + }; + } + } + + return diff; +} + +function withNormalizeContext( + response: { source: string; endpoint: string; queryName: string }, + normalize: () => T, +): T { + try { + return normalize(); + } catch (error) { + if (error instanceof CliError && error.code === "SUBGRAPH_INVALID_RESPONSE") { + const extraDetails = + error.details && typeof error.details === "object" ? (error.details as Record) : {}; + + throw new CliError(error.code, error.message, error.exitCode, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + ...extraDetails, + }); + } + + throw error; + } +} + +async function verifyErc721ListingOnchain(ctx: CommandContext, listing: ReturnType): Promise { + const chain = resolveChain("base"); + const rpcUrl = resolveReadRpcUrl(ctx); + const preflight = await runRpcPreflight(chain, rpcUrl); + + const onchainRaw = (await preflight.client.readContract({ + address: BASE_AAVEGOTCHI_DIAMOND, + abi: BAAZAAR_VERIFY_ABI, + functionName: "getERC721Listing", + args: [BigInt(listing.id)], + })) as { + listingId: bigint; + seller: `0x${string}`; + erc721TokenAddress: `0x${string}`; + erc721TokenId: bigint; + category: bigint; + priceInWei: bigint; + timeCreated: bigint; + timePurchased: bigint; + cancelled: boolean; + }; + + const onchainProjection: Record = { + id: onchainRaw.listingId.toString(), + seller: toLowercaseAddress(onchainRaw.seller), + erc721TokenAddress: toLowercaseAddress(onchainRaw.erc721TokenAddress), + tokenId: onchainRaw.erc721TokenId.toString(), + category: onchainRaw.category.toString(), + priceInWei: onchainRaw.priceInWei.toString(), + timeCreated: onchainRaw.timeCreated.toString(), + timePurchased: onchainRaw.timePurchased.toString(), + cancelled: onchainRaw.cancelled, + }; + + const subgraphProjection: Record = { + id: listing.id, + seller: listing.seller, + erc721TokenAddress: listing.erc721TokenAddress, + tokenId: listing.tokenId, + category: listing.category, + priceInWei: listing.priceInWei, + timeCreated: listing.timeCreated, + timePurchased: listing.timePurchased, + cancelled: listing.cancelled, + }; + + const diff = createMismatchDiff( + ["id", "seller", "erc721TokenAddress", "tokenId", "category", "priceInWei", "timeCreated", "timePurchased", "cancelled"], + subgraphProjection, + onchainProjection, + ); + + if (Object.keys(diff).length > 0) { + throw new CliError("SUBGRAPH_VERIFY_MISMATCH", "Subgraph listing does not match onchain snapshot.", 2, { + source: "core-base", + endpoint: "onchain-verify", + queryName: "baazaar.listing.get.erc721", + listingId: listing.id, + rpcUrl, + contractAddress: BASE_AAVEGOTCHI_DIAMOND, + diff, + }); + } + + return { + verified: true, + rpcUrl, + chainId: preflight.chainId, + contractAddress: BASE_AAVEGOTCHI_DIAMOND, + }; +} + +async function verifyErc1155ListingOnchain( + ctx: CommandContext, + listing: ReturnType, +): Promise { + const chain = resolveChain("base"); + const rpcUrl = resolveReadRpcUrl(ctx); + const preflight = await runRpcPreflight(chain, rpcUrl); + + const onchainRaw = (await preflight.client.readContract({ + address: BASE_AAVEGOTCHI_DIAMOND, + abi: BAAZAAR_VERIFY_ABI, + functionName: "getERC1155Listing", + args: [BigInt(listing.id)], + })) as { + listingId: bigint; + seller: `0x${string}`; + erc1155TokenAddress: `0x${string}`; + erc1155TypeId: bigint; + category: bigint; + quantity: bigint; + priceInWei: bigint; + timeCreated: bigint; + sold: boolean; + cancelled: boolean; + }; + + const onchainProjection: Record = { + id: onchainRaw.listingId.toString(), + seller: toLowercaseAddress(onchainRaw.seller), + erc1155TokenAddress: toLowercaseAddress(onchainRaw.erc1155TokenAddress), + erc1155TypeId: onchainRaw.erc1155TypeId.toString(), + category: onchainRaw.category.toString(), + quantity: onchainRaw.quantity.toString(), + priceInWei: onchainRaw.priceInWei.toString(), + timeCreated: onchainRaw.timeCreated.toString(), + sold: onchainRaw.sold, + cancelled: onchainRaw.cancelled, + }; + + const subgraphProjection: Record = { + id: listing.id, + seller: listing.seller, + erc1155TokenAddress: listing.erc1155TokenAddress, + erc1155TypeId: listing.erc1155TypeId, + category: listing.category, + quantity: listing.quantity, + priceInWei: listing.priceInWei, + timeCreated: listing.timeCreated, + sold: listing.sold, + cancelled: listing.cancelled, + }; + + const diff = createMismatchDiff( + ["id", "seller", "erc1155TokenAddress", "erc1155TypeId", "category", "quantity", "priceInWei", "timeCreated", "sold", "cancelled"], + subgraphProjection, + onchainProjection, + ); + + if (Object.keys(diff).length > 0) { + throw new CliError("SUBGRAPH_VERIFY_MISMATCH", "Subgraph listing does not match onchain snapshot.", 2, { + source: "core-base", + endpoint: "onchain-verify", + queryName: "baazaar.listing.get.erc1155", + listingId: listing.id, + rpcUrl, + contractAddress: BASE_AAVEGOTCHI_DIAMOND, + diff, + }); + } + + return { + verified: true, + rpcUrl, + chainId: preflight.chainId, + contractAddress: BASE_AAVEGOTCHI_DIAMOND, + }; +} + +export async function runBaazaarListingGetSubgraphCommand(ctx: CommandContext): Promise { + const kind = parseKind(getFlagString(ctx.args.flags, "kind")); + const id = parseListingId(getFlagString(ctx.args.flags, "id"), "--id"); + const raw = parseRawFlag(ctx); + const verifyOnchain = parseVerifyOnchainFlag(ctx); + const common = parseCommonSubgraphOptions(ctx); + + if (kind === "erc721") { + const response = await executeSubgraphQuery<{ erc721Listing: unknown | null }>({ + ...common, + queryName: "baazaar.listing.get.erc721", + query: BAAZAAR_ERC721_LISTING_BY_ID_QUERY, + variables: { id }, + raw, + }); + + const listing = response.data.erc721Listing + ? withNormalizeContext(response, () => normalizeBaazaarErc721Listing(response.data.erc721Listing)) + : null; + const verification = verifyOnchain && listing ? await verifyErc721ListingOnchain(ctx, listing) : undefined; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + listingKind: kind, + listing, + ...(verification ? { verification } : {}), + ...(raw ? { raw: response.raw } : {}), + }; + } + + const response = await executeSubgraphQuery<{ erc1155Listing: unknown | null }>({ + ...common, + queryName: "baazaar.listing.get.erc1155", + query: BAAZAAR_ERC1155_LISTING_BY_ID_QUERY, + variables: { id }, + raw, + }); + + const listing = response.data.erc1155Listing + ? withNormalizeContext(response, () => normalizeBaazaarErc1155Listing(response.data.erc1155Listing)) + : null; + const verification = verifyOnchain && listing ? await verifyErc1155ListingOnchain(ctx, listing) : undefined; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + listingKind: kind, + listing, + ...(verification ? { verification } : {}), + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runBaazaarListingActiveSubgraphCommand(ctx: CommandContext): Promise { + const kind = parseKind(getFlagString(ctx.args.flags, "kind")); + const pagination = parsePagination(ctx); + const raw = parseRawFlag(ctx); + const common = parseCommonSubgraphOptions(ctx); + + if (kind === "erc721") { + const response = await executeSubgraphQuery<{ erc721Listings: unknown }>({ + ...common, + queryName: "baazaar.listing.active.erc721", + query: BAAZAAR_ACTIVE_ERC721_QUERY, + variables: pagination, + raw, + }); + + if (!Array.isArray(response.data.erc721Listings)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Expected erc721Listings to be an array.", 2, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + }); + } + const erc721Listings = response.data.erc721Listings; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + listingKind: kind, + pagination, + listings: withNormalizeContext(response, () => normalizeBaazaarErc721Listings(erc721Listings)), + ...(raw ? { raw: response.raw } : {}), + }; + } + + const response = await executeSubgraphQuery<{ erc1155Listings: unknown }>({ + ...common, + queryName: "baazaar.listing.active.erc1155", + query: BAAZAAR_ACTIVE_ERC1155_QUERY, + variables: pagination, + raw, + }); + + if (!Array.isArray(response.data.erc1155Listings)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Expected erc1155Listings to be an array.", 2, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + }); + } + const erc1155Listings = response.data.erc1155Listings; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + listingKind: kind, + pagination, + listings: withNormalizeContext(response, () => normalizeBaazaarErc1155Listings(erc1155Listings)), + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runBaazaarListingMineSubgraphCommand(ctx: CommandContext): Promise { + const kind = parseKind(getFlagString(ctx.args.flags, "kind")); + const seller = parseAddress(getFlagString(ctx.args.flags, "seller"), "--seller"); + const pagination = parsePagination(ctx); + const raw = parseRawFlag(ctx); + const common = parseCommonSubgraphOptions(ctx); + + if (kind === "erc721") { + const response = await executeSubgraphQuery<{ erc721Listings: unknown }>({ + ...common, + queryName: "baazaar.listing.mine.erc721", + query: BAAZAAR_MINE_ERC721_QUERY, + variables: { + seller, + ...pagination, + }, + raw, + }); + + if (!Array.isArray(response.data.erc721Listings)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Expected erc721Listings to be an array.", 2, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + }); + } + const erc721Listings = response.data.erc721Listings; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + listingKind: kind, + seller, + pagination, + listings: withNormalizeContext(response, () => normalizeBaazaarErc721Listings(erc721Listings)), + ...(raw ? { raw: response.raw } : {}), + }; + } + + const response = await executeSubgraphQuery<{ erc1155Listings: unknown }>({ + ...common, + queryName: "baazaar.listing.mine.erc1155", + query: BAAZAAR_MINE_ERC1155_QUERY, + variables: { + seller, + ...pagination, + }, + raw, + }); + + if (!Array.isArray(response.data.erc1155Listings)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Expected erc1155Listings to be an array.", 2, { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + }); + } + const erc1155Listings = response.data.erc1155Listings; + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + listingKind: kind, + seller, + pagination, + listings: withNormalizeContext(response, () => normalizeBaazaarErc1155Listings(erc1155Listings)), + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runBaazaarListingSubgraphCommand(ctx: CommandContext): Promise { + const action = ctx.commandPath[2]; + + if (action === "get") { + return runBaazaarListingGetSubgraphCommand(ctx); + } + + if (action === "active") { + return runBaazaarListingActiveSubgraphCommand(ctx); + } + + if (action === "mine") { + return runBaazaarListingMineSubgraphCommand(ctx); + } + + throw new CliError("UNKNOWN_COMMAND", `Unknown command '${ctx.commandPath.join(" ")}'.`, 2); +} diff --git a/src/commands/subgraph.test.ts b/src/commands/subgraph.test.ts new file mode 100644 index 0000000..7c99507 --- /dev/null +++ b/src/commands/subgraph.test.ts @@ -0,0 +1,182 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CommandContext } from "../types"; + +const { executeSubgraphQueryMock } = vi.hoisted(() => ({ + executeSubgraphQueryMock: vi.fn(), +})); + +vi.mock("../subgraph/client", () => ({ + executeSubgraphQuery: executeSubgraphQueryMock, +})); + +import { runSubgraphCheckCommand, runSubgraphListCommand, runSubgraphQueryCommand } from "./subgraph"; + +const files: string[] = []; + +function writeTmpQuery(contents: string): string { + const filePath = path.join(os.tmpdir(), `agcli-subgraph-query-${Date.now()}-${Math.random()}.graphql`); + fs.writeFileSync(filePath, contents, "utf8"); + files.push(filePath); + return filePath; +} + +function createContext(positionals: string[], flags: Record): CommandContext { + return { + commandPath: positionals, + args: { + positionals, + flags, + }, + globals: { + mode: "agent", + json: true, + yes: true, + }, + }; +} + +afterEach(() => { + vi.clearAllMocks(); + + for (const filePath of files.splice(0)) { + fs.rmSync(filePath, { force: true }); + } +}); + +describe("subgraph commands", () => { + it("lists canonical sources", async () => { + const result = await runSubgraphListCommand(); + const sources = (result as { sources: { alias: string }[] }).sources; + + expect(sources.map((source) => source.alias).sort()).toEqual(["core-base", "gbm-base"]); + }); + + it("runs introspection check and returns sorted fields", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "core-base", + endpoint: "https://example.com/core", + queryName: "introspection", + data: { + __schema: { + queryType: { + fields: [{ name: "zeta" }, { name: "alpha" }], + }, + }, + }, + raw: { data: { ok: true } }, + }); + + const result = await runSubgraphCheckCommand( + createContext(["subgraph", "check"], { + source: "core-base", + raw: true, + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "core-base", + queryName: "introspection", + raw: true, + }), + ); + + expect(result).toMatchObject({ + source: "core-base", + endpoint: "https://example.com/core", + fieldCount: 2, + fields: ["alpha", "zeta"], + raw: { data: { ok: true } }, + }); + }); + + it("runs custom query with variables from inline json", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com/gbm", + queryName: "custom", + data: { auctions: [] }, + }); + + const result = await runSubgraphQueryCommand( + createContext(["subgraph", "query"], { + source: "gbm-base", + query: "query($first:Int!){ auctions(first:$first){ id } }", + "variables-json": '{"first":5}', + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "gbm-base", + queryName: "custom", + variables: { first: 5 }, + }), + ); + + expect(result).toMatchObject({ + source: "gbm-base", + endpoint: "https://example.com/gbm", + queryName: "custom", + data: { auctions: [] }, + }); + }); + + it("supports reading query from file", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "core-base", + endpoint: "https://example.com/core", + queryName: "custom", + data: { ok: true }, + }); + + const queryFile = writeTmpQuery("query { __typename }"); + + await runSubgraphQueryCommand( + createContext(["subgraph", "query"], { + source: "core-base", + "query-file": queryFile, + }), + ); + + expect(executeSubgraphQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "core-base", + query: "query { __typename }", + }), + ); + }); + + it("rejects invalid variables json", async () => { + await expect( + runSubgraphQueryCommand( + createContext(["subgraph", "query"], { + source: "core-base", + query: "query { __typename }", + "variables-json": "[]", + }), + ), + ).rejects.toMatchObject({ + code: "INVALID_VARIABLES_JSON", + }); + }); + + it("requires subgraph-url when allow-untrusted-subgraph is set", async () => { + await expect( + runSubgraphQueryCommand( + createContext(["subgraph", "query"], { + source: "core-base", + query: "query { __typename }", + "allow-untrusted-subgraph": true, + }), + ), + ).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + }); + }); +}); diff --git a/src/commands/subgraph.ts b/src/commands/subgraph.ts new file mode 100644 index 0000000..e046d79 --- /dev/null +++ b/src/commands/subgraph.ts @@ -0,0 +1,174 @@ +import * as fs from "fs"; + +import { getFlagBoolean, getFlagString } from "../args"; +import { CliError } from "../errors"; +import { subgraphVariablesSchema } from "../schemas"; +import { CommandContext, JsonValue } from "../types"; +import { executeSubgraphQuery } from "../subgraph/client"; +import { SUBGRAPH_INTROSPECTION_QUERY } from "../subgraph/queries"; +import { listSubgraphSources, parseSubgraphSourceAlias } from "../subgraph/sources"; + +function parseTimeoutMs(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + + const timeout = Number(value); + if (!Number.isInteger(timeout) || timeout <= 0) { + throw new CliError("INVALID_ARGUMENT", "--timeout-ms must be a positive integer.", 2, { + value, + }); + } + + return timeout; +} + +function parseVariablesJson(value: string | undefined): Record { + if (!value) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new CliError("INVALID_VARIABLES_JSON", "--variables-json must be valid JSON.", 2); + } + + const result = subgraphVariablesSchema.safeParse(parsed); + if (!result.success) { + throw new CliError("INVALID_VARIABLES_JSON", "--variables-json must be a JSON object.", 2, { + issues: result.error.issues, + }); + } + + return result.data; +} + +function readQueryFromInput(flags: Record): string { + const inline = getFlagString(flags, "query"); + const queryFile = getFlagString(flags, "query-file"); + + if (!inline && !queryFile) { + throw new CliError("MISSING_ARGUMENT", "subgraph query requires --query or --query-file.", 2); + } + + if (inline && queryFile) { + throw new CliError("INVALID_ARGUMENT", "Provide only one of --query or --query-file.", 2); + } + + if (inline) { + return inline; + } + + const filePath = queryFile as string; + if (!fs.existsSync(filePath)) { + throw new CliError("MISSING_ARGUMENT", `Query file not found: ${filePath}`, 2); + } + + const query = fs.readFileSync(filePath, "utf8").trim(); + if (!query) { + throw new CliError("INVALID_ARGUMENT", "Query file is empty.", 2, { + queryFile: filePath, + }); + } + + return query; +} + +function parseRawFlag(ctx: CommandContext): boolean { + return getFlagBoolean(ctx.args.flags, "raw"); +} + +function parseCommonRequestOptions(ctx: CommandContext): { + source: ReturnType; + timeoutMs?: number; + authEnvVar?: string; + subgraphUrl?: string; + allowUntrustedSubgraph?: boolean; +} { + const subgraphUrl = getFlagString(ctx.args.flags, "subgraph-url"); + const allowUntrustedSubgraph = getFlagBoolean(ctx.args.flags, "allow-untrusted-subgraph"); + + if (allowUntrustedSubgraph && !subgraphUrl) { + throw new CliError( + "INVALID_ARGUMENT", + "--allow-untrusted-subgraph requires --subgraph-url.", + 2, + ); + } + + return { + source: parseSubgraphSourceAlias(getFlagString(ctx.args.flags, "source")), + timeoutMs: parseTimeoutMs(getFlagString(ctx.args.flags, "timeout-ms")), + authEnvVar: getFlagString(ctx.args.flags, "auth-env-var"), + subgraphUrl, + allowUntrustedSubgraph, + }; +} + +export async function runSubgraphListCommand(): Promise { + return { + sources: listSubgraphSources(), + }; +} + +export async function runSubgraphCheckCommand(ctx: CommandContext): Promise { + const common = parseCommonRequestOptions(ctx); + const raw = parseRawFlag(ctx); + + const response = await executeSubgraphQuery<{ + __schema?: { queryType?: { fields?: { name?: string }[] } }; + }>({ + ...common, + queryName: "introspection", + query: SUBGRAPH_INTROSPECTION_QUERY, + variables: {}, + raw, + }); + + const fields = response.data.__schema?.queryType?.fields; + if (!Array.isArray(fields)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Introspection response missing query fields.", 2, { + source: response.source, + endpoint: response.endpoint, + }); + } + + const fieldNames = fields + .map((field) => field.name) + .filter((name): name is string => typeof name === "string") + .sort((a, b) => a.localeCompare(b)); + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + fieldCount: fieldNames.length, + fields: fieldNames, + ...(raw ? { raw: response.raw } : {}), + }; +} + +export async function runSubgraphQueryCommand(ctx: CommandContext): Promise { + const common = parseCommonRequestOptions(ctx); + const raw = parseRawFlag(ctx); + const query = readQueryFromInput(ctx.args.flags); + const variables = parseVariablesJson(getFlagString(ctx.args.flags, "variables-json")); + + const response = await executeSubgraphQuery({ + ...common, + queryName: "custom", + query, + variables, + raw, + }); + + return { + source: response.source, + endpoint: response.endpoint, + queryName: response.queryName, + data: response.data, + ...(raw ? { raw: response.raw } : {}), + }; +} diff --git a/src/output.ts b/src/output.ts index eeed7d1..67d6aff 100644 --- a/src/output.ts +++ b/src/output.ts @@ -91,11 +91,17 @@ Automation commands: Power-user commands: onchain call Call any ABI function from --abi-file onchain send Send any ABI function as a transaction + subgraph list|check|query List/check/query canonical Goldsky subgraphs Domain namespaces: - gotchi, portal, wearables, items, inventory, baazaar, lending, realm, alchemica, forge, token + gotchi, portal, wearables, items, inventory, baazaar, auction, lending, staking, gotchi-points, realm, alchemica, forge, token (many write flows are mapped to onchain send aliases; unmatched commands return typed not-implemented) +Subgraph wrappers: + baazaar listing get|active|mine Read Baazaar listing data from core-base subgraph + auction get|active|mine|bids|bids-mine + Read GBM auction/bid data from gbm-base subgraph + Global flags: --mode Agent mode implies --json --yes --json, -j Emit JSON envelope output @@ -117,6 +123,9 @@ Examples: ag bootstrap --mode agent --profile prod --chain base --signer env:AGCLI_PRIVATE_KEY --json AGCLI_KEYCHAIN_PASSPHRASE=... AGCLI_PRIVATE_KEY=0x... ag signer keychain import --account-id bot --private-key-env AGCLI_PRIVATE_KEY --json ag tx send --profile prod --to 0xabc... --value-wei 1000000000000000 --wait --json + ag subgraph check --source core-base --json + ag baazaar listing active --kind erc721 --first 20 --json + ag auction active --first 20 --json ag lending create --profile prod --abi-file ./abis/GotchiLendingFacet.json --address 0xabc... --args-json '[...]' --json ag batch run --file ./plan.yaml --json `); diff --git a/src/schemas.ts b/src/schemas.ts index fc1277d..e288ca7 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -86,3 +86,66 @@ export const batchPlanSchema = z.object({ }); export type BatchPlan = z.infer; + +export const subgraphVariablesSchema = z.object({}).catchall(z.unknown()); + +export const baazaarErc721ListingSchema = z.object({ + id: z.union([z.string(), z.number()]).transform((value) => String(value)), + category: z.union([z.string(), z.number()]).transform((value) => String(value)), + erc721TokenAddress: addressSchema, + tokenId: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + seller: addressSchema, + priceInWei: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + cancelled: z.boolean(), + timeCreated: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + timePurchased: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), +}); + +export const baazaarErc1155ListingSchema = z.object({ + id: z.union([z.string(), z.number()]).transform((value) => String(value)), + category: z.union([z.string(), z.number()]).transform((value) => String(value)), + erc1155TokenAddress: addressSchema, + erc1155TypeId: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + quantity: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + seller: addressSchema, + priceInWei: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + cancelled: z.boolean(), + sold: z.boolean(), + timeCreated: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), +}); + +export const gbmAuctionSchema = z.object({ + id: z.union([z.string(), z.number()]).transform((value) => String(value)), + type: z.string().optional(), + contractAddress: addressSchema, + tokenId: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + quantity: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + seller: addressSchema, + highestBid: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + highestBidder: addressSchema.optional(), + totalBids: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + startsAt: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + endsAt: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + claimAt: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)).optional(), + claimed: z.boolean(), + cancelled: z.boolean(), + presetId: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)).optional(), + category: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)).optional(), + buyNowPrice: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)).optional(), + startBidPrice: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)).optional(), +}); + +export const gbmBidSchema = z.object({ + id: z.union([z.string(), z.number()]).transform((value) => String(value)), + bidder: addressSchema, + amount: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + bidTime: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)), + outbid: z.boolean(), + previousBid: z.union([z.string(), z.number(), z.bigint()]).transform((value) => String(value)).optional(), + previousBidder: addressSchema.optional(), + auction: z + .object({ + id: z.union([z.string(), z.number()]).transform((value) => String(value)), + }) + .optional(), +}); diff --git a/src/subgraph/client.test.ts b/src/subgraph/client.test.ts new file mode 100644 index 0000000..292f93b --- /dev/null +++ b/src/subgraph/client.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { executeSubgraphQuery } from "./client"; +import { CORE_BASE_ENDPOINT } from "./sources"; +import { CliError } from "../errors"; + +describe("subgraph client", () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.GOLDSKY_API_KEY; + delete process.env.CUSTOM_GOLDSKY_KEY; + }); + + it("injects bearer auth from default env var", async () => { + process.env.GOLDSKY_API_KEY = "token-123"; + + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + data: { ping: true }, + }), + { status: 200 }, + ), + ); + + vi.stubGlobal("fetch", fetchMock); + + const result = await executeSubgraphQuery<{ ping: boolean }>({ + source: "core-base", + queryName: "ping", + query: "query { ping }", + }); + + expect(result.data.ping).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe(CORE_BASE_ENDPOINT); + expect((fetchMock.mock.calls[0]?.[1] as { headers: Record }).headers.authorization).toBe( + "Bearer token-123", + ); + }); + + it("supports overriding auth env var", async () => { + process.env.CUSTOM_GOLDSKY_KEY = "token-abc"; + + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + data: { ok: true }, + }), + { status: 200 }, + ), + ); + + vi.stubGlobal("fetch", fetchMock); + + await executeSubgraphQuery({ + source: "core-base", + queryName: "ping", + query: "query { ping }", + authEnvVar: "CUSTOM_GOLDSKY_KEY", + }); + + expect((fetchMock.mock.calls[0]?.[1] as { headers: Record }).headers.authorization).toBe( + "Bearer token-abc", + ); + }); + + it("retries once on transport errors", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("network down")) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { ok: true }, + }), + { status: 200 }, + ), + ); + + vi.stubGlobal("fetch", fetchMock); + + const result = await executeSubgraphQuery<{ ok: boolean }>({ + source: "core-base", + queryName: "retry-check", + query: "query { ok }", + }); + + expect(result.data.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("maps timeout failures", async () => { + const fetchMock = vi.fn((_: unknown, init?: RequestInit) => { + return new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }); + }); + + vi.stubGlobal("fetch", fetchMock); + + await expect( + executeSubgraphQuery({ + source: "core-base", + queryName: "timeout-check", + query: "query { slow }", + timeoutMs: 1, + }), + ).rejects.toMatchObject({ + code: "SUBGRAPH_TIMEOUT", + }); + }); + + it("maps graphql errors", async () => { + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + errors: [{ message: "bad query" }], + }), + { status: 200 }, + ), + ); + + vi.stubGlobal("fetch", fetchMock); + + await expect( + executeSubgraphQuery({ + source: "gbm-base", + queryName: "broken", + query: "query { broken }", + }), + ).rejects.toMatchObject({ + code: "SUBGRAPH_GRAPHQL_ERROR", + }); + }); + + it("maps invalid json payloads", async () => { + const fetchMock = vi.fn(async () => new Response("not-json", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + executeSubgraphQuery({ + source: "gbm-base", + queryName: "invalid-json", + query: "query { x }", + }), + ).rejects.toMatchObject({ + code: "SUBGRAPH_INVALID_RESPONSE", + }); + }); + + it("maps http errors", async () => { + const fetchMock = vi.fn(async () => new Response("oops", { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + executeSubgraphQuery({ + source: "core-base", + queryName: "http-error", + query: "query { x }", + }), + ).rejects.toMatchObject({ + code: "SUBGRAPH_HTTP_ERROR", + }); + + try { + await executeSubgraphQuery({ + source: "core-base", + queryName: "http-error", + query: "query { x }", + }); + } catch (error) { + const cliError = error as CliError; + expect(cliError.details).toMatchObject({ + source: "core-base", + endpoint: CORE_BASE_ENDPOINT, + queryName: "http-error", + }); + } + }); +}); diff --git a/src/subgraph/client.ts b/src/subgraph/client.ts new file mode 100644 index 0000000..6096efe --- /dev/null +++ b/src/subgraph/client.ts @@ -0,0 +1,185 @@ +import { CliError } from "../errors"; +import { SubgraphRequestOptions, SubgraphResponseEnvelope } from "../types"; + +import { resolveSubgraphEndpoint } from "./sources"; + +const DEFAULT_TIMEOUT_MS = 10000; +const DEFAULT_RETRIES = 1; +const DEFAULT_AUTH_ENV_VAR = "GOLDSKY_API_KEY"; + +interface GraphQlPayload { + data?: unknown; + errors?: unknown; +} + +function ensureAuthEnvVarName(value: string): string { + if (!/^[A-Z_][A-Z0-9_]*$/.test(value)) { + throw new CliError("INVALID_ARGUMENT", `Invalid auth env var '${value}'.`, 2, { + authEnvVar: value, + }); + } + + return value; +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, + details: { source: string; endpoint: string; queryName: string }, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new CliError("SUBGRAPH_TIMEOUT", `Subgraph query timed out after ${timeoutMs}ms.`, 2, { + ...details, + timeoutMs, + }); + } + + throw new CliError("SUBGRAPH_HTTP_ERROR", "Failed to reach subgraph endpoint.", 2, { + ...details, + message: error instanceof Error ? error.message : String(error), + }); + } finally { + clearTimeout(timer); + } +} + +function parseGraphQlPayload( + raw: string, + details: { source: string; endpoint: string; queryName: string }, +): GraphQlPayload { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Subgraph response was not valid JSON.", 2, details); + } + + if (!parsed || typeof parsed !== "object") { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Subgraph response shape is invalid.", 2, details); + } + + return parsed as GraphQlPayload; +} + +function isRetriableError(error: CliError): boolean { + return error.code === "SUBGRAPH_TIMEOUT" || error.code === "SUBGRAPH_HTTP_ERROR"; +} + +export async function executeSubgraphQuery( + options: SubgraphRequestOptions & { raw?: boolean }, +): Promise> { + const resolved = resolveSubgraphEndpoint({ + source: options.source, + subgraphUrl: options.subgraphUrl, + allowUntrustedSubgraph: options.allowUntrustedSubgraph, + }); + + const authEnvVar = ensureAuthEnvVarName(options.authEnvVar || DEFAULT_AUTH_ENV_VAR); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + throw new CliError("INVALID_ARGUMENT", "--timeout-ms must be a positive integer.", 2, { + timeoutMs, + }); + } + const retries = DEFAULT_RETRIES; + + const headers: Record = { + "content-type": "application/json", + }; + + const authToken = process.env[authEnvVar]; + if (authToken) { + headers.authorization = `Bearer ${authToken}`; + } + + const requestBody = JSON.stringify({ + query: options.query, + variables: options.variables || {}, + }); + + const requestDetails = { + source: resolved.source, + endpoint: resolved.endpoint, + queryName: options.queryName, + }; + + let lastError: CliError | undefined; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + const response = await fetchWithTimeout( + resolved.endpoint, + { + method: "POST", + headers, + body: requestBody, + }, + timeoutMs, + requestDetails, + ); + + const responseText = await response.text(); + if (!response.ok) { + throw new CliError("SUBGRAPH_HTTP_ERROR", `Subgraph endpoint returned HTTP ${response.status}.`, 2, { + ...requestDetails, + status: response.status, + body: responseText.slice(0, 500), + }); + } + + const payload = parseGraphQlPayload(responseText, requestDetails); + + if (Array.isArray(payload.errors) && payload.errors.length > 0) { + throw new CliError("SUBGRAPH_GRAPHQL_ERROR", "Subgraph query returned GraphQL errors.", 2, { + ...requestDetails, + errors: payload.errors, + }); + } + + if (!("data" in payload)) { + throw new CliError("SUBGRAPH_INVALID_RESPONSE", "Subgraph response missing 'data' field.", 2, requestDetails); + } + + return { + source: resolved.source, + endpoint: resolved.endpoint, + queryName: options.queryName, + data: payload.data as TData, + ...(options.raw ? { raw: payload as unknown as object } : {}), + }; + } catch (error) { + if (error instanceof CliError) { + lastError = error; + if (attempt < retries && isRetriableError(error)) { + continue; + } + throw error; + } + + lastError = new CliError("SUBGRAPH_HTTP_ERROR", "Subgraph request failed.", 2, { + ...requestDetails, + message: error instanceof Error ? error.message : String(error), + }); + if (attempt < retries) { + continue; + } + throw lastError; + } + } + + throw ( + lastError || + new CliError("SUBGRAPH_HTTP_ERROR", "Subgraph request failed.", 2, { + ...requestDetails, + }) + ); +} diff --git a/src/subgraph/normalize.test.ts b/src/subgraph/normalize.test.ts new file mode 100644 index 0000000..a4f01a6 --- /dev/null +++ b/src/subgraph/normalize.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeBaazaarErc1155Listing, + normalizeBaazaarErc721Listing, + normalizeGbmAuction, + normalizeGbmBid, + toLowercaseAddress, +} from "./normalize"; + +describe("subgraph normalizers", () => { + it("normalizes erc721 listing fields", () => { + const result = normalizeBaazaarErc721Listing({ + id: 1, + category: 3, + erc721TokenAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + tokenId: 44, + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + priceInWei: 1000, + cancelled: false, + timeCreated: 1700000000, + timePurchased: 0, + }); + + expect(result).toMatchObject({ + id: "1", + category: "3", + erc721TokenAddress: "0xa99c4b08201f2913db8d28e71d020c4298f29dbf", + seller: "0xab59ca4a16925b0a4bac5026c94beb20a29df479", + tokenId: "44", + priceInWei: "1000", + timeCreated: "1700000000", + timePurchased: "0", + }); + }); + + it("normalizes erc1155 listing fields", () => { + const result = normalizeBaazaarErc1155Listing({ + id: "2", + category: "4", + erc1155TokenAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + erc1155TypeId: 99, + quantity: 5, + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + priceInWei: 123, + cancelled: false, + sold: false, + timeCreated: 1700000001, + }); + + expect(result.erc1155TokenAddress).toBe("0xa99c4b08201f2913db8d28e71d020c4298f29dbf"); + expect(result.seller).toBe("0xab59ca4a16925b0a4bac5026c94beb20a29df479"); + expect(result.erc1155TypeId).toBe("99"); + }); + + it("normalizes gbm auction fields", () => { + const result = normalizeGbmAuction({ + id: 11, + contractAddress: "0xA99c4B08201F2913Db8D28e71d020c4298F29dBF", + tokenId: 23, + quantity: 1, + seller: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + highestBid: 500, + highestBidder: "0x80320A0000C7A6a34086E2ACAD6915Ff57FfDA31", + totalBids: 9, + startsAt: 1700000100, + endsAt: 1700000200, + claimed: false, + cancelled: false, + }); + + expect(result.contractAddress).toBe("0xa99c4b08201f2913db8d28e71d020c4298f29dbf"); + expect(result.highestBidder).toBe("0x80320a0000c7a6a34086e2acad6915ff57ffda31"); + expect(result.highestBid).toBe("500"); + }); + + it("normalizes gbm bid fields", () => { + const result = normalizeGbmBid({ + id: 77, + bidder: "0xAb59CA4A16925b0a4BaC5026C94bEB20A29Df479", + amount: 123, + bidTime: 1700000400, + outbid: true, + previousBid: 122, + previousBidder: "0x80320A0000C7A6a34086E2ACAD6915Ff57FfDA31", + auction: { id: 11 }, + }); + + expect(result).toMatchObject({ + id: "77", + bidder: "0xab59ca4a16925b0a4bac5026c94beb20a29df479", + amount: "123", + auctionId: "11", + previousBidder: "0x80320a0000c7a6a34086e2acad6915ff57ffda31", + }); + }); + + it("rejects invalid addresses", () => { + expect(() => toLowercaseAddress("invalid")).toThrowError(/invalid address/i); + }); +}); diff --git a/src/subgraph/normalize.ts b/src/subgraph/normalize.ts new file mode 100644 index 0000000..486d4c8 --- /dev/null +++ b/src/subgraph/normalize.ts @@ -0,0 +1,101 @@ +import { + baazaarErc1155ListingSchema, + baazaarErc721ListingSchema, + gbmAuctionSchema, + gbmBidSchema, +} from "../schemas"; +import { CliError } from "../errors"; +import { + BaazaarErc1155ListingResult, + BaazaarErc721ListingResult, + GbmAuctionResult, + GbmBidResult, + JsonValue, +} from "../types"; + +function toNormalizeError(message: string, details: JsonValue): CliError { + return new CliError("SUBGRAPH_INVALID_RESPONSE", message, 2, details); +} + +export function toLowercaseAddress(value: string): `0x${string}` { + if (!/^0x[a-fA-F0-9]{40}$/.test(value)) { + throw toNormalizeError("Subgraph response includes invalid address.", { + value, + }); + } + + return value.toLowerCase() as `0x${string}`; +} + +function parseWithSchema(name: string, value: unknown, parser: { parse: (input: unknown) => T }): T { + try { + return parser.parse(value); + } catch (error) { + throw toNormalizeError(`Failed to parse subgraph payload for ${name}.`, { + name, + message: error instanceof Error ? error.message : String(error), + }); + } +} + +export function normalizeBaazaarErc721Listing(value: unknown): BaazaarErc721ListingResult { + const parsed = parseWithSchema("erc721Listing", value, baazaarErc721ListingSchema); + + return { + ...parsed, + erc721TokenAddress: toLowercaseAddress(parsed.erc721TokenAddress), + seller: toLowercaseAddress(parsed.seller), + }; +} + +export function normalizeBaazaarErc1155Listing(value: unknown): BaazaarErc1155ListingResult { + const parsed = parseWithSchema("erc1155Listing", value, baazaarErc1155ListingSchema); + + return { + ...parsed, + erc1155TokenAddress: toLowercaseAddress(parsed.erc1155TokenAddress), + seller: toLowercaseAddress(parsed.seller), + }; +} + +export function normalizeGbmAuction(value: unknown): GbmAuctionResult { + const parsed = parseWithSchema("auction", value, gbmAuctionSchema); + + return { + ...parsed, + contractAddress: toLowercaseAddress(parsed.contractAddress), + seller: toLowercaseAddress(parsed.seller), + ...(parsed.highestBidder ? { highestBidder: toLowercaseAddress(parsed.highestBidder) } : {}), + }; +} + +export function normalizeGbmBid(value: unknown): GbmBidResult { + const parsed = parseWithSchema("bid", value, gbmBidSchema); + + return { + id: parsed.id, + bidder: toLowercaseAddress(parsed.bidder), + amount: parsed.amount, + bidTime: parsed.bidTime, + outbid: parsed.outbid, + ...(parsed.previousBid ? { previousBid: parsed.previousBid } : {}), + ...(parsed.previousBidder ? { previousBidder: toLowercaseAddress(parsed.previousBidder) } : {}), + ...(parsed.auction?.id ? { auctionId: parsed.auction.id } : {}), + }; +} + +export function normalizeBaazaarErc721Listings(values: unknown[]): BaazaarErc721ListingResult[] { + return values.map((value) => normalizeBaazaarErc721Listing(value)); +} + +export function normalizeBaazaarErc1155Listings(values: unknown[]): BaazaarErc1155ListingResult[] { + return values.map((value) => normalizeBaazaarErc1155Listing(value)); +} + +export function normalizeGbmAuctions(values: unknown[]): GbmAuctionResult[] { + return values.map((value) => normalizeGbmAuction(value)); +} + +export function normalizeGbmBids(values: unknown[]): GbmBidResult[] { + return values.map((value) => normalizeGbmBid(value)); +} diff --git a/src/subgraph/queries.ts b/src/subgraph/queries.ts new file mode 100644 index 0000000..e225f2c --- /dev/null +++ b/src/subgraph/queries.ts @@ -0,0 +1,264 @@ +export const SUBGRAPH_INTROSPECTION_QUERY = ` +query { + __schema { + queryType { + fields { + name + } + } + } +} +`.trim(); + +export const BAAZAAR_ERC721_LISTING_BY_ID_QUERY = ` +query($id: ID!) { + erc721Listing(id: $id) { + id + category + erc721TokenAddress + tokenId + seller + priceInWei + cancelled + timeCreated + timePurchased + } +} +`.trim(); + +export const BAAZAAR_ERC1155_LISTING_BY_ID_QUERY = ` +query($id: ID!) { + erc1155Listing(id: $id) { + id + category + erc1155TokenAddress + erc1155TypeId + quantity + seller + priceInWei + cancelled + sold + timeCreated + } +} +`.trim(); + +export const BAAZAAR_ACTIVE_ERC721_QUERY = ` +query($first: Int!, $skip: Int!) { + erc721Listings( + first: $first + skip: $skip + orderBy: timeCreated + orderDirection: desc + where: { cancelled: false, timePurchased: "0" } + ) { + id + category + erc721TokenAddress + tokenId + seller + priceInWei + cancelled + timeCreated + timePurchased + } +} +`.trim(); + +export const BAAZAAR_ACTIVE_ERC1155_QUERY = ` +query($first: Int!, $skip: Int!) { + erc1155Listings( + first: $first + skip: $skip + orderBy: timeCreated + orderDirection: desc + where: { cancelled: false, sold: false } + ) { + id + category + erc1155TokenAddress + erc1155TypeId + quantity + seller + priceInWei + cancelled + sold + timeCreated + } +} +`.trim(); + +export const BAAZAAR_MINE_ERC721_QUERY = ` +query($seller: Bytes!, $first: Int!, $skip: Int!) { + erc721Listings( + first: $first + skip: $skip + orderBy: timeCreated + orderDirection: desc + where: { seller: $seller } + ) { + id + category + erc721TokenAddress + tokenId + seller + priceInWei + cancelled + timeCreated + timePurchased + } +} +`.trim(); + +export const BAAZAAR_MINE_ERC1155_QUERY = ` +query($seller: Bytes!, $first: Int!, $skip: Int!) { + erc1155Listings( + first: $first + skip: $skip + orderBy: timeCreated + orderDirection: desc + where: { seller: $seller } + ) { + id + category + erc1155TokenAddress + erc1155TypeId + quantity + seller + priceInWei + cancelled + sold + timeCreated + } +} +`.trim(); + +export const GBM_AUCTION_BY_ID_QUERY = ` +query($id: ID!) { + auction(id: $id) { + id + type + contractAddress + tokenId + quantity + seller + highestBid + highestBidder + totalBids + startsAt + endsAt + claimAt + claimed + cancelled + presetId + category + buyNowPrice + startBidPrice + } +} +`.trim(); + +export const GBM_ACTIVE_AUCTIONS_QUERY = ` +query($now: BigInt!, $first: Int!, $skip: Int!) { + auctions( + first: $first + skip: $skip + orderBy: endsAt + orderDirection: asc + where: { claimed: false, cancelled: false, startsAt_lte: $now, endsAt_gt: $now } + ) { + id + type + contractAddress + tokenId + quantity + seller + highestBid + highestBidder + totalBids + startsAt + endsAt + claimAt + claimed + cancelled + presetId + category + buyNowPrice + startBidPrice + } +} +`.trim(); + +export const GBM_MINE_AUCTIONS_QUERY = ` +query($seller: Bytes!, $first: Int!, $skip: Int!) { + auctions( + first: $first + skip: $skip + orderBy: createdAt + orderDirection: desc + where: { seller: $seller } + ) { + id + type + contractAddress + tokenId + quantity + seller + highestBid + highestBidder + totalBids + startsAt + endsAt + claimAt + claimed + cancelled + presetId + category + buyNowPrice + startBidPrice + } +} +`.trim(); + +export const GBM_BIDS_BY_AUCTION_QUERY = ` +query($auctionId: String!, $first: Int!, $skip: Int!) { + bids( + first: $first + skip: $skip + orderBy: bidTime + orderDirection: desc + where: { auction: $auctionId } + ) { + id + bidder + amount + bidTime + outbid + previousBid + previousBidder + } +} +`.trim(); + +export const GBM_BIDS_BY_BIDDER_QUERY = ` +query($bidder: Bytes!, $first: Int!, $skip: Int!) { + bids( + first: $first + skip: $skip + orderBy: bidTime + orderDirection: desc + where: { bidder: $bidder } + ) { + id + bidder + amount + bidTime + outbid + previousBid + previousBidder + auction { + id + } + } +} +`.trim(); diff --git a/src/subgraph/sources.test.ts b/src/subgraph/sources.test.ts new file mode 100644 index 0000000..a27b869 --- /dev/null +++ b/src/subgraph/sources.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { + CORE_BASE_ENDPOINT, + GBM_BASE_ENDPOINT, + listSubgraphSources, + parseSubgraphSourceAlias, + resolveSubgraphEndpoint, +} from "./sources"; +import { CliError } from "../errors"; + +describe("subgraph source resolver", () => { + it("lists canonical sources", () => { + const sources = listSubgraphSources(); + + expect(sources.map((source) => source.alias).sort()).toEqual(["core-base", "gbm-base"]); + expect(sources.find((source) => source.alias === "core-base")?.endpoint).toBe(CORE_BASE_ENDPOINT); + expect(sources.find((source) => source.alias === "gbm-base")?.endpoint).toBe(GBM_BASE_ENDPOINT); + }); + + it("parses known source aliases", () => { + expect(parseSubgraphSourceAlias("core-base")).toBe("core-base"); + expect(parseSubgraphSourceAlias("GBM-BASE")).toBe("gbm-base"); + }); + + it("rejects unknown alias", () => { + expect(() => parseSubgraphSourceAlias("bad-source")).toThrowError(CliError); + + try { + parseSubgraphSourceAlias("bad-source"); + } catch (error) { + const cliError = error as CliError; + expect(cliError.code).toBe("SUBGRAPH_SOURCE_UNKNOWN"); + } + }); + + it("resolves canonical source endpoint", () => { + const resolved = resolveSubgraphEndpoint({ + source: "core-base", + }); + + expect(resolved.endpoint).toBe(CORE_BASE_ENDPOINT); + expect(resolved.isCustomEndpoint).toBe(false); + }); + + it("blocks non-canonical endpoint without explicit override", () => { + expect(() => + resolveSubgraphEndpoint({ + source: "core-base", + subgraphUrl: "https://example.com/subgraph", + }), + ).toThrowError(CliError); + + try { + resolveSubgraphEndpoint({ + source: "core-base", + subgraphUrl: "https://example.com/subgraph", + }); + } catch (error) { + const cliError = error as CliError; + expect(cliError.code).toBe("SUBGRAPH_ENDPOINT_BLOCKED"); + } + }); + + it("allows custom endpoint only with explicit override flag", () => { + const resolved = resolveSubgraphEndpoint({ + source: "core-base", + subgraphUrl: "https://example.com/subgraph", + allowUntrustedSubgraph: true, + }); + + expect(resolved.endpoint).toBe("https://example.com/subgraph"); + expect(resolved.isCustomEndpoint).toBe(true); + }); + + it("rejects non-https custom endpoints", () => { + expect(() => + resolveSubgraphEndpoint({ + source: "gbm-base", + subgraphUrl: "http://example.com/subgraph", + allowUntrustedSubgraph: true, + }), + ).toThrowError(CliError); + + try { + resolveSubgraphEndpoint({ + source: "gbm-base", + subgraphUrl: "http://example.com/subgraph", + allowUntrustedSubgraph: true, + }); + } catch (error) { + const cliError = error as CliError; + expect(cliError.code).toBe("SUBGRAPH_ENDPOINT_BLOCKED"); + } + }); +}); diff --git a/src/subgraph/sources.ts b/src/subgraph/sources.ts new file mode 100644 index 0000000..90b9a63 --- /dev/null +++ b/src/subgraph/sources.ts @@ -0,0 +1,118 @@ +import { CliError } from "../errors"; +import { SubgraphSourceAlias, SubgraphSourceDefinition } from "../types"; + +export const CORE_BASE_ENDPOINT = + "https://api.goldsky.com/api/public/project_cmh3flagm0001r4p25foufjtt/subgraphs/aavegotchi-core-base/prod/gn"; +export const GBM_BASE_ENDPOINT = + "https://api.goldsky.com/api/public/project_cmh3flagm0001r4p25foufjtt/subgraphs/aavegotchi-gbm-baazaar-base/prod/gn"; + +export const BASE_AAVEGOTCHI_DIAMOND = "0xa99c4b08201f2913db8d28e71d020c4298f29dbf" as const; +export const BASE_GBM_DIAMOND = "0x80320a0000c7a6a34086e2acad6915ff57ffda31" as const; + +const SOURCE_MAP: Record = { + "core-base": { + alias: "core-base", + endpoint: CORE_BASE_ENDPOINT, + description: "Aavegotchi core base subgraph (Baazaar listings and core entities).", + }, + "gbm-base": { + alias: "gbm-base", + endpoint: GBM_BASE_ENDPOINT, + description: "Aavegotchi GBM baazaar base subgraph (auctions and bids).", + }, +}; + +export interface ResolvedSubgraphEndpoint { + source: SubgraphSourceAlias; + endpoint: string; + isCustomEndpoint: boolean; +} + +export function listSubgraphSources(): SubgraphSourceDefinition[] { + return Object.values(SOURCE_MAP); +} + +export function parseSubgraphSourceAlias(value: string | undefined): SubgraphSourceAlias { + if (!value) { + throw new CliError("MISSING_ARGUMENT", "subgraph source is required (--source).", 2); + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "core-base" || normalized === "gbm-base") { + return normalized; + } + + throw new CliError("SUBGRAPH_SOURCE_UNKNOWN", `Unknown subgraph source '${value}'.`, 2, { + source: value, + supportedSources: Object.keys(SOURCE_MAP), + }); +} + +function parseHttpsUrl(value: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new CliError("INVALID_ARGUMENT", "subgraph url must be a valid URL.", 2, { + subgraphUrl: value, + }); + } + + if (parsed.protocol !== "https:") { + throw new CliError("SUBGRAPH_ENDPOINT_BLOCKED", "Custom subgraph url must use HTTPS.", 2, { + subgraphUrl: value, + }); + } + + return parsed.toString(); +} + +export function resolveSubgraphEndpoint(input: { + source: SubgraphSourceAlias; + subgraphUrl?: string; + allowUntrustedSubgraph?: boolean; +}): ResolvedSubgraphEndpoint { + const source = SOURCE_MAP[input.source]; + if (!source) { + throw new CliError("SUBGRAPH_SOURCE_UNKNOWN", `Unknown subgraph source '${input.source}'.`, 2, { + source: input.source, + supportedSources: Object.keys(SOURCE_MAP), + }); + } + + if (!input.subgraphUrl) { + return { + source: source.alias, + endpoint: source.endpoint, + isCustomEndpoint: false, + }; + } + + const endpoint = parseHttpsUrl(input.subgraphUrl); + if (endpoint === source.endpoint) { + return { + source: source.alias, + endpoint, + isCustomEndpoint: false, + }; + } + + if (!input.allowUntrustedSubgraph) { + throw new CliError( + "SUBGRAPH_ENDPOINT_BLOCKED", + "Custom subgraph endpoint blocked by default. Pass --allow-untrusted-subgraph to override.", + 2, + { + source: source.alias, + canonicalEndpoint: source.endpoint, + requestedEndpoint: endpoint, + }, + ); + } + + return { + source: source.alias, + endpoint, + isCustomEndpoint: true, + }; +} diff --git a/src/types.ts b/src/types.ts index b4865f7..6e61b2a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,90 @@ export interface CommandContext { globals: GlobalOptions; } +export type SubgraphSourceAlias = "core-base" | "gbm-base"; + +export interface SubgraphSourceDefinition { + alias: SubgraphSourceAlias; + endpoint: string; + description: string; +} + +export interface SubgraphRequestOptions { + source: SubgraphSourceAlias; + queryName: string; + query: string; + variables?: Record; + timeoutMs?: number; + authEnvVar?: string; + subgraphUrl?: string; + allowUntrustedSubgraph?: boolean; +} + +export interface SubgraphResponseEnvelope { + source: SubgraphSourceAlias; + endpoint: string; + queryName: string; + data: T; + raw?: JsonValue; +} + +export interface BaazaarErc721ListingResult { + id: string; + category: string; + erc721TokenAddress: `0x${string}`; + tokenId: string; + seller: `0x${string}`; + priceInWei: string; + cancelled: boolean; + timeCreated: string; + timePurchased: string; +} + +export interface BaazaarErc1155ListingResult { + id: string; + category: string; + erc1155TokenAddress: `0x${string}`; + erc1155TypeId: string; + quantity: string; + seller: `0x${string}`; + priceInWei: string; + cancelled: boolean; + sold: boolean; + timeCreated: string; +} + +export interface GbmAuctionResult { + id: string; + type?: string; + contractAddress: `0x${string}`; + tokenId: string; + quantity: string; + seller: `0x${string}`; + highestBid: string; + highestBidder?: `0x${string}`; + totalBids: string; + startsAt: string; + endsAt: string; + claimAt?: string; + claimed: boolean; + cancelled: boolean; + presetId?: string; + category?: string; + buyNowPrice?: string; + startBidPrice?: string; +} + +export interface GbmBidResult { + id: string; + bidder: `0x${string}`; + amount: string; + bidTime: string; + outbid: boolean; + previousBid?: string; + previousBidder?: `0x${string}`; + auctionId?: string; +} + export interface SignerReadonlyConfig { type: "readonly"; }