Skip to content

Commit 06e8f1d

Browse files
feat: add --dry-run for tx/onchain/mapped writes
feat: add --dry-run for tx/onchain/mapped writes
2 parents 2f07d55 + 27cddcb commit 06e8f1d

12 files changed

Lines changed: 599 additions & 12 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 0.2.1 - 2026-02-27
4+
5+
### Added
6+
7+
- `--dry-run` mode for write surfaces:
8+
- `ag tx send --dry-run`
9+
- `ag onchain send --dry-run`
10+
- mapped writes (for example `ag token approve --dry-run`)
11+
- `scripts/smoke-write-dryrun.sh` and npm script `smoke:write-dryrun` for automated write-path smoke checks without broadcasting.
12+
13+
### Changed
14+
15+
- Dry-run execution now returns `status: "simulated"` with simulation details and skips journal mutation and transaction submission.
16+
- `--dry-run` is explicitly blocked with `--wait` / `--confirm`.
17+
318
## 0.2.0 - 2026-02-27
419

520
### Added

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,26 @@ Planned domain namespaces are stubbed for parity tracking:
5555
Many Base-era write flows are already executable as mapped aliases in those namespaces (internally routed through `onchain send`).
5656
Example: `ag lending create --abi-file ./abis/GotchiLendingFacet.json --address 0x... --args-json '[...]' --json`
5757

58+
## Dry-run writes
59+
60+
Use `--dry-run` on write commands to run full preflight without broadcasting:
61+
62+
- runs simulation (`eth_call`)
63+
- runs gas + fee estimation
64+
- enforces policy checks
65+
- resolves nonce
66+
- returns `status: \"simulated\"` with simulation details
67+
68+
Supported write surfaces:
69+
70+
- `tx send --dry-run`
71+
- `onchain send --dry-run`
72+
- mapped write aliases (for example: `token approve --dry-run`)
73+
74+
Safety rule:
75+
76+
- `--dry-run` cannot be combined with `--wait` / `--confirm`
77+
5878
## Subgraph sources and endpoint policy
5979

6080
Canonical source aliases:
@@ -186,5 +206,12 @@ npm run typecheck
186206
npm test
187207
npm run build
188208
npm run parity:check
209+
npm run smoke:write-dryrun
189210
npm run ag -- help
190211
```
212+
213+
Write dry-run smoke test notes:
214+
215+
- `npm run smoke:write-dryrun` validates write paths without broadcasting any transaction.
216+
- To run against an installed binary instead of local source:
217+
- `AG_BIN=/absolute/path/to/ag npm run smoke:write-dryrun`

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "aavegotchi-cli",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "Agent-first CLI for automating Aavegotchi app and onchain workflows",
55
"license": "MIT",
66
"repository": {
@@ -30,6 +30,7 @@
3030
"test": "vitest run",
3131
"test:watch": "vitest",
3232
"parity:check": "node scripts/check-parity.mjs",
33+
"smoke:write-dryrun": "bash scripts/smoke-write-dryrun.sh",
3334
"ag": "tsx src/index.ts",
3435
"prepack": "npm run build",
3536
"bootstrap:smoke": "AGCLI_HOME=/tmp/agcli-smoke tsx src/index.ts bootstrap --mode agent --profile smoke --chain base --signer readonly --json"

scripts/smoke-write-dryrun.sh

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
cd "$ROOT_DIR"
6+
7+
AGCLI_HOME="${AGCLI_HOME:-/tmp/agcli-write-dryrun-smoke}"
8+
AG_BIN="${AG_BIN:-}"
9+
TMP_DIR="${AGCLI_SMOKE_TMP:-/tmp/agcli-write-dryrun-run}"
10+
PROFILE_NAME="${AGCLI_PROFILE:-smoke-write}"
11+
PRIVATE_KEY_ENV="${AGCLI_PRIVATE_KEY_ENV:-AGCLI_PRIVATE_KEY}"
12+
GHST_TOKEN="${AGCLI_GHST_TOKEN:-0xcd2f22236dd9dfe2356d7c543161d4d260fd9bcb}"
13+
14+
rm -rf "$TMP_DIR"
15+
mkdir -p "$TMP_DIR"
16+
17+
if [[ -n "$AG_BIN" ]]; then
18+
AG_CMD=("$AG_BIN")
19+
else
20+
AG_CMD=(npx tsx src/index.ts)
21+
fi
22+
23+
if [[ -z "${!PRIVATE_KEY_ENV:-}" ]]; then
24+
printf -v "$PRIVATE_KEY_ENV" "%s" "0x1111111111111111111111111111111111111111111111111111111111111111"
25+
export "$PRIVATE_KEY_ENV"
26+
fi
27+
28+
pass=0
29+
fail=0
30+
LAST_OUT="$TMP_DIR/last.json"
31+
LAST_ERR="$TMP_DIR/last.err"
32+
33+
run_json() {
34+
local name="$1"
35+
shift
36+
37+
echo "--- $name"
38+
if AGCLI_HOME="$AGCLI_HOME" "${AG_CMD[@]}" "$@" --json >"$LAST_OUT" 2>"$LAST_ERR"; then
39+
if node -e 'const fs=require("fs");const p=process.argv[1];const d=JSON.parse(fs.readFileSync(p,"utf8"));if(d.status!=="ok"){process.exit(1)}' "$LAST_OUT"; then
40+
echo "PASS"
41+
pass=$((pass + 1))
42+
return 0
43+
fi
44+
fi
45+
46+
echo "FAIL"
47+
cat "$LAST_ERR" || true
48+
cat "$LAST_OUT" || true
49+
fail=$((fail + 1))
50+
return 1
51+
}
52+
53+
run_expect_error() {
54+
local name="$1"
55+
local expected_code="$2"
56+
shift 2
57+
58+
echo "--- $name"
59+
if AGCLI_HOME="$AGCLI_HOME" "${AG_CMD[@]}" "$@" --json >"$LAST_OUT" 2>"$LAST_ERR"; then
60+
echo "FAIL (expected error $expected_code but command succeeded)"
61+
cat "$LAST_OUT" || true
62+
fail=$((fail + 1))
63+
return 1
64+
fi
65+
66+
if node -e 'const fs=require("fs");const p=process.argv[1];const expected=process.argv[2];const d=JSON.parse(fs.readFileSync(p,"utf8"));if(d.status!=="error"||d.error?.code!==expected){process.exit(1)}' "$LAST_ERR" "$expected_code"; then
67+
echo "PASS"
68+
pass=$((pass + 1))
69+
return 0
70+
fi
71+
72+
echo "FAIL (unexpected error payload)"
73+
cat "$LAST_ERR" || true
74+
fail=$((fail + 1))
75+
return 1
76+
}
77+
78+
extract_json() {
79+
local path="$1"
80+
local expr="$2"
81+
node -e "const fs=require('fs'); const d=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); const v=(${expr}); if(v===undefined||v===null){process.exit(1)} process.stdout.write(String(v));" "$path"
82+
}
83+
84+
APPROVE_ABI="$TMP_DIR/approve.json"
85+
cat >"$APPROVE_ABI" <<'JSON'
86+
[
87+
{
88+
"type": "function",
89+
"name": "approve",
90+
"stateMutability": "nonpayable",
91+
"inputs": [
92+
{ "name": "spender", "type": "address" },
93+
{ "name": "amount", "type": "uint256" }
94+
],
95+
"outputs": [
96+
{ "name": "", "type": "bool" }
97+
]
98+
}
99+
]
100+
JSON
101+
102+
run_json "bootstrap env signer profile" bootstrap --mode agent --profile "$PROFILE_NAME" --chain base --signer "env:$PRIVATE_KEY_ENV"
103+
run_json "signer check" signer check --profile "$PROFILE_NAME"
104+
cp "$LAST_OUT" "$TMP_DIR/signer-check.json"
105+
SIGNER_ADDRESS="$(extract_json "$TMP_DIR/signer-check.json" 'd.data?.signer?.address')"
106+
107+
run_json "tx send dry-run" tx send --profile "$PROFILE_NAME" --to "$SIGNER_ADDRESS" --value-wei 0 --dry-run
108+
run_expect_error "tx send dry-run + wait" "INVALID_ARGUMENT" tx send --profile "$PROFILE_NAME" --to "$SIGNER_ADDRESS" --value-wei 0 --dry-run --wait
109+
110+
run_json "onchain send dry-run (approve)" onchain send --profile "$PROFILE_NAME" --abi-file "$APPROVE_ABI" --address "$GHST_TOKEN" --function approve --args-json "[\"$SIGNER_ADDRESS\",\"1\"]" --dry-run
111+
run_expect_error "onchain send dry-run + wait" "INVALID_ARGUMENT" onchain send --profile "$PROFILE_NAME" --abi-file "$APPROVE_ABI" --address "$GHST_TOKEN" --function approve --args-json "[\"$SIGNER_ADDRESS\",\"1\"]" --dry-run --wait
112+
113+
run_json "mapped token approve dry-run" token approve --profile "$PROFILE_NAME" --abi-file "$APPROVE_ABI" --address "$GHST_TOKEN" --args-json "[\"$SIGNER_ADDRESS\",\"1\"]" --dry-run
114+
115+
echo "RESULT pass=$pass fail=$fail"
116+
if [[ "$fail" -ne 0 ]]; then
117+
exit 1
118+
fi

src/commands/onchain.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ export async function runOnchainSendWithFunction(
189189
}
190190

191191
const waitForReceipt = getFlagBoolean(ctx.args.flags, "wait");
192+
const dryRun = getFlagBoolean(ctx.args.flags, "dry-run");
193+
194+
if (dryRun && waitForReceipt) {
195+
throw new CliError("INVALID_ARGUMENT", "--dry-run cannot be combined with --wait.", 2);
196+
}
192197

193198
const intent: TxIntent = {
194199
idempotencyKey: getFlagString(ctx.args.flags, "idempotency-key"),
@@ -203,6 +208,7 @@ export async function runOnchainSendWithFunction(
203208
noncePolicy: noncePolicyRaw as TxIntent["noncePolicy"],
204209
nonce,
205210
waitForReceipt,
211+
dryRun,
206212
timeoutMs: parseTimeoutMs(getFlagString(ctx.args.flags, "timeout-ms")),
207213
command: commandOverride || `onchain send ${functionName}`,
208214
};

src/commands/tx.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,17 @@ export async function runTxSendCommand(ctx: CommandContext): Promise<JsonValue>
9393
const nonceValue = getFlagString(ctx.args.flags, "nonce");
9494
const nonce = nonceValue ? parseNumberFlag(nonceValue, "--nonce", 0) : undefined;
9595
const waitForReceipt = getFlagBoolean(ctx.args.flags, "wait") || getFlagBoolean(ctx.args.flags, "confirm");
96+
const dryRun = getFlagBoolean(ctx.args.flags, "dry-run");
9697
const timeoutMs = parseNumberFlag(getFlagString(ctx.args.flags, "timeout-ms"), "--timeout-ms", 120000);
9798

9899
if (noncePolicy === "manual" && nonce === undefined) {
99100
throw new CliError("MISSING_NONCE", "--nonce is required when --nonce-policy=manual.", 2);
100101
}
101102

103+
if (dryRun && waitForReceipt) {
104+
throw new CliError("INVALID_ARGUMENT", "--dry-run cannot be combined with --wait/--confirm.", 2);
105+
}
106+
102107
const intent: TxIntent = {
103108
idempotencyKey,
104109
profileName: profile.name,
@@ -112,6 +117,7 @@ export async function runTxSendCommand(ctx: CommandContext): Promise<JsonValue>
112117
noncePolicy,
113118
nonce,
114119
waitForReceipt,
120+
dryRun,
115121
timeoutMs,
116122
command: "tx send",
117123
};

0 commit comments

Comments
 (0)