@forgezero/runtime 0.1.0
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/LICENSE +21 -0
- package/README.md +89 -0
- package/contracts/foundry.toml +9 -0
- package/contracts/src/ColdVault.sol +206 -0
- package/contracts/src/DepositFactory.sol +202 -0
- package/contracts/src/DepositProxy.sol +72 -0
- package/contracts/src/IERC20.sol +7 -0
- package/contracts/src/MockTokens.sol +32 -0
- package/contracts/src/SafeTransferLib.sol +31 -0
- package/contracts/test/Custody.t.sol +361 -0
- package/contracts/test/Vectors.t.sol +45 -0
- package/dist/audit.d.ts +265 -0
- package/dist/audit.js +291 -0
- package/dist/backup.d.ts +243 -0
- package/dist/backup.js +302 -0
- package/dist/calendar.d.ts +136 -0
- package/dist/calendar.js +129 -0
- package/dist/compliance.d.ts +172 -0
- package/dist/compliance.js +168 -0
- package/dist/finance/binance.d.ts +27 -0
- package/dist/finance/binance.js +452 -0
- package/dist/finance/chain-addresses.d.ts +130 -0
- package/dist/finance/chain-addresses.js +462 -0
- package/dist/finance/chain-deposits.d.ts +193 -0
- package/dist/finance/chain-deposits.js +596 -0
- package/dist/finance/chain-reconcile.d.ts +112 -0
- package/dist/finance/chain-reconcile.js +76 -0
- package/dist/finance/chain-withdrawals.d.ts +223 -0
- package/dist/finance/chain-withdrawals.js +631 -0
- package/dist/finance/chain.d.ts +116 -0
- package/dist/finance/chain.js +316 -0
- package/dist/finance/commission.d.ts +155 -0
- package/dist/finance/commission.js +419 -0
- package/dist/finance/custody.d.ts +68 -0
- package/dist/finance/custody.js +107 -0
- package/dist/finance/derive.d.ts +115 -0
- package/dist/finance/derive.js +116 -0
- package/dist/finance/discounts.d.ts +98 -0
- package/dist/finance/discounts.js +90 -0
- package/dist/finance/ledger.d.ts +221 -0
- package/dist/finance/ledger.js +308 -0
- package/dist/finance/market.d.ts +209 -0
- package/dist/finance/market.js +112 -0
- package/dist/finance/money.d.ts +118 -0
- package/dist/finance/money.js +176 -0
- package/dist/finance/rates.d.ts +178 -0
- package/dist/finance/rates.js +292 -0
- package/dist/finance/storage.d.ts +113 -0
- package/dist/finance/storage.js +226 -0
- package/dist/finance/tax.d.ts +132 -0
- package/dist/finance/tax.js +291 -0
- package/dist/finance/transfers.d.ts +153 -0
- package/dist/finance/transfers.js +292 -0
- package/dist/finance/venues.d.ts +190 -0
- package/dist/finance/venues.js +251 -0
- package/dist/identity.d.ts +115 -0
- package/dist/identity.js +111 -0
- package/dist/importers.d.ts +87 -0
- package/dist/importers.js +250 -0
- package/dist/jobs.d.ts +171 -0
- package/dist/jobs.js +250 -0
- package/dist/notify-templates.d.ts +11 -0
- package/dist/notify-templates.js +254 -0
- package/dist/notify.d.ts +172 -0
- package/dist/notify.js +122 -0
- package/dist/openssh.d.ts +36 -0
- package/dist/openssh.js +106 -0
- package/dist/otpauth.d.ts +57 -0
- package/dist/otpauth.js +223 -0
- package/dist/outbox.d.ts +234 -0
- package/dist/outbox.js +236 -0
- package/dist/passkey.d.ts +120 -0
- package/dist/passkey.js +105 -0
- package/dist/phrase.d.ts +87 -0
- package/dist/phrase.js +87 -0
- package/dist/pipeline.d.ts +137 -0
- package/dist/pipeline.js +121 -0
- package/dist/queue.d.ts +243 -0
- package/dist/queue.js +246 -0
- package/dist/schema-typebox.d.ts +24 -0
- package/dist/schema-typebox.js +201 -0
- package/dist/schema.d.ts +134 -0
- package/dist/schema.js +169 -0
- package/dist/serial.d.ts +54 -0
- package/dist/serial.js +40 -0
- package/dist/slip10.d.ts +37 -0
- package/dist/slip10.js +74 -0
- package/dist/snp.d.ts +115 -0
- package/dist/snp.js +109 -0
- package/dist/ssh-agent.d.ts +70 -0
- package/dist/ssh-agent.js +141 -0
- package/dist/ssh-cert.d.ts +73 -0
- package/dist/ssh-cert.js +111 -0
- package/dist/totp.d.ts +104 -0
- package/dist/totp.js +143 -0
- package/package.json +248 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.24;
|
|
3
|
+
|
|
4
|
+
import { DepositFactory } from '../src/DepositFactory.sol';
|
|
5
|
+
import { DepositProxy } from '../src/DepositProxy.sol';
|
|
6
|
+
import { ColdVault } from '../src/ColdVault.sol';
|
|
7
|
+
import { MockToken, MockNoReturnToken, MockFeeToken } from '../src/MockTokens.sol';
|
|
8
|
+
|
|
9
|
+
/** Foundry cheatcodes, declared inline — this repository does not use submodules. */
|
|
10
|
+
interface Vm {
|
|
11
|
+
function prank(address) external;
|
|
12
|
+
function expectRevert(bytes4) external;
|
|
13
|
+
function expectRevert(bytes calldata) external;
|
|
14
|
+
function expectRevert() external;
|
|
15
|
+
function deal(address, uint256) external;
|
|
16
|
+
function addr(uint256) external pure returns (address);
|
|
17
|
+
function sign(uint256, bytes32) external pure returns (uint8, bytes32, bytes32);
|
|
18
|
+
function warp(uint256) external;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
contract CustodyTest {
|
|
22
|
+
Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
|
|
23
|
+
|
|
24
|
+
DepositFactory factory;
|
|
25
|
+
MockToken token;
|
|
26
|
+
address owner = address(this);
|
|
27
|
+
address hot = address(0x8074);
|
|
28
|
+
|
|
29
|
+
function setUp() public {
|
|
30
|
+
factory = new DepositFactory(owner);
|
|
31
|
+
token = new MockToken();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ================================================== deposit factory === */
|
|
35
|
+
|
|
36
|
+
function test_addressIsDerivedWithNoChainWrite() public {
|
|
37
|
+
// A million deposit addresses cost nothing to derive. This is the whole
|
|
38
|
+
// advantage over generating and funding a key per address.
|
|
39
|
+
bytes32 salt = keccak256('anything');
|
|
40
|
+
address derived = factory.computeAddress(salt);
|
|
41
|
+
require(derived != address(0), 'derived');
|
|
42
|
+
require(derived.code.length == 0, 'nothing deployed yet');
|
|
43
|
+
require(factory.computeAddress(salt) == derived, 'stable');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function test_offChainDerivationMatchesTheContract() public {
|
|
47
|
+
// The formula a platform runs off-chain, computed here from the published
|
|
48
|
+
// init-code hash. If these ever disagree, deposits go to an address the
|
|
49
|
+
// platform is not watching.
|
|
50
|
+
bytes32 salt = keccak256('user:42');
|
|
51
|
+
address expected = address(
|
|
52
|
+
uint160(uint256(keccak256(abi.encodePacked(
|
|
53
|
+
bytes1(0xff), address(factory), salt, factory.PROXY_INIT_CODE_HASH()
|
|
54
|
+
))))
|
|
55
|
+
);
|
|
56
|
+
require(factory.computeAddress(salt) == expected, 'formula matches');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function test_theSaltIsOpaque_userOrInvoiceBothWork() public {
|
|
60
|
+
// The runtime never learns what a salt means. A per-user address and a
|
|
61
|
+
// per-invoice address are the same primitive with a different convention,
|
|
62
|
+
// and encoding either here would force it on every platform.
|
|
63
|
+
bytes32 perUser = keccak256(abi.encodePacked('app:user:', uint256(7)));
|
|
64
|
+
bytes32 perInvoice = keccak256(abi.encodePacked('app:invoice:', uint256(7)));
|
|
65
|
+
require(factory.computeAddress(perUser) != factory.computeAddress(perInvoice), 'distinct');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function test_sweepNeedsNoApprovalAndNoGasAtTheDepositAddress() public {
|
|
69
|
+
// The property that makes this design work at all. The deposit address has
|
|
70
|
+
// no key, no native balance and has never signed anything.
|
|
71
|
+
bytes32 salt = keccak256('user:1');
|
|
72
|
+
address deposit = factory.computeAddress(salt);
|
|
73
|
+
token.mint(deposit, 500);
|
|
74
|
+
require(deposit.balance == 0, 'no gas at the deposit address');
|
|
75
|
+
|
|
76
|
+
bytes32[] memory salts = new bytes32[](1);
|
|
77
|
+
salts[0] = salt;
|
|
78
|
+
factory.sweepTo(salts, address(token), hot);
|
|
79
|
+
|
|
80
|
+
require(token.balanceOf(deposit) == 0, 'collected');
|
|
81
|
+
require(token.balanceOf(hot) == 500, 'delivered');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function test_theProxyIsDeployedLazilyAndOnlyOnce() public {
|
|
85
|
+
bytes32 salt = keccak256('user:2');
|
|
86
|
+
address deposit = factory.computeAddress(salt);
|
|
87
|
+
token.mint(deposit, 100);
|
|
88
|
+
|
|
89
|
+
bytes32[] memory salts = new bytes32[](1);
|
|
90
|
+
salts[0] = salt;
|
|
91
|
+
factory.sweepTo(salts, address(token), hot);
|
|
92
|
+
require(deposit.code.length > 0, 'deployed on first sweep');
|
|
93
|
+
|
|
94
|
+
token.mint(deposit, 100);
|
|
95
|
+
factory.sweepTo(salts, address(token), hot);
|
|
96
|
+
require(token.balanceOf(hot) == 200, 'second sweep works');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function test_onlyTheFactoryCanSweepAProxy() public {
|
|
100
|
+
bytes32 salt = keccak256('user:3');
|
|
101
|
+
address deposit = factory.computeAddress(salt);
|
|
102
|
+
token.mint(deposit, 100);
|
|
103
|
+
bytes32[] memory salts = new bytes32[](1);
|
|
104
|
+
salts[0] = salt;
|
|
105
|
+
factory.sweepTo(salts, address(token), hot);
|
|
106
|
+
|
|
107
|
+
// Deployed now — and inert to everybody else.
|
|
108
|
+
vm.prank(address(0xBAD));
|
|
109
|
+
vm.expectRevert(DepositProxy.OnlyFactory.selector);
|
|
110
|
+
DepositProxy(payable(deposit)).sweep(address(token), address(0xBAD));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function test_manyAddressesConsolidateInOneTransaction() public {
|
|
114
|
+
bytes32[] memory salts = new bytes32[](3);
|
|
115
|
+
for (uint256 i = 0; i < 3; i++) {
|
|
116
|
+
salts[i] = keccak256(abi.encodePacked('user:', i));
|
|
117
|
+
token.mint(factory.computeAddress(salts[i]), 100);
|
|
118
|
+
}
|
|
119
|
+
factory.sweepTo(salts, address(token), hot);
|
|
120
|
+
require(token.balanceOf(hot) == 300, 'all three');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function test_anAlreadyEmptyAddressDoesNotUndoTheBatch() public {
|
|
124
|
+
bytes32[] memory salts = new bytes32[](2);
|
|
125
|
+
salts[0] = keccak256('empty');
|
|
126
|
+
salts[1] = keccak256('funded');
|
|
127
|
+
token.mint(factory.computeAddress(salts[1]), 250);
|
|
128
|
+
factory.sweepTo(salts, address(token), hot);
|
|
129
|
+
require(token.balanceOf(hot) == 250, 'funded one still swept');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function test_nativeCoinSweeps() public {
|
|
133
|
+
bytes32 salt = keccak256('native');
|
|
134
|
+
address deposit = factory.computeAddress(salt);
|
|
135
|
+
vm.deal(deposit, 3 ether);
|
|
136
|
+
bytes32[] memory salts = new bytes32[](1);
|
|
137
|
+
salts[0] = salt;
|
|
138
|
+
factory.sweepTo(salts, address(0), hot);
|
|
139
|
+
require(hot.balance == 3 ether, 'native delivered');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function test_usdtStyleTokenSweeps() public {
|
|
143
|
+
// Returns nothing from transfer. A bool-typed call reverts on decode.
|
|
144
|
+
MockNoReturnToken usdt = new MockNoReturnToken();
|
|
145
|
+
bytes32 salt = keccak256('usdt');
|
|
146
|
+
usdt.mint(factory.computeAddress(salt), 900);
|
|
147
|
+
bytes32[] memory salts = new bytes32[](1);
|
|
148
|
+
salts[0] = salt;
|
|
149
|
+
factory.sweepTo(salts, address(usdt), hot);
|
|
150
|
+
require(usdt.balanceOf(hot) == 900, 'usdt swept');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function test_feeOnTransferMovesWhatActuallyArrives() public {
|
|
154
|
+
// The proxy sweeps its measured balance, so the platform never credits a
|
|
155
|
+
// figure the token did not deliver.
|
|
156
|
+
MockFeeToken fee = new MockFeeToken();
|
|
157
|
+
bytes32 salt = keccak256('fee');
|
|
158
|
+
fee.mint(factory.computeAddress(salt), 1000);
|
|
159
|
+
bytes32[] memory salts = new bytes32[](1);
|
|
160
|
+
salts[0] = salt;
|
|
161
|
+
factory.sweepTo(salts, address(fee), hot);
|
|
162
|
+
// 1% on the way to the factory, 1% again on the way out.
|
|
163
|
+
require(fee.balanceOf(hot) < 1000, 'fee was taken');
|
|
164
|
+
require(fee.balanceOf(hot) > 970, 'and only the fee');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function test_aStrangerCannotSweep() public {
|
|
168
|
+
bytes32[] memory salts = new bytes32[](0);
|
|
169
|
+
vm.prank(address(0xBAD));
|
|
170
|
+
vm.expectRevert(DepositFactory.NotOwner.selector);
|
|
171
|
+
factory.sweepTo(salts, address(token), hot);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
contract VaultTest {
|
|
176
|
+
Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
|
|
177
|
+
|
|
178
|
+
ColdVault vault;
|
|
179
|
+
MockToken token;
|
|
180
|
+
|
|
181
|
+
uint256 k1 = 0xA11CE;
|
|
182
|
+
uint256 k2 = 0xB0B;
|
|
183
|
+
uint256 k3 = 0xCA401;
|
|
184
|
+
address c1;
|
|
185
|
+
address c2;
|
|
186
|
+
address c3;
|
|
187
|
+
address operator = address(0x0FF);
|
|
188
|
+
address payee = address(0xFEE);
|
|
189
|
+
|
|
190
|
+
function setUp() public {
|
|
191
|
+
c1 = vm.addr(k1);
|
|
192
|
+
c2 = vm.addr(k2);
|
|
193
|
+
c3 = vm.addr(k3);
|
|
194
|
+
address[] memory custodians = new address[](3);
|
|
195
|
+
// Sorted, because signatures must arrive in ascending signer order.
|
|
196
|
+
(custodians[0], custodians[1], custodians[2]) = _sorted(c1, c2, c3);
|
|
197
|
+
vault = new ColdVault(custodians, 2);
|
|
198
|
+
token = new MockToken();
|
|
199
|
+
token.mint(address(vault), 1_000_000);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function _sorted(address a, address b, address c) private pure returns (address, address, address) {
|
|
203
|
+
address t;
|
|
204
|
+
if (a > b) { t = a; a = b; b = t; }
|
|
205
|
+
if (b > c) { t = b; b = c; c = t; }
|
|
206
|
+
if (a > b) { t = a; a = b; b = t; }
|
|
207
|
+
return (a, b, c);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _sign(uint256 key, bytes32 digest) private pure returns (bytes memory) {
|
|
211
|
+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digest);
|
|
212
|
+
return abi.encodePacked(r, s, v);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Two signatures, ordered by signer address as the vault requires. */
|
|
216
|
+
function _quorum(bytes32 digest) private view returns (bytes[] memory out) {
|
|
217
|
+
out = new bytes[](2);
|
|
218
|
+
uint256[3] memory keys = [k1, k2, k3];
|
|
219
|
+
address[3] memory addrs = [c1, c2, c3];
|
|
220
|
+
// Pick the two lowest addresses so the ordering rule is satisfied.
|
|
221
|
+
uint256 lowest = 0;
|
|
222
|
+
for (uint256 i = 1; i < 3; i++) if (addrs[i] < addrs[lowest]) lowest = i;
|
|
223
|
+
uint256 second = lowest == 0 ? 1 : 0;
|
|
224
|
+
for (uint256 i = 0; i < 3; i++) {
|
|
225
|
+
if (i != lowest && addrs[i] < addrs[second]) second = i;
|
|
226
|
+
}
|
|
227
|
+
if (second == lowest) second = (lowest + 1) % 3;
|
|
228
|
+
out[0] = _sign(keys[lowest], digest);
|
|
229
|
+
out[1] = _sign(keys[second], digest);
|
|
230
|
+
if (addrs[lowest] > addrs[second]) {
|
|
231
|
+
bytes memory tmp = out[0]; out[0] = out[1]; out[1] = tmp;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ======================================================= cold vault === */
|
|
236
|
+
|
|
237
|
+
function test_quorumMovesFunds() public {
|
|
238
|
+
bytes32 digest = vault.digestFor(address(token), payee, 5000);
|
|
239
|
+
vault.withdraw(address(token), payee, 5000, _quorum(digest));
|
|
240
|
+
require(token.balanceOf(payee) == 5000, 'paid');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function test_oneSignatureIsNotAQuorum() public {
|
|
244
|
+
bytes32 digest = vault.digestFor(address(token), payee, 5000);
|
|
245
|
+
bytes[] memory one = new bytes[](1);
|
|
246
|
+
one[0] = _sign(k1, digest);
|
|
247
|
+
vm.expectRevert(abi.encodeWithSelector(ColdVault.NotEnoughSignatures.selector, 1, 2));
|
|
248
|
+
vault.withdraw(address(token), payee, 5000, one);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function test_theSameCustodianTwiceIsNotTwoCustodians() public {
|
|
252
|
+
// Without the ascending-order rule, one custodian submits M copies of
|
|
253
|
+
// their own approval and an M-of-N vault is a 1-of-N vault.
|
|
254
|
+
bytes32 digest = vault.digestFor(address(token), payee, 5000);
|
|
255
|
+
bytes[] memory doubled = new bytes[](2);
|
|
256
|
+
doubled[0] = _sign(k1, digest);
|
|
257
|
+
doubled[1] = _sign(k1, digest);
|
|
258
|
+
vm.expectRevert(ColdVault.SignaturesOutOfOrder.selector);
|
|
259
|
+
vault.withdraw(address(token), payee, 5000, doubled);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function test_aStrangersSignatureIsRejected() public {
|
|
263
|
+
bytes32 digest = vault.digestFor(address(token), payee, 5000);
|
|
264
|
+
bytes[] memory sigs = new bytes[](2);
|
|
265
|
+
uint256 outsider = 0xDEAD;
|
|
266
|
+
address outsiderAddr = vm.addr(outsider);
|
|
267
|
+
// Ordered so the failure is "not a custodian", not the ordering rule.
|
|
268
|
+
if (outsiderAddr < c1) {
|
|
269
|
+
sigs[0] = _sign(outsider, digest);
|
|
270
|
+
sigs[1] = _sign(k1, digest);
|
|
271
|
+
} else {
|
|
272
|
+
sigs[0] = _sign(k1, digest);
|
|
273
|
+
sigs[1] = _sign(outsider, digest);
|
|
274
|
+
}
|
|
275
|
+
vm.expectRevert(abi.encodeWithSelector(ColdVault.NotACustodian.selector, outsiderAddr));
|
|
276
|
+
vault.withdraw(address(token), payee, 5000, sigs);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function test_aSpentApprovalCannotBeReplayed() public {
|
|
280
|
+
// The nonce is what makes a gathered signature spendable once. Without it,
|
|
281
|
+
// a set of approvals collected today drains the vault forever.
|
|
282
|
+
bytes32 digest = vault.digestFor(address(token), payee, 5000);
|
|
283
|
+
bytes[] memory sigs = _quorum(digest);
|
|
284
|
+
vault.withdraw(address(token), payee, 5000, sigs);
|
|
285
|
+
|
|
286
|
+
// The nonce moved, so the same bytes now recover to SOME other address --
|
|
287
|
+
// whichever one, it is not a custodian, and the vault refuses.
|
|
288
|
+
uint256 before = token.balanceOf(payee);
|
|
289
|
+
vm.expectRevert();
|
|
290
|
+
vault.withdraw(address(token), payee, 5000, sigs);
|
|
291
|
+
require(token.balanceOf(payee) == before, 'nothing moved twice');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function test_signaturesDoNotTransferToADifferentAmount() public {
|
|
295
|
+
// The amount is inside what was signed, so a submitter cannot inflate it.
|
|
296
|
+
bytes32 digest = vault.digestFor(address(token), payee, 5000);
|
|
297
|
+
bytes[] memory sigs = _quorum(digest);
|
|
298
|
+
vm.expectRevert();
|
|
299
|
+
vault.withdraw(address(token), payee, 999_999, sigs);
|
|
300
|
+
require(token.balanceOf(payee) == 0, 'nothing moved');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function test_signaturesDoNotTransferToADifferentDestination() public {
|
|
304
|
+
bytes32 digest = vault.digestFor(address(token), payee, 5000);
|
|
305
|
+
bytes[] memory sigs = _quorum(digest);
|
|
306
|
+
vm.expectRevert();
|
|
307
|
+
vault.withdraw(address(token), address(0xBAD), 5000, sigs);
|
|
308
|
+
require(token.balanceOf(address(0xBAD)) == 0, 'nothing moved');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function test_signaturesFromAnotherVaultAreVoidHere() public {
|
|
312
|
+
// `verifyingContract` is in the domain, so approvals gathered for one
|
|
313
|
+
// deployment cannot be pointed at another.
|
|
314
|
+
address[] memory custodians = new address[](3);
|
|
315
|
+
(custodians[0], custodians[1], custodians[2]) = _sorted(c1, c2, c3);
|
|
316
|
+
ColdVault other = new ColdVault(custodians, 2);
|
|
317
|
+
token.mint(address(other), 10_000);
|
|
318
|
+
|
|
319
|
+
bytes32 otherDigest = other.digestFor(address(token), payee, 5000);
|
|
320
|
+
bytes[] memory sigs = _quorum(otherDigest);
|
|
321
|
+
vm.expectRevert();
|
|
322
|
+
vault.withdraw(address(token), payee, 5000, sigs);
|
|
323
|
+
require(token.balanceOf(payee) == 0, 'nothing moved');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function test_aThresholdOfOneIsRefused() public {
|
|
327
|
+
address[] memory custodians = new address[](3);
|
|
328
|
+
(custodians[0], custodians[1], custodians[2]) = _sorted(c1, c2, c3);
|
|
329
|
+
vm.expectRevert(ColdVault.BadThreshold.selector);
|
|
330
|
+
new ColdVault(custodians, 1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function test_aThresholdAboveTheSetIsRefused() public {
|
|
334
|
+
// A vault nobody can ever open is not more secure, it is destroyed.
|
|
335
|
+
address[] memory custodians = new address[](2);
|
|
336
|
+
custodians[0] = c1 < c2 ? c1 : c2;
|
|
337
|
+
custodians[1] = c1 < c2 ? c2 : c1;
|
|
338
|
+
vm.expectRevert(ColdVault.BadThreshold.selector);
|
|
339
|
+
new ColdVault(custodians, 3);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function test_nobodyCanRotateCustodiansWithoutTheQuorum() public {
|
|
343
|
+
address[] memory next = new address[](2);
|
|
344
|
+
next[0] = address(0x1);
|
|
345
|
+
next[1] = address(0x2);
|
|
346
|
+
vm.expectRevert(ColdVault.OnlySelf.selector);
|
|
347
|
+
vault.setCustodians(next, 2);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ------------------------------------------------------------------------
|
|
351
|
+
* There is no hot vault, deliberately.
|
|
352
|
+
*
|
|
353
|
+
* A hot wallet is an ADDRESS, not a contract. The factory already holds float
|
|
354
|
+
* and already sweeps to any destination, so a separate contract to hold
|
|
355
|
+
* working capital adds a deployment, an upgrade path and a second set of
|
|
356
|
+
* permissions without adding a property neither of these already has.
|
|
357
|
+
*
|
|
358
|
+
* Operational funds sit at whatever address the platform sweeps to; the cold
|
|
359
|
+
* vault holds what M-of-N custodians guard. Two things, each doing one job.
|
|
360
|
+
* ---------------------------------------------------------------------- */
|
|
361
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.24;
|
|
3
|
+
|
|
4
|
+
import { DepositFactory } from '../src/DepositFactory.sol';
|
|
5
|
+
import { ColdVault } from '../src/ColdVault.sol';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Emit values the TypeScript client must reproduce.
|
|
9
|
+
*
|
|
10
|
+
* The client mirrors two pure contract functions. Checking it against itself
|
|
11
|
+
* proves nothing — a formula wrong the same way every time is deterministic and
|
|
12
|
+
* still points a platform at an address the factory cannot sweep.
|
|
13
|
+
*/
|
|
14
|
+
contract VectorsTest {
|
|
15
|
+
function test_emitVectors() public {
|
|
16
|
+
// A fixed owner so the factory address is stable across runs.
|
|
17
|
+
DepositFactory factory = new DepositFactory(address(0xF00D));
|
|
18
|
+
|
|
19
|
+
bytes32 salt = keccak256(abi.encodePacked('myapp:user:', 'u1'));
|
|
20
|
+
|
|
21
|
+
address[] memory custodians = new address[](3);
|
|
22
|
+
custodians[0] = address(0x1111111111111111111111111111111111111111);
|
|
23
|
+
custodians[1] = address(0x2222222222222222222222222222222222222222);
|
|
24
|
+
custodians[2] = address(0x3333333333333333333333333333333333333333);
|
|
25
|
+
ColdVault vault = new ColdVault(custodians, 2);
|
|
26
|
+
|
|
27
|
+
bytes32 digest = vault.digestFor(
|
|
28
|
+
address(0xdAC17F958D2ee523a2206206994597C13D831ec7),
|
|
29
|
+
address(0x000000000000000000000000000000000000dEaD),
|
|
30
|
+
1_000_000
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Read from the trace; the TS test asserts against these.
|
|
34
|
+
require(factory.computeAddress(salt) != address(0), 'derived');
|
|
35
|
+
emit Vector('factory', abi.encode(address(factory)));
|
|
36
|
+
emit Vector('proxyInitCodeHash', abi.encode(factory.PROXY_INIT_CODE_HASH()));
|
|
37
|
+
emit Vector('salt', abi.encode(salt));
|
|
38
|
+
emit Vector('depositAddress', abi.encode(factory.computeAddress(salt)));
|
|
39
|
+
emit Vector('vault', abi.encode(address(vault)));
|
|
40
|
+
emit Vector('chainId', abi.encode(block.chainid));
|
|
41
|
+
emit Vector('withdrawDigest', abi.encode(digest));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
event Vector(string name, bytes value);
|
|
45
|
+
}
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An audit trail that can prove it was not edited.
|
|
3
|
+
*
|
|
4
|
+
* ForgeZero already writes an audit log. What it cannot do — what no ordinary
|
|
5
|
+
* log can do — is answer the question that actually matters when somebody asks:
|
|
6
|
+
* *is this the same trail you wrote at the time?* A row in a database is exactly
|
|
7
|
+
* as trustworthy as everyone holding write access to that database, which during
|
|
8
|
+
* an incident is precisely the set of people under suspicion.
|
|
9
|
+
*
|
|
10
|
+
* Chaining fixes that without any new infrastructure. Each record carries the
|
|
11
|
+
* digest of the one before it, so the trail is a hash chain: alter a record and
|
|
12
|
+
* every subsequent link fails to match. Delete one and the seam is visible at
|
|
13
|
+
* the join. The log becomes evidence rather than testimony.
|
|
14
|
+
*
|
|
15
|
+
* ## What this buys, precisely
|
|
16
|
+
*
|
|
17
|
+
* It makes tampering **detectable**, not impossible. Someone with write access
|
|
18
|
+
* can still rewrite the entire chain from the altered record forward. Three
|
|
19
|
+
* things make that expensive, and the package supports all three:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Anchors** — a digest published somewhere the operator does not control
|
|
22
|
+
* (a customer's inbox, an object store with retention lock, a counter-signed
|
|
23
|
+
* report). Rewriting history then requires rewriting the anchor too.
|
|
24
|
+
* 2. **Per-realm chains** — one tampered tenant does not require forging every
|
|
25
|
+
* other tenant's history, and one busy tenant does not serialise the rest.
|
|
26
|
+
* 3. **Sealed digests** — the chain digest is an HMAC under a key held outside
|
|
27
|
+
* the database, so write access alone is not enough to recompute a valid
|
|
28
|
+
* successor. See `sealedChain`.
|
|
29
|
+
*
|
|
30
|
+
* Claiming more than this would be the actual failure. A hash chain in a
|
|
31
|
+
* database you control is *tamper-evident*; only an external anchor makes any
|
|
32
|
+
* part of it *tamper-proof*, and only as far back as the last anchor.
|
|
33
|
+
*
|
|
34
|
+
* ## Why not sign every record
|
|
35
|
+
*
|
|
36
|
+
* A signature per record is a keypair on the write path, a verification cost per
|
|
37
|
+
* row, and a key that must be online to append. The chain gets detection from a
|
|
38
|
+
* hash — no key material, no online signer — and signing is applied to the
|
|
39
|
+
* *anchor* instead, where one signature covers everything since the last one.
|
|
40
|
+
*/
|
|
41
|
+
export declare class AuditChainError extends Error {
|
|
42
|
+
readonly code: 'BROKEN_LINK' | 'BAD_DIGEST' | 'OUT_OF_ORDER' | 'EMPTY_RANGE' | 'NOT_GENESIS';
|
|
43
|
+
/** Where the chain first fails. The only number worth reporting. */
|
|
44
|
+
readonly at?: number | undefined;
|
|
45
|
+
constructor(code: 'BROKEN_LINK' | 'BAD_DIGEST' | 'OUT_OF_ORDER' | 'EMPTY_RANGE' | 'NOT_GENESIS', message: string,
|
|
46
|
+
/** Where the chain first fails. The only number worth reporting. */
|
|
47
|
+
at?: number | undefined);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The first record's predecessor.
|
|
51
|
+
*
|
|
52
|
+
* A constant rather than an empty string so that "this is the start of the
|
|
53
|
+
* chain" and "somebody blanked the field" are different values. The second is a
|
|
54
|
+
* break; the first is not, and a verifier that cannot tell them apart either
|
|
55
|
+
* rejects every valid chain or accepts a truncated one.
|
|
56
|
+
*/
|
|
57
|
+
export declare const GENESIS_DIGEST: string;
|
|
58
|
+
/** What the caller supplies. Everything else is computed. */
|
|
59
|
+
export interface AuditEntry {
|
|
60
|
+
action: string;
|
|
61
|
+
outcome: 'allowed' | 'denied';
|
|
62
|
+
actor?: string;
|
|
63
|
+
realm?: string;
|
|
64
|
+
targetCollection?: string;
|
|
65
|
+
targetKey?: string;
|
|
66
|
+
/** Non-secret context. Never a value, never a credential. */
|
|
67
|
+
detail?: Record<string, string | number | boolean | null>;
|
|
68
|
+
reason?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface AuditRecord extends AuditEntry {
|
|
71
|
+
/** Position in this realm's chain, from 1. Gaps are themselves evidence. */
|
|
72
|
+
sequence: number;
|
|
73
|
+
atMs: number;
|
|
74
|
+
/** The previous record's `digest`, or `GENESIS_DIGEST` for the first. */
|
|
75
|
+
previousDigest: string;
|
|
76
|
+
digest: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The bytes that get hashed.
|
|
80
|
+
*
|
|
81
|
+
* Field order is fixed here and never derived from `Object.keys`, because
|
|
82
|
+
* insertion order differs between a record built by a handler and the same
|
|
83
|
+
* record read back from a database — and a verifier that hashes them in a
|
|
84
|
+
* different order reports every record as tampered.
|
|
85
|
+
*
|
|
86
|
+
* `undefined` and an absent key must serialise identically for the same reason:
|
|
87
|
+
* a driver that drops null fields on write would otherwise break every chain it
|
|
88
|
+
* touches.
|
|
89
|
+
*/
|
|
90
|
+
export declare function canonicalise(record: Omit<AuditRecord, 'digest'>): string;
|
|
91
|
+
/** How a digest is computed. Swappable so the sealed variant can substitute HMAC. */
|
|
92
|
+
export type Digester = (input: string) => Promise<string>;
|
|
93
|
+
export declare const hashDigester: Digester;
|
|
94
|
+
/**
|
|
95
|
+
* A digester keyed by a secret held outside the database.
|
|
96
|
+
*
|
|
97
|
+
* With a plain hash, anyone who can write rows can also recompute a valid chain
|
|
98
|
+
* — detection depends on them not bothering. With an HMAC they additionally need
|
|
99
|
+
* the key, which lives in the vault and not in the collection being defended.
|
|
100
|
+
* That is the difference between "an attacker must be careful" and "an attacker
|
|
101
|
+
* must also have compromised the vault".
|
|
102
|
+
*/
|
|
103
|
+
export declare const sealedDigester: (key: string | Uint8Array) => Digester;
|
|
104
|
+
/** Where records go, and where the tail is read from to chain onto it. */
|
|
105
|
+
export interface AuditStore {
|
|
106
|
+
/** The most recent record in this realm's chain, or null if it has none. */
|
|
107
|
+
tail(realm?: string): Promise<AuditRecord | null>;
|
|
108
|
+
append(record: AuditRecord): Promise<void>;
|
|
109
|
+
/** Ascending by sequence. Inclusive bounds. */
|
|
110
|
+
range(realm: string | undefined, fromSequence: number, toSequence: number): Promise<AuditRecord[]>;
|
|
111
|
+
}
|
|
112
|
+
export interface AppendOptions {
|
|
113
|
+
digester?: Digester;
|
|
114
|
+
now?: () => number;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Add one record to the end of a realm's chain.
|
|
118
|
+
*
|
|
119
|
+
* The read of the tail and the append are **not** atomic here, and cannot be
|
|
120
|
+
* without knowing the store. Two concurrent appends can therefore both chain
|
|
121
|
+
* onto the same predecessor, producing two records with one sequence number.
|
|
122
|
+
* That is a duplicate, not a forgery, and `verifyChain` reports it as
|
|
123
|
+
* `OUT_OF_ORDER` rather than silently accepting one.
|
|
124
|
+
*
|
|
125
|
+
* The fix belongs in the store: a unique index on `(realm, sequence)` turns the
|
|
126
|
+
* race into a failed insert that the caller retries. `createAuditChain` below
|
|
127
|
+
* serialises appends per realm in-process, which closes it for a single node;
|
|
128
|
+
* the unique index is what closes it for a cluster. Both are cheap, and the
|
|
129
|
+
* combination is what makes the sequence trustworthy.
|
|
130
|
+
*/
|
|
131
|
+
export declare function appendRecord(store: AuditStore, entry: AuditEntry, options?: AppendOptions): Promise<AuditRecord>;
|
|
132
|
+
export interface ChainVerdict {
|
|
133
|
+
ok: boolean;
|
|
134
|
+
checked: number;
|
|
135
|
+
/** The first record that fails, and how. Absent when the chain holds. */
|
|
136
|
+
brokenAt?: number;
|
|
137
|
+
reason?: AuditChainError['code'];
|
|
138
|
+
message?: string;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Walk a chain and report the FIRST break.
|
|
142
|
+
*
|
|
143
|
+
* First, not all, deliberately. Once a link fails every subsequent digest is
|
|
144
|
+
* computed over a different predecessor, so everything after it also "fails" —
|
|
145
|
+
* reporting all of them buries the one that matters under thousands that are
|
|
146
|
+
* merely downstream of it. The first break is where the edit happened.
|
|
147
|
+
*
|
|
148
|
+
* `expectGenesis` distinguishes verifying a whole chain from verifying an
|
|
149
|
+
* exported slice. A slice legitimately starts mid-chain with a non-genesis
|
|
150
|
+
* predecessor; a full chain that does not start at genesis has been truncated
|
|
151
|
+
* from the front, which is exactly the deletion this is meant to catch.
|
|
152
|
+
*/
|
|
153
|
+
export declare function verifyChain(records: readonly AuditRecord[], options?: {
|
|
154
|
+
digester?: Digester;
|
|
155
|
+
expectGenesis?: boolean;
|
|
156
|
+
}): Promise<ChainVerdict>;
|
|
157
|
+
export interface AuditExport {
|
|
158
|
+
realm?: string;
|
|
159
|
+
fromSequence: number;
|
|
160
|
+
toSequence: number;
|
|
161
|
+
/** The link the slice hangs from, so a verifier can attach it to the chain. */
|
|
162
|
+
anchorDigest: string;
|
|
163
|
+
records: readonly AuditRecord[];
|
|
164
|
+
/** Digest over the whole slice — one value to publish or counter-sign. */
|
|
165
|
+
sealDigest: string;
|
|
166
|
+
exportedAtMs: number;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Take a verifiable slice out of the chain.
|
|
170
|
+
*
|
|
171
|
+
* Exports carry `anchorDigest` — the digest of the record *before* the slice —
|
|
172
|
+
* because a slice on its own proves only that those records are consistent with
|
|
173
|
+
* each other. Somebody who rewrote history could produce an internally perfect
|
|
174
|
+
* slice. Anchoring it to a digest that was published earlier is what makes the
|
|
175
|
+
* slice evidence about a specific past rather than about a self-consistent story.
|
|
176
|
+
*
|
|
177
|
+
* The export is verified before it is returned. An export that has not been
|
|
178
|
+
* verified is worse than none: it looks authoritative and nobody re-checks it.
|
|
179
|
+
*/
|
|
180
|
+
export declare function exportRange(store: AuditStore, args: {
|
|
181
|
+
realm?: string;
|
|
182
|
+
fromSequence: number;
|
|
183
|
+
toSequence: number;
|
|
184
|
+
}, options?: AppendOptions): Promise<AuditExport>;
|
|
185
|
+
/**
|
|
186
|
+
* Check an export against the anchor somebody kept.
|
|
187
|
+
*
|
|
188
|
+
* The whole point of publishing a digest is being able to do this later. If the
|
|
189
|
+
* anchor does not match, the chain was rewritten *after* the anchor was
|
|
190
|
+
* published — which is the strongest statement this package can make, and the
|
|
191
|
+
* only one that survives an operator with database access.
|
|
192
|
+
*/
|
|
193
|
+
export declare function verifyExport(slice: AuditExport, options?: {
|
|
194
|
+
digester?: Digester;
|
|
195
|
+
expectSealDigest?: string;
|
|
196
|
+
}): Promise<ChainVerdict>;
|
|
197
|
+
/**
|
|
198
|
+
* The `AuditSink` shape `@forgezero/access/effects` expects, backed by a chain.
|
|
199
|
+
*
|
|
200
|
+
* Structural rather than an import, so this package does not depend on `access`
|
|
201
|
+
* for a five-field interface — and so two copies of `access` in a dependency
|
|
202
|
+
* tree cannot make an otherwise valid sink unassignable.
|
|
203
|
+
*/
|
|
204
|
+
export interface EffectAuditRecord {
|
|
205
|
+
route: string;
|
|
206
|
+
method: string;
|
|
207
|
+
outcome: 'allowed' | 'denied';
|
|
208
|
+
status: number;
|
|
209
|
+
code?: string;
|
|
210
|
+
userKey?: string;
|
|
211
|
+
realm?: string;
|
|
212
|
+
targetKey?: string;
|
|
213
|
+
atSec: number;
|
|
214
|
+
detail?: Record<string, unknown>;
|
|
215
|
+
}
|
|
216
|
+
export interface AuditChainOptions extends AppendOptions {
|
|
217
|
+
store: AuditStore;
|
|
218
|
+
/** Called when an append fails. Silence here is how a trail dies unnoticed. */
|
|
219
|
+
onError?: (error: unknown, entry: AuditEntry) => void;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A chain with appends serialised per realm.
|
|
223
|
+
*
|
|
224
|
+
* Serialising is what makes `sequence` mean anything on one node: two appends
|
|
225
|
+
* that read the same tail would otherwise both claim the same position. Per
|
|
226
|
+
* realm rather than globally, so one tenant's write rate cannot become another
|
|
227
|
+
* tenant's latency — the chains are independent and there is no reason to make
|
|
228
|
+
* them contend.
|
|
229
|
+
*
|
|
230
|
+
* This does NOT make appends safe across processes. A unique index on
|
|
231
|
+
* `(realm, sequence)` in the store is what does that, and it is the store's job
|
|
232
|
+
* because only the store knows how to express one.
|
|
233
|
+
*/
|
|
234
|
+
export declare function createAuditChain(options: AuditChainOptions): {
|
|
235
|
+
append: (entry: AuditEntry) => Promise<AuditRecord>;
|
|
236
|
+
verify: (realm?: string, fromSequence?: number, toSequence?: number) => Promise<ChainVerdict>;
|
|
237
|
+
export: (args: {
|
|
238
|
+
realm?: string;
|
|
239
|
+
fromSequence: number;
|
|
240
|
+
toSequence: number;
|
|
241
|
+
}) => Promise<AuditExport>;
|
|
242
|
+
/**
|
|
243
|
+
* The `AuditSink` face.
|
|
244
|
+
*
|
|
245
|
+
* Never throws and never returns a rejected promise: an effect that fails
|
|
246
|
+
* the request when the audit store is down converts a logging outage into
|
|
247
|
+
* an outage, which is the one failure mode `effects` exists to prevent.
|
|
248
|
+
*/
|
|
249
|
+
sink: {
|
|
250
|
+
write(record: EffectAuditRecord): void;
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
export type AuditChain = ReturnType<typeof createAuditChain>;
|
|
254
|
+
/**
|
|
255
|
+
* An in-memory store. Tests and a single-process development run.
|
|
256
|
+
*
|
|
257
|
+
* Enforces the unique `(realm, sequence)` constraint that a real store gets from
|
|
258
|
+
* an index — otherwise a test would pass against this and the same code would
|
|
259
|
+
* produce duplicate sequences in production.
|
|
260
|
+
*/
|
|
261
|
+
export declare function memoryStore(): AuditStore & {
|
|
262
|
+
all(realm?: string): AuditRecord[];
|
|
263
|
+
clear(): void;
|
|
264
|
+
};
|
|
265
|
+
export declare const VERSION = "0.1.0";
|