@dev.sail.money/sailor 1.2.0-73 → 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.
- package/examples/permissions/BoundedSwapNative_UniswapV3_Base.sol +123 -0
- package/examples/permissions/BoundedSwap_UniswapV3_Base.sol +20 -18
- package/examples/permissions/BoundedSwap_UniswapV4_Unichain.sol +17 -12
- package/examples/permissions/README.md +10 -0
- package/package.json +1 -1
- package/packages/cli/dist/index.cjs +118 -51
- package/packages/cli/dist/server.cjs +29 -1
- package/packages/sdk/dist/intelligence.d.ts +1 -1
- package/packages/sdk/dist/intelligence.js +1 -1
- package/templates/default/.agents/skills/sail-mandates/references/examples-index.md +5 -2
|
@@ -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
|
-
// •
|
|
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
|
-
/// @
|
|
66
|
-
///
|
|
67
|
-
///
|
|
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 swap — the 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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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
|
-
// •
|
|
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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
@@ -38686,9 +38686,13 @@ var SigningServer = class {
|
|
|
38686
38686
|
http2.once("error", rej);
|
|
38687
38687
|
});
|
|
38688
38688
|
this.httpServer = http2;
|
|
38689
|
-
if (this.advertise)
|
|
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))
|
|
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:
|
|
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
|
|
40125
|
-
|
|
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 <
|
|
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
|
-
|
|
41681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42347
|
-
|
|
42348
|
-
|
|
42349
|
-
|
|
42350
|
-
|
|
42351
|
-
|
|
42352
|
-
|
|
42353
|
-
|
|
42354
|
-
|
|
42355
|
-
|
|
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
|
|
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").
|
|
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
|
|
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-
|
|
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-
|
|
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 +
|
|
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 +
|
|
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.
|