@agenticprimitives/contracts 1.0.0-alpha.26 → 1.0.0-alpha.27
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/dist/abi/AgentAccount.json +82 -0
- package/dist/abi/CustodyPolicy.json +24 -0
- package/dist/abi/EstateProjectionRegistry.json +293 -0
- package/dist/abi/IApprovedHashRegistryWrite.json +35 -0
- package/dist/abi/PayloadClassesEnforcer.json +117 -0
- package/dist/abi/TreasurySpendPolicy.json +369 -0
- package/dist/abi/index.d.ts +4 -0
- package/dist/abi/index.js +4 -0
- package/package.json +1 -1
- package/src/AgentAccount.sol +70 -1
- package/src/IAgentAccount.sol +23 -0
- package/src/custody/CustodyPolicy.sol +52 -2
- package/src/custody/TreasurySpendPolicy.sol +166 -0
- package/src/enforcers/PayloadClassesEnforcer.sol +30 -0
- package/src/registry/EstateProjectionRegistry.sol +109 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
pragma solidity ^0.8.28;
|
|
3
3
|
|
|
4
|
-
import {IAgentAccount, AgentAccountRecoveryArgs, AgentAccountRecoveryPasskeyAdd} from "../IAgentAccount.sol";
|
|
4
|
+
import {IAgentAccount, AgentAccountRecoveryArgs, AgentAccountRecoveryPasskeyAdd, AgentAccountRotateCredentialArgs} from "../IAgentAccount.sol";
|
|
5
5
|
import {SignatureSlotRecovery} from "../libraries/SignatureSlotRecovery.sol";
|
|
6
6
|
|
|
7
7
|
/// @dev R6.6 — minimal interface for the `account → factory → governance.isPaused()`
|
|
@@ -80,7 +80,8 @@ contract CustodyPolicy {
|
|
|
80
80
|
ChangeValueCeiling, // 12 — T4
|
|
81
81
|
SetRecoveryApprovals, // 13 — T4
|
|
82
82
|
RecoverAccount, // 14 — T6
|
|
83
|
-
ChangeApprovalsRequired
|
|
83
|
+
ChangeApprovalsRequired, // 15 — T4 (per-tier custody quorum threshold)
|
|
84
|
+
RotateCredential // 16 — T4 (spec 410 §1.2: add + retire + re-approve standing wires, atomically)
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
struct ScheduledChange {
|
|
@@ -296,6 +297,9 @@ contract CustodyPolicy {
|
|
|
296
297
|
event T3CeilingChanged(address indexed account, uint256 oldCeiling, uint256 newCeiling);
|
|
297
298
|
event RecoveryThresholdChanged(address indexed account, uint8 oldValue, uint8 newValue);
|
|
298
299
|
event OwnersRotated(address indexed account, uint256 newOwnerCount);
|
|
300
|
+
/// @dev Spec 410 §1.2 — a planned rotation applied: one credential in, one out, `reapproved` wires
|
|
301
|
+
/// re-approved under the new custody epoch in the same transaction.
|
|
302
|
+
event CredentialRotated(address indexed account, uint256 reapproved);
|
|
299
303
|
/// @notice Audit C-10: per-rotation count of custodians removed.
|
|
300
304
|
event CustodiansRemovedDuringRotation(address indexed account, uint256 removedCount);
|
|
301
305
|
/// @notice Audit C-11: CustodyPolicy was uninstalled; the account
|
|
@@ -343,6 +347,8 @@ contract CustodyPolicy {
|
|
|
343
347
|
error UnconfiguredTier(uint8 tier);
|
|
344
348
|
error RecoveryRequiresGuardians();
|
|
345
349
|
error EmptyOwnerSet();
|
|
350
|
+
/// @dev Spec 410 §1.2 — `RotateCredential` needs exactly one credential added and exactly one retired.
|
|
351
|
+
error RotationNeedsOneAddAndOneRemove();
|
|
346
352
|
error CustodyActionNotYetImplemented(uint8 action);
|
|
347
353
|
error CannotDowngradeWithTrustees();
|
|
348
354
|
error TimelockRequiredForTier(uint8 tier);
|
|
@@ -858,6 +864,9 @@ contract CustodyPolicy {
|
|
|
858
864
|
} else if (action == CustodyAction.RecoverAccount) {
|
|
859
865
|
AgentAccountRecoveryArgs memory r = abi.decode(args, (AgentAccountRecoveryArgs));
|
|
860
866
|
_applyRecoverAccount(account, r);
|
|
867
|
+
} else if (action == CustodyAction.RotateCredential) {
|
|
868
|
+
AgentAccountRotateCredentialArgs memory r = abi.decode(args, (AgentAccountRotateCredentialArgs));
|
|
869
|
+
_applyRotateCredential(account, r);
|
|
861
870
|
} else if (action == CustodyAction.ApplySystemUpdate) {
|
|
862
871
|
(address newImpl) = abi.decode(args, (address));
|
|
863
872
|
_execute(account, abi.encodeWithSignature("upgradeToAndCall(address,bytes)", newImpl, ""));
|
|
@@ -977,6 +986,9 @@ contract CustodyPolicy {
|
|
|
977
986
|
removed++;
|
|
978
987
|
}
|
|
979
988
|
}
|
|
989
|
+
// Spec 410 §1.2: a bulk custodian rotation carries no re-approval list; like recovery it voids every
|
|
990
|
+
// approval so nothing planted for a future epoch survives it. Re-approve through `RotateCredential`.
|
|
991
|
+
_revokeAllApprovedDigests(account);
|
|
980
992
|
emit OwnersRotated(account, added);
|
|
981
993
|
// Note: OwnersRotated only carries `added` for ABI compat. A
|
|
982
994
|
// future revision should add a `removed` field; that's a
|
|
@@ -1070,9 +1082,47 @@ contract CustodyPolicy {
|
|
|
1070
1082
|
removedPasskeys++;
|
|
1071
1083
|
}
|
|
1072
1084
|
|
|
1085
|
+
// Spec 410 §1.2 (THESIS-1a): recovery re-approves nothing and voids every approval the account
|
|
1086
|
+
// ever made — including one planted for a future epoch. The person re-issues from a reviewed list.
|
|
1087
|
+
_revokeAllApprovedDigests(account);
|
|
1073
1088
|
emit AccountRecovered(account, addedOwners, removedOwners, addedPasskeys, removedPasskeys);
|
|
1074
1089
|
}
|
|
1075
1090
|
|
|
1091
|
+
/// @dev Spec 410 §1.2 — the planned rotation: add → retire → re-approve, one transaction. The account
|
|
1092
|
+
/// derives each key under the post-retirement epoch (`approveDigest`); this module never computes
|
|
1093
|
+
/// a key. Exactly one credential in and one out, so the account is never credential-less and the
|
|
1094
|
+
/// action cannot be bent into a bulk custody change at the rotation tier.
|
|
1095
|
+
function _applyRotateCredential(address account, AgentAccountRotateCredentialArgs memory r) internal {
|
|
1096
|
+
bool addsOwner = r.addOwner != address(0);
|
|
1097
|
+
bool addsPasskey = r.addPasskey.x != 0 || r.addPasskey.y != 0 || r.addPasskey.credentialIdDigest != bytes32(0);
|
|
1098
|
+
bool removesOwner = r.removeOwner != address(0);
|
|
1099
|
+
bool removesPasskey = r.removePasskeyCredentialIdDigest != bytes32(0);
|
|
1100
|
+
if (addsOwner == addsPasskey || removesOwner == removesPasskey) revert RotationNeedsOneAddAndOneRemove();
|
|
1101
|
+
|
|
1102
|
+
if (addsOwner) {
|
|
1103
|
+
_execute(account, abi.encodeCall(IAgentAccount.addCustodian, (r.addOwner)));
|
|
1104
|
+
} else {
|
|
1105
|
+
_execute(account, abi.encodeWithSignature(
|
|
1106
|
+
"addPasskey(bytes32,uint256,uint256,bytes32)",
|
|
1107
|
+
r.addPasskey.credentialIdDigest, r.addPasskey.x, r.addPasskey.y, r.addPasskey.rpIdHash
|
|
1108
|
+
));
|
|
1109
|
+
}
|
|
1110
|
+
if (removesOwner) {
|
|
1111
|
+
_execute(account, abi.encodeCall(IAgentAccount.removeCustodian, (r.removeOwner)));
|
|
1112
|
+
} else {
|
|
1113
|
+
_execute(account, abi.encodeWithSignature("removePasskey(bytes32)", r.removePasskeyCredentialIdDigest));
|
|
1114
|
+
}
|
|
1115
|
+
for (uint256 i; i < r.reapprove.length; i++) {
|
|
1116
|
+
_execute(account, abi.encodeWithSignature("approveDigest(bytes32)", r.reapprove[i]));
|
|
1117
|
+
}
|
|
1118
|
+
emit CredentialRotated(account, r.reapprove.length);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/// @dev Spec 410 §1.2 — `AgentAccount.revokeAllApprovedDigests()` as the account (module context).
|
|
1122
|
+
function _revokeAllApprovedDigests(address account) internal {
|
|
1123
|
+
_execute(account, abi.encodeWithSignature("revokeAllApprovedDigests()"));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1076
1126
|
/// @dev Spec § 5.1 default-threshold matrix.
|
|
1077
1127
|
function _defaultApprovals(uint8 nCustodians, uint8 tier) internal pure returns (uint8) {
|
|
1078
1128
|
if (nCustodians == 0) return 0;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.28;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @title TreasurySpendPolicy — an aggregate ceiling AT THE RESOURCE (spec 410 §7, THESIS-6)
|
|
6
|
+
* @notice Every caveat enforcer bounds ONE delegation: `ValueEnforcer` a call's value, `PaymentEnforcer` a
|
|
7
|
+
* mandate's budget under (delegator, delegationHash). None of them can see the treasury's other
|
|
8
|
+
* delegates, so two delegates each under their own ceiling can jointly exceed the principal's
|
|
9
|
+
* budget. This module is the ceiling that belongs to the TREASURY: an ERC-7579 HOOK the account
|
|
10
|
+
* installs, consulted on every `execute` / `executeBatch` — a custodian's own userOp and a delegated
|
|
11
|
+
* redemption alike, because the account runs its hooks on both — that adds the value each call moves
|
|
12
|
+
* to the current window and refuses the one that would pass the ceiling.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IT COUNTS. Native value (`value` on the call) when `asset == address(0)`; otherwise ERC-20
|
|
15
|
+
* `transfer(to, amount)` and `transferFrom(from, to, amount)` calldata aimed at `asset`. Anything else
|
|
16
|
+
* moves no value of this asset and is not counted. A batch is one call: its inner calls are summed.
|
|
17
|
+
*
|
|
18
|
+
* WINDOWS are fixed-length epochs from `windowStart` — never sliding — so the check is O(1) and the
|
|
19
|
+
* refusal is reproducible off chain from the receipt and the block time.
|
|
20
|
+
*
|
|
21
|
+
* WHO SETS IT. The budget is the install data: `installModule(4, this, abi.encode(asset, ceiling,
|
|
22
|
+
* windowSeconds))`, which on a custody-governed treasury is a governance selector under its quorum;
|
|
23
|
+
* changing it is an uninstall + reinstall under the same gate. There is no setter a single key could
|
|
24
|
+
* reach. Per-delegation ceilings are untouched: the tighter of the two refuses.
|
|
25
|
+
*
|
|
26
|
+
* WHAT IT IS NOT: a policy of who may spend (that is the mandate), a rate limit on calls, or anything
|
|
27
|
+
* a planner consults — the harness may READ `budget()` to say "this would exceed the treasury's
|
|
28
|
+
* shared budget" before a signature (honesty); the hook is the authority.
|
|
29
|
+
*/
|
|
30
|
+
contract TreasurySpendPolicy {
|
|
31
|
+
struct Budget {
|
|
32
|
+
address asset; // address(0) = native value
|
|
33
|
+
uint256 ceiling; // per window, in the asset's base units
|
|
34
|
+
uint64 windowSeconds; // fixed epochs from windowStart
|
|
35
|
+
uint64 windowStart;
|
|
36
|
+
uint64 windowIndex; // the epoch `spent` belongs to
|
|
37
|
+
uint256 spent;
|
|
38
|
+
bool installed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// account => budget
|
|
42
|
+
mapping(address => Budget) private _budgets;
|
|
43
|
+
|
|
44
|
+
event BudgetInstalled(address indexed account, address indexed asset, uint256 ceiling, uint64 windowSeconds, uint64 windowStart);
|
|
45
|
+
event BudgetUninstalled(address indexed account);
|
|
46
|
+
event Spent(address indexed account, uint64 indexed windowIndex, uint256 amount, uint256 spentInWindow);
|
|
47
|
+
|
|
48
|
+
error BudgetExceeded(uint256 ceiling, uint256 spentInWindow, uint256 amount);
|
|
49
|
+
error NotInstalledOn(address account);
|
|
50
|
+
error AlreadyInstalledOn(address account);
|
|
51
|
+
error BadBudget();
|
|
52
|
+
|
|
53
|
+
bytes4 private constant TRANSFER = bytes4(keccak256("transfer(address,uint256)"));
|
|
54
|
+
bytes4 private constant TRANSFER_FROM = bytes4(keccak256("transferFrom(address,address,uint256)"));
|
|
55
|
+
|
|
56
|
+
// ─── ERC-7579 module surface ───────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function isModuleType(uint256 moduleTypeId) external pure returns (bool) { return moduleTypeId == 4; }
|
|
59
|
+
function moduleId() external pure returns (string memory) { return "agenticprimitives-treasury-spend-policy-1"; }
|
|
60
|
+
|
|
61
|
+
/// @notice Called by the account from `installModule` (governance-gated there): the budget is the install data.
|
|
62
|
+
function onInstall(bytes calldata initData) external {
|
|
63
|
+
Budget storage b = _budgets[msg.sender];
|
|
64
|
+
if (b.installed) revert AlreadyInstalledOn(msg.sender);
|
|
65
|
+
(address asset, uint256 ceiling, uint64 windowSeconds) = abi.decode(initData, (address, uint256, uint64));
|
|
66
|
+
if (windowSeconds == 0) revert BadBudget();
|
|
67
|
+
b.asset = asset;
|
|
68
|
+
b.ceiling = ceiling;
|
|
69
|
+
b.windowSeconds = windowSeconds;
|
|
70
|
+
b.windowStart = uint64(block.timestamp);
|
|
71
|
+
b.windowIndex = 0;
|
|
72
|
+
b.spent = 0;
|
|
73
|
+
b.installed = true;
|
|
74
|
+
emit BudgetInstalled(msg.sender, asset, ceiling, windowSeconds, b.windowStart);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onUninstall(bytes calldata) external {
|
|
78
|
+
if (!_budgets[msg.sender].installed) revert NotInstalledOn(msg.sender);
|
|
79
|
+
delete _budgets[msg.sender];
|
|
80
|
+
emit BudgetUninstalled(msg.sender);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── The hook ──────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/// @notice `preCheck` from the account: `msgData` is `abi.encode(target, value, data)` for `execute` and
|
|
86
|
+
/// `abi.encode(Call[])` for `executeBatch`. Sums the value this call moves in the budget's asset and
|
|
87
|
+
/// refuses when the window's total would pass the ceiling.
|
|
88
|
+
function preCheck(address, uint256, bytes calldata msgData) external returns (bytes memory) {
|
|
89
|
+
Budget storage b = _budgets[msg.sender];
|
|
90
|
+
if (!b.installed) return hex"";
|
|
91
|
+
uint256 amount = _valueMoved(b.asset, msgData);
|
|
92
|
+
if (amount == 0) return hex"";
|
|
93
|
+
uint64 idx = uint64((block.timestamp - b.windowStart) / b.windowSeconds);
|
|
94
|
+
if (idx != b.windowIndex) { b.windowIndex = idx; b.spent = 0; }
|
|
95
|
+
uint256 next = b.spent + amount;
|
|
96
|
+
if (next > b.ceiling) revert BudgetExceeded(b.ceiling, b.spent, amount);
|
|
97
|
+
b.spent = next;
|
|
98
|
+
emit Spent(msg.sender, idx, amount, next);
|
|
99
|
+
return hex"";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function postCheck(bytes calldata) external {}
|
|
103
|
+
|
|
104
|
+
// ─── Reads (the harness's honesty, never its authority) ────────────
|
|
105
|
+
|
|
106
|
+
function budget(address account) external view returns (address asset, uint256 ceiling, uint64 windowSeconds, uint64 windowStart, uint64 windowIndex, uint256 spent, bool installed) {
|
|
107
|
+
Budget storage b = _budgets[account];
|
|
108
|
+
return (b.asset, b.ceiling, b.windowSeconds, b.windowStart, b.windowIndex, b.spent, b.installed);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// @notice What the account has spent in the CURRENT window (0 once the window rolled), and the ceiling.
|
|
112
|
+
function remaining(address account) external view returns (uint256 left, uint256 ceiling) {
|
|
113
|
+
Budget storage b = _budgets[account];
|
|
114
|
+
if (!b.installed) return (type(uint256).max, 0);
|
|
115
|
+
uint64 idx = uint64((block.timestamp - b.windowStart) / b.windowSeconds);
|
|
116
|
+
uint256 spent = idx == b.windowIndex ? b.spent : 0;
|
|
117
|
+
return (b.ceiling > spent ? b.ceiling - spent : 0, b.ceiling);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Internals ─────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/// @dev Both encodings the account uses, told apart by the first word: `abi.encode(Call[])` opens with the
|
|
123
|
+
/// array's offset (exactly 0x20), `abi.encode(target, value, data)` with the target address — and no
|
|
124
|
+
/// contract lives at address 0x20. The decode runs through an external self-call so a malformed payload
|
|
125
|
+
/// is caught rather than reverting the account's call (abi.decode cannot be tried in place).
|
|
126
|
+
function _valueMoved(address asset, bytes calldata msgData) internal view returns (uint256 total) {
|
|
127
|
+
if (msgData.length < 32) return 0;
|
|
128
|
+
uint256 first = uint256(bytes32(msgData[0:32]));
|
|
129
|
+
if (first == 0x20) {
|
|
130
|
+
try this.decodeBatch(msgData) returns (Call[] memory calls) {
|
|
131
|
+
for (uint256 i = 0; i < calls.length; i++) total += _amountOf(asset, calls[i].target, calls[i].value, calls[i].data);
|
|
132
|
+
return total;
|
|
133
|
+
} catch { return 0; }
|
|
134
|
+
}
|
|
135
|
+
try this.decodeSingle(msgData) returns (address target, uint256 value, bytes memory data) {
|
|
136
|
+
return _amountOf(asset, target, value, data);
|
|
137
|
+
} catch { return 0; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
struct Call { address target; uint256 value; bytes data; }
|
|
141
|
+
|
|
142
|
+
function decodeSingle(bytes calldata msgData) external pure returns (address, uint256, bytes memory) {
|
|
143
|
+
return abi.decode(msgData, (address, uint256, bytes));
|
|
144
|
+
}
|
|
145
|
+
function decodeBatch(bytes calldata msgData) external pure returns (Call[] memory) {
|
|
146
|
+
return abi.decode(msgData, (Call[]));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _amountOf(address asset, address target, uint256 value, bytes memory data) internal pure returns (uint256) {
|
|
150
|
+
if (asset == address(0)) return value;
|
|
151
|
+
if (target != asset || data.length < 4) return 0;
|
|
152
|
+
bytes4 sel;
|
|
153
|
+
assembly { sel := mload(add(data, 32)) }
|
|
154
|
+
if (sel == TRANSFER && data.length >= 68) {
|
|
155
|
+
uint256 amount;
|
|
156
|
+
assembly { amount := mload(add(data, 68)) }
|
|
157
|
+
return amount;
|
|
158
|
+
}
|
|
159
|
+
if (sel == TRANSFER_FROM && data.length >= 100) {
|
|
160
|
+
uint256 amount;
|
|
161
|
+
assembly { amount := mload(add(data, 100)) }
|
|
162
|
+
return amount;
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.28;
|
|
3
|
+
|
|
4
|
+
import "../agency/ICaveatEnforcer.sol";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @title PayloadClassesEnforcer — the INFORMATION-FLOW caveat (spec 410 §7)
|
|
8
|
+
* @notice Where a read precedes a write in one plan — read a member's record, then send a message — the
|
|
9
|
+
* mandate names, as ontology classes, what the write MAY CARRY: `apvault:ContactEmail`, never
|
|
10
|
+
* `apvault:GivingHistory`. terms = abi.encode(string[] classIris).
|
|
11
|
+
*
|
|
12
|
+
* THE CHAIN CANNOT READ A PAYLOAD, and this enforcer does not pretend to: its hooks are pure no-ops.
|
|
13
|
+
* What it does is pin the terms INTO THE SIGNED DELEGATION — the caveat is part of the EIP-712 hash
|
|
14
|
+
* the person approved — so the off-chain per-step verifier (`delegation.evaluateCaveats`, strict)
|
|
15
|
+
* judges the write's declared payload classes (the tool contract's `carries`, per argument) against
|
|
16
|
+
* exactly what was signed, and nobody can widen the list without a new signature. An enforcer that
|
|
17
|
+
* judged nothing and pinned nothing would be a comment; this one pins.
|
|
18
|
+
*
|
|
19
|
+
* What it is NOT: authority over the read (the read has its own mandate and scope), a content
|
|
20
|
+
* filter, or a substitute for the vault's per-record scope — those are enforced where the data is.
|
|
21
|
+
*/
|
|
22
|
+
contract PayloadClassesEnforcer is ICaveatEnforcer {
|
|
23
|
+
function beforeHook(bytes calldata, bytes calldata, bytes32, address, address, address, uint256, bytes calldata) external pure override {}
|
|
24
|
+
function afterHook(bytes calldata, bytes calldata, bytes32, address, address, address, uint256, bytes calldata) external pure override {}
|
|
25
|
+
|
|
26
|
+
/// @notice The classes a wire's terms name — for a reader with the wire and no SDK.
|
|
27
|
+
function classesOf(bytes calldata terms) external pure returns (string[] memory) {
|
|
28
|
+
return abi.decode(terms, (string[]));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.28;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @title EstateProjectionRegistry — an estate's authority, projected onto PUBLIC GROUND (spec 410 §4)
|
|
6
|
+
* @notice One registry, many estates. An estate's grants and relationships are enforced on its own chain and
|
|
7
|
+
* held in its principals' vaults; a verifier elsewhere can read neither. What the estate anchors here
|
|
8
|
+
* is two Merkle roots over BLINDED commitments — the live-grant root (every grant currently live) and
|
|
9
|
+
* the membership root (every countersigned relationship credential) — plus the hash of its signed
|
|
10
|
+
* charter (spec 410 §9), under a monotonic sequence. A presentation against these roots tells a
|
|
11
|
+
* verifier in another estate that a grant is live or an agent is a member in that estate as of that
|
|
12
|
+
* sequence, and nothing else (the estate-projection SDK package).
|
|
13
|
+
*
|
|
14
|
+
* WHO MAY WRITE. An estate REGISTERS once: its id — keccak256("eip155:<chainId>:<governance>"), the
|
|
15
|
+
* one fact public ground learns about it — bound to the SIGNER its governance controls here (an EOA
|
|
16
|
+
* or an account on this chain). First registration wins; the signer rotates only by the current
|
|
17
|
+
* signer. `setRoots` is the signer's, with a sequence strictly above the last: a replayed or reordered
|
|
18
|
+
* projection is refused. No admin, no pause, no upgrade: public ground has no operator either.
|
|
19
|
+
*
|
|
20
|
+
* WHAT THIS IS NOT: authority. A root proves what the estate vouches for at a moment; whether the
|
|
21
|
+
* receiving estate lets an agent ACT is its own mandate, per step (ADR-0056 / ADR-0057).
|
|
22
|
+
*/
|
|
23
|
+
contract EstateProjectionRegistry {
|
|
24
|
+
struct Roots {
|
|
25
|
+
bytes32 liveGrantRoot;
|
|
26
|
+
bytes32 membershipRoot;
|
|
27
|
+
bytes32 charterHash;
|
|
28
|
+
uint64 sequence;
|
|
29
|
+
uint64 anchoredAt;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
mapping(bytes32 estateId => address signer) public signerOf;
|
|
33
|
+
mapping(bytes32 estateId => Roots) private _roots;
|
|
34
|
+
|
|
35
|
+
event EstateRegistered(bytes32 indexed estateId, address indexed signer);
|
|
36
|
+
event EstateSignerRotated(bytes32 indexed estateId, address indexed from, address indexed to);
|
|
37
|
+
event RootsAnchored(bytes32 indexed estateId, uint64 indexed sequence, bytes32 liveGrantRoot, bytes32 membershipRoot, bytes32 charterHash);
|
|
38
|
+
|
|
39
|
+
error AlreadyRegistered(bytes32 estateId);
|
|
40
|
+
error NotRegistered(bytes32 estateId);
|
|
41
|
+
error NotTheSigner(bytes32 estateId, address caller);
|
|
42
|
+
error SequenceNotAbove(uint64 last, uint64 given);
|
|
43
|
+
error ZeroAddress();
|
|
44
|
+
|
|
45
|
+
/// @notice Bind an estate id to the signer its governance controls on this chain. First come, once.
|
|
46
|
+
function register(bytes32 estateId, address signer) external {
|
|
47
|
+
if (signer == address(0)) revert ZeroAddress();
|
|
48
|
+
if (signerOf[estateId] != address(0)) revert AlreadyRegistered(estateId);
|
|
49
|
+
signerOf[estateId] = signer;
|
|
50
|
+
emit EstateRegistered(estateId, signer);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rotateSigner(bytes32 estateId, address to) external {
|
|
54
|
+
if (to == address(0)) revert ZeroAddress();
|
|
55
|
+
address cur = signerOf[estateId];
|
|
56
|
+
if (cur == address(0)) revert NotRegistered(estateId);
|
|
57
|
+
if (msg.sender != cur) revert NotTheSigner(estateId, msg.sender);
|
|
58
|
+
signerOf[estateId] = to;
|
|
59
|
+
emit EstateSignerRotated(estateId, cur, to);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// @notice Anchor the estate's roots under `sequence` (strictly above the last). Refreshed on every revocation
|
|
63
|
+
/// and membership change; a verifier's freshness policy reads `anchoredAt`.
|
|
64
|
+
function setRoots(bytes32 estateId, bytes32 liveGrantRoot, bytes32 membershipRoot, bytes32 charterHash, uint64 sequence) external {
|
|
65
|
+
address cur = signerOf[estateId];
|
|
66
|
+
if (cur == address(0)) revert NotRegistered(estateId);
|
|
67
|
+
if (msg.sender != cur) revert NotTheSigner(estateId, msg.sender);
|
|
68
|
+
Roots storage r = _roots[estateId];
|
|
69
|
+
if (sequence <= r.sequence) revert SequenceNotAbove(r.sequence, sequence);
|
|
70
|
+
r.liveGrantRoot = liveGrantRoot;
|
|
71
|
+
r.membershipRoot = membershipRoot;
|
|
72
|
+
r.charterHash = charterHash;
|
|
73
|
+
r.sequence = sequence;
|
|
74
|
+
r.anchoredAt = uint64(block.timestamp);
|
|
75
|
+
emit RootsAnchored(estateId, sequence, liveGrantRoot, membershipRoot, charterHash);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// @notice The one read a verifier makes: the roots as anchored, with the sequence and when.
|
|
79
|
+
function roots(bytes32 estateId) external view returns (bytes32 liveGrantRoot, bytes32 membershipRoot, bytes32 charterHash, uint64 sequence, uint64 anchoredAt) {
|
|
80
|
+
Roots storage r = _roots[estateId];
|
|
81
|
+
return (r.liveGrantRoot, r.membershipRoot, r.charterHash, r.sequence, r.anchoredAt);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// @notice keccak256("eip155:<chainId>:<governance>") — the same derivation the SDK's `estateIdOf` uses.
|
|
85
|
+
function estateIdOf(uint256 chainId, address governance) external pure returns (bytes32) {
|
|
86
|
+
return keccak256(abi.encodePacked("eip155:", _utoa(chainId), ":", _lowerHex(governance)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _utoa(uint256 v) private pure returns (string memory) {
|
|
90
|
+
if (v == 0) return "0";
|
|
91
|
+
uint256 j = v; uint256 len;
|
|
92
|
+
while (j != 0) { len++; j /= 10; }
|
|
93
|
+
bytes memory b = new bytes(len);
|
|
94
|
+
while (v != 0) { len -= 1; b[len] = bytes1(uint8(48 + v % 10)); v /= 10; }
|
|
95
|
+
return string(b);
|
|
96
|
+
}
|
|
97
|
+
function _lowerHex(address a) private pure returns (string memory) {
|
|
98
|
+
bytes memory alphabet = "0123456789abcdef";
|
|
99
|
+
bytes memory s = new bytes(42);
|
|
100
|
+
s[0] = "0"; s[1] = "x";
|
|
101
|
+
uint160 v = uint160(a);
|
|
102
|
+
for (uint256 i = 0; i < 20; i++) {
|
|
103
|
+
uint8 bt = uint8(v >> (8 * (19 - i)));
|
|
104
|
+
s[2 + i * 2] = alphabet[bt >> 4];
|
|
105
|
+
s[3 + i * 2] = alphabet[bt & 0x0f];
|
|
106
|
+
}
|
|
107
|
+
return string(s);
|
|
108
|
+
}
|
|
109
|
+
}
|