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

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/README.md CHANGED
@@ -62,7 +62,7 @@ The path from nothing to a running agent follows the protocol lifecycle:
62
62
  2. **Author your permissions** — describe what the agent may do. Permission contracts encode the bounds: tokens, amounts, venues, call targets. Author them in the scaffolded Foundry workspace.
63
63
  3. **Simulate, deploy, and sign your mandate** — `sailor mandate simulate` probes a permission off-chain before authorizing it. `sailor mandate deploy --attach` deploys and registers it on-chain. `sailor mandate sign` builds and signs the registration payload against live on-chain state.
64
64
  4. **Run** — `sailor run` executes the agent loop. Three execution hosts compose: run it locally on a schedule, install it as a local OS service (`sailor service install` — launchd/systemd/Task Scheduler) that restarts on crash, or let the GitHub Actions cron workflow the scaffold provides run it. `sailor trigger github` fires that workflow on demand.
65
- 5. **Operate** — `sailor doctor` checks kernel health and gas balances; `sailor chains` lists supported chains and deployment addresses; `sailor session pause` instantly revokes dispatch rights without touching Safe custody.
65
+ 5. **Operate** — `sailor doctor` checks kernel health and gas balances; `sailor chains` lists supported chains and deployment addresses; `sailor session pause` instantly revokes dispatch rights without touching Safe custody. After a CLI upgrade, `sailor update` resyncs agent tooling files (skills, `AGENTS.md`, `Dockerfile`) without touching user code or runtime state.
66
66
 
67
67
  Run `npx sailor init my-agent`, open the scaffolded folder in Claude Code, Cursor, or any AI coding assistant, and say **"start"**.
68
68
 
@@ -158,6 +158,9 @@ sailor trigger github # fire the agent's GitHub Actions workflow on demand
158
158
 
159
159
  # Dashboard
160
160
  sailor ui start # prints the per-project dashboard URL
161
+
162
+ # Maintenance
163
+ sailor update # re-sync skills, AGENTS.md, and tooling files after a CLI upgrade
161
164
  ```
162
165
 
163
166
  `sailor run` writes reverted transactions to stderr as `reverted: <txHash> (gas used: N)`; successful dispatches are appended to `.sail/activity.jsonl`.
@@ -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-76",
4
4
  "description": "Operator toolkit for Sail Protocol",
5
5
  "bin": {
6
6
  "sailor": "packages/cli/dist/index.cjs"