@dev.sail.money/sailor 0.1.0-local → 1.0.0-38
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/AGENTS.md +139 -140
- package/LICENSE +21 -21
- package/README.md +428 -430
- package/docs/PERMISSION_MODEL.md +93 -93
- package/examples/permissions/BoundedApproveAndCallBatch.sol +179 -179
- package/examples/permissions/BoundedBet_Limitless_Base.sol +97 -97
- package/examples/permissions/BoundedBorrow_AaveV3_Arbitrum.sol +94 -94
- package/examples/permissions/BoundedPerp_GMXv2_Arbitrum.sol +154 -154
- package/examples/permissions/BoundedStake_Venice_Base.sol +85 -85
- package/examples/permissions/BoundedSupply_AaveV3_Arbitrum.sol +82 -82
- package/examples/permissions/BoundedSwap_UniswapV3_Base.sol +116 -116
- package/examples/permissions/BoundedSwap_UniswapV4_Unichain.sol +150 -150
- package/examples/permissions/BoundedTransfer_ERC20_Ethereum.sol +73 -73
- package/examples/permissions/BoundedVault_ERC4626_Base.sol +97 -97
- package/examples/permissions/README.md +79 -79
- package/examples/permissions/SailCalldata.sol +118 -118
- package/examples/permissions/foundry.toml +10 -10
- package/examples/permissions/interfaces/IBatchPermission.sol +38 -38
- package/examples/permissions/interfaces/IPermission.sol +18 -18
- package/package.json +44 -45
- package/packages/cli/README.md +34 -34
- package/packages/cli/dist/index.cjs +734 -705
- package/packages/cli/dist/server.cjs +627 -538
- package/packages/sdk/README.md +65 -65
- package/packages/sdk/dist/intelligence.d.ts +1 -1
- package/packages/sdk/dist/intelligence.js +1 -1
- package/packages/sdk/package.json +80 -80
- package/packages/ui/dist/assets/{add-BxpXfVWe.js → add-Dl1etsL9.js} +1 -1
- package/packages/ui/dist/assets/{all-wallets-BKTn_sWK.js → all-wallets-C0eHLOGG.js} +1 -1
- package/packages/ui/dist/assets/{app-store-CfuKbwxR.js → app-store-B-VMDEZ3.js} +1 -1
- package/packages/ui/dist/assets/{apple-BKSBbNYg.js → apple-DkDXzKns.js} +1 -1
- package/packages/ui/dist/assets/{arrow-bottom-D4bG6gZi.js → arrow-bottom-DtPzuS76.js} +1 -1
- package/packages/ui/dist/assets/{arrow-bottom-circle-BNTs1p0T.js → arrow-bottom-circle-D7odSAO8.js} +1 -1
- package/packages/ui/dist/assets/{arrow-left-2uee3vYv.js → arrow-left-zJV9tpx0.js} +1 -1
- package/packages/ui/dist/assets/{arrow-right-BktjMV6h.js → arrow-right-BOREfe7o.js} +1 -1
- package/packages/ui/dist/assets/{arrow-top-Izu28fX4.js → arrow-top-CipQc3Af.js} +1 -1
- package/packages/ui/dist/assets/{bank-USBaAyFM.js → bank-C5s7eoV5.js} +1 -1
- package/packages/ui/dist/assets/{basic-C_9KjTEH.js → basic-D2es4Vq8.js} +1 -1
- package/packages/ui/dist/assets/{browser-DAEMAKV7.js → browser-DITQWDC9.js} +1 -1
- package/packages/ui/dist/assets/{card-DT8yDkKN.js → card-C3DDkaYK.js} +1 -1
- package/packages/ui/dist/assets/{ccip-CkqfGSxX.js → ccip-UBXL3JiN.js} +1 -1
- package/packages/ui/dist/assets/{checkmark-bold-D2gjOQo2.js → checkmark-bold-D8yW0_K_.js} +1 -1
- package/packages/ui/dist/assets/{checkmark-CsgdEXFj.js → checkmark-ngef3MAl.js} +1 -1
- package/packages/ui/dist/assets/{chevron-bottom-tprFynYV.js → chevron-bottom-C56BipDR.js} +1 -1
- package/packages/ui/dist/assets/{chevron-left-D2Zj1gNB.js → chevron-left-BmIPtPl_.js} +1 -1
- package/packages/ui/dist/assets/{chevron-right-D1rRuAVe.js → chevron-right-BnySHQ8h.js} +1 -1
- package/packages/ui/dist/assets/{chevron-top-24dL1mbL.js → chevron-top-BDGZnNW3.js} +1 -1
- package/packages/ui/dist/assets/{chrome-store-Vy-5niYX.js → chrome-store-BYIqJZVF.js} +1 -1
- package/packages/ui/dist/assets/{clock-qBjLnVdJ.js → clock-Bl4mUHAM.js} +1 -1
- package/packages/ui/dist/assets/{close-DARDwgcu.js → close-B9rhEX6U.js} +1 -1
- package/packages/ui/dist/assets/{coinPlaceholder-BvpIbPlD.js → coinPlaceholder-1cO0FQsl.js} +1 -1
- package/packages/ui/dist/assets/{compass-BMTO0ayt.js → compass-7i-VuXu2.js} +1 -1
- package/packages/ui/dist/assets/{copy-PaXeRHza.js → copy-OqqXix2J.js} +1 -1
- package/packages/ui/dist/assets/{core-BFnStQd-.js → core-tX9kIIDJ.js} +3 -3
- package/packages/ui/dist/assets/cursor-BoyeQ9fN.js +3 -0
- package/packages/ui/dist/assets/{cursor-transparent-BEMdi-8q.js → cursor-transparent-5aoRH67u.js} +1 -1
- package/packages/ui/dist/assets/{desktop-CfuLLThw.js → desktop-BaPXK9R6.js} +1 -1
- package/packages/ui/dist/assets/{disconnect-DhwgJMiR.js → disconnect-LlK5K1CF.js} +1 -1
- package/packages/ui/dist/assets/{discord-po8qoN1s.js → discord-BdcQNWY_.js} +1 -1
- package/packages/ui/dist/assets/{etherscan-BEsz0_yx.js → etherscan-Bb-WxpO1.js} +1 -1
- package/packages/ui/dist/assets/{events-Bz33Unzu.js → events-DjdZr6no.js} +1 -1
- package/packages/ui/dist/assets/{exclamation-triangle-7CjTAGOQ.js → exclamation-triangle-COx4VtPV.js} +1 -1
- package/packages/ui/dist/assets/{extension-CmxjEWEt.js → extension-DF63DTWO.js} +1 -1
- package/packages/ui/dist/assets/{external-link-CmQ--bNS.js → external-link-DyghCkQu.js} +1 -1
- package/packages/ui/dist/assets/{facebook-CIBn9b65.js → facebook-Dcg4bZMR.js} +1 -1
- package/packages/ui/dist/assets/{fallback-DATyrQlb.js → fallback-DJIr_fH3.js} +1 -1
- package/packages/ui/dist/assets/{farcaster-OJ3Jasxg.js → farcaster-BkmV5HjO.js} +1 -1
- package/packages/ui/dist/assets/{filters-D4x09zeL.js → filters-DLHj1T_P.js} +1 -1
- package/packages/ui/dist/assets/{github-ZlIuMArp.js → github-BFDCgKrF.js} +1 -1
- package/packages/ui/dist/assets/{google-Gwg85sfv.js → google-C08SpmIy.js} +1 -1
- package/packages/ui/dist/assets/{help-circle-D1uOWYcX.js → help-circle-DU1IFmWp.js} +1 -1
- package/packages/ui/dist/assets/{id-C0-5UdYk.js → id-DtDRGf3L.js} +1 -1
- package/packages/ui/dist/assets/{image-D_DUsv8-.js → image-CgCXJEjT.js} +1 -1
- package/packages/ui/dist/assets/{index-izd7vu_r.js → index-8chM4S5Y.js} +1 -1
- package/packages/ui/dist/assets/{index-CrYzBWfD.js → index-B5sCtNuq.js} +1 -1
- package/packages/ui/dist/assets/{index-DdbJhIdl.js → index-BBfBEazf.js} +3 -3
- package/packages/ui/dist/assets/{index-BCzex_R6.js → index-BhXPwltt.js} +1 -1
- package/packages/ui/dist/assets/index-Cm05Py20.css +1 -0
- package/packages/ui/dist/assets/{index-DiojfeVM.js → index-D37bD6Yt.js} +1 -1
- package/packages/ui/dist/assets/index-DZfBh-cg.js +1775 -0
- package/packages/ui/dist/assets/{index.es-DdkHhQAj.js → index.es-BEcNQEn-.js} +4 -4
- package/packages/ui/dist/assets/{info-CiRd_kEG.js → info-ClsdYA4P.js} +1 -1
- package/packages/ui/dist/assets/{info-circle-ypxjqarK.js → info-circle-DJmn4Bsv.js} +1 -1
- package/packages/ui/dist/assets/{lightbulb-B-pxLxd8.js → lightbulb-CXSftjXS.js} +1 -1
- package/packages/ui/dist/assets/{mail-BYmicuVZ.js → mail-cdYKOl9P.js} +1 -1
- package/packages/ui/dist/assets/{metamask-sdk-Ccl6DG7Q.js → metamask-sdk-Co3aIEln.js} +1 -1
- package/packages/ui/dist/assets/{mobile-CtP5PqVT.js → mobile-DEHYlk8L.js} +1 -1
- package/packages/ui/dist/assets/{more-6C2733we.js → more-Cq_fo8pI.js} +1 -1
- package/packages/ui/dist/assets/{network-placeholder-CdhxMzqd.js → network-placeholder-_dLCK4xB.js} +1 -1
- package/packages/ui/dist/assets/{nftPlaceholder-DVmTWEAY.js → nftPlaceholder-Dz4HKEr4.js} +1 -1
- package/packages/ui/dist/assets/{off-DNYLughs.js → off-B1k1lhkr.js} +1 -1
- package/packages/ui/dist/assets/{parseSignature-Dq2B5Bu3.js → parseSignature-Cr0ptV2X.js} +1 -1
- package/packages/ui/dist/assets/{play-store-D7Qut5ta.js → play-store-lYqe4eeL.js} +1 -1
- package/packages/ui/dist/assets/{plus-kqMyjt3q.js → plus-QMgh1krr.js} +1 -1
- package/packages/ui/dist/assets/{qr-code-DiUCWRbz.js → qr-code-ClVHbZWN.js} +1 -1
- package/packages/ui/dist/assets/{recycle-horizontal-Boe3XiS-.js → recycle-horizontal-CxGYnWid.js} +1 -1
- package/packages/ui/dist/assets/{refresh-CrBgBQYO.js → refresh-CPysMza_.js} +1 -1
- package/packages/ui/dist/assets/{reown-logo-CFZCCHSx.js → reown-logo-OoL_zJd0.js} +1 -1
- package/packages/ui/dist/assets/{search-ChTDrghU.js → search-2GaRbf1I.js} +1 -1
- package/packages/ui/dist/assets/{secp256k1-DAV5Q_FR.js → secp256k1-BrB8qSSy.js} +1 -1
- package/packages/ui/dist/assets/{send-DLFbBFe1.js → send-CC2UuIfD.js} +1 -1
- package/packages/ui/dist/assets/{swapHorizontal-BEs3emfG.js → swapHorizontal-BRqYwsqT.js} +1 -1
- package/packages/ui/dist/assets/{swapHorizontalBold-CC-Hfa7W.js → swapHorizontalBold-Bj0GSRq9.js} +1 -1
- package/packages/ui/dist/assets/{swapHorizontalMedium-BmR0H8DC.js → swapHorizontalMedium-oLOjpU2A.js} +1 -1
- package/packages/ui/dist/assets/{swapHorizontalRoundedBold-BdP5NGIH.js → swapHorizontalRoundedBold-TJ652QXb.js} +1 -1
- package/packages/ui/dist/assets/{swapVertical-CPrGEJPY.js → swapVertical-e0NLyV3x.js} +1 -1
- package/packages/ui/dist/assets/{telegram-CxNoZ80Q.js → telegram-WhJHVeoU.js} +1 -1
- package/packages/ui/dist/assets/{three-dots-BRa6SBpL.js → three-dots-BlBAOyW-.js} +1 -1
- package/packages/ui/dist/assets/{twitch-BC338bG5.js → twitch-BH7vWmPc.js} +1 -1
- package/packages/ui/dist/assets/{twitterIcon-BGZmt2i9.js → twitterIcon-As0Nkanp.js} +1 -1
- package/packages/ui/dist/assets/{verify-CEstW0zw.js → verify-BEJ0QuLl.js} +1 -1
- package/packages/ui/dist/assets/{verify-filled-OkZb0weU.js → verify-filled-B8Ww2N7z.js} +1 -1
- package/packages/ui/dist/assets/{w3m-modal-pS09ECwE.js → w3m-modal-5rOSZgOR.js} +1 -1
- package/packages/ui/dist/assets/{wallet-BXVKCgC9.js → wallet-CAfC3aml.js} +1 -1
- package/packages/ui/dist/assets/{wallet-placeholder-C_kNhB1c.js → wallet-placeholder-4RZI464Z.js} +1 -1
- package/packages/ui/dist/assets/{walletconnect-CRKIuUHH.js → walletconnect-BiltKqAe.js} +1 -1
- package/packages/ui/dist/assets/{warning-circle-DB2NnwlJ.js → warning-circle-CI4jqpHo.js} +1 -1
- package/packages/ui/dist/assets/{x-DT4RmwL5.js → x-FBttjBWO.js} +1 -1
- package/packages/ui/dist/index.html +14 -14
- package/scripts/check-docs.mjs +262 -262
- package/scripts/check-init.mjs +108 -108
- package/templates/custom-mandate/.sail/contracts/interfaces/IPermission.sol +18 -18
- package/templates/custom-mandate/README.md +116 -116
- package/templates/custom-mandate/foundry.toml +8 -8
- package/templates/custom-mandate/mandates/BoundedCallPermission.sol +41 -41
- package/templates/custom-mandate/mandates/README.md +16 -16
- package/templates/custom-mandate/mandates/SailCalldata.sol +118 -118
- package/templates/default/.cursor/rules +25 -25
- package/templates/default/.env.example +20 -20
- package/templates/default/.github/workflows/agent-tick.yml +33 -33
- package/templates/default/.sail/README.md +13 -13
- package/templates/default/.sail/config.json +10 -10
- package/templates/default/AGENTS.md +171 -171
- package/templates/default/CLAUDE.md +2 -2
- package/templates/default/README.md +16 -16
- package/templates/default/_gitignore +13 -13
- package/templates/default/docs/PERMISSION_MODEL.md +93 -93
- package/templates/default/examples/dca/README.md +16 -16
- package/templates/default/examples/dca/agent.ts +174 -174
- package/templates/default/examples/dca/mandate.ts +45 -45
- package/templates/default/package.json +17 -17
- package/templates/default/src/agent.ts +37 -37
- package/templates/default/src/config.ts +24 -24
- package/templates/default/src/mandate.ts +22 -22
- package/templates/default/tsconfig.json +17 -17
- package/templates/default/ui/README.md +3 -3
- package/templates/lifi-permissions/LifiBoundedApprovePermissionCloneable.sol +84 -84
- package/templates/lifi-permissions/LifiDiamondSwapPermissionCloneable.sol +97 -97
- package/templates/lifi-permissions/README.md +53 -53
- package/packages/ui/dist/assets/cursor-BDvw-B17.js +0 -3
- package/packages/ui/dist/assets/index-BUhrHLpY.js +0 -1775
- package/packages/ui/dist/assets/index-Cq02kQmy.css +0 -1
- package/scripts/postinstall.js +0 -81
package/docs/PERMISSION_MODEL.md
CHANGED
|
@@ -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
|
+
}
|