@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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/contracts/foundry.toml +9 -0
  4. package/contracts/src/ColdVault.sol +206 -0
  5. package/contracts/src/DepositFactory.sol +202 -0
  6. package/contracts/src/DepositProxy.sol +72 -0
  7. package/contracts/src/IERC20.sol +7 -0
  8. package/contracts/src/MockTokens.sol +32 -0
  9. package/contracts/src/SafeTransferLib.sol +31 -0
  10. package/contracts/test/Custody.t.sol +361 -0
  11. package/contracts/test/Vectors.t.sol +45 -0
  12. package/dist/audit.d.ts +265 -0
  13. package/dist/audit.js +291 -0
  14. package/dist/backup.d.ts +243 -0
  15. package/dist/backup.js +302 -0
  16. package/dist/calendar.d.ts +136 -0
  17. package/dist/calendar.js +129 -0
  18. package/dist/compliance.d.ts +172 -0
  19. package/dist/compliance.js +168 -0
  20. package/dist/finance/binance.d.ts +27 -0
  21. package/dist/finance/binance.js +452 -0
  22. package/dist/finance/chain-addresses.d.ts +130 -0
  23. package/dist/finance/chain-addresses.js +462 -0
  24. package/dist/finance/chain-deposits.d.ts +193 -0
  25. package/dist/finance/chain-deposits.js +596 -0
  26. package/dist/finance/chain-reconcile.d.ts +112 -0
  27. package/dist/finance/chain-reconcile.js +76 -0
  28. package/dist/finance/chain-withdrawals.d.ts +223 -0
  29. package/dist/finance/chain-withdrawals.js +631 -0
  30. package/dist/finance/chain.d.ts +116 -0
  31. package/dist/finance/chain.js +316 -0
  32. package/dist/finance/commission.d.ts +155 -0
  33. package/dist/finance/commission.js +419 -0
  34. package/dist/finance/custody.d.ts +68 -0
  35. package/dist/finance/custody.js +107 -0
  36. package/dist/finance/derive.d.ts +115 -0
  37. package/dist/finance/derive.js +116 -0
  38. package/dist/finance/discounts.d.ts +98 -0
  39. package/dist/finance/discounts.js +90 -0
  40. package/dist/finance/ledger.d.ts +221 -0
  41. package/dist/finance/ledger.js +308 -0
  42. package/dist/finance/market.d.ts +209 -0
  43. package/dist/finance/market.js +112 -0
  44. package/dist/finance/money.d.ts +118 -0
  45. package/dist/finance/money.js +176 -0
  46. package/dist/finance/rates.d.ts +178 -0
  47. package/dist/finance/rates.js +292 -0
  48. package/dist/finance/storage.d.ts +113 -0
  49. package/dist/finance/storage.js +226 -0
  50. package/dist/finance/tax.d.ts +132 -0
  51. package/dist/finance/tax.js +291 -0
  52. package/dist/finance/transfers.d.ts +153 -0
  53. package/dist/finance/transfers.js +292 -0
  54. package/dist/finance/venues.d.ts +190 -0
  55. package/dist/finance/venues.js +251 -0
  56. package/dist/identity.d.ts +115 -0
  57. package/dist/identity.js +111 -0
  58. package/dist/importers.d.ts +87 -0
  59. package/dist/importers.js +250 -0
  60. package/dist/jobs.d.ts +171 -0
  61. package/dist/jobs.js +250 -0
  62. package/dist/notify-templates.d.ts +11 -0
  63. package/dist/notify-templates.js +254 -0
  64. package/dist/notify.d.ts +172 -0
  65. package/dist/notify.js +122 -0
  66. package/dist/openssh.d.ts +36 -0
  67. package/dist/openssh.js +106 -0
  68. package/dist/otpauth.d.ts +57 -0
  69. package/dist/otpauth.js +223 -0
  70. package/dist/outbox.d.ts +234 -0
  71. package/dist/outbox.js +236 -0
  72. package/dist/passkey.d.ts +120 -0
  73. package/dist/passkey.js +105 -0
  74. package/dist/phrase.d.ts +87 -0
  75. package/dist/phrase.js +87 -0
  76. package/dist/pipeline.d.ts +137 -0
  77. package/dist/pipeline.js +121 -0
  78. package/dist/queue.d.ts +243 -0
  79. package/dist/queue.js +246 -0
  80. package/dist/schema-typebox.d.ts +24 -0
  81. package/dist/schema-typebox.js +201 -0
  82. package/dist/schema.d.ts +134 -0
  83. package/dist/schema.js +169 -0
  84. package/dist/serial.d.ts +54 -0
  85. package/dist/serial.js +40 -0
  86. package/dist/slip10.d.ts +37 -0
  87. package/dist/slip10.js +74 -0
  88. package/dist/snp.d.ts +115 -0
  89. package/dist/snp.js +109 -0
  90. package/dist/ssh-agent.d.ts +70 -0
  91. package/dist/ssh-agent.js +141 -0
  92. package/dist/ssh-cert.d.ts +73 -0
  93. package/dist/ssh-cert.js +111 -0
  94. package/dist/totp.d.ts +104 -0
  95. package/dist/totp.js +143 -0
  96. package/package.json +248 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ForgeZero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @forgezero/runtime
2
+
3
+ **The machinery behind a request handler.** Background jobs that never overlap
4
+ themselves, keyed queues, a transactional outbox, a hash-chained audit trail,
5
+ templated mail, encrypted backups, schema validation, and money that is never a
6
+ floating-point number.
7
+
8
+ Twenty-six modules. Each is its own entry point, so you install one package and
9
+ your bundler includes only what you imported.
10
+
11
+ ```bash
12
+ bun add @forgezero/runtime
13
+ ```
14
+
15
+ ```ts
16
+ import { parseAmount } from '@forgezero/runtime/finance/money';
17
+ import { createScheduler } from '@forgezero/runtime/jobs';
18
+
19
+ // There is no root export. `import from '@forgezero/runtime'` is meant to fail
20
+ // rather than resolve to whichever module happened to be listed first.
21
+ ```
22
+
23
+ ## What is in it
24
+
25
+ | | |
26
+ |---|---|
27
+ | **Spine** | `jobs` `queue` `serial` `outbox` |
28
+ | **Record** | `audit` `backup` `compliance` `calendar` |
29
+ | **Identity** | `identity` `totp` `notify` `notify/templates` `schema` `schema/typebox` |
30
+ | **Finance** | `finance/money` `finance/storage` `finance/ledger` `finance/commission` `finance/rates` `finance/transfers` `finance/tax` `finance/chain` `finance/custody` `finance/derive` `finance/venues` `finance/binance` |
31
+
32
+ None of it knows what your product does. You bind your own rules to these; you
33
+ do not fork them.
34
+
35
+ ## Three things worth knowing before you use it
36
+
37
+ **Money is never a number.** An amount is minor units as a `bigint` with its
38
+ asset attached, so two currencies cannot be added by accident and a rounding
39
+ mode is always stated. `finance/storage` writes the same amount twice — the
40
+ authoritative string, and a lossy double used for `ORDER BY` only. One ETH is
41
+ 10^18 minor units and a signed 64-bit column overflows around nine ETH, which is
42
+ why the exact value is never the sortable one.
43
+
44
+ ```ts
45
+ const fee = mulRate(parseAmount('1250.00', 'USD'), '0.015', 'down');
46
+ formatAmount(fee); // '18.75' — never 18.749999999
47
+ toStored(fee); // { units: '1875', value: '18.75', asset: 'USD', sort: 18.75 }
48
+ ```
49
+
50
+ **A scheduler, never `setInterval`.** An interval fires whether or not the last
51
+ run finished, so work slower than its period runs twice over the same data. The
52
+ next run is scheduled after the current one completes. The lock is a lease with
53
+ a fence, not a mutex — a process that dies does not block its job for ever.
54
+
55
+ **The cursor advances only on a complete batch.** Fetch, process every item,
56
+ then write the cursor — never per item, never before. Item three of ten throwing
57
+ retries the whole batch from the same position, which means `process` must be
58
+ idempotent, and idempotent retries beat records nobody ever looks at again.
59
+
60
+ ## Optional peers
61
+
62
+ Nothing third-party is bundled — vendoring a crypto library means a fix for it
63
+ never reaches you until this package republishes. Most subpaths need nothing:
64
+
65
+ ```bash
66
+ bun add @noble/hashes # finance/custody
67
+ bun add @noble/curves @noble/post-quantum # identity
68
+ bun add @sinclair/typebox # schema/typebox
69
+ ```
70
+
71
+ ## The Solidity ships with it
72
+
73
+ `finance/custody` computes counterfactual CREATE2 deposit addresses; the
74
+ contracts that make those addresses real are in the tarball, not in a repository
75
+ you have to go find.
76
+
77
+ ```
78
+ node_modules/@forgezero/runtime/contracts/src/
79
+ DepositProxy.sol deployed lazily at first sweep — no key, no gas per address
80
+ DepositFactory.sol CREATE2, salt opaque to the contract
81
+ ColdVault.sol M-of-N EIP-712, signatures ordered by ascending signer
82
+ ```
83
+
84
+ Full documentation: **https://forgezero.net/docs/runtime**
85
+
86
+ ## Licence
87
+
88
+ MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
89
+ deploys — and usable entirely on its own, with no ForgeZero account.
@@ -0,0 +1,9 @@
1
+ [profile.default]
2
+ src = "src"
3
+ test = "test"
4
+ out = "out"
5
+ libs = []
6
+ solc_version = "0.8.24"
7
+ optimizer = true
8
+ optimizer_runs = 200
9
+ evm_version = "shanghai"
@@ -0,0 +1,206 @@
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
+ }
@@ -0,0 +1,202 @@
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
+ }
@@ -0,0 +1,72 @@
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
+ }
@@ -0,0 +1,7 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.24;
3
+
4
+ interface IERC20 {
5
+ function balanceOf(address account) external view returns (uint256);
6
+ function transfer(address to, uint256 amount) external returns (bool);
7
+ }
@@ -0,0 +1,32 @@
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
+ }
@@ -0,0 +1,31 @@
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
+ }