@forgezero/runtime 0.1.15 → 0.1.17
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/README.md +1 -1
- package/dist/jobs.d.ts +6 -7
- package/package.json +3 -6
- package/contracts/foundry.toml +0 -9
- package/contracts/src/ColdVault.sol +0 -206
- package/contracts/src/DepositFactory.sol +0 -202
- package/contracts/src/DepositProxy.sol +0 -72
- package/contracts/src/IERC20.sol +0 -7
- package/contracts/src/MockTokens.sol +0 -32
- package/contracts/src/SafeTransferLib.sol +0 -31
- package/contracts/test/Custody.t.sol +0 -361
- package/contracts/test/Vectors.t.sol +0 -45
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
and commit the generator and rendered files together.
|
|
6
6
|
-->
|
|
7
7
|
|
|
8
|
-
#
|
|
8
|
+
# Platform runtime
|
|
9
9
|
|
|
10
10
|
The machinery behind a request handler — jobs, queues, an outbox, a hash-chained audit trail, mail, backups, schema validation and exact money. 30 public modules, each imported on its own.
|
|
11
11
|
|
package/dist/jobs.d.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Jobs — background work that does not corrupt anything when it goes wrong.
|
|
3
3
|
*
|
|
4
|
-
* ForgeZero needs session and step-up reaping, provider health probes
|
|
5
|
-
* invoice runs.
|
|
6
|
-
* They are the same machinery, and the same four mistakes sink all of them:
|
|
4
|
+
* ForgeZero needs session and step-up reaping, provider health probes, Vault
|
|
5
|
+
* rotations and invoice runs. The same four mistakes sink all of them:
|
|
7
6
|
*
|
|
8
7
|
* OVERLAP `setInterval` fires again while the previous run is still going.
|
|
9
|
-
* Two
|
|
8
|
+
* Two rotation scans may promote competing credential generations.
|
|
10
9
|
* CURSOR the cursor advances past a batch that failed halfway, so the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* records in it are never seen again and the missed work is found
|
|
11
|
+
* only after an operational failure.
|
|
13
12
|
* DUPLICATE two processes run the same job because nothing coordinated them.
|
|
14
13
|
* SHUTDOWN a deploy kills a run mid-write, leaving a record half-updated.
|
|
15
14
|
*
|
|
@@ -200,7 +199,7 @@ export interface CursorJobSpec<T> extends Omit<JobSpec, 'run'> {
|
|
|
200
199
|
* item three of ten throws, the whole batch is retried from the same cursor on
|
|
201
200
|
* the next run. That means `process` must be idempotent — items one and two will
|
|
202
201
|
* be seen again — and idempotent retries are strictly better than the
|
|
203
|
-
* alternative, which is
|
|
202
|
+
* alternative, which is records that are never processed at all.
|
|
204
203
|
*
|
|
205
204
|
* The lease is re-checked between batches. A run that stalled past its lease and
|
|
206
205
|
* woke up must not keep writing while another process is doing the same work.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgezero/runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
"prepublishOnly": "bun ../tools/package-task.ts prepublish runtime"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"@forgezero/access": "^0.1.
|
|
137
|
+
"@forgezero/access": "^0.1.12"
|
|
138
138
|
},
|
|
139
139
|
"peerDependencies": {
|
|
140
140
|
"@noble/ciphers": "^2.2.0",
|
|
@@ -200,9 +200,6 @@
|
|
|
200
200
|
"files": [
|
|
201
201
|
"dist",
|
|
202
202
|
"README.md",
|
|
203
|
-
"LICENSE"
|
|
204
|
-
"contracts/src",
|
|
205
|
-
"contracts/test",
|
|
206
|
-
"contracts/foundry.toml"
|
|
203
|
+
"LICENSE"
|
|
207
204
|
]
|
|
208
205
|
}
|
package/contracts/foundry.toml
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity ^0.8.24;
|
|
3
|
-
|
|
4
|
-
import { IERC20 } from './IERC20.sol';
|
|
5
|
-
import { SafeTransferLib } from './SafeTransferLib.sol';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Cold storage: M-of-N custodians, and nothing else can move anything.
|
|
9
|
-
*
|
|
10
|
-
* There is no operator role here, no owner who can withdraw, and no key held by
|
|
11
|
-
* any running process. Funds move only when M distinct custodians have each
|
|
12
|
-
* signed the same withdrawal off-chain — on a Ledger, on an air-gapped machine,
|
|
13
|
-
* on whatever they keep their key in. The signatures are collected however the
|
|
14
|
-
* platform likes and submitted by anyone; the submitter has no power beyond
|
|
15
|
-
* paying for gas.
|
|
16
|
-
*
|
|
17
|
-
* That is what makes it cold. Compromising every server the platform runs does
|
|
18
|
-
* not move a single token, because no server holds a key that can.
|
|
19
|
-
*
|
|
20
|
-
* ## EIP-712, so a custodian can read what they are signing
|
|
21
|
-
*
|
|
22
|
-
* A raw hash tells a hardware wallet nothing, and a custodian approving an
|
|
23
|
-
* opaque blob is a custodian who will eventually approve the wrong one. The
|
|
24
|
-
* typed structure shows the token, the destination and the amount on the
|
|
25
|
-
* device's own screen.
|
|
26
|
-
*
|
|
27
|
-
* ## What binds a signature to exactly one withdrawal
|
|
28
|
-
*
|
|
29
|
-
* chainId a testnet rehearsal cannot be replayed on mainnet
|
|
30
|
-
* verifyingContract a signature for one vault is void at another
|
|
31
|
-
* nonce each approval is spendable once, in order
|
|
32
|
-
* token/to/amount changing any of them invalidates every signature
|
|
33
|
-
*
|
|
34
|
-
* Without all four, a signature gathered once is a signature reusable forever.
|
|
35
|
-
*/
|
|
36
|
-
contract ColdVault {
|
|
37
|
-
using SafeTransferLib for address;
|
|
38
|
-
|
|
39
|
-
/* --------------------------------------------------------------- state --- */
|
|
40
|
-
|
|
41
|
-
mapping(address => bool) public isCustodian;
|
|
42
|
-
address[] private custodianList;
|
|
43
|
-
uint256 public threshold;
|
|
44
|
-
uint256 public nonce;
|
|
45
|
-
|
|
46
|
-
bytes32 private immutable DOMAIN_SEPARATOR;
|
|
47
|
-
|
|
48
|
-
bytes32 private constant WITHDRAW_TYPEHASH =
|
|
49
|
-
keccak256('Withdraw(address token,address to,uint256 amount,uint256 nonce)');
|
|
50
|
-
|
|
51
|
-
/* -------------------------------------------------------------- events --- */
|
|
52
|
-
|
|
53
|
-
event Withdrawn(address indexed token, address indexed to, uint256 amount, uint256 nonce);
|
|
54
|
-
event CustodiansChanged(address[] custodians, uint256 threshold);
|
|
55
|
-
|
|
56
|
-
/* -------------------------------------------------------------- errors --- */
|
|
57
|
-
|
|
58
|
-
error BadThreshold();
|
|
59
|
-
error DuplicateCustodian();
|
|
60
|
-
error ZeroAddress();
|
|
61
|
-
error NotEnoughSignatures(uint256 got, uint256 need);
|
|
62
|
-
error SignaturesOutOfOrder();
|
|
63
|
-
error NotACustodian(address signer);
|
|
64
|
-
error NativeSendFailed();
|
|
65
|
-
error OnlySelf();
|
|
66
|
-
|
|
67
|
-
constructor(address[] memory initialCustodians, uint256 requiredSignatures) {
|
|
68
|
-
_setCustodians(initialCustodians, requiredSignatures);
|
|
69
|
-
DOMAIN_SEPARATOR = keccak256(
|
|
70
|
-
abi.encode(
|
|
71
|
-
keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
|
|
72
|
-
keccak256('ForgeZeroColdVault'),
|
|
73
|
-
keccak256('1'),
|
|
74
|
-
block.chainid,
|
|
75
|
-
address(this)
|
|
76
|
-
)
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/* ------------------------------------------------------------ withdraw --- */
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Move funds, given M valid custodian signatures over exactly this request.
|
|
84
|
-
*
|
|
85
|
-
* Signatures must be ordered by ascending signer address. That is not a style
|
|
86
|
-
* choice: it is how the same custodian signing twice is rejected in one pass
|
|
87
|
-
* without allocating a set, and without it an M-of-N vault is satisfied by
|
|
88
|
-
* one custodian submitting M copies of their own approval.
|
|
89
|
-
*/
|
|
90
|
-
function withdraw(
|
|
91
|
-
address token,
|
|
92
|
-
address to,
|
|
93
|
-
uint256 amount,
|
|
94
|
-
bytes[] calldata signatures
|
|
95
|
-
) external {
|
|
96
|
-
if (to == address(0)) revert ZeroAddress();
|
|
97
|
-
if (signatures.length < threshold) revert NotEnoughSignatures(signatures.length, threshold);
|
|
98
|
-
|
|
99
|
-
bytes32 digest = keccak256(
|
|
100
|
-
abi.encodePacked(
|
|
101
|
-
'\x19\x01',
|
|
102
|
-
DOMAIN_SEPARATOR,
|
|
103
|
-
keccak256(abi.encode(WITHDRAW_TYPEHASH, token, to, amount, nonce))
|
|
104
|
-
)
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
address previous = address(0);
|
|
108
|
-
for (uint256 i = 0; i < signatures.length; ) {
|
|
109
|
-
address signer = _recover(digest, signatures[i]);
|
|
110
|
-
if (!isCustodian[signer]) revert NotACustodian(signer);
|
|
111
|
-
// Strictly increasing. Equal means the same custodian counted twice.
|
|
112
|
-
if (signer <= previous) revert SignaturesOutOfOrder();
|
|
113
|
-
previous = signer;
|
|
114
|
-
unchecked { ++i; }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Bumped BEFORE the transfer, so every gathered signature is spent whether
|
|
118
|
-
// or not the token behaves, and a reverting token cannot be used to replay.
|
|
119
|
-
unchecked { ++nonce; }
|
|
120
|
-
|
|
121
|
-
if (token == address(0)) {
|
|
122
|
-
(bool ok, ) = to.call{ value: amount }('');
|
|
123
|
-
if (!ok) revert NativeSendFailed();
|
|
124
|
-
} else {
|
|
125
|
-
token.safeTransfer(to, amount);
|
|
126
|
-
}
|
|
127
|
-
emit Withdrawn(token, to, amount, nonce - 1);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Rotate the custodian set — itself requiring the current quorum.
|
|
132
|
-
*
|
|
133
|
-
* Callable only by this contract, through `withdraw`-style quorum: the
|
|
134
|
-
* platform submits a call to `address(this)` and the existing M must approve
|
|
135
|
-
* it. A custodian set changeable by anything less than the set itself is not
|
|
136
|
-
* a quorum.
|
|
137
|
-
*/
|
|
138
|
-
function setCustodians(address[] calldata nextCustodians, uint256 requiredSignatures) external {
|
|
139
|
-
if (msg.sender != address(this)) revert OnlySelf();
|
|
140
|
-
_setCustodians(nextCustodians, requiredSignatures);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function custodians() external view returns (address[] memory) {
|
|
144
|
-
return custodianList;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** What a custodian's device should be asked to sign, for the next withdrawal. */
|
|
148
|
-
function digestFor(address token, address to, uint256 amount) external view returns (bytes32) {
|
|
149
|
-
return
|
|
150
|
-
keccak256(
|
|
151
|
-
abi.encodePacked(
|
|
152
|
-
'\x19\x01',
|
|
153
|
-
DOMAIN_SEPARATOR,
|
|
154
|
-
keccak256(abi.encode(WITHDRAW_TYPEHASH, token, to, amount, nonce))
|
|
155
|
-
)
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/* ----------------------------------------------------------- internals --- */
|
|
160
|
-
|
|
161
|
-
function _setCustodians(address[] memory custodians_, uint256 requiredSignatures) private {
|
|
162
|
-
// A threshold of one is not a quorum, and a threshold above the set size
|
|
163
|
-
// is a vault nobody can ever open.
|
|
164
|
-
if (requiredSignatures < 2 || requiredSignatures > custodians_.length) revert BadThreshold();
|
|
165
|
-
|
|
166
|
-
for (uint256 i = 0; i < custodianList.length; ) {
|
|
167
|
-
isCustodian[custodianList[i]] = false;
|
|
168
|
-
unchecked { ++i; }
|
|
169
|
-
}
|
|
170
|
-
delete custodianList;
|
|
171
|
-
|
|
172
|
-
for (uint256 i = 0; i < custodians_.length; ) {
|
|
173
|
-
address who = custodians_[i];
|
|
174
|
-
if (who == address(0)) revert ZeroAddress();
|
|
175
|
-
if (isCustodian[who]) revert DuplicateCustodian();
|
|
176
|
-
isCustodian[who] = true;
|
|
177
|
-
custodianList.push(who);
|
|
178
|
-
unchecked { ++i; }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
threshold = requiredSignatures;
|
|
182
|
-
emit CustodiansChanged(custodians_, requiredSignatures);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function _recover(bytes32 digest, bytes calldata signature) private pure returns (address) {
|
|
186
|
-
if (signature.length != 65) return address(0);
|
|
187
|
-
bytes32 r;
|
|
188
|
-
bytes32 s;
|
|
189
|
-
uint8 v;
|
|
190
|
-
assembly {
|
|
191
|
-
r := calldataload(signature.offset)
|
|
192
|
-
s := calldataload(add(signature.offset, 32))
|
|
193
|
-
v := byte(0, calldataload(add(signature.offset, 64)))
|
|
194
|
-
}
|
|
195
|
-
if (v < 27) v += 27;
|
|
196
|
-
// secp256k1 signatures are malleable: (r, s) and (r, n-s) both verify. A
|
|
197
|
-
// vault that accepted both would let a submitted approval be mutated into a
|
|
198
|
-
// second distinct signature by the same custodian.
|
|
199
|
-
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
|
|
200
|
-
return address(0);
|
|
201
|
-
}
|
|
202
|
-
return ecrecover(digest, v, r, s);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
receive() external payable {}
|
|
206
|
-
}
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity ^0.8.24;
|
|
3
|
-
|
|
4
|
-
import { IERC20 } from './IERC20.sol';
|
|
5
|
-
import { DepositProxy } from './DepositProxy.sol';
|
|
6
|
-
import { SafeTransferLib } from './SafeTransferLib.sol';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Deterministic deposit addresses, and the sweep that collects them.
|
|
10
|
-
*
|
|
11
|
-
* ## The salt is opaque, deliberately
|
|
12
|
-
*
|
|
13
|
-
* This contract never learns what a salt MEANS. A platform that gives each user
|
|
14
|
-
* one lasting deposit address and a platform that mints a fresh address per
|
|
15
|
-
* invoice both work here unchanged, because the only thing this needs is a
|
|
16
|
-
* distinct 32-byte value. That is the difference between a runtime and one
|
|
17
|
-
* business's contract: encoding "user" or "invoice" here would force the choice
|
|
18
|
-
* on everybody who ever deployed it.
|
|
19
|
-
*
|
|
20
|
-
* The convention belongs to the caller — `keccak256("<app>:user:" || key)` or
|
|
21
|
-
* `keccak256("<app>:invoice:" || key)` — and both are just bytes32 to this
|
|
22
|
-
* contract.
|
|
23
|
-
*
|
|
24
|
-
* ## No chain write to create an address
|
|
25
|
-
*
|
|
26
|
-
* `computeAddress` is pure arithmetic over the deployer, the salt and the proxy
|
|
27
|
-
* init-code hash, so a platform derives a million deposit addresses with zero
|
|
28
|
-
* transactions and zero gas. The proxy is deployed lazily, at the first sweep,
|
|
29
|
-
* and only for addresses that actually received something.
|
|
30
|
-
*
|
|
31
|
-
* ## What this contract does NOT do
|
|
32
|
-
*
|
|
33
|
-
* It holds no policy. There is no per-user balance, no reserve, no fee, no
|
|
34
|
-
* notion of what is owed to whom — those are the platform's, and they belong in
|
|
35
|
-
* the platform's ledger where they can be changed without a redeploy. This moves
|
|
36
|
-
* tokens and nothing else.
|
|
37
|
-
*/
|
|
38
|
-
contract DepositFactory {
|
|
39
|
-
using SafeTransferLib for address;
|
|
40
|
-
|
|
41
|
-
address public owner;
|
|
42
|
-
address public pendingOwner;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* `keccak256(type(DepositProxy).creationCode)`, fixed at deploy.
|
|
46
|
-
*
|
|
47
|
-
* Exposed so an off-chain caller can mirror `computeAddress` byte for byte
|
|
48
|
-
* instead of hardcoding a hash that silently stops matching after a compiler
|
|
49
|
-
* upgrade.
|
|
50
|
-
*/
|
|
51
|
-
bytes32 public immutable PROXY_INIT_CODE_HASH;
|
|
52
|
-
|
|
53
|
-
event ProxyDeployed(bytes32 indexed salt, address indexed proxy);
|
|
54
|
-
event Swept(bytes32 indexed salt, address indexed token, uint256 amount);
|
|
55
|
-
event PaidOut(address indexed token, address indexed to, uint256 amount);
|
|
56
|
-
event OwnerProposed(address indexed newOwner);
|
|
57
|
-
event OwnerAccepted(address indexed previous, address indexed next);
|
|
58
|
-
|
|
59
|
-
error NotOwner();
|
|
60
|
-
error NotPendingOwner();
|
|
61
|
-
error ZeroAddress();
|
|
62
|
-
error Create2Failed();
|
|
63
|
-
error LengthMismatch();
|
|
64
|
-
error NativeSendFailed();
|
|
65
|
-
|
|
66
|
-
modifier onlyOwner() {
|
|
67
|
-
if (msg.sender != owner) revert NotOwner();
|
|
68
|
-
_;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
constructor(address initialOwner) {
|
|
72
|
-
if (initialOwner == address(0)) revert ZeroAddress();
|
|
73
|
-
owner = initialOwner;
|
|
74
|
-
PROXY_INIT_CODE_HASH = keccak256(type(DepositProxy).creationCode);
|
|
75
|
-
emit OwnerAccepted(address(0), initialOwner);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The deposit address for a salt. Pure, so deriving costs nothing. */
|
|
79
|
-
function computeAddress(bytes32 salt) public view returns (address) {
|
|
80
|
-
return
|
|
81
|
-
address(
|
|
82
|
-
uint160(
|
|
83
|
-
uint256(
|
|
84
|
-
keccak256(
|
|
85
|
-
abi.encodePacked(bytes1(0xff), address(this), salt, PROXY_INIT_CODE_HASH)
|
|
86
|
-
)
|
|
87
|
-
)
|
|
88
|
-
)
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Many at once, so a platform can reconcile a page of addresses in one call. */
|
|
93
|
-
function computeAddresses(bytes32[] calldata salts) external view returns (address[] memory out) {
|
|
94
|
-
out = new address[](salts.length);
|
|
95
|
-
for (uint256 i = 0; i < salts.length; ) {
|
|
96
|
-
out[i] = computeAddress(salts[i]);
|
|
97
|
-
unchecked { ++i; }
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Collect deposits into this contract, then pay out to the given destinations.
|
|
103
|
-
*
|
|
104
|
-
* One primitive rather than several, because every real operation is some
|
|
105
|
-
* combination of the two halves:
|
|
106
|
-
*
|
|
107
|
-
* withdraw for one user salts=[one] destinations=[external]
|
|
108
|
-
* consolidate to the float salts=[many] destinations=[hotAddress]
|
|
109
|
-
* move float to cold salts=[] destinations=[coldVault]
|
|
110
|
-
*
|
|
111
|
-
* Anything swept beyond what is paid out stays here as float, which is what
|
|
112
|
-
* makes the middle case a single transaction instead of one per address.
|
|
113
|
-
*/
|
|
114
|
-
function sweepAndPay(
|
|
115
|
-
bytes32[] calldata salts,
|
|
116
|
-
address token,
|
|
117
|
-
address[] calldata destinations,
|
|
118
|
-
uint256[] calldata amounts
|
|
119
|
-
) external onlyOwner {
|
|
120
|
-
if (destinations.length != amounts.length) revert LengthMismatch();
|
|
121
|
-
|
|
122
|
-
for (uint256 i = 0; i < salts.length; ) {
|
|
123
|
-
address proxy = _ensureProxy(salts[i]);
|
|
124
|
-
uint256 before = _balanceOf(token);
|
|
125
|
-
DepositProxy(payable(proxy)).sweep(token, address(this));
|
|
126
|
-
// The DIFFERENCE, not a number passed in. A fee-on-transfer token
|
|
127
|
-
// delivers less than it was asked to send, and an event reporting the
|
|
128
|
-
// requested figure is a ledger entry for money that never arrived.
|
|
129
|
-
emit Swept(salts[i], token, _balanceOf(token) - before);
|
|
130
|
-
unchecked { ++i; }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
for (uint256 j = 0; j < destinations.length; ) {
|
|
134
|
-
if (destinations[j] == address(0)) revert ZeroAddress();
|
|
135
|
-
_payOut(token, destinations[j], amounts[j]);
|
|
136
|
-
unchecked { ++j; }
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Sweep only. The common case: consolidate into a vault held elsewhere. */
|
|
141
|
-
function sweepTo(bytes32[] calldata salts, address token, address destination)
|
|
142
|
-
external
|
|
143
|
-
onlyOwner
|
|
144
|
-
{
|
|
145
|
-
if (destination == address(0)) revert ZeroAddress();
|
|
146
|
-
for (uint256 i = 0; i < salts.length; ) {
|
|
147
|
-
DepositProxy(payable(_ensureProxy(salts[i]))).sweep(token, address(this));
|
|
148
|
-
unchecked { ++i; }
|
|
149
|
-
}
|
|
150
|
-
uint256 balance = _balanceOf(token);
|
|
151
|
-
if (balance > 0) _payOut(token, destination, balance);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function proposeOwner(address next) external onlyOwner {
|
|
155
|
-
if (next == address(0)) revert ZeroAddress();
|
|
156
|
-
pendingOwner = next;
|
|
157
|
-
emit OwnerProposed(next);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Accepted by the new owner's own key.
|
|
162
|
-
*
|
|
163
|
-
* A one-step transfer to a mistyped address bricks every deposit address this
|
|
164
|
-
* factory derives — the funds stay reachable only by a key nobody holds.
|
|
165
|
-
*/
|
|
166
|
-
function acceptOwnership() external {
|
|
167
|
-
if (msg.sender != pendingOwner) revert NotPendingOwner();
|
|
168
|
-
address previous = owner;
|
|
169
|
-
owner = msg.sender;
|
|
170
|
-
pendingOwner = address(0);
|
|
171
|
-
emit OwnerAccepted(previous, msg.sender);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function _ensureProxy(bytes32 salt) internal returns (address proxy) {
|
|
175
|
-
proxy = computeAddress(salt);
|
|
176
|
-
if (proxy.code.length == 0) {
|
|
177
|
-
bytes memory bytecode = type(DepositProxy).creationCode;
|
|
178
|
-
assembly {
|
|
179
|
-
proxy := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
|
|
180
|
-
}
|
|
181
|
-
if (proxy == address(0)) revert Create2Failed();
|
|
182
|
-
emit ProxyDeployed(salt, proxy);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function _balanceOf(address token) internal view returns (uint256) {
|
|
187
|
-
return token == address(0) ? address(this).balance : IERC20(token).balanceOf(address(this));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function _payOut(address token, address to, uint256 amount) internal {
|
|
191
|
-
if (amount == 0) return;
|
|
192
|
-
if (token == address(0)) {
|
|
193
|
-
(bool ok, ) = to.call{ value: amount }('');
|
|
194
|
-
if (!ok) revert NativeSendFailed();
|
|
195
|
-
} else {
|
|
196
|
-
token.safeTransfer(to, amount);
|
|
197
|
-
}
|
|
198
|
-
emit PaidOut(token, to, amount);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
receive() external payable {}
|
|
202
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity ^0.8.24;
|
|
3
|
-
|
|
4
|
-
import { IERC20 } from './IERC20.sol';
|
|
5
|
-
import { SafeTransferLib } from './SafeTransferLib.sol';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* A deposit address that needs no key, no gas and no approval.
|
|
9
|
-
*
|
|
10
|
-
* This is the whole reason the CREATE2 pattern beats the obvious alternative.
|
|
11
|
-
* Sweeping from an ordinary account requires that account to sign an approval,
|
|
12
|
-
* which means every deposit address must first be funded with native coin and
|
|
13
|
-
* must have a private key derived, held and used. With a thousand deposit
|
|
14
|
-
* addresses that is a thousand gas top-ups and a thousand keys.
|
|
15
|
-
*
|
|
16
|
-
* A CREATE2 address holds balances before any code exists at it. The factory
|
|
17
|
-
* deploys this contract there only at the moment of the first sweep, and the
|
|
18
|
-
* constructor records the deployer, so nothing but that factory can ever move
|
|
19
|
-
* the funds.
|
|
20
|
-
*
|
|
21
|
-
* ## Never change this file
|
|
22
|
-
*
|
|
23
|
-
* The address depends on `keccak256(type(DepositProxy).creationCode)`. Adding a
|
|
24
|
-
* field, a function, or even changing the compiler settings changes the init
|
|
25
|
-
* code hash, which changes EVERY derived deposit address. Funds already sent to
|
|
26
|
-
* the old addresses stay recoverable only by the old factory. A change here is
|
|
27
|
-
* a new contract, deployed by a new factory, with addresses re-derived.
|
|
28
|
-
*/
|
|
29
|
-
contract DepositProxy {
|
|
30
|
-
using SafeTransferLib for address;
|
|
31
|
-
|
|
32
|
-
/** The factory that deployed this. Immutable, and the only caller allowed. */
|
|
33
|
-
address public immutable factory;
|
|
34
|
-
|
|
35
|
-
event Swept(address indexed token, address indexed to, uint256 amount);
|
|
36
|
-
|
|
37
|
-
error OnlyFactory();
|
|
38
|
-
error NativeSendFailed();
|
|
39
|
-
|
|
40
|
-
constructor() {
|
|
41
|
-
factory = msg.sender;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Move the whole balance of `token` to `to`. `address(0)` means native coin.
|
|
46
|
-
*
|
|
47
|
-
* Returns silently on a zero balance rather than reverting, so a batch across
|
|
48
|
-
* many addresses is not undone by the ones that were already swept.
|
|
49
|
-
*
|
|
50
|
-
* The amount is read from the chain rather than passed in: a figure computed
|
|
51
|
-
* off-chain is stale by the time it mines, and a fee-on-transfer token would
|
|
52
|
-
* make it wrong even if it were not.
|
|
53
|
-
*/
|
|
54
|
-
function sweep(address token, address to) external {
|
|
55
|
-
if (msg.sender != factory) revert OnlyFactory();
|
|
56
|
-
uint256 amount;
|
|
57
|
-
if (token == address(0)) {
|
|
58
|
-
amount = address(this).balance;
|
|
59
|
-
if (amount == 0) return;
|
|
60
|
-
(bool ok, ) = to.call{ value: amount }('');
|
|
61
|
-
if (!ok) revert NativeSendFailed();
|
|
62
|
-
} else {
|
|
63
|
-
amount = IERC20(token).balanceOf(address(this));
|
|
64
|
-
if (amount == 0) return;
|
|
65
|
-
token.safeTransfer(to, amount);
|
|
66
|
-
}
|
|
67
|
-
emit Swept(token, to, amount);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Native coin sent to a deposit address lands here without hitting a fallback. */
|
|
71
|
-
receive() external payable {}
|
|
72
|
-
}
|
package/contracts/src/IERC20.sol
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity ^0.8.24;
|
|
3
|
-
|
|
4
|
-
contract MockToken {
|
|
5
|
-
event Transfer(address indexed from, address indexed to, uint256 value);
|
|
6
|
-
mapping(address => uint256) public balanceOf;
|
|
7
|
-
function mint(address to, uint256 a) external { balanceOf[to] += a; emit Transfer(address(0), to, a); }
|
|
8
|
-
function transfer(address to, uint256 a) external returns (bool) {
|
|
9
|
-
balanceOf[msg.sender] -= a; balanceOf[to] += a; emit Transfer(msg.sender, to, a); return true;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** USDT: `transfer` returns NOTHING. */
|
|
14
|
-
contract MockNoReturnToken {
|
|
15
|
-
event Transfer(address indexed from, address indexed to, uint256 value);
|
|
16
|
-
mapping(address => uint256) public balanceOf;
|
|
17
|
-
function mint(address to, uint256 a) external { balanceOf[to] += a; emit Transfer(address(0), to, a); }
|
|
18
|
-
function transfer(address to, uint256 a) external {
|
|
19
|
-
balanceOf[msg.sender] -= a; balanceOf[to] += a; emit Transfer(msg.sender, to, a);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Takes a 1% cut on the way through. Delivers less than it was asked to send. */
|
|
24
|
-
contract MockFeeToken {
|
|
25
|
-
event Transfer(address indexed from, address indexed to, uint256 value);
|
|
26
|
-
mapping(address => uint256) public balanceOf;
|
|
27
|
-
function mint(address to, uint256 a) external { balanceOf[to] += a; emit Transfer(address(0), to, a); }
|
|
28
|
-
function transfer(address to, uint256 a) external returns (bool) {
|
|
29
|
-
uint256 fee = a / 100;
|
|
30
|
-
balanceOf[msg.sender] -= a; balanceOf[to] += a - fee; emit Transfer(msg.sender, to, a - fee); return true;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity ^0.8.24;
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* ERC-20 transfer that survives the tokens people actually hold.
|
|
6
|
-
*
|
|
7
|
-
* returns true -> pass
|
|
8
|
-
* returns NOTHING -> pass (USDT on Ethereum, and it is not alone)
|
|
9
|
-
* returns false -> revert
|
|
10
|
-
* reverts -> revert
|
|
11
|
-
*
|
|
12
|
-
* The empty-returndata case is the one that matters. A call typed to return
|
|
13
|
-
* `bool` reverts on decode against USDT, and that failure appears only on a
|
|
14
|
-
* chain where real money is moving.
|
|
15
|
-
*
|
|
16
|
-
* Pair this with balanceOf-based sweeping — as the proxy and factory do — so a
|
|
17
|
-
* fee-on-transfer token moves what is ACTUALLY held rather than an overstated
|
|
18
|
-
* figure. Otherwise the platform pays out coins it does not have.
|
|
19
|
-
*/
|
|
20
|
-
library SafeTransferLib {
|
|
21
|
-
bytes4 private constant TRANSFER_SELECTOR = 0xa9059cbb;
|
|
22
|
-
|
|
23
|
-
error TransferFailed();
|
|
24
|
-
|
|
25
|
-
function safeTransfer(address token, address to, uint256 amount) internal {
|
|
26
|
-
(bool ok, bytes memory data) = token.call(
|
|
27
|
-
abi.encodeWithSelector(TRANSFER_SELECTOR, to, amount)
|
|
28
|
-
);
|
|
29
|
-
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed();
|
|
30
|
-
}
|
|
31
|
-
}
|
|
@@ -1,361 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
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
|
-
}
|