@agenticprimitives/contracts 0.1.0-alpha.2

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 (84) hide show
  1. package/AUDIT.md +67 -0
  2. package/CLAUDE.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +45 -0
  5. package/deployments-anvil.json +1 -0
  6. package/deployments-base-sepolia.json +1 -0
  7. package/dist/abi/AgentNameAttributeResolver.json +798 -0
  8. package/dist/abi/AgentNamePredicates.json +1 -0
  9. package/dist/abi/AgentNameRegistry.json +826 -0
  10. package/dist/abi/AgentNameUniversalResolver.json +222 -0
  11. package/dist/abi/AgentProfilePredicates.json +1 -0
  12. package/dist/abi/AgentProfileResolver.json +1044 -0
  13. package/dist/abi/AgentRelationship.json +583 -0
  14. package/dist/abi/AgentRelationshipPredicates.json +1 -0
  15. package/dist/abi/AgenticGovernance.json +259 -0
  16. package/dist/abi/AllowedMethodsEnforcer.json +108 -0
  17. package/dist/abi/AllowedTargetsEnforcer.json +103 -0
  18. package/dist/abi/ApprovedHashRegistry.json +114 -0
  19. package/dist/abi/AttributeStorage.json +557 -0
  20. package/dist/abi/CaveatEnforcerBase.json +130 -0
  21. package/dist/abi/GovernanceManaged.json +43 -0
  22. package/dist/abi/IAttributeReader.json +98 -0
  23. package/dist/abi/ICaveatEnforcer.json +98 -0
  24. package/dist/abi/IDelegationManager.json +211 -0
  25. package/dist/abi/IERC7579Module.json +34 -0
  26. package/dist/abi/IERC7579ModuleLifecycle.json +60 -0
  27. package/dist/abi/IGovernanceView.json +34 -0
  28. package/dist/abi/MultiSendCallOnly.json +29 -0
  29. package/dist/abi/MultiSendCallOnlyHarness.json +42 -0
  30. package/dist/abi/OntologyTermRegistry.json +397 -0
  31. package/dist/abi/P256Verifier.json +1 -0
  32. package/dist/abi/PermissionlessSubregistry.json +207 -0
  33. package/dist/abi/RelationshipTypeRegistry.json +455 -0
  34. package/dist/abi/ShapeRegistry.json +627 -0
  35. package/dist/abi/SmartAgentModuleTypes.json +1 -0
  36. package/dist/abi/TimestampEnforcer.json +108 -0
  37. package/dist/abi/ValueEnforcer.json +103 -0
  38. package/dist/abi/WebAuthnLib.json +1 -0
  39. package/dist/abi/index.d.ts +35 -0
  40. package/dist/abi/index.js +35 -0
  41. package/package.json +48 -0
  42. package/spec.md +52 -0
  43. package/src/AgentAccount.sol +1374 -0
  44. package/src/AgentAccountFactory.sol +274 -0
  45. package/src/ApprovedHashRegistry.sol +57 -0
  46. package/src/IAgentAccount.sol +138 -0
  47. package/src/SmartAgentPaymaster.sol +281 -0
  48. package/src/UniversalSignatureValidator.sol +136 -0
  49. package/src/agency/DelegationManager.sol +374 -0
  50. package/src/agency/ICaveatEnforcer.sol +62 -0
  51. package/src/agency/IDelegationManager.sol +69 -0
  52. package/src/custody/CustodyPolicy.sol +892 -0
  53. package/src/custody/IERC7579Module.sol +60 -0
  54. package/src/enforcers/AllowedMethodsEnforcer.AUDIT.md +51 -0
  55. package/src/enforcers/AllowedMethodsEnforcer.sol +48 -0
  56. package/src/enforcers/AllowedTargetsEnforcer.AUDIT.md +49 -0
  57. package/src/enforcers/AllowedTargetsEnforcer.sol +44 -0
  58. package/src/enforcers/CaveatEnforcerBase.sol +19 -0
  59. package/src/enforcers/QuorumEnforcer.AUDIT.md +71 -0
  60. package/src/enforcers/QuorumEnforcer.sol +191 -0
  61. package/src/enforcers/TimestampEnforcer.AUDIT.md +50 -0
  62. package/src/enforcers/TimestampEnforcer.sol +43 -0
  63. package/src/enforcers/ValueEnforcer.AUDIT.md +51 -0
  64. package/src/enforcers/ValueEnforcer.sol +41 -0
  65. package/src/governance/AgenticGovernance.sol +140 -0
  66. package/src/governance/GovernanceManaged.sol +75 -0
  67. package/src/governance/IGovernance.sol +15 -0
  68. package/src/identity/AgentProfilePredicates.sol +40 -0
  69. package/src/identity/AgentProfileResolver.sol +194 -0
  70. package/src/libraries/MultiSendCallOnly.sol +95 -0
  71. package/src/libraries/P256Verifier.sol +47 -0
  72. package/src/libraries/SignatureSlotRecovery.sol +196 -0
  73. package/src/libraries/WebAuthnLib.sol +164 -0
  74. package/src/naming/AgentNameAttributeResolver.sol +95 -0
  75. package/src/naming/AgentNamePredicates.sol +74 -0
  76. package/src/naming/AgentNameRegistry.sol +362 -0
  77. package/src/naming/AgentNameUniversalResolver.sol +210 -0
  78. package/src/naming/PermissionlessSubregistry.sol +98 -0
  79. package/src/ontology/AttributeStorage.sol +289 -0
  80. package/src/ontology/OntologyTermRegistry.sol +146 -0
  81. package/src/ontology/ShapeRegistry.sol +240 -0
  82. package/src/relationships/AgentRelationship.sol +289 -0
  83. package/src/relationships/AgentRelationshipPredicates.sol +44 -0
  84. package/src/relationships/RelationshipTypeRegistry.sol +143 -0
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ /**
5
+ * @title IERC7579Module
6
+ * @notice Minimal marker interface from ERC-7579 so external tooling can
7
+ * introspect our modules without requiring a full 7579 account port.
8
+ *
9
+ * Our AgentAccount is not a full ERC-7579 account — it keeps the
10
+ * MetaMask-DeleGator-style execution model. But our module contracts
11
+ * (validators, enforcers) can declare themselves as 7579-shaped so
12
+ * compatible wallets and explorers identify them correctly.
13
+ *
14
+ * Module type ids (from the ERC-7579 spec):
15
+ * 1 Validator — validates signatures / UserOps
16
+ * 2 Executor — executes on behalf of the account
17
+ * 3 Fallback — receives arbitrary calls
18
+ * 4 Hook — pre/post execution hooks
19
+ * 5 Policy — Kernel-style policy attachment (not in base spec)
20
+ *
21
+ * We use TYPE_VALIDATOR for PasskeyValidator and a custom
22
+ * TYPE_CAVEAT_ENFORCER (id=100, outside the 7579 reserved range) for our
23
+ * delegation caveat enforcers, so standard tooling doesn't mis-type them
24
+ * while still picking up the 7579 introspection shape.
25
+ */
26
+ interface IERC7579Module {
27
+ /**
28
+ * @notice Returns true iff this module implements the given module type.
29
+ * @dev Callers pass one of the canonical type ids listed above.
30
+ */
31
+ function isModuleType(uint256 moduleTypeId) external view returns (bool);
32
+
33
+ /// @notice Stable identifier for this module implementation.
34
+ /// Format is implementation-defined; most deployments use
35
+ /// `{vendor}-{kind}-{version}` e.g. "smart-agent-rate-limit-1".
36
+ function moduleId() external view returns (string memory);
37
+ }
38
+
39
+ /**
40
+ * @title IERC7579ModuleLifecycle
41
+ * @notice Optional extension to IERC7579Module — adds the install/uninstall
42
+ * lifecycle hooks. Phase 3 modules that participate in
43
+ * AgentAccount.installModule must implement this. The base interface
44
+ * (`IERC7579Module`) is left lifecycle-free so existing introspect-only
45
+ * modules (e.g. caveat enforcers) keep their shape unchanged.
46
+ */
47
+ interface IERC7579ModuleLifecycle is IERC7579Module {
48
+ function onInstall(bytes calldata data) external;
49
+ function onUninstall(bytes calldata data) external;
50
+ }
51
+
52
+ library SmartAgentModuleTypes {
53
+ uint256 internal constant TYPE_VALIDATOR = 1;
54
+ uint256 internal constant TYPE_EXECUTOR = 2;
55
+ uint256 internal constant TYPE_FALLBACK = 3;
56
+ uint256 internal constant TYPE_HOOK = 4;
57
+ // Smart-Agent-specific — outside the ERC-7579 reserved range to avoid
58
+ // accidental cross-classification by ecosystem tools.
59
+ uint256 internal constant TYPE_CAVEAT_ENFORCER = 100;
60
+ }
@@ -0,0 +1,51 @@
1
+ # `AllowedMethodsEnforcer` — Security & Architecture Audit
2
+
3
+ **Status:** shipped
4
+ **Last refreshed:** 2026-05-21
5
+ **Owners:** delegation package CODEOWNERS
6
+ **Registry entry:** [`docs/architecture/enforcer-registry/enforcers.json`](../../../../docs/architecture/enforcer-registry/enforcers.json) (entry: `AllowedMethodsEnforcer`)
7
+ **Live address (Base Sepolia):** `0xdE873dEa2EdC4288FEB32Ade7fDdA798983289c0`
8
+
9
+ ## 1. Charter
10
+
11
+ Constrains the redemption to a function-selector allowlist (`bytes4`). The selector is the first 4 bytes of the redemption's `data` parameter (calldata). The redeemer cannot escape — the enforcer reverts if the selector is not in the list.
12
+
13
+ Does NOT do: target-address scoping (`AllowedTargetsEnforcer`), per-argument scoping (`ArgumentRuleEnforcer`, phase 6c.6).
14
+
15
+ ## 2. Security invariants
16
+
17
+ - The allowlist is fixed in `terms` at delegation creation.
18
+ - Selectors are 4 bytes; the enforcer reads `data[0:4]` exactly. Calldata shorter than 4 bytes is rejected.
19
+ - Empty allowlist is rejected at validation time.
20
+ - The enforcer DOES NOT check that the selector corresponds to a real function on the target. A delegator allowlisting selector `0xdeadbeef` for target `T` would grant that call regardless of whether `T` implements anything at that selector — `T` would simply revert at call time. This is correct behavior; the enforcer's job is bounding, not validating.
21
+
22
+ ## 3. Threat model
23
+
24
+ | Threat | Likelihood | Impact | Mitigation | Status |
25
+ | --- | --- | --- | --- | --- |
26
+ | Selector collision (two different ABIs share a selector) | Medium | Medium | Selectors are 4-byte truncations of keccak; collisions are possible. Spec 208 doc recommends pairing with `ArgumentRuleEnforcer` for value-moving calls so the FULL signature is bound. | Documented |
27
+ | Allowlist-bypass via fallback functions | Medium | High | Selector check is on calldata, not on what the target's fallback does. Pair with `AllowedTargetsEnforcer` so the target is a known contract. | Documented |
28
+ | Gas DoS from large allowlist | Low | Low | SDK lint warns above 16 entries. | **Open: AM-1** |
29
+
30
+ ## 4. DTK / smart-agent parity
31
+
32
+ **DTK:** `AllowedMethodsEnforcer` — byte-identical shape.
33
+
34
+ **smart-agent:** `AllowedMethodsEnforcer.sol` — same.
35
+
36
+ ## 5. Test posture
37
+
38
+ Forge tests: `packages/contracts/test/Enforcers.t.sol` covers single-selector / multi / empty / short-calldata cases (4 tests).
39
+
40
+ ## 6. Open findings
41
+
42
+ | ID | Severity | Finding | Status |
43
+ | --- | --- | --- | --- |
44
+ | AM-1 | P3 | Add SDK lint warning above 16 selectors. | Open |
45
+ | AM-2 | P2 | Document the "pair with `AllowedTargetsEnforcer`" pattern as a hard recommendation; consider linting against AllowedMethodsEnforcer used alone. | Open |
46
+
47
+ ## 7. Cross-references
48
+
49
+ - [Registry entry](../../../../docs/architecture/enforcer-registry/enforcers.json)
50
+ - Companion: [`AllowedTargetsEnforcer.AUDIT.md`](./AllowedTargetsEnforcer.AUDIT.md)
51
+ - Subsumes-in-future: [`specs/208-argument-level-caveats.md`](../../../../specs/208-argument-level-caveats.md) — ArgumentRuleEnforcer's per-rule `selector` field overlaps with this enforcer's allowlist. Both stay shipped; ArgumentRule is for cases needing per-arg gates.
@@ -0,0 +1,48 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../agency/ICaveatEnforcer.sol";
5
+
6
+ /**
7
+ * @title AllowedMethodsEnforcer
8
+ * @notice Restricts delegated calls to specific function selectors.
9
+ * @dev terms = abi.encode(bytes4[] allowedSelectors)
10
+ * Follows ERC-7710 / MetaMask DeleGator beforeHook/afterHook pattern.
11
+ */
12
+ contract AllowedMethodsEnforcer is ICaveatEnforcer {
13
+ error MethodNotAllowed();
14
+ error CalldataTooShort();
15
+
16
+ function beforeHook(
17
+ bytes calldata terms,
18
+ bytes calldata,
19
+ bytes32,
20
+ address,
21
+ address,
22
+ address,
23
+ uint256,
24
+ bytes calldata callData
25
+ ) external pure override {
26
+ if (callData.length < 4) revert CalldataTooShort();
27
+
28
+ bytes4 selector = bytes4(callData[:4]);
29
+ bytes4[] memory allowed = abi.decode(terms, (bytes4[]));
30
+ for (uint256 i = 0; i < allowed.length; i++) {
31
+ if (allowed[i] == selector) return;
32
+ }
33
+ revert MethodNotAllowed();
34
+ }
35
+
36
+ function afterHook(
37
+ bytes calldata,
38
+ bytes calldata,
39
+ bytes32,
40
+ address,
41
+ address,
42
+ address,
43
+ uint256,
44
+ bytes calldata
45
+ ) external pure override {
46
+ // No post-execution check needed
47
+ }
48
+ }
@@ -0,0 +1,49 @@
1
+ # `AllowedTargetsEnforcer` — Security & Architecture Audit
2
+
3
+ **Status:** shipped
4
+ **Last refreshed:** 2026-05-21
5
+ **Owners:** delegation package CODEOWNERS
6
+ **Registry entry:** [`docs/architecture/enforcer-registry/enforcers.json`](../../../../docs/architecture/enforcer-registry/enforcers.json) (entry: `AllowedTargetsEnforcer`)
7
+ **Live address (Base Sepolia):** `0x4D00295D51962a9E81Dada0b90FeA49567863dC5`
8
+
9
+ ## 1. Charter
10
+
11
+ Constrains the redemption `target` address to an allowlist set at delegation creation. The redeemer cannot escape — the enforcer reverts if `target` is not in the list.
12
+
13
+ Does NOT do: per-method scoping (use `AllowedMethodsEnforcer` for that), per-arg scoping (phase 6c.6 ships `ArgumentRuleEnforcer` for argument-level checks).
14
+
15
+ ## 2. Security invariants
16
+
17
+ - The allowlist is encoded in `terms` at delegation creation. Redeemer cannot widen it via `args`.
18
+ - The check is an exact `==` per entry. No CREATE2 prediction, no factory pattern, no proxy unwrap — the deployed address at redeem time IS the address checked.
19
+ - Empty allowlist is rejected at validation time (would be a useless delegation).
20
+ - The allowlist is a `bytes` blob decoded as `address[]`; on-chain we walk it linearly.
21
+
22
+ ## 3. Threat model
23
+
24
+ | Threat | Likelihood | Impact | Mitigation | Status |
25
+ | --- | --- | --- | --- | --- |
26
+ | Allowlist-bypass via proxy or fallback to forwarder | Medium | High | Out-of-scope here — the redemption target is a STATIC address; if the target itself is a proxy, the delegator must understand what they're allowlisting | By design (documented) |
27
+ | Allowlist size DoS (gas exhaustion) | Low | Low | Linear scan; SDK lint warns above 32 entries; on-chain enforces no hard cap (gas naturally limits it) | **Open: AT-1** — formal cap recommendation pending |
28
+ | Address checksum mistakes in terms | Low (caught off-chain) | High (wrong allowlist) | viem auto-checksums; SDK validates each entry as a valid 20-byte address | Covered |
29
+
30
+ ## 4. DTK / smart-agent parity
31
+
32
+ **DTK:** `AllowedTargetsEnforcer` — byte-identical shape. Independent port.
33
+
34
+ **smart-agent:** `AllowedTargetsEnforcer.sol` — same.
35
+
36
+ ## 5. Test posture
37
+
38
+ Forge tests: `packages/contracts/test/Enforcers.t.sol` — single-target / multi-target / empty / mismatch cases (4 tests).
39
+
40
+ ## 6. Open findings
41
+
42
+ | ID | Severity | Finding | Status |
43
+ | --- | --- | --- | --- |
44
+ | AT-1 | P3 | Add a soft cap (32 entries) + lint warning at the SDK layer; on-chain enforces no hard cap. | Open |
45
+
46
+ ## 7. Cross-references
47
+
48
+ - [Registry entry](../../../../docs/architecture/enforcer-registry/enforcers.json)
49
+ - Companion enforcer: [`AllowedMethodsEnforcer.AUDIT.md`](./AllowedMethodsEnforcer.AUDIT.md) — typical pairing for "target T, method M only."
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../agency/ICaveatEnforcer.sol";
5
+
6
+ /**
7
+ * @title AllowedTargetsEnforcer
8
+ * @notice Restricts delegated calls to a specific set of target contracts.
9
+ * @dev terms = abi.encode(address[] allowedTargets)
10
+ * Follows ERC-7710 / MetaMask DeleGator beforeHook/afterHook pattern.
11
+ */
12
+ contract AllowedTargetsEnforcer is ICaveatEnforcer {
13
+ error TargetNotAllowed();
14
+
15
+ function beforeHook(
16
+ bytes calldata terms,
17
+ bytes calldata,
18
+ bytes32,
19
+ address,
20
+ address,
21
+ address target,
22
+ uint256,
23
+ bytes calldata
24
+ ) external pure override {
25
+ address[] memory allowed = abi.decode(terms, (address[]));
26
+ for (uint256 i = 0; i < allowed.length; i++) {
27
+ if (allowed[i] == target) return;
28
+ }
29
+ revert TargetNotAllowed();
30
+ }
31
+
32
+ function afterHook(
33
+ bytes calldata,
34
+ bytes calldata,
35
+ bytes32,
36
+ address,
37
+ address,
38
+ address,
39
+ uint256,
40
+ bytes calldata
41
+ ) external pure override {
42
+ // No post-execution check needed
43
+ }
44
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../agency/ICaveatEnforcer.sol";
5
+ import "../custody/IERC7579Module.sol";
6
+
7
+ /**
8
+ * @title CaveatEnforcerBase
9
+ * @notice Shared base for caveat enforcers that want to expose the
10
+ * ERC-7579 introspection shape. Each enforcer only has to
11
+ * override `moduleId()`.
12
+ */
13
+ abstract contract CaveatEnforcerBase is ICaveatEnforcer, IERC7579Module {
14
+ function isModuleType(uint256 moduleTypeId) external pure virtual override returns (bool) {
15
+ return moduleTypeId == SmartAgentModuleTypes.TYPE_CAVEAT_ENFORCER;
16
+ }
17
+
18
+ function moduleId() external pure virtual override returns (string memory);
19
+ }
@@ -0,0 +1,71 @@
1
+ # `QuorumEnforcer` — Security & Architecture Audit
2
+
3
+ **Status:** shipped
4
+ **Last refreshed:** 2026-05-21
5
+ **Owners:** delegation package CODEOWNERS + agent-account CODEOWNERS
6
+ **Registry entry:** [`docs/architecture/enforcer-registry/enforcers.json`](../../../../docs/architecture/enforcer-registry/enforcers.json) (entry: `QuorumEnforcer`)
7
+ **Live address (Base Sepolia):** `0x3418A5297C75989000985802B8ab01229CDDDD24`
8
+ **Spec:** [`specs/207-smart-account-threshold-policy.md`](../../../../specs/207-smart-account-threshold-policy.md)
9
+
10
+ ## 1. Charter
11
+
12
+ n-of-m Safe-compatible signature aggregation as a caveat. The delegation's `args` carries packed sorted-ascending owner signatures; the enforcer verifies `threshold` of them recover to addresses in the bound `signers` set, then permits the redemption.
13
+
14
+ This is the **agenticprimitives-only** enforcer — DTK does not have an equivalent because DTK frames multi-sig as account-shaped. We make it caveat-shaped (see [doctrine](../../../../specs/207-smart-account-threshold-policy.md) § 12) so threshold-policy threads through delegations.
15
+
16
+ ## 2. Security invariants
17
+
18
+ - Signatures are sorted-ascending by recovered address (anti-duplicate scheme; matches Safe convention).
19
+ - Recovery uses `SignatureSlotRecovery` library — shared with the `ThresholdValidator` admin path so signature shapes are uniform.
20
+ - Four signature paths supported:
21
+ - v=27/28 → ECDSA over the payload hash directly
22
+ - v>30 → eth_sign EIP-191 wrapped (v - 4 = recovery)
23
+ - v=1 → pre-approved hash via `ApprovedHashRegistry` (passkey-only or hardware-wallet signers participating in quorum without producing ECDSA)
24
+ - v=0 → ERC-1271 contract sig (signer is a smart account)
25
+ - Bound `signers` set is set at delegation creation; redeemer cannot widen.
26
+ - `threshold ≤ signers.length` (caveat-builder enforces; redundant on-chain check).
27
+ - `threshold ≤ 255` (uint8 packing).
28
+
29
+ ## 3. Threat model
30
+
31
+ | Threat | Likelihood | Impact | Mitigation | Status |
32
+ | --- | --- | --- | --- | --- |
33
+ | Duplicate signer (sig replay within quorum) | High by design | High | Sorted-ascending invariant; `signer <= prev` reverts | Covered |
34
+ | Signer not in bound set | Medium | High | `_inSet` per-signer check | Covered |
35
+ | Stale ApprovedHashRegistry approval | Medium | Medium | Per-signer + per-hash approval; spam-resistant by construction (only approvals from signers in the bound set count) | Covered |
36
+ | ERC-1271 sig from compromised smart-account signer | Low | High | Outside the enforcer's scope — the signer contract's own auth gate is the trust root | Documented |
37
+ | Signature malleability | Low | Low | ECDSA uses canonical s-value (ecrecover handles); v variants don't change recovered address | Covered |
38
+
39
+ ## 4. DTK / smart-agent parity
40
+
41
+ **DTK:** NONE. Multi-sig in DTK is account-shape (gnosis-safe-style), not caveat-shape. This is a deliberate divergence — see [dtk-alignment-audit.md § 5.4](../../../../docs/architecture/dtk-alignment-audit.md). DTK's `ExecuteByMultipleEnforcer` provides multi-redeemer support but is not equivalent.
42
+
43
+ **smart-agent:** `QuorumEnforcer.sol` — same shape; agenticprimitives ports the contract + SignatureSlotRecovery helper.
44
+
45
+ ## 5. Test posture
46
+
47
+ Forge tests: `packages/contracts/test/QuorumEnforcer.t.sol` covers:
48
+ - single-sig (threshold=1)
49
+ - multi-sig threshold=2 of 3
50
+ - unsorted sig rejection
51
+ - duplicate signer rejection
52
+ - non-member signer rejection
53
+ - v=1 approved-hash path (with ApprovedHashRegistry)
54
+ - v=0 ERC-1271 path
55
+ - 8 tests total. Counts toward the 172 workspace total.
56
+
57
+ Property tests: none yet. Phase 7 should add fuzz over signer permutations.
58
+
59
+ ## 6. Open findings
60
+
61
+ | ID | Severity | Finding | Status |
62
+ | --- | --- | --- | --- |
63
+ | QE-1 | P2 | Add property fuzz over signer permutations to catch off-by-one in sorted-asc invariant. | Open (phase 7) |
64
+ | QE-2 | P3 | SDK helper to compute the canonical payload hash for off-chain signing isn't yet documented for the v=0 ERC-1271 case (smart-account signers). | Open |
65
+
66
+ ## 7. Cross-references
67
+
68
+ - [Registry entry](../../../../docs/architecture/enforcer-registry/enforcers.json)
69
+ - [`specs/207-smart-account-threshold-policy.md`](../../../../specs/207-smart-account-threshold-policy.md) — the doctrine motivating this enforcer's existence.
70
+ - Shared library: `packages/contracts/src/libraries/SignatureSlotRecovery.sol` — used by both this enforcer AND `ThresholdValidator`'s admin-action `_verifyQuorum`. Audit changes here affect both.
71
+ - Companion contract: [`ApprovedHashRegistry`](../ApprovedHashRegistry.sol) — required for the v=1 path.
@@ -0,0 +1,191 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../agency/ICaveatEnforcer.sol";
5
+ import "../libraries/SignatureSlotRecovery.sol";
6
+
7
+ /**
8
+ * @title QuorumEnforcer
9
+ * @notice N-of-M signature aggregation over a payload hash. Adopts
10
+ * Safe's `checkSignatures` packing format verbatim so:
11
+ * (a) the agenticprimitives multi-sig SDK can interoperate
12
+ * with Safe-shaped signature blobs from external tooling,
13
+ * (b) we inherit Safe's battle-tested anti-duplicate scheme
14
+ * (sorted-ascending signer ordering) without inventing
15
+ * our own, and
16
+ * (c) `v` values for the four signature types (ECDSA,
17
+ * eth_sign, pre-approved hash, ERC-1271) match Safe's
18
+ * disambiguation so a single quorum verifier supports
19
+ * every signer category we care about.
20
+ *
21
+ * @dev terms = abi.encode(
22
+ * address[] signerSet, // signers bound at delegation-mint time
23
+ * uint8 threshold, // minimum valid sigs required
24
+ * address approvedHashRegistry // companion contract for v=1 path
25
+ * )
26
+ *
27
+ * args = abi.encode(
28
+ * bytes32 payloadHash, // what the signers signed (typically the
29
+ * // EIP-712 typed-data hash of the action)
30
+ * bytes signatures // packed sig blob, sorted-ascending by signer
31
+ * )
32
+ *
33
+ * Sig blob layout per entry (65 bytes per slot in the sorted region):
34
+ * {32 r/data}{32 s/data}{1 v/type}
35
+ *
36
+ * v-byte type discrimination:
37
+ * v == 27 || v == 28 → ECDSA over `payloadHash`
38
+ * v > 30 → eth_sign ECDSA: signer pre-wrapped payloadHash with
39
+ * "\x19Ethereum Signed Message:\n32"; v is passed as
40
+ * {31, 32} (subtract 4 to recover).
41
+ * v == 1 → pre-approved hash; r holds the signer address
42
+ * (left-padded); signer must have called
43
+ * ApprovedHashRegistry.approveHash(payloadHash).
44
+ * v == 0 → ERC-1271 contract signature; r holds signer address
45
+ * (left-padded), s holds the byte offset into the
46
+ * `signatures` blob where the length-prefixed
47
+ * dynamic sig tail starts.
48
+ *
49
+ * v == 2 (RIP-7212 secp256r1 / passkey) is reserved but not
50
+ * implemented here — passkey-only signers can use the `v == 1`
51
+ * approveHash escape hatch (call `ApprovedHashRegistry.approveHash`
52
+ * from the passkey-owned account in the same userOp as the
53
+ * delegation redemption, batched via `MultiSendCallOnly`).
54
+ *
55
+ * Sorted-ascending signer ordering is the anti-duplicate
56
+ * mechanism: every pair of adjacent recovered signers must
57
+ * satisfy `prev < curr`. This eliminates the need for a separate
58
+ * "seen" mapping and ensures a malicious caller can't submit the
59
+ * same signer's signature twice to inflate the threshold count.
60
+ *
61
+ * Each recovered signer must be in `signerSet`. Membership is the
62
+ * only authorization check this enforcer does — runtime
63
+ * eligibility (e.g. "is this signer still an active owner of the
64
+ * smart account?") is intentionally out of scope so the enforcer
65
+ * stays composable with whatever upstream authority model the
66
+ * delegation chain enforces.
67
+ *
68
+ * Only the first `threshold` slots are checked. Excess entries
69
+ * beyond the threshold are ignored — callers SHOULD NOT pad blobs
70
+ * unnecessarily as calldata cost scales linearly.
71
+ */
72
+ contract QuorumEnforcer is ICaveatEnforcer {
73
+ error InsufficientQuorum(uint256 supplied, uint8 threshold);
74
+ error UnauthorizedSigner(address signer);
75
+ error DuplicateOrUnsortedSigner(address signer);
76
+ error PayloadHashMismatch(bytes32 expected, bytes32 supplied);
77
+
78
+ /// @notice Typehash for the quorum-action binding (contract audit C-4).
79
+ /// @dev Bound to (chainId, enforcer, delegationHash, delegator, redeemer,
80
+ /// target, value, keccak256(callData)) so quorum signatures cannot
81
+ /// be replayed across executions, accounts, or chains.
82
+ bytes32 public constant QUORUM_ACTION_TYPEHASH = keccak256(
83
+ "QuorumAction(uint256 chainId,address enforcer,bytes32 delegationHash,address delegator,address redeemer,address target,uint256 value,bytes32 callDataHash)"
84
+ );
85
+
86
+ /**
87
+ * @notice Compute the canonical payload hash quorum signers MUST sign.
88
+ * @dev Public so the off-chain signing path can mirror it byte-for-byte
89
+ * via TS helpers. Equivalent to the inline computation in
90
+ * `beforeHook` — exposing it as a view function keeps the
91
+ * two implementations in lockstep.
92
+ */
93
+ function computeQuorumPayloadHash(
94
+ bytes32 delegationHash,
95
+ address delegator,
96
+ address redeemer,
97
+ address target,
98
+ uint256 value,
99
+ bytes calldata callData
100
+ ) public view returns (bytes32) {
101
+ return keccak256(
102
+ abi.encode(
103
+ QUORUM_ACTION_TYPEHASH,
104
+ block.chainid,
105
+ address(this),
106
+ delegationHash,
107
+ delegator,
108
+ redeemer,
109
+ target,
110
+ value,
111
+ keccak256(callData)
112
+ )
113
+ );
114
+ }
115
+
116
+ function beforeHook(
117
+ bytes calldata terms,
118
+ bytes calldata args,
119
+ bytes32 delegationHash,
120
+ address delegator,
121
+ address redeemer,
122
+ address target,
123
+ uint256 value,
124
+ bytes calldata callData
125
+ ) external view override {
126
+ (address[] memory signerSet, uint8 threshold, address approvedHashRegistry) =
127
+ abi.decode(terms, (address[], uint8, address));
128
+ (bytes32 suppliedPayloadHash, bytes memory signatures) = abi.decode(args, (bytes32, bytes));
129
+
130
+ // Contract audit C-4: bind the signed payload to the actual
131
+ // execution context. Previously the redeemer chose payloadHash
132
+ // freely, so a quorum signed off-chain to "approve a transfer
133
+ // to alice" could be replayed against "approve a transfer to
134
+ // attacker" with no on-chain check. Reject any mismatch.
135
+ bytes32 expectedPayloadHash = keccak256(
136
+ abi.encode(
137
+ QUORUM_ACTION_TYPEHASH,
138
+ block.chainid,
139
+ address(this),
140
+ delegationHash,
141
+ delegator,
142
+ redeemer,
143
+ target,
144
+ value,
145
+ keccak256(callData)
146
+ )
147
+ );
148
+ if (suppliedPayloadHash != expectedPayloadHash) {
149
+ revert PayloadHashMismatch(expectedPayloadHash, suppliedPayloadHash);
150
+ }
151
+
152
+ if (signatures.length < uint256(threshold) * 65) {
153
+ revert InsufficientQuorum(signatures.length / 65, threshold);
154
+ }
155
+
156
+ address prev;
157
+ for (uint256 i; i < threshold; i++) {
158
+ address signer = SignatureSlotRecovery.recoverFromSlot(
159
+ expectedPayloadHash,
160
+ signatures,
161
+ i,
162
+ approvedHashRegistry
163
+ );
164
+
165
+ // Sorted-ascending check (also rejects duplicates).
166
+ if (signer <= prev) revert DuplicateOrUnsortedSigner(signer);
167
+ prev = signer;
168
+
169
+ // Membership in the signer set bound at delegation time.
170
+ if (!_inSet(signer, signerSet)) revert UnauthorizedSigner(signer);
171
+ }
172
+ }
173
+
174
+ function afterHook(
175
+ bytes calldata,
176
+ bytes calldata,
177
+ bytes32,
178
+ address,
179
+ address,
180
+ address,
181
+ uint256,
182
+ bytes calldata
183
+ ) external pure override {}
184
+
185
+ function _inSet(address signer, address[] memory set) internal pure returns (bool) {
186
+ for (uint256 i; i < set.length; i++) {
187
+ if (set[i] == signer) return true;
188
+ }
189
+ return false;
190
+ }
191
+ }
@@ -0,0 +1,50 @@
1
+ # `TimestampEnforcer` — Security & Architecture Audit
2
+
3
+ **Status:** shipped
4
+ **Last refreshed:** 2026-05-21
5
+ **Owners:** delegation package CODEOWNERS
6
+ **Registry entry:** [`docs/architecture/enforcer-registry/enforcers.json`](../../../../docs/architecture/enforcer-registry/enforcers.json) (entry: `TimestampEnforcer`)
7
+ **Live address (Base Sepolia):** `0x81AB5167ccEfD6a2cD7C95baFE27a120A94F37f0`
8
+
9
+ ## 1. Charter
10
+
11
+ `TimestampEnforcer` constrains a delegation to a `[validAfter, validUntil]` window using `block.timestamp` at the redemption call. Both bounds are inclusive; either can be `0` (meaning "no bound on this side"). A delegation with both bounds 0 has no time constraint.
12
+
13
+ Does NOT do: block-number windows (DTK has a separate `BlockNumberEnforcer`; we don't), wall-clock reasoning, time-of-day patterns. All time questions resolve to "what does `block.timestamp` say at redeem time."
14
+
15
+ ## 2. Security invariants
16
+
17
+ - The redeemer cannot widen the window. `terms` is set at delegation creation; `args` is ignored.
18
+ - `block.timestamp` clock skew across nodes is bounded to ~15s on most L1/L2; the enforcer applies no slack — callers should NOT issue delegations with tightly-bounded windows < 1 minute apart.
19
+ - `validAfter == 0` is permitted (the delegation is valid immediately on creation).
20
+ - `validUntil == 0` is permitted (the delegation has no expiry — useful for never-expiring roles, dangerous if accidental).
21
+
22
+ ## 3. Threat model
23
+
24
+ | Threat | Likelihood | Impact | Mitigation | Status |
25
+ | --- | --- | --- | --- | --- |
26
+ | Block timestamp manipulation by miner | Low (L2 sequencer-controlled) | Low (~1-15s drift) | Spec doc warns against sub-minute windows | Documented |
27
+ | Delegation issued with `validUntil = 0` by accident | Medium | High (never-expires) | SDK lint should warn on `0` validUntil | **Open: TE-1** — lint not yet implemented |
28
+ | Multi-redemption inside the window | High by design | None (this is the design) | The TimestampEnforcer alone doesn't limit count; pair with a usage-counting enforcer if needed | By design |
29
+
30
+ ## 4. DTK / smart-agent parity
31
+
32
+ **DTK:** `TimestampEnforcer` — same name, same semantics. Independent port of the ERC-7710 spec, not the DTK source. Wire-format compatible — a DTK-tooled delegation with this caveat verifies against our enforcer at our deployed address.
33
+
34
+ **smart-agent:** `TimestampEnforcer.sol` — same. agenticprimitives ports the contract directly.
35
+
36
+ ## 5. Test posture
37
+
38
+ Forge tests: `packages/contracts/test/Enforcers.t.sol` covers the validAfter / validUntil / 0-bound / equal-bound cases (4 tests). No property tests yet.
39
+
40
+ ## 6. Open findings
41
+
42
+ | ID | Severity | Finding | Status |
43
+ | --- | --- | --- | --- |
44
+ | TE-1 | P3 | SDK lint should warn on `validUntil = 0` (never-expires) when not paired with a revocable-by-owner flag. | Open |
45
+
46
+ ## 7. Cross-references
47
+
48
+ - [Registry entry](../../../../docs/architecture/enforcer-registry/enforcers.json) (search: `TimestampEnforcer`)
49
+ - [`packages/delegation/AUDIT.md`](../../../../packages/delegation/AUDIT.md) — consumer of this enforcer.
50
+ - [`docs/architecture/dtk-alignment-audit.md`](../../../../docs/architecture/dtk-alignment-audit.md) § 3 — parity verdict.
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../agency/ICaveatEnforcer.sol";
5
+
6
+ /**
7
+ * @title TimestampEnforcer
8
+ * @notice Enforces a time window — delegation is only valid between two timestamps.
9
+ * @dev terms = abi.encode(uint256 validAfter, uint256 validUntil)
10
+ * Follows ERC-7710 / MetaMask DeleGator beforeHook/afterHook pattern.
11
+ */
12
+ contract TimestampEnforcer is ICaveatEnforcer {
13
+ error TimestampNotYetValid();
14
+ error TimestampExpired();
15
+
16
+ function beforeHook(
17
+ bytes calldata terms,
18
+ bytes calldata,
19
+ bytes32,
20
+ address,
21
+ address,
22
+ address,
23
+ uint256,
24
+ bytes calldata
25
+ ) external view override {
26
+ (uint256 validAfter, uint256 validUntil) = abi.decode(terms, (uint256, uint256));
27
+ if (block.timestamp < validAfter) revert TimestampNotYetValid();
28
+ if (block.timestamp > validUntil) revert TimestampExpired();
29
+ }
30
+
31
+ function afterHook(
32
+ bytes calldata,
33
+ bytes calldata,
34
+ bytes32,
35
+ address,
36
+ address,
37
+ address,
38
+ uint256,
39
+ bytes calldata
40
+ ) external pure override {
41
+ // No post-execution check needed for timestamps
42
+ }
43
+ }