@dev.sail.money/sailor 1.2.0-74 → 1.2.0-75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // Protocol : Uniswap V3 — NATIVE-ASSET (ETH) swap
6
+ // Version : SwapRouter02 (NOT the older SwapRouter — different selectors)
7
+ // Chain : Base mainnet (8453)
8
+ // Target : SwapRouter02 0x2626664c2603336E57B271c5C0b26F421741e481 (verified on Basescan)
9
+ //
10
+ // WHY A SEPARATE EXAMPLE FOR NATIVE ETH:
11
+ // When the asset being spent is the chain's NATIVE asset (ETH), the value that actually
12
+ // leaves the account is the call's `msg.value` — exposed to the permission as `Context.value`
13
+ // (`ctx.value`) — NOT the calldata `amountIn`. SwapRouter02 swaps native ETH by wrapping the
14
+ // ETH you send (tokenIn = WETH in the calldata) into WETH inside the router. So a permission
15
+ // adapted naively from the ERC-20 swap example (BoundedSwap_UniswapV3_Base.sol) would bound
16
+ // `amountIn` while leaving `ctx.value` UNBOUNDED — and the on-chain bound would NOT cap the
17
+ // funds actually spent. That is the trap this example exists to close.
18
+ //
19
+ // THE RULE THIS EXAMPLE DEMONSTRATES:
20
+ // For ANY value-carrying call, `Context.value` MUST be explicitly bounded. Bounding the
21
+ // calldata amount is not enough — the real spend is `ctx.value`.
22
+ //
23
+ // ENFORCES ON-CHAIN (kernel calls evaluate() on every dispatch; false ⇒ dispatch blocked):
24
+ // exactInputSingle((address,address,uint24,address,uint256,uint256,uint160)) selector 0x04e45aaf
25
+ // • target must be SWAP_ROUTER
26
+ // • ctx.value ≤ MAX_AMOUNT_IN ← bounds the REAL native spend
27
+ // • amountIn == ctx.value ← forces the native path; no value/amount drift
28
+ // • tokenIn must equal WETH ← the router wraps ctx.value into WETH
29
+ // • tokenOut must be in ALLOWED_TOKENS_OUT
30
+ //
31
+ // AGENT-ENFORCED / NOT BOUNDED HERE (off-chain — can change without redeploying this contract):
32
+ // • fee tier, sqrtPriceLimitX96, recipient address
33
+ // • swap frequency / cadence
34
+ // • slippage — see the note below
35
+ //
36
+ // SLIPPAGE IS NOT BOUNDED ON-CHAIN HERE — AND CANNOT BE, without a price oracle:
37
+ // `amountOutMinimum` (output token) and `ctx.value`/`amountIn` (native ETH) are denominated in
38
+ // DIFFERENT tokens, so a ratio between them bounds nothing real (see BoundedSwap_UniswapV3_Base
39
+ // for the full explanation). Compute `amountOutMinimum` OFF-CHAIN from a live quote and pass it
40
+ // in per swap; the router reverts if the output falls below it. This contract only caps the
41
+ // native input spend (MAX_AMOUNT_IN).
42
+ //
43
+ // VERIFY BEFORE USE:
44
+ // • Confirm SwapRouter02 + WETH addresses on your chain (Base defaults shown; verify on-chain).
45
+ // • Native ETH swaps on SwapRouter02 are typically wrapped in a payable multicall (so the
46
+ // router can refundETH any unspent wei). This example bounds the single exactInputSingle
47
+ // leg; if your agent routes the swap through `multicall`, add a permission that decodes the
48
+ // multicall and applies these same ctx.value / amountIn checks to the inner call.
49
+ // • Selector 0x04e45aaf = exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))
50
+ // on SwapRouter02 (verified via `cast sig`). The OLDER SwapRouter's exactInputSingle (the
51
+ // deadline variant) is 0x414bf389 — a different selector; do not confuse the two.
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ import {IPermission, Context} from "@sail/interfaces/IPermission.sol";
55
+
56
+ contract BoundedSwapNative_UniswapV3_Base is IPermission {
57
+ bytes32 private constant DISCRIMINATOR = keccak256("BoundedSwapNative_UniswapV3_Base");
58
+
59
+ address public immutable SWAP_ROUTER;
60
+ /// @dev The wrapped-native token (WETH). SwapRouter02 wraps the ETH sent as ctx.value
61
+ /// into this token, so the calldata tokenIn for a native swap is WETH.
62
+ address public immutable WETH;
63
+ mapping(address => bool) public isAllowedTokenOut;
64
+ /// @dev Per-call cap on the native spend, in wei. Bounds ctx.value, the REAL spend.
65
+ uint256 public immutable MAX_AMOUNT_IN;
66
+
67
+ bytes4 private constant SEL_EXACT_INPUT_SINGLE = 0x04e45aaf;
68
+
69
+ struct ExactInputSingleParams {
70
+ address tokenIn;
71
+ address tokenOut;
72
+ uint24 fee;
73
+ address recipient;
74
+ uint256 amountIn;
75
+ uint256 amountOutMinimum;
76
+ uint160 sqrtPriceLimitX96;
77
+ }
78
+
79
+ /// @param swapRouter SwapRouter02 address (0x2626... on Base)
80
+ /// @param weth Wrapped-native token address (WETH on Base: 0x4200...0006)
81
+ /// @param allowedTokensOut Tokens the agent may receive
82
+ /// @param maxAmountIn Per-call native spend cap in wei (bounds ctx.value)
83
+ /// @dev No slippage parameter: slippage cannot be bounded on-chain without a price oracle
84
+ /// (see the header note). Pass a tight `amountOutMinimum`, computed off-chain from a
85
+ /// live quote, on each swap — the router enforces it by reverting.
86
+ constructor(
87
+ address swapRouter,
88
+ address weth,
89
+ address[] memory allowedTokensOut,
90
+ uint256 maxAmountIn
91
+ ) {
92
+ SWAP_ROUTER = swapRouter;
93
+ WETH = weth;
94
+ MAX_AMOUNT_IN = maxAmountIn;
95
+ for (uint256 i = 0; i < allowedTokensOut.length; i++) {
96
+ isAllowedTokenOut[allowedTokensOut[i]] = true;
97
+ }
98
+ }
99
+
100
+ function evaluate(bytes calldata txData, Context calldata ctx) external view returns (bool) {
101
+ if (txData.length < 4) return false;
102
+ if (ctx.target != SWAP_ROUTER || ctx.selector != SEL_EXACT_INPUT_SINGLE) return false;
103
+ if (txData.length < 4 + 7 * 32) return false;
104
+
105
+ ExactInputSingleParams memory p = abi.decode(txData[4:], (ExactInputSingleParams));
106
+
107
+ // Bound the REAL native spend. ctx.value is the ETH actually leaving the account;
108
+ // bounding amountIn alone would leave the spend uncapped.
109
+ if (ctx.value > MAX_AMOUNT_IN) return false;
110
+ // No drift between the declared amount and the native value sent — a native swap pays
111
+ // entirely with ctx.value, so amountIn must equal it exactly.
112
+ if (p.amountIn != ctx.value) return false;
113
+
114
+ // tokenIn is WETH: the router wraps ctx.value into WETH before swapping.
115
+ if (p.tokenIn != WETH) return false;
116
+ if (!isAllowedTokenOut[p.tokenOut]) return false;
117
+ // amountOutMinimum intentionally not checked — see header (slippage cannot be bounded
118
+ // on-chain; the router enforces the off-chain-computed value the agent passes in).
119
+ return true;
120
+ }
121
+
122
+ function discriminator() external pure returns (bytes32) { return DISCRIMINATOR; }
123
+ }
@@ -13,7 +13,6 @@ pragma solidity 0.8.26;
13
13
  // • tokenIn must equal FIXED_TOKEN_IN
14
14
  // • tokenOut must be in ALLOWED_TOKENS_OUT
15
15
  // • amountIn ≤ MAX_AMOUNT_IN
16
- // • amountOutMinimum ≥ amountIn × MIN_BPS / 10 000 (slippage floor — see caveat below)
17
16
  // approve(address,uint256) selector 0x095ea7b3
18
17
  // • target must be FIXED_TOKEN_IN (the ERC-20 being approved)
19
18
  // • spender must be SWAP_ROUTER
@@ -22,15 +21,24 @@ pragma solidity 0.8.26;
22
21
  // AGENT-ENFORCED / NOT BOUNDED HERE (off-chain — can change without redeploying this contract):
23
22
  // • fee tier, sqrtPriceLimitX96, recipient address
24
23
  // • swap frequency / cadence
25
- // • real (cross-denomination) slippage — see MIN_BPS caveat in evaluate()
24
+ // • slippage — see the note below
25
+ //
26
+ // SLIPPAGE IS NOT BOUNDED ON-CHAIN HERE — AND CANNOT BE, without a price oracle:
27
+ // `amountOutMinimum` (output token) and `amountIn` (input token) are denominated in DIFFERENT
28
+ // tokens. Comparing them as a ratio — the pattern an earlier version of this example shipped —
29
+ // is meaningless for any pair whose tokens differ in price or decimals (e.g. USDC→WETH): the
30
+ // check is either trivially satisfied or trivially failed, giving false confidence while
31
+ // protecting nothing. So this permission deliberately does NOT constrain `amountOutMinimum`.
32
+ // Real slippage protection must be computed OFF-CHAIN from a live quote (e.g. the Quoter or a
33
+ // price feed) and passed in as `amountOutMinimum` on each swap — the router reverts if the
34
+ // output falls below it. Your agent owns choosing a tight, fresh value per call; this contract
35
+ // only caps the input spend (MAX_AMOUNT_IN).
26
36
  //
27
37
  // VERIFY BEFORE USE:
28
38
  // • Confirm SwapRouter02 address on your chain (Base default shown above; verified on Basescan).
29
39
  // • Selector 0x04e45aaf = exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))
30
40
  // on SwapRouter02 (verified via `cast sig`). The OLDER SwapRouter's exactInputSingle (the
31
41
  // deadline variant) is 0x414bf389 — a different selector; do not confuse the two.
32
- // • Confirm that amountOutMinimum slippage floor fits your price-impact expectations for the
33
- // chosen pool. Large amountIn values may trigger price impact beyond MIN_BPS.
34
42
  // ─────────────────────────────────────────────────────────────────────────────
35
43
 
36
44
  import {IPermission, Context} from "@sail/interfaces/IPermission.sol";
@@ -42,8 +50,6 @@ contract BoundedSwap_UniswapV3_Base is IPermission {
42
50
  address public immutable FIXED_TOKEN_IN;
43
51
  mapping(address => bool) public isAllowedTokenOut;
44
52
  uint256 public immutable MAX_AMOUNT_IN;
45
- /// @dev Slippage floor in basis points. 9 900 = allow at most 1% slippage.
46
- uint256 public immutable MIN_BPS;
47
53
 
48
54
  bytes4 private constant SEL_EXACT_INPUT_SINGLE = 0x04e45aaf;
49
55
  bytes4 private constant SEL_APPROVE = 0x095ea7b3;
@@ -62,21 +68,18 @@ contract BoundedSwap_UniswapV3_Base is IPermission {
62
68
  /// @param fixedTokenIn The one token the agent is allowed to sell
63
69
  /// @param allowedTokensOut Tokens the agent may receive
64
70
  /// @param maxAmountIn Per-call spend cap in fixedTokenIn base units
65
- /// @param minBps Minimum amountOutMinimum as fraction of amountIn ×10 000
66
- /// e.g. 9900 require amountOutMinimum 99% of amountIn
67
- /// (denominated in fixedTokenIn units, not USD set per your pair)
71
+ /// @dev No slippage parameter: slippage cannot be bounded on-chain without a price oracle
72
+ /// (see the header note). Pass a tight `amountOutMinimum`, computed off-chain from a
73
+ /// live quote, on each swapthe router enforces it by reverting.
68
74
  constructor(
69
75
  address swapRouter,
70
76
  address fixedTokenIn,
71
77
  address[] memory allowedTokensOut,
72
- uint256 maxAmountIn,
73
- uint256 minBps
78
+ uint256 maxAmountIn
74
79
  ) {
75
- require(minBps <= 10_000, "minBps > 10000");
76
80
  SWAP_ROUTER = swapRouter;
77
81
  FIXED_TOKEN_IN = fixedTokenIn;
78
82
  MAX_AMOUNT_IN = maxAmountIn;
79
- MIN_BPS = minBps;
80
83
  for (uint256 i = 0; i < allowedTokensOut.length; i++) {
81
84
  isAllowedTokenOut[allowedTokensOut[i]] = true;
82
85
  }
@@ -92,11 +95,10 @@ contract BoundedSwap_UniswapV3_Base is IPermission {
92
95
  if (p.tokenIn != FIXED_TOKEN_IN) return false;
93
96
  if (!isAllowedTokenOut[p.tokenOut]) return false;
94
97
  if (p.amountIn > MAX_AMOUNT_IN) return false;
95
- // Slippage floor: amountOutMinimum amountIn × MIN_BPS / 10 000.
96
- // WARNING: compares tokenOut against tokenIn base units. For same-price/same-decimal
97
- // pairs this maps to a slippage %. For cross-price pairs (e.g. USDC→WETH) it is
98
- // trivially satisfied — real slippage is enforced by the agent off-chain, not here.
99
- if (p.amountOutMinimum < (p.amountIn * MIN_BPS) / 10_000) return false;
98
+ // amountOutMinimum is intentionally NOT checked here it is denominated in the
99
+ // output token, so a ratio against amountIn (input token) bounds nothing real for a
100
+ // cross-price pair (see header). The router enforces the off-chain-computed
101
+ // amountOutMinimum the agent passes in.
100
102
  return true;
101
103
  }
102
104
 
@@ -19,12 +19,19 @@ pragma solidity 0.8.26;
19
19
  // • tokenIn (from poolKey, derived by zeroForOne) must be FIXED_CURRENCY_IN
20
20
  // • tokenOut must be in ALLOWED_CURRENCIES_OUT
21
21
  // • amountIn ≤ MAX_AMOUNT_IN
22
- // • amountOutMinimum ≥ amountIn × MIN_BPS / 10 000 (slippage floor — see caveat below)
23
22
  //
24
23
  // AGENT-ENFORCED / NOT BOUNDED HERE (off-chain — can change without redeploying this contract):
25
- // • real (cross-denomination) slippage — see MIN_BPS caveat in evaluate()
24
+ // • slippage — see the note below
26
25
  // • swap frequency / cadence
27
26
  //
27
+ // SLIPPAGE IS NOT BOUNDED ON-CHAIN HERE — AND CANNOT BE, without a price oracle:
28
+ // `amountOutMinimum` (output currency) and `amountIn` (input currency) are denominated in
29
+ // DIFFERENT tokens. A ratio between them — the pattern an earlier version of this example
30
+ // shipped — bounds nothing real for any pair whose tokens differ in price or decimals. So this
31
+ // permission deliberately does NOT constrain `amountOutMinimum`. Compute it OFF-CHAIN from a
32
+ // live quote and pass it in per swap; the router reverts if the output falls below it. This
33
+ // contract only caps the input spend (MAX_AMOUNT_IN).
34
+ //
28
35
  // DOCUMENTED LIMITATIONS (on-chain, but intentionally not constrained):
29
36
  // • hookData is not inspected (hooks can alter swap behavior on-chain; if the
30
37
  // pool uses a hook that significantly changes execution, this permission cannot
@@ -54,7 +61,6 @@ contract BoundedSwap_UniswapV4_Unichain is IPermission {
54
61
  address public immutable FIXED_CURRENCY_IN;
55
62
  mapping(address => bool) public isAllowedCurrencyOut;
56
63
  uint256 public immutable MAX_AMOUNT_IN;
57
- uint256 public immutable MIN_BPS;
58
64
 
59
65
  // execute(bytes,bytes[],uint256) — with deadline
60
66
  bytes4 private constant SEL_EXECUTE_DEADLINE = 0x3593564c;
@@ -84,18 +90,18 @@ contract BoundedSwap_UniswapV4_Unichain is IPermission {
84
90
  bytes hookData; // not inspected — see limitations header
85
91
  }
86
92
 
93
+ /// @dev No slippage parameter: slippage cannot be bounded on-chain without a price oracle
94
+ /// (see the header note). Pass a tight `amountOutMinimum`, computed off-chain from a
95
+ /// live quote, on each swap — the router enforces it by reverting.
87
96
  constructor(
88
97
  address universalRouter,
89
98
  address fixedCurrencyIn,
90
99
  address[] memory allowedCurrenciesOut,
91
- uint256 maxAmountIn,
92
- uint256 minBps
100
+ uint256 maxAmountIn
93
101
  ) {
94
- require(minBps <= 10_000, "minBps > 10000");
95
102
  UNIVERSAL_ROUTER = universalRouter;
96
103
  FIXED_CURRENCY_IN = fixedCurrencyIn;
97
104
  MAX_AMOUNT_IN = maxAmountIn;
98
- MIN_BPS = minBps;
99
105
  for (uint256 i = 0; i < allowedCurrenciesOut.length; i++) {
100
106
  isAllowedCurrencyOut[allowedCurrenciesOut[i]] = true;
101
107
  }
@@ -137,11 +143,10 @@ contract BoundedSwap_UniswapV4_Unichain is IPermission {
137
143
  if (tokenIn != FIXED_CURRENCY_IN) return false;
138
144
  if (!isAllowedCurrencyOut[tokenOut]) return false;
139
145
  if (p.amountIn > MAX_AMOUNT_IN) return false;
140
- // Slippage floor: amountOutMinimum amountIn × MIN_BPS / 10 000.
141
- // WARNING: compares tokenOut against tokenIn base units. For same-price/same-decimal
142
- // pairs this maps to a slippage %. For cross-price pairs it is trivially satisfied —
143
- // real slippage is enforced by the agent off-chain, not by this contract.
144
- if (p.amountOutMinimum < (uint256(p.amountIn) * MIN_BPS) / 10_000) return false;
146
+ // amountOutMinimum is intentionally NOT checked it is denominated in the output
147
+ // currency, so a ratio against amountIn (input currency) bounds nothing real for a
148
+ // cross-price pair (see header). The router enforces the off-chain-computed
149
+ // amountOutMinimum the agent passes in.
145
150
 
146
151
  return true;
147
152
  }
@@ -23,6 +23,15 @@ against the protocol's real ABI, and confirm what it enforces before deploying.
23
23
 
24
24
  You own what you deploy.
25
25
 
26
+ ## Bound `Context.value` on every value-carrying call
27
+
28
+ For any call that can carry native asset (ETH), the value actually leaving the account is the
29
+ call's `msg.value` — exposed to your permission as `Context.value` (`ctx.value`) — **not** the
30
+ calldata amount. A permission that bounds only a calldata `amount`/`amountIn` leaves the real
31
+ spend uncapped. So: **every value-carrying call must explicitly bound `Context.value`**, and where
32
+ the calldata also declares an amount, assert `amount == ctx.value` so the two cannot drift. See
33
+ `BoundedSwapNative_UniswapV3_Base.sol` for the worked native-ETH example.
34
+
26
35
  ---
27
36
 
28
37
  ## Examples
@@ -35,6 +44,7 @@ off-chain agent code — these do *not* hold on-chain). Read both before deployi
35
44
  |---|---|---|---|---|
36
45
  | `BoundedSwap_UniswapV3_Base.sol` | Uniswap Swap | V3 SwapRouter02 | Base | Full decode |
37
46
  | `BoundedSwap_UniswapV4_Unichain.sol` | Uniswap Swap | V4 Universal Router | Unichain | Partial decode — see header |
47
+ | `BoundedSwapNative_UniswapV3_Base.sol` | Uniswap Swap (native ETH) | V3 SwapRouter02 | Base | Full decode — bounds `Context.value` (native spend) |
38
48
  | `BoundedBorrow_AaveV3_Arbitrum.sol` | Aave Borrow | V3 Pool | Arbitrum | Full decode |
39
49
  | `BoundedSupply_AaveV3_Arbitrum.sol` | Aave Supply | V3 Pool | Arbitrum | Full decode |
40
50
  | `BoundedVault_ERC4626_Base.sol` | ERC-4626 Vault deposit/withdraw | EIP-4626 standard | Base (any EVM) | Full decode |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.sail.money/sailor",
3
- "version": "1.2.0-74",
3
+ "version": "1.2.0-75",
4
4
  "description": "Operator toolkit for Sail Protocol",
5
5
  "bin": {
6
6
  "sailor": "packages/cli/dist/index.cjs"
@@ -38686,9 +38686,13 @@ var SigningServer = class {
38686
38686
  http2.once("error", rej);
38687
38687
  });
38688
38688
  this.httpServer = http2;
38689
- if (this.advertise) this.writeRuntimeState();
38689
+ if (this.advertise) {
38690
+ reapStaleRuntimeState(this.projectRoot);
38691
+ this.writeRuntimeState();
38692
+ }
38690
38693
  process.once("SIGINT", () => this.stop());
38691
38694
  process.once("SIGTERM", () => this.stop());
38695
+ process.once("exit", () => this.stop());
38692
38696
  }
38693
38697
  stop() {
38694
38698
  for (const [id, entry] of this.pending) {
@@ -38833,6 +38837,28 @@ var SigningServer = class {
38833
38837
  sailFile(...segments) {
38834
38838
  return (0, import_node_path4.join)(this.projectRoot, ".sail", ...segments);
38835
38839
  }
38840
+ /**
38841
+ * Persist the active chain into config.json. The onboarding stage machine keys
38842
+ * off config.json.chainId; leaving it null after SMA creation misclassifies the
38843
+ * stage on resume. Best-effort — never blocks the account save on a config write.
38844
+ */
38845
+ syncConfigChainId(chainId) {
38846
+ if (chainId == null) return;
38847
+ try {
38848
+ const path9 = this.sailFile("config.json");
38849
+ let config = {};
38850
+ try {
38851
+ config = JSON.parse((0, import_node_fs5.readFileSync)(path9, "utf-8"));
38852
+ } catch {
38853
+ }
38854
+ if (Number(config.chainId) === Number(chainId)) return;
38855
+ config.chainId = Number(chainId);
38856
+ (0, import_node_fs5.mkdirSync)(this.sailFile(), { recursive: true });
38857
+ (0, import_node_fs5.writeFileSync)(path9, `${JSON.stringify(config, null, 2)}
38858
+ `);
38859
+ } catch {
38860
+ }
38861
+ }
38836
38862
  /** Stream a JSON file back, or a fallback body when it is missing/invalid. */
38837
38863
  sendJsonFile(res, filePath, fallback2) {
38838
38864
  try {
@@ -38875,6 +38901,7 @@ var SigningServer = class {
38875
38901
  (0, import_node_fs5.mkdirSync)(baseSailDir, { recursive: true });
38876
38902
  (0, import_node_fs5.writeFileSync)(this.sailFile("account.json"), `${JSON.stringify(record, null, 2)}
38877
38903
  `);
38904
+ this.syncConfigChainId(chainId);
38878
38905
  res.writeHead(200, { "Content-Type": "application/json" });
38879
38906
  res.end(JSON.stringify({ ok: true }));
38880
38907
  }).catch((err) => {
@@ -39123,6 +39150,14 @@ var SigningServer = class {
39123
39150
  }
39124
39151
  }
39125
39152
  writeRuntimeState() {
39153
+ const path9 = (0, import_node_path4.join)(this.runtimeDir, SERVER_STATE_FILE);
39154
+ if ((0, import_node_fs5.existsSync)(path9)) {
39155
+ try {
39156
+ const existing = JSON.parse((0, import_node_fs5.readFileSync)(path9, "utf8"));
39157
+ if (existing.pid != null && existing.pid !== process.pid && pidAlive(existing.pid)) return;
39158
+ } catch {
39159
+ }
39160
+ }
39126
39161
  if (!(0, import_node_fs5.existsSync)(this.runtimeDir)) (0, import_node_fs5.mkdirSync)(this.runtimeDir, { recursive: true });
39127
39162
  (0, import_node_fs5.writeFileSync)(
39128
39163
  (0, import_node_path4.join)(this.runtimeDir, SERVER_STATE_FILE),
@@ -39143,11 +39178,33 @@ var SigningServer = class {
39143
39178
  removeRuntimeState() {
39144
39179
  const path9 = (0, import_node_path4.join)(this.runtimeDir, SERVER_STATE_FILE);
39145
39180
  try {
39146
- if ((0, import_node_fs5.existsSync)(path9)) (0, import_node_fs5.unlinkSync)(path9);
39181
+ if (!(0, import_node_fs5.existsSync)(path9)) return;
39182
+ const state = JSON.parse((0, import_node_fs5.readFileSync)(path9, "utf8"));
39183
+ if (state.pid != null && state.pid !== process.pid) return;
39184
+ (0, import_node_fs5.unlinkSync)(path9);
39147
39185
  } catch {
39148
39186
  }
39149
39187
  }
39150
39188
  };
39189
+ function pidAlive(pid) {
39190
+ try {
39191
+ process.kill(pid, 0);
39192
+ return true;
39193
+ } catch (err) {
39194
+ return err.code === "EPERM";
39195
+ }
39196
+ }
39197
+ function reapStaleRuntimeState(projectRoot = process.cwd()) {
39198
+ const path9 = (0, import_node_path4.join)(projectRoot, RUNTIME_SUBDIR, SERVER_STATE_FILE);
39199
+ try {
39200
+ if (!(0, import_node_fs5.existsSync)(path9)) return;
39201
+ const state = JSON.parse((0, import_node_fs5.readFileSync)(path9, "utf8"));
39202
+ if (state.pid != null && state.pid !== process.pid && !pidAlive(state.pid)) {
39203
+ (0, import_node_fs5.unlinkSync)(path9);
39204
+ }
39205
+ } catch {
39206
+ }
39207
+ }
39151
39208
  async function findAvailablePort(startPort) {
39152
39209
  return new Promise((res) => {
39153
39210
  const probe = (0, import_node_net.createServer)();
@@ -39256,9 +39313,10 @@ async function discoverDaemon(projectRoot = process.cwd()) {
39256
39313
  return await client.ping() ? client : null;
39257
39314
  }
39258
39315
  async function createSigningChannel(projectRoot = process.cwd()) {
39316
+ reapStaleRuntimeState(projectRoot);
39259
39317
  const daemon = await discoverDaemon(projectRoot);
39260
39318
  if (daemon) return daemon;
39261
- return new SigningServer({ projectRoot, advertise: false });
39319
+ return new SigningServer({ projectRoot, advertise: true });
39262
39320
  }
39263
39321
 
39264
39322
  // src/commands/account.ts
@@ -40121,15 +40179,20 @@ async function resolvePermissionForBatch(params) {
40121
40179
  }
40122
40180
 
40123
40181
  // src/commands/doctor.ts
40124
- var LOW_GAS_THRESHOLD_WEI = 500000000000000n;
40125
- async function nativeBalance(pc, address) {
40182
+ var LOW_GAS_THRESHOLD_L1_WEI = 5000000000000000n;
40183
+ var LOW_GAS_THRESHOLD_L2_WEI = 200000000000000n;
40184
+ var L1_GAS_CHAINS = /* @__PURE__ */ new Set([1, 11155111]);
40185
+ function lowGasThresholdWei(chainId) {
40186
+ return L1_GAS_CHAINS.has(chainId) ? LOW_GAS_THRESHOLD_L1_WEI : LOW_GAS_THRESHOLD_L2_WEI;
40187
+ }
40188
+ async function nativeBalance(pc, address, chainId) {
40126
40189
  const wei = await pc.getBalance({ address });
40127
40190
  return {
40128
40191
  address,
40129
40192
  wei: wei.toString(),
40130
40193
  eth: formatEther(wei),
40131
40194
  funded: wei > 0n,
40132
- low: wei > 0n && wei < LOW_GAS_THRESHOLD_WEI
40195
+ low: wei > 0n && wei < lowGasThresholdWei(chainId)
40133
40196
  };
40134
40197
  }
40135
40198
  function keystoreAddress(role, safe) {
@@ -40208,8 +40271,8 @@ async function doctor(options = {}) {
40208
40271
  let ownerBal = null;
40209
40272
  let managerBal = null;
40210
40273
  try {
40211
- if (ownerAddr) ownerBal = await nativeBalance(pc, ownerAddr);
40212
- if (managerAddr) managerBal = await nativeBalance(pc, managerAddr);
40274
+ if (ownerAddr) ownerBal = await nativeBalance(pc, ownerAddr, chainId);
40275
+ if (managerAddr) managerBal = await nativeBalance(pc, managerAddr, chainId);
40213
40276
  } catch {
40214
40277
  }
40215
40278
  if (options.json) {
@@ -41624,6 +41687,18 @@ Mandate command failed: ${msg}`), {
41624
41687
  });
41625
41688
  process.exit(1);
41626
41689
  }
41690
+ function announceSigningUrl(json) {
41691
+ const url = signingPageUrl(void 0, projectPort(process.cwd()));
41692
+ if (json) {
41693
+ process.stdout.write(`${JSON.stringify({ status: "waiting_for_signature", url })}
41694
+ `);
41695
+ } else {
41696
+ console.log(`
41697
+ \u2192 Open the Sailor dashboard to approve signing requests:
41698
+ ${url}
41699
+ `);
41700
+ }
41701
+ }
41627
41702
  function publicClientFor(project) {
41628
41703
  return createPublicClient({
41629
41704
  chain: getChainById(project.chainId),
@@ -41677,15 +41752,8 @@ async function runDeploy(project, channel, options) {
41677
41752
  const deployData = encodeDeployData({ abi: abi2, bytecode, args });
41678
41753
  const chainId = project.chainId;
41679
41754
  const publicClient = publicClientFor(project);
41680
- say(() => {
41681
- console.log(
41682
- `
41683
- \u2192 Open the Sailor dashboard to approve signing requests:
41684
- ${signingPageUrl(channel, projectPort(process.cwd()))}
41685
- `
41686
- );
41687
- console.log(`Pushing deploy request for "${contractName}"\u2026`);
41688
- });
41755
+ announceSigningUrl(json);
41756
+ say(() => console.log(`Pushing deploy request for "${contractName}"\u2026`));
41689
41757
  const response = await channel.requestSignature({
41690
41758
  type: "transaction",
41691
41759
  kind: "deploy-mandate",
@@ -41835,6 +41903,15 @@ function parseAddressList(csv, flag) {
41835
41903
  }
41836
41904
  async function mandateDeployClone(options) {
41837
41905
  const project = requireProject();
41906
+ const templateMap = project.deployment.standaloneTemplates ?? {};
41907
+ if (Object.keys(templateMap).length === 0) {
41908
+ fail(
41909
+ new Error(
41910
+ `deploy-clone is unavailable on chain ${project.chainId}: no clone templates are deployed against this kernel (${project.deployment.kernel}) yet. Deploy your permission directly with \`sailor mandate deploy\` instead.`
41911
+ ),
41912
+ options.json
41913
+ );
41914
+ }
41838
41915
  const channel = await createSigningChannel(process.cwd());
41839
41916
  try {
41840
41917
  await channel.start();
@@ -41911,11 +41988,8 @@ ${spec.label} clone (${options.template})`);
41911
41988
  console.log(` predicted clone: ${clone}`);
41912
41989
  console.log(` SMA: ${sma}`);
41913
41990
  for (const d of spec.describe(initParams)) console.log(` ${d.label}: ${d.value}`);
41914
- console.log(`
41915
- \u2192 Open the Sailor dashboard to approve signing requests:
41916
- ${signingPageUrl(channel, projectPort(process.cwd()))}
41917
- `);
41918
41991
  });
41992
+ announceSigningUrl(json);
41919
41993
  const nonce = await publicClient.readContract({
41920
41994
  address: project.contracts.kernel,
41921
41995
  abi: SailKernelAbi,
@@ -42073,14 +42147,7 @@ async function runAttach(project, channel, options) {
42073
42147
  const mandateAddress = getAddress(rawAddress);
42074
42148
  const label = options.label ?? tracked?.name ?? "mandate";
42075
42149
  const publicClient = publicClientFor(project);
42076
- if (!json) {
42077
- console.log(
42078
- `
42079
- \u2192 Open the Sailor dashboard to approve signing requests:
42080
- ${signingPageUrl(channel, projectPort(process.cwd()))}
42081
- `
42082
- );
42083
- }
42150
+ announceSigningUrl(json);
42084
42151
  const txHash = await attachToSma(
42085
42152
  project,
42086
42153
  channel,
@@ -42157,13 +42224,8 @@ async function runRevoke(project, channel, options) {
42157
42224
  console.log(`
42158
42225
  Revoking ${targets.length} permission(s) from ${sma}:`);
42159
42226
  for (const p of targets) console.log(` ${nameFor(p) ?? p} ${p}`);
42160
- console.log(
42161
- `
42162
- \u2192 Open the Sailor dashboard to approve signing requests:
42163
- ${signingPageUrl(channel, projectPort(process.cwd()))}
42164
- `
42165
- );
42166
42227
  });
42228
+ announceSigningUrl(json);
42167
42229
  const response = await channel.requestSignature({
42168
42230
  type: "typed-data",
42169
42231
  kind: "revoke-permissions",
@@ -42340,21 +42402,27 @@ function mandateTemplates(options) {
42340
42402
  { chainId, community }
42341
42403
  );
42342
42404
  }
42343
- function mandateContractsList() {
42405
+ function mandateContractsList(options = {}) {
42344
42406
  const store = new MandateStore();
42345
42407
  const mandates = store.list();
42346
- if (mandates.length === 0) {
42347
- console.log('No permission contracts deployed yet. Use "sailor mandate deploy".');
42348
- return;
42349
- }
42350
- for (const m of mandates) {
42351
- console.log(m.name, `(chain ${m.chainId})`);
42352
- console.log(" Address: ", m.address);
42353
- console.log(" Deployed:", m.deployedAt);
42354
- if (m.attachments?.length) {
42355
- console.log(" Registered on:", m.attachments.map((a) => a.sma).join(", "));
42356
- }
42357
- }
42408
+ emit(
42409
+ !!options.json,
42410
+ () => {
42411
+ if (mandates.length === 0) {
42412
+ console.log('No permission contracts deployed yet. Use "sailor mandate deploy".');
42413
+ return;
42414
+ }
42415
+ for (const m of mandates) {
42416
+ console.log(m.name, `(chain ${m.chainId})`);
42417
+ console.log(" Address: ", m.address);
42418
+ console.log(" Deployed:", m.deployedAt);
42419
+ if (m.attachments?.length) {
42420
+ console.log(" Registered on:", m.attachments.map((a) => a.sma).join(", "));
42421
+ }
42422
+ }
42423
+ },
42424
+ { status: "ok", mandates }
42425
+ );
42358
42426
  }
42359
42427
  function mandateUpdate(options) {
42360
42428
  const { address, name, sourcePath, artifactPath, json } = options;
@@ -42691,7 +42759,6 @@ ${unregistered.length} permission(s) are not yet registered on this SMA. Initiat
42691
42759
  safe: account2.safe,
42692
42760
  chainId,
42693
42761
  signedAt: (/* @__PURE__ */ new Date()).toISOString(),
42694
- signature: "",
42695
42762
  registeredOnChain: true,
42696
42763
  // Only include permissions that are currently active on-chain.
42697
42764
  permissions: activePermissions.map((p) => ({ template: p.label, params: {} }))
@@ -44965,14 +45032,14 @@ mandate.command("prepare").description("Prepare a mandate draft for review and s
44965
45032
  mandate.command("sign").description("Review and confirm the permissions authorized for your SMA").option("--yes", "Skip the confirmation prompt (for non-interactive / CI use)").action(actionWith(mandateSign));
44966
45033
  mandate.command("deploy").description("Deploy a Foundry-compiled permission contract via the browser signing UI").option("--artifact <path>", "Path to the Foundry artifact JSON (out/<Name>.sol/<Name>.json)").option("--contract <name>", "Contract name; resolves to <out>/<name>.sol/<name>.json").option("--out <dir>", "Foundry output directory", "out").option("--name <label>", "Label to track this permission under (defaults to contract name)").option("--args <json>", `Constructor args as JSON array. Bash: '["0x..","1"]'. PowerShell: '[\\"0x..\\",\\"1\\"]'. Use --args-file to avoid quoting.`).option("--args-file <path>", "Path to a JSON file containing constructor args array (recommended on PowerShell)").option("--build", "Run `forge build` before deploying").option("--attach", "After deploy, register the permission on --sma").option("--sma <address>", "SMA to register on (required with --attach)").option("--json", "Emit machine-readable JSON").action(actionWith(mandateDeploy));
44967
45034
  mandate.command("attach").description("Register an already-deployed permission on an SMA (EIP-712 RegisterPermission)").requiredOption("--address <mandateOrName>", "Permission address, or a name tracked locally").requiredOption("--sma <address>", "SMA to register the permission on").option("--label <label>", "Human-readable label shown in the signing UI").option("--json", "Emit machine-readable JSON").action(actionWith(mandateAttach));
44968
- mandate.command("deploy-clone").description("Deploy + register a standalone clone permission (e.g. boundedApprove) via the signing UI").requiredOption("--template <key>", "Standalone clone template key (e.g. boundedApprove)").requiredOption("--sma <address>", "SMA to deploy the clone for and register it on").option("--tokens <csv>", "Comma-separated allowed token addresses").option("--spenders <csv>", "Comma-separated allowed spender addresses").option("--max <amount>", "Max amount per tx in base units (default: uint256 max)").option("--label <label>", "Human-readable label to track this permission under").option("--json", "Emit machine-readable JSON").action(actionWith(mandateDeployClone));
45035
+ mandate.command("deploy-clone").description("[currently unavailable \u2014 no clone templates deployed on any chain; use `mandate deploy`] Deploy + register a standalone clone permission via the signing UI").requiredOption("--template <key>", "Standalone clone template key (e.g. boundedApprove)").requiredOption("--sma <address>", "SMA to deploy the clone for and register it on").option("--tokens <csv>", "Comma-separated allowed token addresses").option("--spenders <csv>", "Comma-separated allowed spender addresses").option("--max <amount>", "Max amount per tx in base units (default: uint256 max)").option("--label <label>", "Human-readable label to track this permission under").option("--json", "Emit machine-readable JSON").action(actionWith(mandateDeployClone));
44969
45036
  mandate.command("revoke").description("Revoke permission(s) from an SMA (EIP-712 RevokePermissions, owner-authorized)").option("--address <permissionOrName>", "Permission address, or a name tracked locally").requiredOption("--sma <address>", "Safe (SMA) to revoke the permission(s) from").option("--all", "Revoke every permission currently registered on the SMA").option("--json", "Output JSON").action(actionWith(mandateRevoke));
44970
45037
  mandate.command("templates").description("Show how to author your own permission contract (and any community-deployed addresses)").option("--json", "Emit machine-readable JSON").action(actionWith(mandateTemplates));
44971
45038
  mandate.command("simulate").description(
44972
45039
  "Probe a permission against sample calls off-chain (eth_call, NO gas) \u2014 prove it accepts the calls you want and rejects the ones you don't, before authorizing on-chain"
44973
45040
  ).requiredOption("--address <permissionOrName>", "Permission to probe (address or tracked name)").option("--sma <address>", "SMA to probe as (ctx.account; defaults to .sail/account.json)").option("--target <address>", "Inline single call: target contract address").option("--calldata <hex>", "Inline single call: 0x-prefixed calldata").option("--value <wei>", "Inline single call: ETH value in wei (default 0)").option("--expect <pass|fail>", "Inline single call: expected outcome (sets non-zero exit on mismatch)").option("--label <text>", "Inline single call: human-readable label").option("--calls <file>", "Batch: JSON array of { target, calldata, value?, expect?, label? }").option("--json", "Emit machine-readable JSON").action(actionWith(mandateSimulate));
44974
45041
  mandate.command("update").description("Update metadata for a tracked permission contract (rename, source path, artifact path)").requiredOption("--address <mandateOrName>", "Permission address or tracked name to update").option("--name <label>", "New tracking label (must be unique within the same chain)").option("--source-path <path>", "Update the relative path to the Solidity source file").option("--artifact-path <path>", "Update the relative path to the Foundry artifact JSON").option("--json", "Emit machine-readable JSON").action(actionWith(mandateUpdate));
44975
- mandate.command("list").description("List permission contracts deployed from this project").action(action(async () => mandateContractsList()));
45042
+ mandate.command("list").description("List permission contracts deployed from this project").option("--json", "Emit machine-readable JSON").action(actionWith(mandateContractsList));
44976
45043
  program2.command("onboard").description("Set up an SMA, register a permission, confirm the agent is operational").option("--sma <address>", "Use a specific SMA address instead of prompting").option("--new-sma", "Create a new SMA via SailKernel").option("--salt <n>", "CREATE2 salt for deterministic Safe address (default: 0; use 0 for first SMA, increment for subsequent)").option("--template <kindOrAddress>", "Register this permission contract (kind, label, or address)").option("--skip-mandate", "Skip the permission registration step").option("--json", "Emit machine-readable JSON (implies non-interactive)").action(actionWith(onboard));
44977
45044
  var station = program2.command("station").description("Manage the persistent signing station (browser signing daemon)");
44978
45045
  station.command("start").description("Start the signing station and keep it running (blocks \u2014 run in the background)").option("--json", "Emit machine-readable JSON").action(actionWith(stationStart));
@@ -48831,6 +48831,24 @@ function startServer(sailDir, { port = PORT } = {}) {
48831
48831
  app.use((0, import_cors.default)({ origin: "http://localhost:3333" }));
48832
48832
  app.use(import_express.default.json());
48833
48833
  const at = (name) => import_node_path.default.join(sailDir, name);
48834
+ const syncConfigChainId = (chainId) => {
48835
+ if (chainId == null) return;
48836
+ try {
48837
+ const config = (() => {
48838
+ try {
48839
+ return JSON.parse(import_node_fs2.default.readFileSync(at("config.json"), "utf-8"));
48840
+ } catch {
48841
+ return {};
48842
+ }
48843
+ })();
48844
+ if (Number(config.chainId) === Number(chainId)) return;
48845
+ config.chainId = Number(chainId);
48846
+ import_node_fs2.default.mkdirSync(sailDir, { recursive: true });
48847
+ import_node_fs2.default.writeFileSync(at("config.json"), `${JSON.stringify(config, null, 2)}
48848
+ `);
48849
+ } catch {
48850
+ }
48851
+ };
48834
48852
  const overviewCacheByAccount = /* @__PURE__ */ new Map();
48835
48853
  const overviewInFlight = /* @__PURE__ */ new Set();
48836
48854
  const overviewCacheKey = (safe, chainId) => `${safe.toLowerCase()}-${chainId}`;
@@ -48971,6 +48989,7 @@ function startServer(sailDir, { port = PORT } = {}) {
48971
48989
  `);
48972
48990
  import_node_fs2.default.writeFileSync(at("account.json"), `${JSON.stringify(record, null, 2)}
48973
48991
  `);
48992
+ syncConfigChainId(chainId);
48974
48993
  res.json({ ok: true });
48975
48994
  } catch (err) {
48976
48995
  res.status(500).json({ error: String(err) });
@@ -49009,6 +49028,7 @@ function startServer(sailDir, { port = PORT } = {}) {
49009
49028
  }
49010
49029
  import_node_fs2.default.writeFileSync(at("account.json"), `${JSON.stringify(target, null, 2)}
49011
49030
  `);
49031
+ syncConfigChainId(target.chainId);
49012
49032
  res.json({ ok: true, active: target });
49013
49033
  } catch (err) {
49014
49034
  res.status(500).json({ error: String(err) });
@@ -49535,7 +49555,14 @@ function startServer(sailDir, { port = PORT } = {}) {
49535
49555
  managerAddress = ks?.address ? getAddress(`0x${String(ks.address).replace(/^0x/, "")}`) : null;
49536
49556
  } catch {
49537
49557
  }
49538
- const chainId = config?.chainId ?? 8453;
49558
+ const accountChainId = (() => {
49559
+ try {
49560
+ return JSON.parse(import_node_fs2.default.readFileSync(at("account.json"), "utf-8"))?.chainId ?? null;
49561
+ } catch {
49562
+ return null;
49563
+ }
49564
+ })();
49565
+ const chainId = config?.chainId ?? accountChainId ?? 8453;
49539
49566
  let deployment = null;
49540
49567
  try {
49541
49568
  deployment = getSailDeployment(chainId);
@@ -49787,6 +49814,7 @@ function startServer(sailDir, { port = PORT } = {}) {
49787
49814
  }
49788
49815
  import_node_fs2.default.writeFileSync(at("account.json"), `${JSON.stringify(record, null, 2)}
49789
49816
  `);
49817
+ syncConfigChainId(chainId);
49790
49818
  res.json({ ok: true, account: record });
49791
49819
  } catch (err) {
49792
49820
  res.status(500).json({ error: String(err) });
@@ -5,7 +5,7 @@
5
5
  * Do not edit manually — run `pnpm build` to regenerate.
6
6
  *
7
7
  * Spec version : 1.2.0
8
- * Generated at : 2026-06-16T18:15:06.743Z
8
+ * Generated at : 2026-06-17T16:48:08.178Z
9
9
  */
10
10
  export declare const SAIL_INTELLIGENCE_BASE_URL = "https://api.sail.money";
11
11
  export declare const SAIL_INTELLIGENCE_DOCS_URL = "https://api.sail.money/docs";
@@ -5,7 +5,7 @@
5
5
  * Do not edit manually — run `pnpm build` to regenerate.
6
6
  *
7
7
  * Spec version : 1.2.0
8
- * Generated at : 2026-06-16T18:15:06.743Z
8
+ * Generated at : 2026-06-17T16:48:08.178Z
9
9
  */
10
10
  export const SAIL_INTELLIGENCE_BASE_URL = "https://api.sail.money";
11
11
  export const SAIL_INTELLIGENCE_DOCS_URL = "https://api.sail.money/docs";
@@ -6,8 +6,9 @@ Permissions only bound on-chain actions: venues with off-chain order matching (P
6
6
 
7
7
  | File | Protocol / chain | Teaches | Key verified selectors |
8
8
  |---|---|---|---|
9
- | `BoundedSwap_UniswapV3_Base.sol` | Uniswap V3 SwapRouter02 · Base | Selector allowlist + amount cap + tokenOut allowlist + min-out slippage floor (MIN_BPS) | `0x04e45aaf` exactInputSingle (SwapRouter02), `0x095ea7b3` approve |
10
- | `BoundedSwap_UniswapV4_Unichain.sol` | Uniswap V4 Universal Router · Unichain | Decoding command/action bytes: single V4_SWAP command, single SWAP_EXACT_IN_SINGLE action, currency + amount + slippage bounds | `0x3593564c` execute(bytes,bytes[],uint256), `0x24856bc3` execute(bytes,bytes[]) |
9
+ | `BoundedSwap_UniswapV3_Base.sol` | Uniswap V3 SwapRouter02 · Base | Selector allowlist + input-spend cap + tokenOut allowlist; teaches why slippage CANNOT be bounded on-chain (amountOutMinimum is a different token than amountIn — pass an off-chain-quoted value) | `0x04e45aaf` exactInputSingle (SwapRouter02), `0x095ea7b3` approve |
10
+ | `BoundedSwap_UniswapV4_Unichain.sol` | Uniswap V4 Universal Router · Unichain | Decoding command/action bytes: single V4_SWAP command, single SWAP_EXACT_IN_SINGLE action, currency + input-spend bounds; same slippage caveat as the V3 example | `0x3593564c` execute(bytes,bytes[],uint256), `0x24856bc3` execute(bytes,bytes[]) |
11
+ | `BoundedSwapNative_UniswapV3_Base.sol` | Uniswap V3 SwapRouter02 (native ETH) · Base | Native-asset spend: bound `ctx.value` (the real spend) + assert `amountIn == ctx.value`; tokenIn = WETH (router wraps the sent ETH). The canonical "bound Context.value" example | `0x04e45aaf` exactInputSingle (SwapRouter02) |
11
12
  | `BoundedBorrow_AaveV3_Arbitrum.sol` | Aave V3 Pool · Arbitrum | Asset allowlist + borrow cap + `onBehalfOf == ctx.account` binding + rate-mode allowlist (use [2]; stable deprecated in V3.1) | `0xa415bcad` borrow(address,uint256,uint256,uint16,address) |
12
13
  | `BoundedSupply_AaveV3_Arbitrum.sol` | Aave V3 Pool · Arbitrum | SailCalldata-based decoding; supply cap + `onBehalfOf == ctx.account`; supply does NOT gate withdrawal, and the prior approve needs its own permission | `0x617ba037` supply(address,uint256,address,uint16) |
13
14
  | `BoundedVault_ERC4626_Base.sol` | ERC-4626 standard · any EVM | Vault allowlist; deposit capped + receiver bound; withdraw/redeem receiver+owner bound but amount-unbounded (SMA can always fully exit) | `0x6e553f65` deposit, `0xb460af94` withdraw, `0xba087652` redeem |
@@ -19,6 +20,8 @@ Permissions only bound on-chain actions: venues with off-chain order matching (P
19
20
 
20
21
  ## Lessons the examples encode
21
22
 
23
+ - **Value-carrying calls — bound `Context.value`, always.** For any call that can carry native asset, the value actually leaving the account is `ctx.value` (`msg.value`), NOT the calldata amount. A permission that bounds only a calldata `amount`/`amountIn` leaves the real spend uncapped. Rule: **every value-carrying call must explicitly bound `Context.value`** — and where the calldata declares its own amount, also assert `amount == ctx.value` so the two can't drift. `BoundedSwapNative_UniswapV3_Base.sol` is the worked native-ETH example; the ERC-20 `BoundedSwap_UniswapV3_Base.sol` carries no value, so it bounds `amountIn` only. When in doubt, add `if (ctx.value > MAX) return false;`.
24
+ - **Slippage can't be bounded on-chain without a price oracle.** `amountOutMinimum` (output token) and `amountIn`/`ctx.value` (input token) are different denominations — a ratio between them is meaningless for any cross-price pair (USDC→WETH), giving false confidence while protecting nothing. The swap examples deliberately do NOT constrain `amountOutMinimum`; the agent computes it off-chain from a live quote and the router enforces it by reverting. Bound the input spend on-chain; bound the output off-chain.
22
25
  - **Venice — the wrong-selector bug.** The live contract's function is `stake(address,uint256)` = `0xadc9772e`. The intuitive single-arg `stake(uint256)` = `0xa694fc3a` does not exist on the target. Gating the wrong selector fails closed: every legitimate stake silently rejected. Always confirm the selector against the deployed contract.
23
26
  - **GMX — versioned ABIs drift.** GMX has multiple ExchangeRouter deployments and the `CreateOrderParams` struct has gained fields over time. Before deploying: pick the exact router the agent calls, read its verified ABI, recompute with `cast sig "createOrder(<exact tuple>)"`, and update the selector and struct if they differ. The committed `0x212234c3` is verified against gmx-io/gmx-synthetics at time of writing — re-verify anyway.
24
27
  - **Limitless — unverified ABI as a worked warning.** The exchange address and `buy` signature are inferred from CTF patterns, not verified against the deployed contract. The contract's own header lists the verification steps; do them before any deploy.