@dev.sail.money/sailor 0.0.2-31 → 0.1.0-local

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.
Files changed (153) hide show
  1. package/AGENTS.md +140 -140
  2. package/LICENSE +21 -21
  3. package/README.md +430 -430
  4. package/docs/PERMISSION_MODEL.md +93 -93
  5. package/examples/permissions/BoundedApproveAndCallBatch.sol +179 -179
  6. package/examples/permissions/BoundedBet_Limitless_Base.sol +97 -97
  7. package/examples/permissions/BoundedBorrow_AaveV3_Arbitrum.sol +94 -94
  8. package/examples/permissions/BoundedPerp_GMXv2_Arbitrum.sol +154 -154
  9. package/examples/permissions/BoundedStake_Venice_Base.sol +85 -85
  10. package/examples/permissions/BoundedSupply_AaveV3_Arbitrum.sol +82 -82
  11. package/examples/permissions/BoundedSwap_UniswapV3_Base.sol +116 -116
  12. package/examples/permissions/BoundedSwap_UniswapV4_Unichain.sol +150 -150
  13. package/examples/permissions/BoundedTransfer_ERC20_Ethereum.sol +73 -73
  14. package/examples/permissions/BoundedVault_ERC4626_Base.sol +97 -97
  15. package/examples/permissions/README.md +79 -79
  16. package/examples/permissions/SailCalldata.sol +118 -118
  17. package/examples/permissions/foundry.toml +10 -10
  18. package/examples/permissions/interfaces/IBatchPermission.sol +38 -38
  19. package/examples/permissions/interfaces/IPermission.sol +18 -18
  20. package/package.json +45 -45
  21. package/packages/cli/README.md +34 -34
  22. package/packages/cli/dist/index.cjs +705 -687
  23. package/packages/cli/dist/server.cjs +506 -495
  24. package/packages/sdk/README.md +65 -65
  25. package/packages/sdk/dist/intelligence.d.ts +1 -1
  26. package/packages/sdk/dist/intelligence.js +1 -1
  27. package/packages/sdk/package.json +80 -80
  28. package/packages/ui/dist/assets/{add-B0J2XPqD.js → add-BxpXfVWe.js} +1 -1
  29. package/packages/ui/dist/assets/{all-wallets-DAWTUGbI.js → all-wallets-BKTn_sWK.js} +1 -1
  30. package/packages/ui/dist/assets/{app-store-B-bz9zO1.js → app-store-CfuKbwxR.js} +1 -1
  31. package/packages/ui/dist/assets/{apple-CW_aatUl.js → apple-BKSBbNYg.js} +1 -1
  32. package/packages/ui/dist/assets/{arrow-bottom-D9xphoWP.js → arrow-bottom-D4bG6gZi.js} +1 -1
  33. package/packages/ui/dist/assets/{arrow-bottom-circle-D-N3HlXh.js → arrow-bottom-circle-BNTs1p0T.js} +1 -1
  34. package/packages/ui/dist/assets/{arrow-left-DofAd9ta.js → arrow-left-2uee3vYv.js} +1 -1
  35. package/packages/ui/dist/assets/{arrow-right-CLBZVLVF.js → arrow-right-BktjMV6h.js} +1 -1
  36. package/packages/ui/dist/assets/{arrow-top-B47Y4sI6.js → arrow-top-Izu28fX4.js} +1 -1
  37. package/packages/ui/dist/assets/{bank-CVHPZeNC.js → bank-USBaAyFM.js} +1 -1
  38. package/packages/ui/dist/assets/{basic-CijTV8XG.js → basic-C_9KjTEH.js} +1 -1
  39. package/packages/ui/dist/assets/{browser-D12J6hPl.js → browser-DAEMAKV7.js} +1 -1
  40. package/packages/ui/dist/assets/{card-suad8wBG.js → card-DT8yDkKN.js} +1 -1
  41. package/packages/ui/dist/assets/{ccip-Bev57e2Y.js → ccip-CkqfGSxX.js} +1 -1
  42. package/packages/ui/dist/assets/{checkmark-DSzbM9ge.js → checkmark-CsgdEXFj.js} +1 -1
  43. package/packages/ui/dist/assets/{checkmark-bold-Ctlpy8fR.js → checkmark-bold-D2gjOQo2.js} +1 -1
  44. package/packages/ui/dist/assets/{chevron-bottom-DD4PYpsh.js → chevron-bottom-tprFynYV.js} +1 -1
  45. package/packages/ui/dist/assets/{chevron-left-HJzgI5fr.js → chevron-left-D2Zj1gNB.js} +1 -1
  46. package/packages/ui/dist/assets/{chevron-right-BAJMtoWG.js → chevron-right-D1rRuAVe.js} +1 -1
  47. package/packages/ui/dist/assets/{chevron-top-CSTGBRNq.js → chevron-top-24dL1mbL.js} +1 -1
  48. package/packages/ui/dist/assets/{chrome-store-CSgmzP0o.js → chrome-store-Vy-5niYX.js} +1 -1
  49. package/packages/ui/dist/assets/{clock-BGKXrbjA.js → clock-qBjLnVdJ.js} +1 -1
  50. package/packages/ui/dist/assets/{close-B-9LI-cc.js → close-DARDwgcu.js} +1 -1
  51. package/packages/ui/dist/assets/{coinPlaceholder-C9zB6O8f.js → coinPlaceholder-BvpIbPlD.js} +1 -1
  52. package/packages/ui/dist/assets/{compass-DNbNVsgN.js → compass-BMTO0ayt.js} +1 -1
  53. package/packages/ui/dist/assets/{copy-r_J027hY.js → copy-PaXeRHza.js} +1 -1
  54. package/packages/ui/dist/assets/{core-CuWvvvu4.js → core-BFnStQd-.js} +3 -3
  55. package/packages/ui/dist/assets/cursor-BDvw-B17.js +3 -0
  56. package/packages/ui/dist/assets/{cursor-transparent-By6KxbOE.js → cursor-transparent-BEMdi-8q.js} +1 -1
  57. package/packages/ui/dist/assets/{desktop-DRMmsjrd.js → desktop-CfuLLThw.js} +1 -1
  58. package/packages/ui/dist/assets/{disconnect-C69Z8KUW.js → disconnect-DhwgJMiR.js} +1 -1
  59. package/packages/ui/dist/assets/{discord-p3AKvqDk.js → discord-po8qoN1s.js} +1 -1
  60. package/packages/ui/dist/assets/{etherscan-C2zTiWaN.js → etherscan-BEsz0_yx.js} +1 -1
  61. package/packages/ui/dist/assets/{events-DKTfpIHs.js → events-Bz33Unzu.js} +1 -1
  62. package/packages/ui/dist/assets/{exclamation-triangle-D4IJznwI.js → exclamation-triangle-7CjTAGOQ.js} +1 -1
  63. package/packages/ui/dist/assets/{extension-C0y2g1tg.js → extension-CmxjEWEt.js} +1 -1
  64. package/packages/ui/dist/assets/{external-link-fkbBBTcW.js → external-link-CmQ--bNS.js} +1 -1
  65. package/packages/ui/dist/assets/{facebook-nsIgKROR.js → facebook-CIBn9b65.js} +1 -1
  66. package/packages/ui/dist/assets/{fallback-DHv3hSPW.js → fallback-DATyrQlb.js} +1 -1
  67. package/packages/ui/dist/assets/{farcaster-CzBHn8fo.js → farcaster-OJ3Jasxg.js} +1 -1
  68. package/packages/ui/dist/assets/{filters-gW1TGI8D.js → filters-D4x09zeL.js} +1 -1
  69. package/packages/ui/dist/assets/{github-D9UuzE25.js → github-ZlIuMArp.js} +1 -1
  70. package/packages/ui/dist/assets/{google-DxUfChw6.js → google-Gwg85sfv.js} +1 -1
  71. package/packages/ui/dist/assets/{help-circle-2dNDsXrX.js → help-circle-D1uOWYcX.js} +1 -1
  72. package/packages/ui/dist/assets/{id-rNBDU8mz.js → id-C0-5UdYk.js} +1 -1
  73. package/packages/ui/dist/assets/{image-C9Peu4QW.js → image-D_DUsv8-.js} +1 -1
  74. package/packages/ui/dist/assets/{index-B1wosqUU.js → index-BCzex_R6.js} +1 -1
  75. package/packages/ui/dist/assets/index-BUhrHLpY.js +1775 -0
  76. package/packages/ui/dist/assets/index-Cq02kQmy.css +1 -0
  77. package/packages/ui/dist/assets/{index-B1aFIpJ0.js → index-CrYzBWfD.js} +1 -1
  78. package/packages/ui/dist/assets/{index-_F9WbMAT.js → index-DdbJhIdl.js} +3 -3
  79. package/packages/ui/dist/assets/{index-JwrWbcaz.js → index-DiojfeVM.js} +1 -1
  80. package/packages/ui/dist/assets/{index-4lrTXbkY.js → index-izd7vu_r.js} +1 -1
  81. package/packages/ui/dist/assets/{index.es-wlYgJouQ.js → index.es-DdkHhQAj.js} +4 -4
  82. package/packages/ui/dist/assets/{info-cGbqKpFv.js → info-CiRd_kEG.js} +1 -1
  83. package/packages/ui/dist/assets/{info-circle-B8Xfr9A0.js → info-circle-ypxjqarK.js} +1 -1
  84. package/packages/ui/dist/assets/{lightbulb-CM2m-PnZ.js → lightbulb-B-pxLxd8.js} +1 -1
  85. package/packages/ui/dist/assets/{mail-_qO7Zcxu.js → mail-BYmicuVZ.js} +1 -1
  86. package/packages/ui/dist/assets/{metamask-sdk-Dy961bnw.js → metamask-sdk-Ccl6DG7Q.js} +1 -1
  87. package/packages/ui/dist/assets/{mobile-C6TDJh2K.js → mobile-CtP5PqVT.js} +1 -1
  88. package/packages/ui/dist/assets/{more-3pPTR0Gx.js → more-6C2733we.js} +1 -1
  89. package/packages/ui/dist/assets/{network-placeholder-BtFT2yZA.js → network-placeholder-CdhxMzqd.js} +1 -1
  90. package/packages/ui/dist/assets/{nftPlaceholder-BfBZEH1N.js → nftPlaceholder-DVmTWEAY.js} +1 -1
  91. package/packages/ui/dist/assets/{off-Bg5cnmyC.js → off-DNYLughs.js} +1 -1
  92. package/packages/ui/dist/assets/{parseSignature-CSIsnC1G.js → parseSignature-Dq2B5Bu3.js} +1 -1
  93. package/packages/ui/dist/assets/{play-store-Dg32m5PL.js → play-store-D7Qut5ta.js} +1 -1
  94. package/packages/ui/dist/assets/{plus-Ce97GbOa.js → plus-kqMyjt3q.js} +1 -1
  95. package/packages/ui/dist/assets/{qr-code-D3KdZWUh.js → qr-code-DiUCWRbz.js} +1 -1
  96. package/packages/ui/dist/assets/{recycle-horizontal-DOKfyzVh.js → recycle-horizontal-Boe3XiS-.js} +1 -1
  97. package/packages/ui/dist/assets/{refresh-DSjW7q17.js → refresh-CrBgBQYO.js} +1 -1
  98. package/packages/ui/dist/assets/{reown-logo-B0n-8waR.js → reown-logo-CFZCCHSx.js} +1 -1
  99. package/packages/ui/dist/assets/{search-CL2iyGid.js → search-ChTDrghU.js} +1 -1
  100. package/packages/ui/dist/assets/{secp256k1-DdqDRGog.js → secp256k1-DAV5Q_FR.js} +1 -1
  101. package/packages/ui/dist/assets/{send-C_Rm4fzj.js → send-DLFbBFe1.js} +1 -1
  102. package/packages/ui/dist/assets/{swapHorizontal-0d_94RdY.js → swapHorizontal-BEs3emfG.js} +1 -1
  103. package/packages/ui/dist/assets/{swapHorizontalBold-BukSRa8V.js → swapHorizontalBold-CC-Hfa7W.js} +1 -1
  104. package/packages/ui/dist/assets/{swapHorizontalMedium-DvroDkEf.js → swapHorizontalMedium-BmR0H8DC.js} +1 -1
  105. package/packages/ui/dist/assets/{swapHorizontalRoundedBold-BAehcn9y.js → swapHorizontalRoundedBold-BdP5NGIH.js} +1 -1
  106. package/packages/ui/dist/assets/{swapVertical-kblIte_7.js → swapVertical-CPrGEJPY.js} +1 -1
  107. package/packages/ui/dist/assets/{telegram-DHLO89MI.js → telegram-CxNoZ80Q.js} +1 -1
  108. package/packages/ui/dist/assets/{three-dots-ctb5FHLw.js → three-dots-BRa6SBpL.js} +1 -1
  109. package/packages/ui/dist/assets/{twitch-CK_fCqNu.js → twitch-BC338bG5.js} +1 -1
  110. package/packages/ui/dist/assets/{twitterIcon-BCngN3WD.js → twitterIcon-BGZmt2i9.js} +1 -1
  111. package/packages/ui/dist/assets/{verify-Dy-B59vy.js → verify-CEstW0zw.js} +1 -1
  112. package/packages/ui/dist/assets/{verify-filled-DHDHx8Lk.js → verify-filled-OkZb0weU.js} +1 -1
  113. package/packages/ui/dist/assets/{w3m-modal-DRNXP3Ww.js → w3m-modal-pS09ECwE.js} +1 -1
  114. package/packages/ui/dist/assets/{wallet-DriPOF7d.js → wallet-BXVKCgC9.js} +1 -1
  115. package/packages/ui/dist/assets/{wallet-placeholder-B4ukOjpR.js → wallet-placeholder-C_kNhB1c.js} +1 -1
  116. package/packages/ui/dist/assets/{walletconnect-Cjl1Ki75.js → walletconnect-CRKIuUHH.js} +1 -1
  117. package/packages/ui/dist/assets/{warning-circle-C7eCTFhJ.js → warning-circle-DB2NnwlJ.js} +1 -1
  118. package/packages/ui/dist/assets/{x-B8jYZY9t.js → x-DT4RmwL5.js} +1 -1
  119. package/packages/ui/dist/index.html +14 -14
  120. package/scripts/check-docs.mjs +262 -262
  121. package/scripts/check-init.mjs +108 -108
  122. package/scripts/postinstall.js +81 -56
  123. package/templates/custom-mandate/.sail/contracts/interfaces/IPermission.sol +18 -18
  124. package/templates/custom-mandate/README.md +116 -116
  125. package/templates/custom-mandate/foundry.toml +8 -8
  126. package/templates/custom-mandate/mandates/BoundedCallPermission.sol +41 -41
  127. package/templates/custom-mandate/mandates/README.md +16 -16
  128. package/templates/custom-mandate/mandates/SailCalldata.sol +118 -118
  129. package/templates/default/.cursor/rules +25 -25
  130. package/templates/default/.env.example +20 -20
  131. package/templates/default/.github/workflows/agent-tick.yml +33 -33
  132. package/templates/default/.sail/README.md +13 -13
  133. package/templates/default/.sail/config.json +10 -10
  134. package/templates/default/AGENTS.md +171 -171
  135. package/templates/default/CLAUDE.md +2 -2
  136. package/templates/default/README.md +16 -16
  137. package/templates/default/_gitignore +13 -13
  138. package/templates/default/docs/PERMISSION_MODEL.md +93 -93
  139. package/templates/default/examples/dca/README.md +16 -16
  140. package/templates/default/examples/dca/agent.ts +174 -174
  141. package/templates/default/examples/dca/mandate.ts +45 -45
  142. package/templates/default/package.json +17 -17
  143. package/templates/default/src/agent.ts +37 -37
  144. package/templates/default/src/config.ts +24 -24
  145. package/templates/default/src/mandate.ts +22 -22
  146. package/templates/default/tsconfig.json +17 -17
  147. package/templates/default/ui/README.md +3 -3
  148. package/templates/lifi-permissions/LifiBoundedApprovePermissionCloneable.sol +84 -84
  149. package/templates/lifi-permissions/LifiDiamondSwapPermissionCloneable.sol +97 -97
  150. package/templates/lifi-permissions/README.md +53 -53
  151. package/packages/ui/dist/assets/cursor-0ZcCqvYy.js +0 -3
  152. package/packages/ui/dist/assets/index-BzT0MJhc.js +0 -1775
  153. package/packages/ui/dist/assets/index-n8bp1ZEc.css +0 -1
@@ -1,93 +1,93 @@
1
- # Sail permission model: conjunctive vs selective
2
-
3
- The deployed SailKernel ships in **two incompatible dispatch models**. Which one a
4
- chain runs changes how dispatches are signed *and* how permissions must be written.
5
- Get this wrong and every dispatch reverts with an opaque selector. This is the single
6
- most important thing to understand before operating an SMA.
7
-
8
- ## TL;DR
9
-
10
- | | **Conjunctive** (older) | **Selective** (newer) |
11
- |---|---|---|
12
- | Chains today (bundled kernels) | None — all bundled kernels moved to selective | Base (8453), Base Sepolia (84532), Arbitrum (42161), Unichain (130) |
13
- | `dispatch(...)` | `(account, target, value, data, sig, deadline)` — **no `permission`** | `(account, permission, target, value, data, sig, deadline)` |
14
- | Which permissions are checked | **ALL** registered permissions; **all must return true** | only the **one** permission named in the call |
15
- | EIP-712 `Dispatch` struct | no `permission` field | includes `permission` |
16
- | Batch (`dispatchBatch`/`previewBatch`) | **not available** | available |
17
- | **Permission design rule** | **MUST pass through calls outside its domain** | may return false freely |
18
-
19
- Don't guess from a version string — **detect it on-chain** (see below).
20
-
21
- ## The conjunctive pass-through rule (the big footgun)
22
-
23
- On a conjunctive kernel the kernel calls `evaluate(txData, ctx)` on **every**
24
- registered permission and ANDs the results. A single `false` blocks the whole
25
- dispatch (`PermissionDenied`).
26
-
27
- So a permission that only cares about, say, approvals **must still return `true` for
28
- every call it doesn't govern**:
29
-
30
- ```solidity
31
- function evaluate(bytes calldata txData, Context calldata ctx) external view returns (bool) {
32
- // Pass through calls outside this permission's domain (conjunctive model).
33
- if (ctx.selector != APPROVE) return true; // <-- without this line, this
34
- // permission bricks swaps,
35
- // transfers, everything.
36
- // ...domain-specific checks for approve...
37
- }
38
- ```
39
-
40
- A permission that returns `false` (or reverts, or runs out of gas — both treated as
41
- `false`) on unrelated calls **bricks the entire account**: no dispatch of any kind
42
- can pass. We hit exactly this during bring-up with permissions that "blocked each
43
- other." The fix was to redeploy every permission with pass-through semantics.
44
-
45
- Corollary: on a conjunctive kernel you **cannot** have two permissions that each
46
- enforce a different token's approve — each would reject the other's token. To support
47
- approving both DAI and USDC you need **one** approve permission that allows both (see
48
- `templates/lifi-permissions/`), not two narrow ones.
49
-
50
- Selective kernels don't have this problem: each dispatch names one permission and
51
- only that one is consulted.
52
-
53
- ## Detect the model on-chain
54
-
55
- The SDK reads each kernel's public `DISPATCH_TYPEHASH` constant and matches it
56
- against the canonical hashes for each model. Never assume.
57
-
58
- ```ts
59
- const caps = await client.capabilities();
60
- // caps.dispatchModel: "conjunctive" | "selective"
61
- // caps.dispatchTypehash, caps.source ("onchain-typehash" | "static-hint")
62
- ```
63
-
64
- Verified typehashes:
65
-
66
- - conjunctive `DISPATCH_TYPEHASH` = `0x7510c80e…`
67
- `Dispatch(address account,address target,uint256 value,bytes32 dataHash,uint256 nonce,uint256 deadline)`
68
- - selective `DISPATCH_TYPEHASH` =
69
- `Dispatch(address account,address permission,address target,uint256 value,bytes32 dataHash,uint256 nonce,uint256 deadline)`
70
-
71
- `client.dispatch.single(...)` already signs the correct struct and uses the correct
72
- ABI for the detected model — you don't sign by hand. `client.dispatch.batch` /
73
- `preview` throw a clear error on conjunctive kernels (no `dispatchBatch`).
74
-
75
- ## Roles (unchanged across models)
76
-
77
- | Role | Authority |
78
- |------|-----------|
79
- | **Owner** | Holds the Safe; custody anchor. |
80
- | **Permission Signer** | Authorizes which `IPermission` contracts apply (EIP-712 `RegisterPermissions` / `RevokePermissions`). Signed in the browser signing station — the agent never holds this key. |
81
- | **Manager** | Executes dispatches within the registered permissions (ECDSA / ERC-1271). The agent's hot key. |
82
-
83
- ## Preflight before spending gas
84
-
85
- Run `sailor doctor` (read-only, no gas, no keys):
86
-
87
- - detects the dispatch model,
88
- - lists registered permissions,
89
- - on a conjunctive kernel, **probes each permission for pass-through** and flags any
90
- that would brick dispatch.
91
-
92
- See [AGENTS.md](../AGENTS.md) for the operational decision tree and the
93
- revert failure-mode catalog.
1
+ # Sail permission model: conjunctive vs selective
2
+
3
+ The deployed SailKernel ships in **two incompatible dispatch models**. Which one a
4
+ chain runs changes how dispatches are signed *and* how permissions must be written.
5
+ Get this wrong and every dispatch reverts with an opaque selector. This is the single
6
+ most important thing to understand before operating an SMA.
7
+
8
+ ## TL;DR
9
+
10
+ | | **Conjunctive** (older) | **Selective** (newer) |
11
+ |---|---|---|
12
+ | Chains today (bundled kernels) | None — all bundled kernels moved to selective | Base (8453), Base Sepolia (84532), Arbitrum (42161), Unichain (130) |
13
+ | `dispatch(...)` | `(account, target, value, data, sig, deadline)` — **no `permission`** | `(account, permission, target, value, data, sig, deadline)` |
14
+ | Which permissions are checked | **ALL** registered permissions; **all must return true** | only the **one** permission named in the call |
15
+ | EIP-712 `Dispatch` struct | no `permission` field | includes `permission` |
16
+ | Batch (`dispatchBatch`/`previewBatch`) | **not available** | available |
17
+ | **Permission design rule** | **MUST pass through calls outside its domain** | may return false freely |
18
+
19
+ Don't guess from a version string — **detect it on-chain** (see below).
20
+
21
+ ## The conjunctive pass-through rule (the big footgun)
22
+
23
+ On a conjunctive kernel the kernel calls `evaluate(txData, ctx)` on **every**
24
+ registered permission and ANDs the results. A single `false` blocks the whole
25
+ dispatch (`PermissionDenied`).
26
+
27
+ So a permission that only cares about, say, approvals **must still return `true` for
28
+ every call it doesn't govern**:
29
+
30
+ ```solidity
31
+ function evaluate(bytes calldata txData, Context calldata ctx) external view returns (bool) {
32
+ // Pass through calls outside this permission's domain (conjunctive model).
33
+ if (ctx.selector != APPROVE) return true; // <-- without this line, this
34
+ // permission bricks swaps,
35
+ // transfers, everything.
36
+ // ...domain-specific checks for approve...
37
+ }
38
+ ```
39
+
40
+ A permission that returns `false` (or reverts, or runs out of gas — both treated as
41
+ `false`) on unrelated calls **bricks the entire account**: no dispatch of any kind
42
+ can pass. We hit exactly this during bring-up with permissions that "blocked each
43
+ other." The fix was to redeploy every permission with pass-through semantics.
44
+
45
+ Corollary: on a conjunctive kernel you **cannot** have two permissions that each
46
+ enforce a different token's approve — each would reject the other's token. To support
47
+ approving both DAI and USDC you need **one** approve permission that allows both (see
48
+ `templates/lifi-permissions/`), not two narrow ones.
49
+
50
+ Selective kernels don't have this problem: each dispatch names one permission and
51
+ only that one is consulted.
52
+
53
+ ## Detect the model on-chain
54
+
55
+ The SDK reads each kernel's public `DISPATCH_TYPEHASH` constant and matches it
56
+ against the canonical hashes for each model. Never assume.
57
+
58
+ ```ts
59
+ const caps = await client.capabilities();
60
+ // caps.dispatchModel: "conjunctive" | "selective"
61
+ // caps.dispatchTypehash, caps.source ("onchain-typehash" | "static-hint")
62
+ ```
63
+
64
+ Verified typehashes:
65
+
66
+ - conjunctive `DISPATCH_TYPEHASH` = `0x7510c80e…`
67
+ `Dispatch(address account,address target,uint256 value,bytes32 dataHash,uint256 nonce,uint256 deadline)`
68
+ - selective `DISPATCH_TYPEHASH` =
69
+ `Dispatch(address account,address permission,address target,uint256 value,bytes32 dataHash,uint256 nonce,uint256 deadline)`
70
+
71
+ `client.dispatch.single(...)` already signs the correct struct and uses the correct
72
+ ABI for the detected model — you don't sign by hand. `client.dispatch.batch` /
73
+ `preview` throw a clear error on conjunctive kernels (no `dispatchBatch`).
74
+
75
+ ## Roles (unchanged across models)
76
+
77
+ | Role | Authority |
78
+ |------|-----------|
79
+ | **Owner** | Holds the Safe; custody anchor. |
80
+ | **Permission Signer** | Authorizes which `IPermission` contracts apply (EIP-712 `RegisterPermissions` / `RevokePermissions`). Signed in the browser signing station — the agent never holds this key. |
81
+ | **Manager** | Executes dispatches within the registered permissions (ECDSA / ERC-1271). The agent's hot key. |
82
+
83
+ ## Preflight before spending gas
84
+
85
+ Run `sailor doctor` (read-only, no gas, no keys):
86
+
87
+ - detects the dispatch model,
88
+ - lists registered permissions,
89
+ - on a conjunctive kernel, **probes each permission for pass-through** and flags any
90
+ that would brick dispatch.
91
+
92
+ See [AGENTS.md](../AGENTS.md) for the operational decision tree and the
93
+ revert failure-mode catalog.
@@ -1,179 +1,179 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
3
-
4
- // ─────────────────────────────────────────────────────────────────────────────
5
- // Protocol : Sail batch dispatch (IBatchPermission) — protocol-agnostic
6
- // Version : Single-tenant, constructor-configured (mirrors the multi-tenant
7
- // SharedApproveAndCallBatchPermission shape from SailProtocol)
8
- // Chain : Any EVM with a SELECTIVE kernel (Base, Arbitrum, Unichain, Base Sepolia)
9
- //
10
- // WHAT THIS TEACHES — the atomic approve → call → reset pattern.
11
- // A bare ERC-20 approve is dangerous: it leaves a standing allowance an attacker can drain.
12
- // The safe shape is to approve, consume the allowance in the SAME transaction, then reset it
13
- // to zero — all-or-nothing. No single-call IPermission can enforce "the reset must be the
14
- // final call", because per-call evaluation never sees the other calls. A batch permission
15
- // sees the WHOLE sequence at once, so it can.
16
- //
17
- // Gated via the kernel's dispatchBatch (NOT dispatch). This contract's single-call evaluate()
18
- // deliberately returns false — it is batch-only. The manager names this permission in the
19
- // batch signature; only it is consulted (selective model).
20
- //
21
- // ENFORCES ON-CHAIN (kernel calls evaluateBatch() via staticcall; false/revert ⇒ batch blocked):
22
- // The batch MUST be exactly these 3 calls, in this order, each with value == 0:
23
- // calls[0] = approve(spender,amount) selector 0x095ea7b3 on an allowlisted token
24
- // • token (calls[0].target) must be in ALLOWED_TOKENS (cap > 0)
25
- // • spender must be in ALLOWED_SPENDERS
26
- // • 0 < amount ≤ maxApprovalAmount[token]
27
- // calls[1] = <consuming call> on an allowlisted (target, selector)
28
- // • target must be in ALLOWED_CONSUMING_TARGETS
29
- // • selector must be in ALLOWED_CONSUMING_SELECTORS
30
- // • if REQUIRE_AMOUNT_MATCH: c1.data[4:36] (the first ABI-encoded argument of the
31
- // consuming call) must equal the approve amount. Only enable this flag if the
32
- // consuming function's FIRST parameter is the amount — if the amount appears in a
33
- // later position the check reads the wrong slot and is silently bypassed.
34
- // calls[2] = approve(spender,0) selector 0x095ea7b3 — mandatory reset
35
- // • same token and same spender as calls[0]
36
- // • amount must be exactly 0
37
- // Any deviation (wrong length, wrong token/spender/target/selector, over-cap, non-zero reset,
38
- // reordering, non-zero value, malformed calldata) ⇒ false or revert.
39
- //
40
- // ⚠ FUND DESTINATION NOT BOUNDED: unlike every single-call permission in this repo, this contract
41
- // makes NO assertion that the consuming call routes funds to ctx.account. The recipient, receiver,
42
- // or beneficiary inside calls[1] is unchecked. An agent bug that names an external address in the
43
- // consuming call's arguments will not be blocked here. To close this gap, either:
44
- // a) use a consuming target/selector that structurally routes output to msg.sender (the SMA), or
45
- // b) pair this contract with a protocol-specific single-call permission that bounds the recipient.
46
- //
47
- // AGENT-ENFORCED / NOT BOUNDED HERE (off-chain — can change without redeploying this contract):
48
- // • The full calldata of the consuming call beyond its leading uint256 (recipients, paths, etc.)
49
- // — bound those with a protocol-specific permission if the consuming call needs tighter limits.
50
- //
51
- // VERIFY BEFORE USE:
52
- // • approve selector 0x095ea7b3 is the ERC-20 standard. ALLOWED_CONSUMING_SELECTORS must match
53
- // the real selector(s) of the protocol call you intend to bracket.
54
- // • Per-token caps are in each token's base units (decimals differ — USDC 6, WETH 18).
55
- // • Requires a SELECTIVE kernel (dispatchBatch exists). Conjunctive kernels have no batch path.
56
- // • `sailor mandate simulate` probes single evaluate() only; it cannot probe evaluateBatch().
57
- // Verify this contract by calling evaluateBatch(calls, ctx) directly (eth_call) with PASS/FAIL
58
- // batches before authorizing on-chain.
59
- // ─────────────────────────────────────────────────────────────────────────────
60
-
61
- import {IPermission, Context} from "@sail/interfaces/IPermission.sol";
62
- import {IBatchPermission, Call, BatchContext} from "@sail/interfaces/IBatchPermission.sol";
63
-
64
- contract BoundedApproveAndCallBatch is IPermission, IBatchPermission {
65
- bytes32 private constant DISCRIMINATOR = keccak256("BoundedApproveAndCallBatch");
66
-
67
- bytes4 private constant SEL_APPROVE = 0x095ea7b3; // approve(address,uint256)
68
- uint256 private constant APPROVE_CALLDATA_LEN = 68; // 4 + 32 (spender) + 32 (amount)
69
- uint256 private constant CONSUMING_MIN_LEN = 36; // 4 + 32 (leading uint256 arg)
70
-
71
- mapping(address => uint256) public maxApprovalAmount; // token => cap (0 = not allowed)
72
- mapping(address => bool) public isSpender; // spender allowlist
73
- mapping(address => bool) public isConsumingTarget; // consuming-call target allowlist
74
- mapping(bytes4 => bool) public isConsumingSelector; // consuming-call selector allowlist
75
- bool public immutable REQUIRE_AMOUNT_MATCH;
76
-
77
- /// @param tokens Allowlisted ERC-20 tokens that may be approved
78
- /// @param maxApprovalAmounts Per-token approve cap, index-parallel with `tokens` (each > 0)
79
- /// @param spenders Allowlisted spenders that may receive the allowance
80
- /// @param consumingTargets Allowlisted targets for the middle (consuming) call
81
- /// @param consumingSelectors Allowlisted selectors for the middle (consuming) call
82
- /// @param requireAmountMatch If true, the consuming call's leading uint256 must equal the approve amount
83
- constructor(
84
- address[] memory tokens,
85
- uint256[] memory maxApprovalAmounts,
86
- address[] memory spenders,
87
- address[] memory consumingTargets,
88
- bytes4[] memory consumingSelectors,
89
- bool requireAmountMatch
90
- ) {
91
- require(tokens.length == maxApprovalAmounts.length, "tokens/amounts length mismatch");
92
- require(tokens.length > 0 && spenders.length > 0, "empty token/spender allowlist");
93
- require(consumingTargets.length > 0 && consumingSelectors.length > 0, "empty consuming allowlist");
94
-
95
- for (uint256 i = 0; i < tokens.length; i++) {
96
- require(tokens[i] != address(0) && maxApprovalAmounts[i] > 0, "bad token/cap");
97
- maxApprovalAmount[tokens[i]] = maxApprovalAmounts[i];
98
- }
99
- for (uint256 i = 0; i < spenders.length; i++) {
100
- require(spenders[i] != address(0), "zero spender");
101
- isSpender[spenders[i]] = true;
102
- }
103
- for (uint256 i = 0; i < consumingTargets.length; i++) {
104
- require(consumingTargets[i] != address(0), "zero target");
105
- isConsumingTarget[consumingTargets[i]] = true;
106
- }
107
- for (uint256 i = 0; i < consumingSelectors.length; i++) {
108
- require(consumingSelectors[i] != bytes4(0), "zero selector");
109
- isConsumingSelector[consumingSelectors[i]] = true;
110
- }
111
- REQUIRE_AMOUNT_MATCH = requireAmountMatch;
112
- }
113
-
114
- // ── IBatchPermission ─────────────────────────────────────────────────────
115
-
116
- /// @inheritdoc IBatchPermission
117
- function isBatchPermission() external pure returns (bool) { return true; }
118
-
119
- /// @inheritdoc IBatchPermission
120
- function evaluateBatch(Call[] calldata calls, BatchContext calldata ctx) external view returns (bool) {
121
- ctx; // batch context unused — bounds depend only on the call sequence, not the SMA
122
- if (calls.length != 3) return false;
123
-
124
- // ── calls[0]: approve(spender, amount) on an allowlisted token ───────
125
- Call calldata c0 = calls[0];
126
- if (c0.value != 0) return false;
127
- if (c0.data.length != APPROVE_CALLDATA_LEN) return false;
128
- if (bytes4(c0.data[0:4]) != SEL_APPROVE) return false;
129
-
130
- address token = c0.target;
131
- uint256 cap = maxApprovalAmount[token];
132
- if (cap == 0) return false; // token not allowlisted
133
-
134
- (address spender, uint256 approveAmount) = _decodeApprove(c0.data);
135
- if (!isSpender[spender]) return false;
136
- if (approveAmount == 0) return false;
137
- if (approveAmount > cap) return false;
138
-
139
- // ── calls[1]: consuming call on an allowlisted (target, selector) ────
140
- Call calldata c1 = calls[1];
141
- if (c1.value != 0) return false;
142
- if (!isConsumingTarget[c1.target]) return false;
143
- if (c1.data.length < CONSUMING_MIN_LEN) return false;
144
- if (!isConsumingSelector[bytes4(c1.data[0:4])]) return false;
145
- if (REQUIRE_AMOUNT_MATCH) {
146
- if (uint256(bytes32(c1.data[4:36])) != approveAmount) return false;
147
- }
148
-
149
- // ── calls[2]: approve(spender, 0) — mandatory reset of same token+spender ─
150
- Call calldata c2 = calls[2];
151
- if (c2.value != 0) return false;
152
- if (c2.target != token) return false;
153
- if (c2.data.length != APPROVE_CALLDATA_LEN) return false;
154
- if (bytes4(c2.data[0:4]) != SEL_APPROVE) return false;
155
-
156
- (address resetSpender, uint256 resetAmount) = _decodeApprove(c2.data);
157
- if (resetSpender != spender) return false;
158
- if (resetAmount != 0) return false;
159
-
160
- return true;
161
- }
162
-
163
- // ── IPermission (batch-only: single dispatch is never authorised) ────────
164
-
165
- /// @inheritdoc IPermission
166
- function evaluate(bytes calldata, Context calldata) external pure returns (bool) {
167
- return false;
168
- }
169
-
170
- /// @inheritdoc IPermission
171
- function discriminator() external pure returns (bytes32) { return DISCRIMINATOR; }
172
-
173
- // ── internal calldata decoding (bounds-checked by callers above) ─────────
174
-
175
- function _decodeApprove(bytes calldata data) internal pure returns (address spender, uint256 amount) {
176
- spender = address(uint160(uint256(bytes32(data[4:36]))));
177
- amount = uint256(bytes32(data[36:68]));
178
- }
179
- }
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // Protocol : Sail batch dispatch (IBatchPermission) — protocol-agnostic
6
+ // Version : Single-tenant, constructor-configured (mirrors the multi-tenant
7
+ // SharedApproveAndCallBatchPermission shape from SailProtocol)
8
+ // Chain : Any EVM with a SELECTIVE kernel (Base, Arbitrum, Unichain, Base Sepolia)
9
+ //
10
+ // WHAT THIS TEACHES — the atomic approve → call → reset pattern.
11
+ // A bare ERC-20 approve is dangerous: it leaves a standing allowance an attacker can drain.
12
+ // The safe shape is to approve, consume the allowance in the SAME transaction, then reset it
13
+ // to zero — all-or-nothing. No single-call IPermission can enforce "the reset must be the
14
+ // final call", because per-call evaluation never sees the other calls. A batch permission
15
+ // sees the WHOLE sequence at once, so it can.
16
+ //
17
+ // Gated via the kernel's dispatchBatch (NOT dispatch). This contract's single-call evaluate()
18
+ // deliberately returns false — it is batch-only. The manager names this permission in the
19
+ // batch signature; only it is consulted (selective model).
20
+ //
21
+ // ENFORCES ON-CHAIN (kernel calls evaluateBatch() via staticcall; false/revert ⇒ batch blocked):
22
+ // The batch MUST be exactly these 3 calls, in this order, each with value == 0:
23
+ // calls[0] = approve(spender,amount) selector 0x095ea7b3 on an allowlisted token
24
+ // • token (calls[0].target) must be in ALLOWED_TOKENS (cap > 0)
25
+ // • spender must be in ALLOWED_SPENDERS
26
+ // • 0 < amount ≤ maxApprovalAmount[token]
27
+ // calls[1] = <consuming call> on an allowlisted (target, selector)
28
+ // • target must be in ALLOWED_CONSUMING_TARGETS
29
+ // • selector must be in ALLOWED_CONSUMING_SELECTORS
30
+ // • if REQUIRE_AMOUNT_MATCH: c1.data[4:36] (the first ABI-encoded argument of the
31
+ // consuming call) must equal the approve amount. Only enable this flag if the
32
+ // consuming function's FIRST parameter is the amount — if the amount appears in a
33
+ // later position the check reads the wrong slot and is silently bypassed.
34
+ // calls[2] = approve(spender,0) selector 0x095ea7b3 — mandatory reset
35
+ // • same token and same spender as calls[0]
36
+ // • amount must be exactly 0
37
+ // Any deviation (wrong length, wrong token/spender/target/selector, over-cap, non-zero reset,
38
+ // reordering, non-zero value, malformed calldata) ⇒ false or revert.
39
+ //
40
+ // ⚠ FUND DESTINATION NOT BOUNDED: unlike every single-call permission in this repo, this contract
41
+ // makes NO assertion that the consuming call routes funds to ctx.account. The recipient, receiver,
42
+ // or beneficiary inside calls[1] is unchecked. An agent bug that names an external address in the
43
+ // consuming call's arguments will not be blocked here. To close this gap, either:
44
+ // a) use a consuming target/selector that structurally routes output to msg.sender (the SMA), or
45
+ // b) pair this contract with a protocol-specific single-call permission that bounds the recipient.
46
+ //
47
+ // AGENT-ENFORCED / NOT BOUNDED HERE (off-chain — can change without redeploying this contract):
48
+ // • The full calldata of the consuming call beyond its leading uint256 (recipients, paths, etc.)
49
+ // — bound those with a protocol-specific permission if the consuming call needs tighter limits.
50
+ //
51
+ // VERIFY BEFORE USE:
52
+ // • approve selector 0x095ea7b3 is the ERC-20 standard. ALLOWED_CONSUMING_SELECTORS must match
53
+ // the real selector(s) of the protocol call you intend to bracket.
54
+ // • Per-token caps are in each token's base units (decimals differ — USDC 6, WETH 18).
55
+ // • Requires a SELECTIVE kernel (dispatchBatch exists). Conjunctive kernels have no batch path.
56
+ // • `sailor mandate simulate` probes single evaluate() only; it cannot probe evaluateBatch().
57
+ // Verify this contract by calling evaluateBatch(calls, ctx) directly (eth_call) with PASS/FAIL
58
+ // batches before authorizing on-chain.
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+
61
+ import {IPermission, Context} from "@sail/interfaces/IPermission.sol";
62
+ import {IBatchPermission, Call, BatchContext} from "@sail/interfaces/IBatchPermission.sol";
63
+
64
+ contract BoundedApproveAndCallBatch is IPermission, IBatchPermission {
65
+ bytes32 private constant DISCRIMINATOR = keccak256("BoundedApproveAndCallBatch");
66
+
67
+ bytes4 private constant SEL_APPROVE = 0x095ea7b3; // approve(address,uint256)
68
+ uint256 private constant APPROVE_CALLDATA_LEN = 68; // 4 + 32 (spender) + 32 (amount)
69
+ uint256 private constant CONSUMING_MIN_LEN = 36; // 4 + 32 (leading uint256 arg)
70
+
71
+ mapping(address => uint256) public maxApprovalAmount; // token => cap (0 = not allowed)
72
+ mapping(address => bool) public isSpender; // spender allowlist
73
+ mapping(address => bool) public isConsumingTarget; // consuming-call target allowlist
74
+ mapping(bytes4 => bool) public isConsumingSelector; // consuming-call selector allowlist
75
+ bool public immutable REQUIRE_AMOUNT_MATCH;
76
+
77
+ /// @param tokens Allowlisted ERC-20 tokens that may be approved
78
+ /// @param maxApprovalAmounts Per-token approve cap, index-parallel with `tokens` (each > 0)
79
+ /// @param spenders Allowlisted spenders that may receive the allowance
80
+ /// @param consumingTargets Allowlisted targets for the middle (consuming) call
81
+ /// @param consumingSelectors Allowlisted selectors for the middle (consuming) call
82
+ /// @param requireAmountMatch If true, the consuming call's leading uint256 must equal the approve amount
83
+ constructor(
84
+ address[] memory tokens,
85
+ uint256[] memory maxApprovalAmounts,
86
+ address[] memory spenders,
87
+ address[] memory consumingTargets,
88
+ bytes4[] memory consumingSelectors,
89
+ bool requireAmountMatch
90
+ ) {
91
+ require(tokens.length == maxApprovalAmounts.length, "tokens/amounts length mismatch");
92
+ require(tokens.length > 0 && spenders.length > 0, "empty token/spender allowlist");
93
+ require(consumingTargets.length > 0 && consumingSelectors.length > 0, "empty consuming allowlist");
94
+
95
+ for (uint256 i = 0; i < tokens.length; i++) {
96
+ require(tokens[i] != address(0) && maxApprovalAmounts[i] > 0, "bad token/cap");
97
+ maxApprovalAmount[tokens[i]] = maxApprovalAmounts[i];
98
+ }
99
+ for (uint256 i = 0; i < spenders.length; i++) {
100
+ require(spenders[i] != address(0), "zero spender");
101
+ isSpender[spenders[i]] = true;
102
+ }
103
+ for (uint256 i = 0; i < consumingTargets.length; i++) {
104
+ require(consumingTargets[i] != address(0), "zero target");
105
+ isConsumingTarget[consumingTargets[i]] = true;
106
+ }
107
+ for (uint256 i = 0; i < consumingSelectors.length; i++) {
108
+ require(consumingSelectors[i] != bytes4(0), "zero selector");
109
+ isConsumingSelector[consumingSelectors[i]] = true;
110
+ }
111
+ REQUIRE_AMOUNT_MATCH = requireAmountMatch;
112
+ }
113
+
114
+ // ── IBatchPermission ─────────────────────────────────────────────────────
115
+
116
+ /// @inheritdoc IBatchPermission
117
+ function isBatchPermission() external pure returns (bool) { return true; }
118
+
119
+ /// @inheritdoc IBatchPermission
120
+ function evaluateBatch(Call[] calldata calls, BatchContext calldata ctx) external view returns (bool) {
121
+ ctx; // batch context unused — bounds depend only on the call sequence, not the SMA
122
+ if (calls.length != 3) return false;
123
+
124
+ // ── calls[0]: approve(spender, amount) on an allowlisted token ───────
125
+ Call calldata c0 = calls[0];
126
+ if (c0.value != 0) return false;
127
+ if (c0.data.length != APPROVE_CALLDATA_LEN) return false;
128
+ if (bytes4(c0.data[0:4]) != SEL_APPROVE) return false;
129
+
130
+ address token = c0.target;
131
+ uint256 cap = maxApprovalAmount[token];
132
+ if (cap == 0) return false; // token not allowlisted
133
+
134
+ (address spender, uint256 approveAmount) = _decodeApprove(c0.data);
135
+ if (!isSpender[spender]) return false;
136
+ if (approveAmount == 0) return false;
137
+ if (approveAmount > cap) return false;
138
+
139
+ // ── calls[1]: consuming call on an allowlisted (target, selector) ────
140
+ Call calldata c1 = calls[1];
141
+ if (c1.value != 0) return false;
142
+ if (!isConsumingTarget[c1.target]) return false;
143
+ if (c1.data.length < CONSUMING_MIN_LEN) return false;
144
+ if (!isConsumingSelector[bytes4(c1.data[0:4])]) return false;
145
+ if (REQUIRE_AMOUNT_MATCH) {
146
+ if (uint256(bytes32(c1.data[4:36])) != approveAmount) return false;
147
+ }
148
+
149
+ // ── calls[2]: approve(spender, 0) — mandatory reset of same token+spender ─
150
+ Call calldata c2 = calls[2];
151
+ if (c2.value != 0) return false;
152
+ if (c2.target != token) return false;
153
+ if (c2.data.length != APPROVE_CALLDATA_LEN) return false;
154
+ if (bytes4(c2.data[0:4]) != SEL_APPROVE) return false;
155
+
156
+ (address resetSpender, uint256 resetAmount) = _decodeApprove(c2.data);
157
+ if (resetSpender != spender) return false;
158
+ if (resetAmount != 0) return false;
159
+
160
+ return true;
161
+ }
162
+
163
+ // ── IPermission (batch-only: single dispatch is never authorised) ────────
164
+
165
+ /// @inheritdoc IPermission
166
+ function evaluate(bytes calldata, Context calldata) external pure returns (bool) {
167
+ return false;
168
+ }
169
+
170
+ /// @inheritdoc IPermission
171
+ function discriminator() external pure returns (bytes32) { return DISCRIMINATOR; }
172
+
173
+ // ── internal calldata decoding (bounds-checked by callers above) ─────────
174
+
175
+ function _decodeApprove(bytes calldata data) internal pure returns (address spender, uint256 amount) {
176
+ spender = address(uint160(uint256(bytes32(data[4:36]))));
177
+ amount = uint256(bytes32(data[36:68]));
178
+ }
179
+ }