diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f4e4e..5b90a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,26 @@ ## Unreleased +## 0.2.5 - 2026-02-27 + ### Added -- Built-in ABI defaults for all mapped write commands, removing the need to manually look up ABI signatures for mapped execution and `--help`. +- First-class auction bid UX (no manual ABI/address/args): + - `ag auction bid --auction-id --amount-ghst [--dry-run]` + - internal GBM diamond + ABI resolution for `commitBid` + - preflight checks for auction-open state, expected/unbid status, minimum bid, GHST balance, and GHST allowance +- Batch-native unbid flow: + - `ag auction bid-unbid --amount-ghst --max-total-ghst [--dry-run]` + - per-auction summary and explicit skip reasons +- Bankr environment ergonomics: + - profile-level env file support via `bootstrap --env-file ` + - Bankr env auto-discovery (`AGCLI_BANKR_ENV_FILE`, `AGCLI_HOME` defaults, `~/.config/openclaw/bankr.env`, local `.env.bankr`/`bankr.env`) ### Changed -- Expanded mapped metadata coverage beyond auctions: - - canonical Base addresses now auto-resolve for high-confidence command families (Aavegotchi diamond, GBM diamond, Forge diamond, GLTR staking, Merkle distributor). - - command families with dynamic target contracts still auto-resolve ABI and only require `--address`. +- Added optional GHST auto-approve path for auction bidding (`--auto-approve`). +- Added race-safe auction submit behavior by rechecking auction highest bid/bidder immediately before send. +- Improved simulation revert decoding with structured `reasonCode` details (for example: `INSUFFICIENT_ALLOWANCE`, `BID_BELOW_START`, `AUCTION_STATE_CHANGED`). ## 0.2.4 - 2026-02-27 diff --git a/README.md b/README.md index d7422c0..3dd5f90 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ npm run ag -- bootstrap --mode agent --profile prod --chain base --signer readon - `subgraph list|check|query` - `baazaar listing get|active|mine` (subgraph-first read wrappers) - `auction get|active|mine|bids|bids-mine` (subgraph-first read wrappers) +- `auction bid|bid-unbid` (first-class write UX) - ` read` (routes to generic onchain call for that domain) Planned domain namespaces are stubbed for parity tracking: @@ -54,7 +55,7 @@ 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`). Mapped writes now include built-in ABI defaults, so `--abi-file` is no longer required for mapped command execution/help. -Example with built-in defaults: `ag auction bid --args-json '[...]' --dry-run --json` +Example with built-in defaults: `ag baazaar buy-now --args-json '[...]' --dry-run --json` Example with explicit metadata: `ag lending create --abi-file ./abis/GotchiLendingFacet.json --address 0x... --args-json '[...]' --json` ## Command help and discoverability @@ -71,7 +72,6 @@ Mapped write commands now expose their onchain function mapping, defaults (if av ```bash ag baazaar buy-now --help -ag auction bid --help ``` If you provide `--abi-file` with `--help`, the CLI prints ABI-derived function signature and input names for the mapped method: @@ -171,6 +171,27 @@ Raw GraphQL passthrough (typed projection remains included): npm run ag -- auction active --first 5 --raw --json ``` +## First-class auction bidding + +Single auction bid (no manual ABI/address/arg packing): + +```bash +npm run ag -- auction bid --auction-id 5666 --amount-ghst 1 --dry-run --json +``` + +Bid all currently unbid auctions up to a max total: + +```bash +npm run ag -- auction bid-unbid --amount-ghst 1 --max-total-ghst 10 --dry-run --json +``` + +Notes: + +- `auction bid` resolves GBM diamond + ABI internally. +- Preflight checks include auction-open state, expected/unbid checks, minimum bid, GHST balance, and GHST allowance. +- `--auto-approve` can submit GHST `approve()` automatically when allowance is insufficient. +- `auction bid-unbid` emits per-auction results and explicit skip reasons in one JSON report. + ## Signer backends - `readonly` (read-only mode) @@ -179,6 +200,7 @@ npm run ag -- auction active --first 5 --raw --json - `remote:URL|ADDRESS|AUTH_ENV` (HTTP signer service) - `ledger:DERIVATION_PATH|ADDRESS|BRIDGE_ENV` (external bridge command signer) - `bankr[:ADDRESS|API_KEY_ENV|API_URL]` (Bankr-native signer via `/agent/me` + `/agent/submit`; defaults: `BANKR_API_KEY`, `https://api.bankr.bot`) +- Optional profile env file support (`bootstrap --env-file `) plus Bankr auto-discovery (`$AGCLI_BANKR_ENV_FILE`, `$AGCLI_HOME/bankr.env`, `$AGCLI_HOME/.env.bankr`, `~/.config/openclaw/bankr.env`, `./.env.bankr`, `./bankr.env`) Remote signer contract: @@ -195,7 +217,7 @@ Bankr bootstrap example: ```bash BANKR_API_KEY=... \ -npm run ag -- bootstrap --mode agent --profile bankr --chain base --signer bankr --json +npm run ag -- bootstrap --mode agent --profile bankr --chain base --signer bankr --env-file ~/.config/openclaw/bankr.env --json ``` Ledger bridge contract: diff --git a/package-lock.json b/package-lock.json index 04cebed..edf6c6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aavegotchi-cli", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aavegotchi-cli", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "dependencies": { "better-sqlite3": "^11.8.1", diff --git a/package.json b/package.json index ac9556e..ee059b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aavegotchi-cli", - "version": "0.2.4", + "version": "0.2.5", "description": "Agent-first CLI for automating Aavegotchi app and onchain workflows", "license": "MIT", "repository": { diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 5002b34..c341180 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -34,6 +34,8 @@ const BUILTIN_COMMANDS = [ "auction mine", "auction bids", "auction bids-mine", + "auction bid", + "auction bid-unbid", ] as const; function listDomainReadCommands(): string[] { @@ -76,11 +78,11 @@ function levenshteinDistance(a: string, b: string): number { } export function listKnownCommands(): string[] { - return [ + return [...new Set([ ...BUILTIN_COMMANDS, ...listDomainReadCommands(), ...listMappedCommands(), - ]; + ])]; } export function suggestCommands(input: string, max = 5): string[] { diff --git a/src/command-runner.test.ts b/src/command-runner.test.ts index 1ac8c83..e9ea548 100644 --- a/src/command-runner.test.ts +++ b/src/command-runner.test.ts @@ -5,11 +5,15 @@ import { CommandContext } from "./types"; const { findMappedFunctionMock, runMappedDomainCommandMock, + runAuctionBidCommandMock, + runAuctionBidUnbidCommandMock, runAuctionSubgraphCommandMock, runBaazaarListingSubgraphCommandMock, } = vi.hoisted(() => ({ findMappedFunctionMock: vi.fn(), runMappedDomainCommandMock: vi.fn(), + runAuctionBidCommandMock: vi.fn(), + runAuctionBidUnbidCommandMock: vi.fn(), runAuctionSubgraphCommandMock: vi.fn(), runBaazaarListingSubgraphCommandMock: vi.fn(), })); @@ -19,6 +23,11 @@ vi.mock("./commands/mapped", () => ({ runMappedDomainCommand: runMappedDomainCommandMock, })); +vi.mock("./commands/auction-bid", () => ({ + runAuctionBidCommand: runAuctionBidCommandMock, + runAuctionBidUnbidCommand: runAuctionBidUnbidCommandMock, +})); + vi.mock("./commands/auction-subgraph", () => ({ runAuctionSubgraphCommand: runAuctionSubgraphCommandMock, })); @@ -75,4 +84,26 @@ describe("command runner routing", () => { expect(runAuctionSubgraphCommandMock).not.toHaveBeenCalled(); expect(runMappedDomainCommandMock).toHaveBeenCalledTimes(1); }); + + it("routes auction bid to first-class command before mapped fallback", async () => { + findMappedFunctionMock.mockReturnValue("commitBid"); + runAuctionBidCommandMock.mockResolvedValue({ status: "simulated" }); + + const result = await executeCommand(createCtx(["auction", "bid"])); + + expect(result.commandName).toBe("auction bid"); + expect(result.data).toEqual({ status: "simulated" }); + expect(runAuctionBidCommandMock).toHaveBeenCalledTimes(1); + expect(runMappedDomainCommandMock).not.toHaveBeenCalled(); + }); + + it("routes auction bid-unbid to first-class batch command", async () => { + runAuctionBidUnbidCommandMock.mockResolvedValue({ summary: { success: 1 } }); + + const result = await executeCommand(createCtx(["auction", "bid-unbid"])); + + expect(result.commandName).toBe("auction bid-unbid"); + expect(result.data).toEqual({ summary: { success: 1 } }); + expect(runAuctionBidUnbidCommandMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/command-runner.ts b/src/command-runner.ts index 149a897..8a7e067 100644 --- a/src/command-runner.ts +++ b/src/command-runner.ts @@ -5,6 +5,7 @@ import { runBatchRunCommand } from "./commands/batch"; import { runBootstrapCommand } from "./commands/bootstrap"; import { findMappedFunction, runMappedDomainCommand } from "./commands/mapped"; import { runOnchainCallCommand, runOnchainSendCommand } from "./commands/onchain"; +import { runAuctionBidCommand, runAuctionBidUnbidCommand } from "./commands/auction-bid"; import { runAuctionSubgraphCommand } from "./commands/auction-subgraph"; import { runBaazaarListingSubgraphCommand } from "./commands/baazaar-subgraph"; import { @@ -238,6 +239,20 @@ export async function executeCommand(ctx: CommandContext): Promise ({ + loadConfigMock: vi.fn(), + getProfileOrThrowMock: vi.fn(), + getPolicyOrThrowMock: vi.fn(), + resolveChainMock: vi.fn(), + resolveRpcUrlMock: vi.fn(), + toViemChainMock: vi.fn(), + applyProfileEnvironmentMock: vi.fn(), + runRpcPreflightMock: vi.fn(), + resolveSignerRuntimeMock: vi.fn(), + executeSubgraphQueryMock: vi.fn(), + executeTxIntentMock: vi.fn(), +})); + +vi.mock("../config", () => ({ + loadConfig: loadConfigMock, + getProfileOrThrow: getProfileOrThrowMock, + getPolicyOrThrow: getPolicyOrThrowMock, +})); + +vi.mock("../chains", () => ({ + resolveChain: resolveChainMock, + resolveRpcUrl: resolveRpcUrlMock, + toViemChain: toViemChainMock, +})); + +vi.mock("../profile-env", () => ({ + applyProfileEnvironment: applyProfileEnvironmentMock, +})); + +vi.mock("../rpc", () => ({ + runRpcPreflight: runRpcPreflightMock, +})); + +vi.mock("../signer", () => ({ + resolveSignerRuntime: resolveSignerRuntimeMock, +})); + +vi.mock("../subgraph/client", () => ({ + executeSubgraphQuery: executeSubgraphQueryMock, +})); + +vi.mock("../tx-engine", () => ({ + executeTxIntent: executeTxIntentMock, +})); + +import { runAuctionBidCommand, runAuctionBidUnbidCommand } from "./auction-bid"; + +function createCtx(path: string[], flags: Record): CommandContext { + return { + commandPath: path, + args: { + positionals: path, + flags, + }, + globals: { + mode: "agent", + json: true, + yes: true, + profile: "prod", + }, + }; +} + +function buildAuctionRow(overrides: Record = {}): Record { + return { + id: "5666", + type: "erc721", + contractAddress: "0x1111111111111111111111111111111111111111", + tokenId: "1", + quantity: "1", + seller: "0x2222222222222222222222222222222222222222", + highestBid: "0", + highestBidder: ZERO_ADDRESS, + totalBids: "0", + startsAt: "1700000000", + endsAt: "2700000000", + claimAt: "0", + claimed: false, + cancelled: false, + presetId: "1", + category: "1", + buyNowPrice: "0", + startBidPrice: "1000000000000000000", + ...overrides, + }; +} + +function setupCommonMocks(): void { + loadConfigMock.mockReturnValue({}); + getProfileOrThrowMock.mockReturnValue({ + name: "prod", + chain: "base", + chainId: 8453, + rpcUrl: "https://mainnet.base.org", + signer: { type: "env", envVar: "AGCLI_PRIVATE_KEY" }, + policy: "default", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + getPolicyOrThrowMock.mockReturnValue({ + name: "default", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + resolveChainMock.mockReturnValue({ + key: "base", + chainId: 8453, + defaultRpcUrl: "https://mainnet.base.org", + }); + resolveRpcUrlMock.mockReturnValue("https://mainnet.base.org"); + toViemChainMock.mockReturnValue({ + id: 8453, + name: "Base", + network: "base", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: ["https://mainnet.base.org"] } }, + }); + applyProfileEnvironmentMock.mockReturnValue({ + source: "none", + path: null, + loaded: [], + skippedExisting: [], + }); + resolveSignerRuntimeMock.mockResolvedValue({ + summary: { + signerType: "env", + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + canSign: true, + backendStatus: "ready", + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + setupCommonMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("auction bid command", () => { + it("returns structured allowance preflight error when allowance is missing", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com", + queryName: "auction.bid.preflight", + data: { + auction: buildAuctionRow(), + }, + }); + + runRpcPreflightMock.mockResolvedValue({ + chainId: 8453, + blockNumber: "1", + chainName: "Base", + client: { + readContract: vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "getAuctionHighestBid") return 0n; + if (functionName === "getAuctionHighestBidder") return ZERO_ADDRESS; + if (functionName === "getContractAddress") return "0x1111111111111111111111111111111111111111"; + if (functionName === "getTokenId") return 1n; + if (functionName === "getAuctionStartTime") return 1n; + if (functionName === "getAuctionEndTime") return 9999999999n; + if (functionName === "getAuctionIncMin") return 100n; + if (functionName === "balanceOf") return 2_000000000000000000n; + if (functionName === "allowance") return 0n; + throw new Error(`unexpected function ${functionName}`); + }), + }, + }); + + await expect( + runAuctionBidCommand( + createCtx(["auction", "bid"], { + "auction-id": "5666", + "amount-ghst": "1", + "dry-run": true, + }), + ), + ).rejects.toMatchObject({ + code: "INSUFFICIENT_ALLOWANCE", + details: expect.objectContaining({ + reasonCode: "INSUFFICIENT_ALLOWANCE", + }), + }); + }); + + it("supports dry-run auto-approve and returns skipped bid simulation result", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com", + queryName: "auction.bid.preflight", + data: { + auction: buildAuctionRow(), + }, + }); + + runRpcPreflightMock.mockResolvedValue({ + chainId: 8453, + blockNumber: "1", + chainName: "Base", + client: { + readContract: vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "getAuctionHighestBid") return 0n; + if (functionName === "getAuctionHighestBidder") return ZERO_ADDRESS; + if (functionName === "getContractAddress") return "0x1111111111111111111111111111111111111111"; + if (functionName === "getTokenId") return 1n; + if (functionName === "getAuctionStartTime") return 1n; + if (functionName === "getAuctionEndTime") return 9999999999n; + if (functionName === "getAuctionIncMin") return 100n; + if (functionName === "balanceOf") return 2_000000000000000000n; + if (functionName === "allowance") return 0n; + throw new Error(`unexpected function ${functionName}`); + }), + }, + }); + + executeTxIntentMock.mockResolvedValue({ + status: "simulated", + dryRun: true, + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + to: "0xcd2f22236dd9dfe2356d7c543161d4d260fd9bcb", + nonce: 1, + gasLimit: "21000", + }); + + const result = (await runAuctionBidCommand( + createCtx(["auction", "bid"], { + "auction-id": "5666", + "amount-ghst": "1", + "dry-run": true, + "auto-approve": true, + }), + )) as { result: { skippedBidSimulation: boolean }; approval: unknown }; + + expect(result.result.skippedBidSimulation).toBe(true); + expect(result.approval).toBeDefined(); + expect(executeTxIntentMock).toHaveBeenCalledTimes(1); + expect(executeTxIntentMock).toHaveBeenCalledWith( + expect.objectContaining({ + command: "auction approve-ghst", + dryRun: true, + }), + expect.objectContaining({ chainId: 8453 }), + ); + }); +}); + +describe("auction bid-unbid command", () => { + it("skips auctions whose start bid exceeds target amount", async () => { + executeSubgraphQueryMock.mockResolvedValueOnce({ + source: "gbm-base", + endpoint: "https://example.com", + queryName: "auction.bid-unbid.active", + data: { + auctions: [ + buildAuctionRow({ + id: "9001", + startBidPrice: "2000000000000000000", + }), + ], + }, + }); + + const result = (await runAuctionBidUnbidCommand( + createCtx(["auction", "bid-unbid"], { + "amount-ghst": "1", + "max-total-ghst": "10", + "dry-run": true, + }), + )) as { + selected: number; + skipped: Array<{ auctionId: string; reasonCode: string }>; + summary: { skipped: number }; + }; + + expect(result.selected).toBe(0); + expect(result.summary.skipped).toBe(1); + expect(result.skipped[0]).toMatchObject({ + auctionId: "9001", + reasonCode: "START_BID_ABOVE_AMOUNT", + }); + }); +}); diff --git a/src/commands/auction-bid.ts b/src/commands/auction-bid.ts new file mode 100644 index 0000000..228d1b0 --- /dev/null +++ b/src/commands/auction-bid.ts @@ -0,0 +1,989 @@ +import { encodeFunctionData, parseAbi, parseUnits } from "viem"; + +import { getFlagBoolean, getFlagString } from "../args"; +import { resolveChain, resolveRpcUrl, toViemChain } from "../chains"; +import { getPolicyOrThrow, getProfileOrThrow, loadConfig } from "../config"; +import { CliError } from "../errors"; +import { applyProfileEnvironment } from "../profile-env"; +import { runRpcPreflight } from "../rpc"; +import { resolveSignerRuntime } from "../signer"; +import { normalizeGbmAuction, normalizeGbmAuctions } from "../subgraph/normalize"; +import { executeSubgraphQuery } from "../subgraph/client"; +import { GBM_ACTIVE_AUCTIONS_QUERY, GBM_AUCTION_BY_ID_QUERY } from "../subgraph/queries"; +import { BASE_GBM_DIAMOND } from "../subgraph/sources"; +import { executeTxIntent } from "../tx-engine"; +import { CommandContext, FlagValue, JsonValue, TxIntent } from "../types"; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; +const GHST_DECIMALS = 18; +const BATCH_DEFAULT_FIRST = 200; +const BATCH_MAX_FIRST = 200; + +// Canonical addresses from aavegotchi-base deployments. +const GHST_BY_CHAIN_ID: Record = { + 8453: "0xcd2f22236dd9dfe2356d7c543161d4d260fd9bcb", + 84532: "0xe97f36a00058aa7dfc4e85d23532c3f70453a7ae", +}; + +const GBM_DIAMOND_BY_CHAIN_ID: Record = { + 8453: BASE_GBM_DIAMOND, + 84532: "0x8572ce8ad6c9788bb6da3509117646047dd8b543", +}; + +const GBM_BID_WRITE_ABI = parseAbi(["function commitBid(uint256,uint256,uint256,address,uint256,uint256,bytes)"]); +const GBM_AUCTION_READ_ABI = parseAbi([ + "function getAuctionHighestBid(uint256 _auctionId) view returns (uint256)", + "function getAuctionHighestBidder(uint256 _auctionId) view returns (address)", + "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 getAuctionIncMin(uint256 _auctionId) view returns (uint64)", +]); +const ERC20_ABI = parseAbi([ + "function balanceOf(address) view returns (uint256)", + "function allowance(address,address) view returns (uint256)", + "function approve(address,uint256) returns (bool)", +]); + +interface BidContext { + profileName: string; + policy: TxIntent["policy"]; + chain: ReturnType; + rpcUrl: string; + signer: TxIntent["signer"]; + signerAddress: `0x${string}`; + gbmDiamond: `0x${string}`; + ghstToken: `0x${string}`; + environment: JsonValue; +} + +interface AuctionOnchainSnapshot { + auctionId: string; + highestBidWei: bigint; + highestBidder: `0x${string}`; + contractAddress: `0x${string}`; + tokenId: bigint; + startsAt: bigint; + endsAt: bigint; + incMin: bigint; +} + +interface PreflightCheck { + check: string; + status: "pass" | "fail" | "auto-fixed" | "skip"; + reasonCode?: string; + details?: JsonValue; +} + +function parseAddress(value: unknown, hint: string): `0x${string}` { + if (typeof value !== "string" || !/^0x[a-fA-F0-9]{40}$/.test(value)) { + throw new CliError("INVALID_ARGUMENT", `${hint} must be a valid EVM address.`, 2, { value }); + } + + return value.toLowerCase() as `0x${string}`; +} + +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 parseNonNegativeBigint(value: string, label: string): bigint { + if (!/^\d+$/.test(value)) { + throw new CliError("INVALID_ARGUMENT", `${label} must be a non-negative integer string.`, 2, { value }); + } + + return BigInt(value); +} + +function parseGhstAmount(value: string, label: string, allowZero = false): bigint { + if (!/^\d+(\.\d{1,18})?$/.test(value)) { + throw new CliError("INVALID_ARGUMENT", `${label} must be a decimal GHST amount (up to 18 decimals).`, 2, { + value, + }); + } + + const parsed = parseUnits(value, GHST_DECIMALS); + if (!allowZero && parsed <= 0n) { + throw new CliError("INVALID_ARGUMENT", `${label} must be greater than 0.`, 2, { value }); + } + + return parsed; +} + +function parseAmountWeiFromFlags( + flags: Record, + options: { + weiKey: string; + ghstKey: string; + label: string; + allowZero?: boolean; + }, +): bigint { + const weiRaw = getFlagString(flags, options.weiKey); + const ghstRaw = getFlagString(flags, options.ghstKey); + if (!weiRaw && !ghstRaw) { + throw new CliError( + "MISSING_ARGUMENT", + `${options.label} is required. Use --${options.ghstKey} or --${options.weiKey} .`, + 2, + ); + } + + if (weiRaw && ghstRaw) { + throw new CliError("INVALID_ARGUMENT", `Use either --${options.ghstKey} or --${options.weiKey}, not both.`, 2); + } + + const parsed = weiRaw ? parseNonNegativeBigint(weiRaw, `--${options.weiKey}`) : parseGhstAmount(ghstRaw as string, `--${options.ghstKey}`); + if (!options.allowZero && parsed <= 0n) { + throw new CliError("INVALID_ARGUMENT", `${options.label} must be greater than 0.`, 2); + } + + return parsed; +} + +function parseOptionalExpectedHighestBid(flags: Record): bigint | undefined { + const expectedWei = getFlagString(flags, "expected-highest-bid-wei"); + const expectedGhst = getFlagString(flags, "expected-highest-bid-ghst"); + + if (!expectedWei && !expectedGhst) { + return undefined; + } + + if (expectedWei && expectedGhst) { + throw new CliError("INVALID_ARGUMENT", "Use either --expected-highest-bid-ghst or --expected-highest-bid-wei, not both.", 2); + } + + return expectedWei + ? parseNonNegativeBigint(expectedWei, "--expected-highest-bid-wei") + : parseGhstAmount(expectedGhst as string, "--expected-highest-bid-ghst", true); +} + +function parseNoncePolicy(flags: Record): TxIntent["noncePolicy"] { + const noncePolicy = getFlagString(flags, "nonce-policy") || "safe"; + if (noncePolicy !== "safe" && noncePolicy !== "replace" && noncePolicy !== "manual") { + throw new CliError("INVALID_NONCE_POLICY", `Unsupported nonce policy '${noncePolicy}'.`, 2); + } + + return noncePolicy; +} + +function parseNonce(flags: Record): number | undefined { + const raw = getFlagString(flags, "nonce"); + if (!raw) { + return undefined; + } + + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new CliError("INVALID_ARGUMENT", "--nonce must be a non-negative integer.", 2, { + value: raw, + }); + } + + return parsed; +} + +function parseTimeoutMs(flags: Record): number { + const raw = getFlagString(flags, "timeout-ms"); + if (!raw) { + return 120000; + } + + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new CliError("INVALID_ARGUMENT", "--timeout-ms must be a positive integer.", 2, { + value: raw, + }); + } + + return parsed; +} + +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 classifyRevert(message: string): { reasonCode: string; reason: string } { + const normalized = message.toLowerCase(); + if (normalized.includes("allowance")) { + return { + reasonCode: "INSUFFICIENT_ALLOWANCE", + reason: "Allowance is below required amount for transferFrom.", + }; + } + + if ((normalized.includes("bid") && normalized.includes("low")) || normalized.includes("start bid")) { + return { + reasonCode: "BID_BELOW_START", + reason: "Bid amount is below minimum required value.", + }; + } + + if ( + normalized.includes("auction") && + (normalized.includes("ended") || + normalized.includes("not active") || + normalized.includes("already") || + normalized.includes("state")) + ) { + return { + reasonCode: "AUCTION_STATE_CHANGED", + reason: "Auction state changed between preflight and submit.", + }; + } + + return { + reasonCode: "UNKNOWN_REVERT", + reason: "Revert reason could not be classified.", + }; +} + +function throwPreflightError( + reasonCode: string, + message: string, + checks: PreflightCheck[], + details?: Record, +): never { + throw new CliError(reasonCode, message, 2, { + reasonCode, + checks, + ...(details || {}), + }); +} + +function parseBatchIdempotencyKey(base: string | undefined, auctionId: string, amountWei: bigint): string { + if (base) { + return `${base}:${auctionId}:${amountWei.toString()}`; + } + + return `auction.bid-unbid:${auctionId}:${amountWei.toString()}`; +} + +async function resolveBidContext(ctx: CommandContext): Promise { + const config = loadConfig(); + const profileName = getFlagString(ctx.args.flags, "profile") || ctx.globals.profile; + const profile = getProfileOrThrow(config, profileName); + const policy = getPolicyOrThrow(config, profile.policy); + const environment = applyProfileEnvironment(profile); + + const chain = resolveChain(profile.chain); + const rpcUrl = resolveRpcUrl(chain, getFlagString(ctx.args.flags, "rpc-url") || profile.rpcUrl); + const preflight = await runRpcPreflight(chain, rpcUrl); + + const signerRuntime = await resolveSignerRuntime(profile.signer, preflight.client, rpcUrl, toViemChain(chain, rpcUrl)); + const signerAddress = signerRuntime.summary.address; + if (!signerAddress) { + throw new CliError("MISSING_SIGNER_ADDRESS", "Signer address is required for auction bidding.", 2, { + signerType: signerRuntime.summary.signerType, + backendStatus: signerRuntime.summary.backendStatus, + }); + } + + const gbmDiamond = GBM_DIAMOND_BY_CHAIN_ID[chain.chainId]; + const ghstToken = GHST_BY_CHAIN_ID[chain.chainId]; + + if (!gbmDiamond || !ghstToken) { + throw new CliError( + "UNSUPPORTED_CHAIN", + `auction bid currently supports chains ${Object.keys(GBM_DIAMOND_BY_CHAIN_ID).join(", ")}.`, + 2, + { + chainId: chain.chainId, + }, + ); + } + + return { + profileName: profile.name, + policy, + chain, + rpcUrl, + signer: profile.signer, + signerAddress, + gbmDiamond, + ghstToken, + environment, + }; +} + +async function fetchAuctionFromSubgraph(auctionId: string): Promise> { + const response = await executeSubgraphQuery<{ auction: unknown | null }>({ + source: "gbm-base", + queryName: "auction.bid.preflight", + query: GBM_AUCTION_BY_ID_QUERY, + variables: { id: auctionId }, + }); + + if (!response.data.auction) { + throw new CliError("AUCTION_NOT_FOUND", `Auction '${auctionId}' was not found in the GBM subgraph.`, 2, { + auctionId, + source: response.source, + endpoint: response.endpoint, + }); + } + + return normalizeGbmAuction(response.data.auction); +} + +async function readOnchainSnapshot( + bidContext: BidContext, + auctionId: string, +): Promise { + const preflight = await runRpcPreflight(bidContext.chain, bidContext.rpcUrl); + const id = BigInt(auctionId); + + const [highestBidWeiRaw, highestBidderRaw, contractAddressRaw, tokenIdRaw, startsAtRaw, endsAtRaw, incMinRaw] = await Promise.all([ + preflight.client.readContract({ + address: bidContext.gbmDiamond, + abi: GBM_AUCTION_READ_ABI, + functionName: "getAuctionHighestBid", + args: [id], + }), + preflight.client.readContract({ + address: bidContext.gbmDiamond, + abi: GBM_AUCTION_READ_ABI, + functionName: "getAuctionHighestBidder", + args: [id], + }), + preflight.client.readContract({ + address: bidContext.gbmDiamond, + abi: GBM_AUCTION_READ_ABI, + functionName: "getContractAddress", + args: [id], + }), + preflight.client.readContract({ + address: bidContext.gbmDiamond, + abi: GBM_AUCTION_READ_ABI, + functionName: "getTokenId", + args: [id], + }), + preflight.client.readContract({ + address: bidContext.gbmDiamond, + abi: GBM_AUCTION_READ_ABI, + functionName: "getAuctionStartTime", + args: [id], + }), + preflight.client.readContract({ + address: bidContext.gbmDiamond, + abi: GBM_AUCTION_READ_ABI, + functionName: "getAuctionEndTime", + args: [id], + }), + preflight.client.readContract({ + address: bidContext.gbmDiamond, + abi: GBM_AUCTION_READ_ABI, + functionName: "getAuctionIncMin", + args: [id], + }), + ]); + + return { + auctionId, + highestBidWei: highestBidWeiRaw as bigint, + highestBidder: parseAddress(highestBidderRaw, "auction highest bidder"), + contractAddress: parseAddress(contractAddressRaw, "auction token contract"), + tokenId: tokenIdRaw as bigint, + startsAt: startsAtRaw as bigint, + endsAt: endsAtRaw as bigint, + incMin: incMinRaw as bigint, + }; +} + +function computeMinimumBidWei(snapshot: AuctionOnchainSnapshot, startBidPriceWei: bigint): bigint { + if (snapshot.highestBidWei > 0n) { + const denominator = 10000n; + return (snapshot.highestBidWei * (denominator + snapshot.incMin)) / denominator; + } + + return startBidPriceWei; +} + +function mapSimulationError(error: CliError): CliError { + if (error.code !== "SIMULATION_REVERT") { + return error; + } + + const message = String((error.details as { message?: unknown } | undefined)?.message || ""); + const classified = classifyRevert(message); + return new CliError("AUCTION_BID_REVERT", "Auction bid simulation reverted.", 2, { + reasonCode: classified.reasonCode, + reason: classified.reason, + rawMessage: message, + }); +} + +async function runSingleBid( + ctx: CommandContext, + options?: { + forceAuctionId?: string; + forceAmountWei?: bigint; + forceExpectedHighestBidWei?: bigint; + forceRequireUnbid?: boolean; + forceIdempotencyKey?: string; + }, +): Promise { + const dryRun = getFlagBoolean(ctx.args.flags, "dry-run"); + const waitForReceipt = getFlagBoolean(ctx.args.flags, "wait"); + const autoApprove = getFlagBoolean(ctx.args.flags, "auto-approve"); + const requireUnbid = options?.forceRequireUnbid ?? getFlagBoolean(ctx.args.flags, "require-unbid"); + const timeoutMs = parseTimeoutMs(ctx.args.flags); + const noncePolicy = parseNoncePolicy(ctx.args.flags); + const nonce = parseNonce(ctx.args.flags); + + if (dryRun && waitForReceipt) { + throw new CliError("INVALID_ARGUMENT", "--dry-run cannot be combined with --wait.", 2); + } + + if (noncePolicy === "manual" && nonce === undefined) { + throw new CliError("MISSING_NONCE", "--nonce is required when --nonce-policy=manual.", 2); + } + + const auctionId = options?.forceAuctionId || parseAuctionId(getFlagString(ctx.args.flags, "auction-id"), "--auction-id"); + const amountWei = + options?.forceAmountWei || + parseAmountWeiFromFlags(ctx.args.flags, { + weiKey: "amount-wei", + ghstKey: "amount-ghst", + label: "Bid amount", + }); + const expectedHighestBidWei = options?.forceExpectedHighestBidWei ?? parseOptionalExpectedHighestBid(ctx.args.flags); + const idempotencyKey = options?.forceIdempotencyKey || getFlagString(ctx.args.flags, "idempotency-key"); + const autoApproveMaxWei = getFlagString(ctx.args.flags, "auto-approve-max-wei") + ? parseNonNegativeBigint(getFlagString(ctx.args.flags, "auto-approve-max-wei") as string, "--auto-approve-max-wei") + : getFlagString(ctx.args.flags, "auto-approve-max-ghst") + ? parseGhstAmount(getFlagString(ctx.args.flags, "auto-approve-max-ghst") as string, "--auto-approve-max-ghst") + : undefined; + + const bidContext = await resolveBidContext(ctx); + const subgraphAuction = await fetchAuctionFromSubgraph(auctionId); + const startBidPriceWei = parseNonNegativeBigint(subgraphAuction.startBidPrice || "0", "startBidPrice"); + const quantity = parseNonNegativeBigint(subgraphAuction.quantity, "quantity"); + const nowSec = BigInt(Math.floor(Date.now() / 1000)); + const checks: PreflightCheck[] = []; + + const onchainBefore = await readOnchainSnapshot(bidContext, auctionId); + const minimumBidWei = computeMinimumBidWei(onchainBefore, startBidPriceWei); + + if (onchainBefore.startsAt <= nowSec && nowSec < onchainBefore.endsAt) { + checks.push({ check: "AUCTION_OPEN", status: "pass" }); + } else { + checks.push({ + check: "AUCTION_OPEN", + status: "fail", + reasonCode: "AUCTION_NOT_OPEN", + details: { + nowSec: nowSec.toString(), + startsAt: onchainBefore.startsAt.toString(), + endsAt: onchainBefore.endsAt.toString(), + }, + }); + throwPreflightError("AUCTION_NOT_OPEN", "Auction is not currently open for bidding.", checks, { + auctionId, + }); + } + + if (requireUnbid) { + const unbid = onchainBefore.highestBidWei === 0n && onchainBefore.highestBidder === ZERO_ADDRESS; + if (!unbid) { + checks.push({ + check: "REQUIRE_UNBID", + status: "fail", + reasonCode: "AUCTION_ALREADY_BID", + details: { + highestBidWei: onchainBefore.highestBidWei.toString(), + highestBidder: onchainBefore.highestBidder, + }, + }); + throwPreflightError("AUCTION_ALREADY_BID", "Auction already has a highest bid.", checks, { + auctionId, + }); + } + + checks.push({ check: "REQUIRE_UNBID", status: "pass" }); + } else { + checks.push({ check: "REQUIRE_UNBID", status: "skip" }); + } + + if (expectedHighestBidWei !== undefined) { + if (onchainBefore.highestBidWei !== expectedHighestBidWei) { + checks.push({ + check: "EXPECTED_HIGHEST_BID", + status: "fail", + reasonCode: "EXPECTED_HIGHEST_BID_MISMATCH", + details: { + expectedHighestBidWei: expectedHighestBidWei.toString(), + currentHighestBidWei: onchainBefore.highestBidWei.toString(), + }, + }); + throwPreflightError("EXPECTED_HIGHEST_BID_MISMATCH", "Current highest bid does not match expected value.", checks, { + auctionId, + }); + } + + checks.push({ check: "EXPECTED_HIGHEST_BID", status: "pass" }); + } else { + checks.push({ check: "EXPECTED_HIGHEST_BID", status: "skip" }); + } + + if (amountWei >= minimumBidWei) { + checks.push({ check: "BID_MINIMUM", status: "pass" }); + } else { + checks.push({ + check: "BID_MINIMUM", + status: "fail", + reasonCode: "BID_BELOW_START", + details: { + amountWei: amountWei.toString(), + minimumBidWei: minimumBidWei.toString(), + highestBidWei: onchainBefore.highestBidWei.toString(), + startBidPriceWei: startBidPriceWei.toString(), + }, + }); + throwPreflightError("BID_BELOW_START", "Bid amount is below the current minimum bid requirement.", checks, { + auctionId, + }); + } + + const preflight = await runRpcPreflight(bidContext.chain, bidContext.rpcUrl); + const [ghstBalanceWeiRaw, allowanceWeiRaw] = await Promise.all([ + preflight.client.readContract({ + address: bidContext.ghstToken, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [bidContext.signerAddress], + }), + preflight.client.readContract({ + address: bidContext.ghstToken, + abi: ERC20_ABI, + functionName: "allowance", + args: [bidContext.signerAddress, bidContext.gbmDiamond], + }), + ]); + + const ghstBalanceWei = ghstBalanceWeiRaw as bigint; + const allowanceWei = allowanceWeiRaw as bigint; + + if (ghstBalanceWei >= amountWei) { + checks.push({ check: "GHST_BALANCE", status: "pass" }); + } else { + checks.push({ + check: "GHST_BALANCE", + status: "fail", + reasonCode: "INSUFFICIENT_GHST_BALANCE", + details: { + balanceWei: ghstBalanceWei.toString(), + requiredWei: amountWei.toString(), + }, + }); + throwPreflightError("INSUFFICIENT_GHST_BALANCE", "GHST balance is below bid amount.", checks, { + auctionId, + signer: bidContext.signerAddress, + }); + } + + let approval: JsonValue | undefined; + let approvalNeeded = allowanceWei < amountWei; + if (approvalNeeded) { + if (!autoApprove) { + checks.push({ + check: "GHST_ALLOWANCE", + status: "fail", + reasonCode: "INSUFFICIENT_ALLOWANCE", + details: { + allowanceWei: allowanceWei.toString(), + requiredWei: amountWei.toString(), + }, + }); + throwPreflightError( + "INSUFFICIENT_ALLOWANCE", + "GHST allowance is below bid amount. Re-run with --auto-approve to submit approve() automatically.", + checks, + { + auctionId, + ghstToken: bidContext.ghstToken, + spender: bidContext.gbmDiamond, + }, + ); + } + + const approveAmountWei = autoApproveMaxWei ?? amountWei; + if (approveAmountWei < amountWei) { + throw new CliError("AUTO_APPROVE_LIMIT_TOO_LOW", "auto-approve cap is below required bid amount.", 2, { + approveAmountWei: approveAmountWei.toString(), + requiredWei: amountWei.toString(), + }); + } + + checks.push({ + check: "GHST_ALLOWANCE", + status: "auto-fixed", + reasonCode: "INSUFFICIENT_ALLOWANCE", + details: { + allowanceWei: allowanceWei.toString(), + requiredWei: amountWei.toString(), + approveAmountWei: approveAmountWei.toString(), + }, + }); + + const approveData = encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [bidContext.gbmDiamond, approveAmountWei], + }); + + const approveIntent: TxIntent = { + idempotencyKey: idempotencyKey ? `${idempotencyKey}:approve` : undefined, + profileName: bidContext.profileName, + chainId: bidContext.chain.chainId, + rpcUrl: bidContext.rpcUrl, + signer: bidContext.signer, + policy: bidContext.policy, + to: bidContext.ghstToken, + data: approveData, + noncePolicy: "safe", + waitForReceipt: dryRun ? false : true, + dryRun, + timeoutMs, + command: "auction approve-ghst", + }; + + try { + const approvalResult = await executeTxIntent(approveIntent, bidContext.chain); + approval = { + autoApprove: true, + approveAmountWei: approveAmountWei.toString(), + result: approvalResult, + }; + } catch (error: unknown) { + if (error instanceof CliError) { + throw mapSimulationError(error); + } + + throw error; + } + } else { + checks.push({ check: "GHST_ALLOWANCE", status: "pass" }); + } + + if (!dryRun) { + const onchainBeforeSend = await readOnchainSnapshot(bidContext, auctionId); + if ( + onchainBeforeSend.highestBidWei !== onchainBefore.highestBidWei || + onchainBeforeSend.highestBidder !== onchainBefore.highestBidder + ) { + throw new CliError("AUCTION_STATE_CHANGED", "Auction state changed before submit; aborting.", 2, { + reasonCode: "AUCTION_STATE_CHANGED", + previousHighestBidWei: onchainBefore.highestBidWei.toString(), + currentHighestBidWei: onchainBeforeSend.highestBidWei.toString(), + previousHighestBidder: onchainBefore.highestBidder, + currentHighestBidder: onchainBeforeSend.highestBidder, + auctionId, + }); + } + } + + const commitArgs: readonly [bigint, bigint, bigint, `0x${string}`, bigint, bigint, `0x${string}`] = [ + BigInt(auctionId), + amountWei, + onchainBefore.highestBidWei, + onchainBefore.contractAddress, + onchainBefore.tokenId, + quantity, + "0x" as `0x${string}`, + ]; + + if (dryRun && approvalNeeded) { + return { + profile: bidContext.profileName, + chainId: bidContext.chain.chainId, + command: "auction bid", + environment: bidContext.environment, + auction: { + id: auctionId, + gbmDiamond: bidContext.gbmDiamond, + tokenContract: onchainBefore.contractAddress, + tokenId: onchainBefore.tokenId.toString(), + quantity: quantity.toString(), + startBidPriceWei: startBidPriceWei.toString(), + highestBidWei: onchainBefore.highestBidWei.toString(), + highestBidder: onchainBefore.highestBidder, + minBidWei: minimumBidWei.toString(), + }, + preflight: { + checks, + signer: bidContext.signerAddress, + ghstToken: bidContext.ghstToken, + balanceWei: ghstBalanceWei.toString(), + allowanceWei: allowanceWei.toString(), + amountWei: amountWei.toString(), + }, + ...(approval ? { approval } : {}), + result: { + status: "simulated", + dryRun: true, + skippedBidSimulation: true, + reasonCode: "INSUFFICIENT_ALLOWANCE", + reason: "Approval was simulated, so bid simulation is skipped in dry-run mode.", + commitBidArgs: commitArgs, + }, + }; + } + + const bidData = encodeFunctionData({ + abi: GBM_BID_WRITE_ABI, + functionName: "commitBid", + args: commitArgs, + }); + + const bidIntent: TxIntent = { + idempotencyKey, + profileName: bidContext.profileName, + chainId: bidContext.chain.chainId, + rpcUrl: bidContext.rpcUrl, + signer: bidContext.signer, + policy: bidContext.policy, + to: bidContext.gbmDiamond, + data: bidData, + noncePolicy, + nonce, + waitForReceipt, + dryRun, + timeoutMs, + command: "auction bid", + }; + + let bidResult: JsonValue; + try { + bidResult = await executeTxIntent(bidIntent, bidContext.chain); + } catch (error: unknown) { + if (error instanceof CliError) { + throw mapSimulationError(error); + } + + throw error; + } + + return { + profile: bidContext.profileName, + chainId: bidContext.chain.chainId, + command: "auction bid", + environment: bidContext.environment, + auction: { + id: auctionId, + gbmDiamond: bidContext.gbmDiamond, + tokenContract: onchainBefore.contractAddress, + tokenId: onchainBefore.tokenId.toString(), + quantity: quantity.toString(), + startBidPriceWei: startBidPriceWei.toString(), + highestBidWei: onchainBefore.highestBidWei.toString(), + highestBidder: onchainBefore.highestBidder, + minBidWei: minimumBidWei.toString(), + }, + preflight: { + checks, + signer: bidContext.signerAddress, + ghstToken: bidContext.ghstToken, + balanceWei: ghstBalanceWei.toString(), + allowanceWei: allowanceWei.toString(), + amountWei: amountWei.toString(), + }, + ...(approval ? { approval } : {}), + result: bidResult, + }; +} + +export async function runAuctionBidCommand(ctx: CommandContext): Promise { + return runSingleBid(ctx); +} + +export async function runAuctionBidUnbidCommand(ctx: CommandContext): Promise { + const amountWei = parseAmountWeiFromFlags(ctx.args.flags, { + weiKey: "amount-wei", + ghstKey: "amount-ghst", + label: "Bid amount", + }); + const maxTotalWei = parseAmountWeiFromFlags(ctx.args.flags, { + weiKey: "max-total-wei", + ghstKey: "max-total-ghst", + label: "Max total bid amount", + }); + + if (maxTotalWei < amountWei) { + throw new CliError("INVALID_ARGUMENT", "max total amount must be greater than or equal to amount per auction.", 2, { + amountWei: amountWei.toString(), + maxTotalWei: maxTotalWei.toString(), + }); + } + + const first = parseBoundedIntFlag(getFlagString(ctx.args.flags, "first"), "--first", BATCH_DEFAULT_FIRST, 1, BATCH_MAX_FIRST); + const skip = parseBoundedIntFlag(getFlagString(ctx.args.flags, "skip"), "--skip", 0, 0, 100000); + + const now = Math.floor(Date.now() / 1000).toString(); + const response = await executeSubgraphQuery<{ auctions: unknown }>({ + source: "gbm-base", + queryName: "auction.bid-unbid.active", + query: GBM_ACTIVE_AUCTIONS_QUERY, + variables: { + now, + first, + skip, + }, + }); + + 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 activeAuctions = normalizeGbmAuctions(response.data.auctions); + const skipped: Array> = []; + const selected: string[] = []; + let plannedTotalWei = 0n; + + for (const auction of activeAuctions) { + const auctionId = auction.id; + const highestBidWei = parseNonNegativeBigint(auction.highestBid, "highestBid"); + const startBidWei = parseNonNegativeBigint(auction.startBidPrice || "0", "startBidPrice"); + const highestBidder = auction.highestBidder || ZERO_ADDRESS; + const unbid = highestBidWei === 0n && highestBidder === ZERO_ADDRESS; + + if (!unbid) { + skipped.push({ + auctionId, + reasonCode: "NOT_UNBID", + reason: "Auction already has a highest bid.", + highestBidWei: highestBidWei.toString(), + highestBidder, + }); + continue; + } + + if (startBidWei > amountWei) { + skipped.push({ + auctionId, + reasonCode: "START_BID_ABOVE_AMOUNT", + reason: "Auction start bid is above the configured amount.", + startBidWei: startBidWei.toString(), + amountWei: amountWei.toString(), + }); + continue; + } + + if (plannedTotalWei + amountWei > maxTotalWei) { + skipped.push({ + auctionId, + reasonCode: "MAX_TOTAL_REACHED", + reason: "Adding this auction would exceed max total amount.", + plannedTotalWei: plannedTotalWei.toString(), + maxTotalWei: maxTotalWei.toString(), + }); + continue; + } + + selected.push(auctionId); + plannedTotalWei += amountWei; + } + + const baseIdempotencyKey = getFlagString(ctx.args.flags, "idempotency-key"); + const results: Array> = []; + + for (const auctionId of selected) { + const stepFlags: Record = { ...ctx.args.flags }; + delete stepFlags["amount-ghst"]; + delete stepFlags["max-total-ghst"]; + delete stepFlags["max-total-wei"]; + delete stepFlags.first; + delete stepFlags.skip; + stepFlags["auction-id"] = auctionId; + stepFlags["amount-wei"] = amountWei.toString(); + stepFlags["require-unbid"] = true; + stepFlags["expected-highest-bid-wei"] = "0"; + stepFlags["idempotency-key"] = parseBatchIdempotencyKey(baseIdempotencyKey, auctionId, amountWei); + + const stepCtx: CommandContext = { + commandPath: ["auction", "bid"], + args: { + positionals: ["auction", "bid"], + flags: stepFlags, + }, + globals: ctx.globals, + }; + + try { + const result = await runSingleBid(stepCtx, { + forceAuctionId: auctionId, + forceAmountWei: amountWei, + forceExpectedHighestBidWei: 0n, + forceRequireUnbid: true, + forceIdempotencyKey: parseBatchIdempotencyKey(baseIdempotencyKey, auctionId, amountWei), + }); + results.push({ + auctionId, + status: "ok", + result, + }); + } catch (error: unknown) { + if (error instanceof CliError) { + results.push({ + auctionId, + status: "error", + code: error.code, + message: error.message, + details: error.details || null, + }); + continue; + } + + throw error; + } + } + + const okCount = results.filter((result) => result.status === "ok").length; + const errorCount = results.length - okCount; + + return { + command: "auction bid-unbid", + amountWei: amountWei.toString(), + maxTotalWei: maxTotalWei.toString(), + atTime: now, + scanned: activeAuctions.length, + selected: selected.length, + plannedTotalWei: plannedTotalWei.toString(), + summary: { + success: okCount, + error: errorCount, + skipped: skipped.length, + }, + skipped, + results, + }; +} diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index 814212d..5427a0f 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -10,6 +10,7 @@ import { upsertProfile, } from "../config"; import { CliError } from "../errors"; +import { applySignerEnvironment } from "../profile-env"; import { runRpcPreflight } from "../rpc"; import { parseSigner, resolveSignerRuntime } from "../signer"; import { CommandContext, JsonValue, ProfileConfig } from "../types"; @@ -69,6 +70,7 @@ export async function runBootstrapCommand(ctx: CommandContext): Promise { rpcUrl: profile.rpcUrl, policy: profile.policy, signerType: profile.signer.type, + envFile: profile.envFile || null, active: config.activeProfile === profile.name, updatedAt: profile.updatedAt, })); diff --git a/src/commands/signer.ts b/src/commands/signer.ts index c45a6cd..3ed9d3f 100644 --- a/src/commands/signer.ts +++ b/src/commands/signer.ts @@ -3,6 +3,7 @@ import { resolveChain, resolveRpcUrl, toViemChain } from "../chains"; import { getProfileOrThrow, loadConfig } from "../config"; import { CliError } from "../errors"; import { keychainImportFromEnv, keychainList, keychainRemove } from "../keychain"; +import { applyProfileEnvironment } from "../profile-env"; import { runRpcPreflight } from "../rpc"; import { resolveSignerRuntime } from "../signer"; import { CommandContext, JsonValue } from "../types"; @@ -11,6 +12,7 @@ export async function runSignerCheckCommand(ctx: CommandContext): Promise const requestedProfile = getFlagString(ctx.args.flags, "profile") || ctx.globals.profile; const profile = getProfileOrThrow(config, requestedProfile); const policy = getPolicyOrThrow(config, profile.policy); + const environment = applyProfileEnvironment(profile); const chain = resolveChain(profile.chain); const rpcUrl = resolveRpcUrl(chain, getFlagString(ctx.args.flags, "rpc-url") || profile.rpcUrl); @@ -128,6 +130,7 @@ export async function runTxSendCommand(ctx: CommandContext): Promise profile: profile.name, policy: policy.name, chainId: chain.chainId, + environment, ...result, }; } diff --git a/src/output.test.ts b/src/output.test.ts index a720570..ec0ee7b 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -30,13 +30,12 @@ describe("help output", () => { expect(text).toContain("--abi-file (override built-in ABI)"); }); - it("prints built-in mapped defaults for auction bid without --abi-file", () => { + it("prints first-class help for auction bid", () => { const text = buildHelpText(["auction", "bid"]); - expect(text).toContain("commitBid(uint256,uint256,uint256,address,uint256,uint256,bytes)"); - expect(text).toContain("source: base.gbm-diamond"); - expect(text).toContain("address: 0x80320a0000c7a6a34086e2acad6915ff57ffda31"); - expect(text).toContain("ag auction bid --profile --args-json"); - expect(text).toContain("--abi-file (override built-in ABI)"); + expect(text).toContain("ag auction bid --auction-id "); + expect(text).toContain("--amount-ghst "); + expect(text).toContain("--auto-approve"); + expect(text).toContain("Resolves GBM diamond + ABI internally"); }); it("prints ABI-derived mapped function signature when --abi-file is supplied", () => { diff --git a/src/output.ts b/src/output.ts index 584c42d..9d2706a 100644 --- a/src/output.ts +++ b/src/output.ts @@ -73,6 +73,7 @@ Power-user commands: Subgraph wrappers: baazaar listing get|active|mine auction get|active|mine|bids|bids-mine + auction bid|bid-unbid Domain namespaces: gotchi, portal, wearables, items, inventory, baazaar, auction, lending, staking, gotchi-points, realm, alchemica, forge, token @@ -98,7 +99,7 @@ Examples: const STATIC_HELP: Record = { bootstrap: ` Usage: - ag bootstrap --profile [--chain ] [--rpc-url ] [--signer ] [--policy ] [--skip-signer-check] [--json] + ag bootstrap --profile [--chain ] [--rpc-url ] [--signer ] [--env-file ] [--policy ] [--skip-signer-check] [--json] Required: --profile @@ -295,6 +296,8 @@ Usage: ag auction mine --seller <0x...> [--first ] [--skip ] [--at-time ] [--json] ag auction bids --auction-id [--first ] [--skip ] [--json] ag auction bids-mine --bidder <0x...> [--first ] [--skip ] [--json] + ag auction bid --auction-id --amount-ghst [--dry-run] [--auto-approve] [--json] + ag auction bid-unbid --amount-ghst --max-total-ghst [--dry-run] [--auto-approve] [--json] ag auction --help `, "auction get": ` @@ -316,6 +319,24 @@ Usage: "auction bids-mine": ` Usage: ag auction bids-mine --bidder <0x...> [--first ] [--skip ] [--json] +`, + "auction bid": ` +Usage: + ag auction bid --auction-id (--amount-ghst | --amount-wei ) [--require-unbid] [--expected-highest-bid-ghst | --expected-highest-bid-wei ] [--auto-approve] [--auto-approve-max-ghst | --auto-approve-max-wei ] [--nonce-policy ] [--nonce ] [--dry-run] [--wait] [--timeout-ms ] [--idempotency-key ] [--json] + +Notes: + - Resolves GBM diamond + ABI internally (no manual ABI/address flags). + - Runs preflight checks (auction state, min bid, GHST balance, GHST allowance). + - Rechecks auction state immediately before submit to prevent stale-state sends. + - --dry-run with --auto-approve simulates approval and skips bid simulation because allowance state is unchanged on-chain. +`, + "auction bid-unbid": ` +Usage: + ag auction bid-unbid (--amount-ghst | --amount-wei ) (--max-total-ghst | --max-total-wei ) [--first ] [--skip ] [--auto-approve] [--dry-run] [--wait] [--timeout-ms ] [--idempotency-key ] [--json] + +Behavior: + - Scans active auctions, selects unbid auctions, and skips those above your target amount. + - Emits per-auction status plus clear skip reasons in one JSON report. `, }; @@ -473,16 +494,16 @@ export function buildHelpText(commandPath: string[] = [], flags: Flags = {}): st return buildGlobalHelpText().trim(); } - const mappedHelp = buildMappedCommandHelp(target, flags); - if (mappedHelp) { - return mappedHelp.trim(); - } - const key = target.join(" "); if (STATIC_HELP[key]) { return STATIC_HELP[key].trim(); } + const mappedHelp = buildMappedCommandHelp(target, flags); + if (mappedHelp) { + return mappedHelp.trim(); + } + if (target.length === 1 && isDomainStubRoot(target[0])) { return buildDomainRootHelp(target[0]).trim(); } diff --git a/src/profile-env.ts b/src/profile-env.ts new file mode 100644 index 0000000..24a6e2d --- /dev/null +++ b/src/profile-env.ts @@ -0,0 +1,182 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { resolveAgcliHome } from "./config"; +import { CliError } from "./errors"; +import { ProfileConfig, SignerConfig } from "./types"; + +export interface SignerEnvDiagnostics { + source: "profile" | "auto" | "none"; + path: string | null; + loaded: string[]; + skippedExisting: string[]; + searched?: string[]; +} + +function parseAssignment(rawLine: string, lineNumber: number, filePath: string): [string, string] | null { + const trimmed = rawLine.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return null; + } + + const withoutExport = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed; + const equalsIndex = withoutExport.indexOf("="); + if (equalsIndex <= 0) { + throw new CliError("INVALID_ENV_FILE", `Invalid env assignment at ${filePath}:${lineNumber}.`, 2, { + line: rawLine, + }); + } + + const key = withoutExport.slice(0, equalsIndex).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new CliError("INVALID_ENV_FILE", `Invalid env key '${key}' at ${filePath}:${lineNumber}.`, 2); + } + + let value = withoutExport.slice(equalsIndex + 1).trim(); + if ( + (value.startsWith("\"") && value.endsWith("\"") && value.length >= 2) || + (value.startsWith("'") && value.endsWith("'") && value.length >= 2) + ) { + value = value.slice(1, -1); + } + + return [key, value]; +} + +function loadEnvAssignments(filePath: string): Array<[string, string]> { + const raw = fs.readFileSync(filePath, "utf8"); + const lines = raw.split(/\r?\n/); + const assignments: Array<[string, string]> = []; + + for (let index = 0; index < lines.length; index++) { + const parsed = parseAssignment(lines[index], index + 1, filePath); + if (parsed) { + assignments.push(parsed); + } + } + + return assignments; +} + +function resolveEnvPath(envFile: string, customHome?: string): string { + if (envFile.startsWith("~/")) { + return path.join(os.homedir(), envFile.slice(2)); + } + + if (path.isAbsolute(envFile)) { + return envFile; + } + + return path.resolve(resolveAgcliHome(customHome), envFile); +} + +function applyEnvFromPath( + source: "profile" | "auto", + envPath: string, + searched?: string[], +): SignerEnvDiagnostics { + const loaded: string[] = []; + const skippedExisting: string[] = []; + const assignments = loadEnvAssignments(envPath); + + for (const [key, value] of assignments) { + if (process.env[key] === undefined) { + process.env[key] = value; + loaded.push(key); + } else { + skippedExisting.push(key); + } + } + + return { + source, + path: envPath, + loaded, + skippedExisting, + ...(searched ? { searched } : {}), + }; +} + +function uniquePaths(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = path.resolve(value); + if (!seen.has(normalized)) { + seen.add(normalized); + result.push(normalized); + } + } + + return result; +} + +function buildBankrAutoCandidates(customHome?: string): string[] { + const home = resolveAgcliHome(customHome); + const configured = process.env.AGCLI_BANKR_ENV_FILE; + const cwd = process.cwd(); + + return uniquePaths( + [ + configured || "", + path.join(home, "bankr.env"), + path.join(home, ".env.bankr"), + path.join(os.homedir(), ".config/openclaw/bankr.env"), + path.join(cwd, ".env.bankr"), + path.join(cwd, "bankr.env"), + ].filter(Boolean), + ); +} + +export function applySignerEnvironment( + signer: SignerConfig, + options?: { + envFile?: string; + customHome?: string; + }, +): SignerEnvDiagnostics { + const explicitEnvFile = options?.envFile?.trim(); + if (explicitEnvFile) { + const resolvedPath = resolveEnvPath(explicitEnvFile, options?.customHome); + if (!fs.existsSync(resolvedPath)) { + throw new CliError("ENV_FILE_NOT_FOUND", `Environment file not found: ${resolvedPath}`, 2, { + envFile: explicitEnvFile, + resolvedPath, + }); + } + + return applyEnvFromPath("profile", resolvedPath); + } + + if (signer.type !== "bankr") { + return { + source: "none", + path: null, + loaded: [], + skippedExisting: [], + }; + } + + const searched = buildBankrAutoCandidates(options?.customHome); + for (const candidate of searched) { + if (fs.existsSync(candidate)) { + return applyEnvFromPath("auto", candidate, searched); + } + } + + return { + source: "none", + path: null, + loaded: [], + skippedExisting: [], + searched, + }; +} + +export function applyProfileEnvironment(profile: ProfileConfig, customHome?: string): SignerEnvDiagnostics { + return applySignerEnvironment(profile.signer, { + envFile: profile.envFile, + customHome, + }); +} diff --git a/src/schemas.ts b/src/schemas.ts index 89ef2a5..c6cd6a8 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -46,6 +46,7 @@ export const profileSchema = z.object({ chainId: z.number().int().positive(), rpcUrl: z.string().url(), signer: signerSchema, + envFile: z.string().min(1).optional(), policy: z.string().min(1), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), diff --git a/src/tx-engine.ts b/src/tx-engine.ts index aef51e5..1289c9f 100644 --- a/src/tx-engine.ts +++ b/src/tx-engine.ts @@ -52,6 +52,70 @@ function mapJournalToResult(entry: JournalEntry): TxExecutionResult { }; } +function extractErrorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + + if (error instanceof Error) { + const typed = error as Error & { shortMessage?: string; details?: string; cause?: unknown }; + return typed.shortMessage || typed.details || typed.message; + } + + if (typeof error === "object" && error !== null) { + const maybe = error as { shortMessage?: unknown; message?: unknown; details?: unknown }; + if (typeof maybe.shortMessage === "string") { + return maybe.shortMessage; + } + + if (typeof maybe.message === "string") { + return maybe.message; + } + + if (typeof maybe.details === "string") { + return maybe.details; + } + } + + return String(error); +} + +function classifySimulationRevert(message: string): { reasonCode: string; reason: string } { + const normalized = message.toLowerCase(); + + if (normalized.includes("allowance")) { + return { + reasonCode: "INSUFFICIENT_ALLOWANCE", + reason: "Allowance is below required token spend.", + }; + } + + if ((normalized.includes("bid") && normalized.includes("low")) || normalized.includes("start bid")) { + return { + reasonCode: "BID_BELOW_START", + reason: "Bid amount is below minimum threshold.", + }; + } + + if ( + normalized.includes("auction") && + (normalized.includes("ended") || + normalized.includes("state") || + normalized.includes("not active") || + normalized.includes("already")) + ) { + return { + reasonCode: "AUCTION_STATE_CHANGED", + reason: "Auction state changed before execution.", + }; + } + + return { + reasonCode: "UNKNOWN_REVERT", + reason: "Revert reason could not be classified.", + }; +} + async function resolveNonce(intent: TxIntent, ctx: ExecutionContext, address: `0x${string}`): Promise { if (intent.noncePolicy === "manual") { if (intent.nonce === undefined) { @@ -151,8 +215,12 @@ export async function executeTxIntent(intent: TxIntent, chain: ResolvedChain, cu value: intent.valueWei, }); } catch (error) { + const message = extractErrorMessage(error); + const classified = classifySimulationRevert(message); throw new CliError("SIMULATION_REVERT", "Transaction simulation reverted.", 2, { - message: error instanceof Error ? error.message : String(error), + message, + reasonCode: classified.reasonCode, + reason: classified.reason, }); } diff --git a/src/types.ts b/src/types.ts index bd56c72..6b89b6b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -186,6 +186,7 @@ export interface ProfileConfig { chainId: number; rpcUrl: string; signer: SignerConfig; + envFile?: string; policy: string; createdAt: string; updatedAt: string;