@pickhooks/mode-kit 0.0.0-stage → 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.
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import {IStickyMode, IStickyCoin3, Orders} from "./IStickyMode.sol";
5
+
6
+ /// @title the base of every pickhooks mode
7
+ /// @notice Registration by the factory, the coin check on every call, and the helpers all modes share. A mode keeps
8
+ /// its state per coin (the caller) and answers with orders; it holds no coins and no BNB.
9
+ abstract contract ModeBase is IStickyMode {
10
+ error NotFactory();
11
+ error NotCoin();
12
+ error Bad(string what);
13
+
14
+ uint256 internal constant SUPPLY = 1_000_000_000 ether;
15
+ uint8 internal constant BUY = 1;
16
+ uint8 internal constant SELL = 2;
17
+ uint8 internal constant SEND = 4;
18
+ address internal constant DEAD = 0x000000000000000000000000000000000000dEaD;
19
+
20
+ /// @notice The only factory that registers coins with this mode.
21
+ address public immutable factory;
22
+ /// @notice The creator of each coin of this mode (0: not a coin of this mode).
23
+ mapping(address => address) public creatorOf;
24
+
25
+ event Registered(address indexed coin, address indexed creator, bytes data);
26
+
27
+ constructor(address factory_) {
28
+ factory = factory_;
29
+ }
30
+
31
+ modifier onlyCoin() {
32
+ if (creatorOf[msg.sender] == address(0)) revert NotCoin();
33
+ _;
34
+ }
35
+
36
+ function register(address coin, address creator, bytes calldata data) external {
37
+ if (msg.sender != factory) revert NotFactory();
38
+ if (creatorOf[coin] != address(0) || creator == address(0)) revert Bad("registered");
39
+ creatorOf[coin] = creator;
40
+ _register(coin, creator, data);
41
+ emit Registered(coin, creator, data);
42
+ }
43
+
44
+ function onTrade(Trade calldata t) external onlyCoin returns (uint256[] memory) {
45
+ return _trade(msg.sender, t);
46
+ }
47
+
48
+ function onPlay(address player, bytes calldata data) external onlyCoin returns (uint256[] memory) {
49
+ return _play(msg.sender, player, data);
50
+ }
51
+
52
+ function onPoke(address caller, bytes calldata data) external onlyCoin returns (uint256[] memory) {
53
+ return _poke(msg.sender, caller, data);
54
+ }
55
+
56
+ function _register(address coin, address creator, bytes calldata data) internal virtual;
57
+
58
+ function _trade(address, Trade calldata) internal virtual returns (uint256[] memory) {
59
+ return Orders.none();
60
+ }
61
+
62
+ function _play(address, address, bytes calldata) internal virtual returns (uint256[] memory) {
63
+ revert Bad("no play");
64
+ }
65
+
66
+ function _poke(address, address, bytes calldata) internal virtual returns (uint256[] memory) {
67
+ revert Bad("no poke");
68
+ }
69
+
70
+ // ---------------------------------------------------------------- helpers
71
+
72
+ /// @dev Millionths of the supply.
73
+ function _ppm(uint256 amount) internal pure returns (uint256) {
74
+ return amount * 1e6 / SUPPLY;
75
+ }
76
+
77
+ function _coinsOfPpm(uint256 ppm) internal pure returns (uint256) {
78
+ return SUPPLY * ppm / 1e6;
79
+ }
80
+
81
+ /// @dev A holder a game may reward: not the creator, not a pool, not the coin, not the dead address.
82
+ function _player(address coin, address who) internal view returns (bool) {
83
+ if (who == address(0) || who == DEAD || who == coin || who == creatorOf[coin]) return false;
84
+ IStickyCoin3 c = IStickyCoin3(coin);
85
+ return who != c.pool() && who != c.pool2();
86
+ }
87
+
88
+ function _pot(address coin) internal view returns (uint256) {
89
+ return IStickyCoin3(coin).gamePot();
90
+ }
91
+
92
+ function _min(uint256 a, uint256 b) internal pure returns (uint256) {
93
+ return a < b ? a : b;
94
+ }
95
+
96
+ /// @dev The action of play or poke data: its first word (`abi.encode(uint8 action, ...)`).
97
+ function _op(bytes calldata data) internal pure returns (uint8) {
98
+ if (data.length < 32) revert Bad("no action");
99
+ return abi.decode(data[:32], (uint8));
100
+ }
101
+
102
+ /// @dev A wallet: no code, or an EIP-7702 delegation.
103
+ function _isWallet(address a) internal view returns (bool) {
104
+ uint256 size;
105
+ assembly ("memory-safe") {
106
+ size := extcodesize(a)
107
+ }
108
+ if (size == 0) return true;
109
+ if (size != 23) return false;
110
+ bytes32 h;
111
+ assembly ("memory-safe") {
112
+ extcodecopy(a, 0, 0, 3)
113
+ h := mload(0)
114
+ }
115
+ return bytes3(h) == 0xef0100;
116
+ }
117
+
118
+ /// @dev Settings in a range: `x` when set, the default `d` when zero; reverts outside [lo, hi].
119
+ function _within(uint256 x, uint256 d, uint256 lo, uint256 hi, string memory what) internal pure returns (uint256) {
120
+ if (x == 0) x = d;
121
+ if (x < lo || x > hi) revert Bad(what);
122
+ return x;
123
+ }
124
+ }
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import {IStickyCoin3} from "../IStickyMode.sol";
5
+
6
+ /// @title the live pickhooks contracts a mode plugs into (BNB Chain, chain id 56)
7
+ /// @notice The harness forks BNB Chain and runs a mode against these exact contracts: the sticky 3 factory that is live
8
+ /// on pickhooks.com, the coin code it deploys, and PancakeSwap's real routers and pools.
9
+ library Live {
10
+ /// @notice The sticky 3 factory (verified on BscScan). Only its owner lists a mode (`setMode`).
11
+ address internal constant FACTORY = 0x4b41a3Ee8C80C568aE6Da7C5572867D5640C0574;
12
+ /// @notice The drand quicknet beacon modes that draw use (verified on BscScan).
13
+ address internal constant DRAND_BEACON = 0x9c264D2934B2027Bad622417b6747E558EC465CC;
14
+ /// @notice The fee locker of the sticky 3 factory: collects each coin's fees (creator side, platform side).
15
+ address internal constant LOCKER = 0xCea0C138612B3F61D472d18F575Bb314561c024F;
16
+ address internal constant WBNB = 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c;
17
+ /// @notice PancakeSwap V3 SwapRouter (the coin's curve is a 1 % V3 pool).
18
+ address internal constant ROUTER_V3 = 0x1b81D678ffb9C0263b24A97847620C99d213eB14;
19
+ /// @notice PancakeSwap V2 router (every coin also has a V2 pair, LP burned).
20
+ address internal constant ROUTER_V2 = 0x10ED43C718714eb63d5aA57B78B54704E256024E;
21
+ }
22
+
23
+ /// @notice The live factory, as far as a mode and its tests need it.
24
+ interface IStickyFactory3 {
25
+ struct LaunchArgs {
26
+ string name;
27
+ string symbol;
28
+ string metadata;
29
+ uint256[] rows;
30
+ uint32 release;
31
+ bytes32 salt;
32
+ uint16 keepBps;
33
+ uint16 minCreatorBps;
34
+ address switcher;
35
+ uint8 switchMask;
36
+ address mode;
37
+ bytes modeData;
38
+ }
39
+
40
+ event ModeLaunched(address indexed token, address indexed mode, uint16 gameBps, bytes data);
41
+
42
+ function launch(LaunchArgs calldata a) external payable returns (address token);
43
+ function setMode(address m, bool on) external;
44
+ function modes(address m) external view returns (bool);
45
+ function owner() external view returns (address);
46
+ function seedBnb() external view returns (uint256);
47
+ }
48
+
49
+ /// @notice The live coin, as far as a mode's tests need it (the mode itself reads `IStickyCoin3`).
50
+ interface IStickyCoin3Live is IStickyCoin3 {
51
+ event ModeSkipped(uint8 kind);
52
+
53
+ function MODE_GAS() external view returns (uint256);
54
+ function mode() external view returns (address);
55
+ function shadow(address who) external pure returns (address);
56
+ function releaseHeld(address who) external;
57
+ }
58
+
59
+ interface ISwapRouterV3 {
60
+ struct ExactInputSingleParams {
61
+ address tokenIn;
62
+ address tokenOut;
63
+ uint24 fee;
64
+ address recipient;
65
+ uint256 deadline;
66
+ uint256 amountIn;
67
+ uint256 amountOutMinimum;
68
+ uint160 sqrtPriceLimitX96;
69
+ }
70
+
71
+ function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut);
72
+ }
73
+
74
+ interface IRouterV2 {
75
+ function swapExactETHForTokensSupportingFeeOnTransferTokens(uint256, address[] calldata, address, uint256)
76
+ external
77
+ payable;
78
+ function swapExactTokensForETHSupportingFeeOnTransferTokens(uint256, uint256, address[] calldata, address, uint256)
79
+ external;
80
+ }
@@ -0,0 +1,322 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import {Test, Vm} from "forge-std/Test.sol";
5
+ import {IStickyMode} from "../IStickyMode.sol";
6
+ import {Live, IStickyFactory3, IStickyCoin3Live, ISwapRouterV3, IRouterV2} from "./Live.sol";
7
+
8
+ /// @title the mode kit's harness
9
+ /// @notice Forks BNB Chain, lists your mode on the live sticky 3 factory (as its owner, on the fork only), launches
10
+ /// coins with it and trades them the way every terminal does: the V3 router, the V2 router, plain sends. The
11
+ /// coin code, the pools and the routers are the live ones, so what passes here is what runs on pickhooks.com.
12
+ ///
13
+ /// Extend it, return your mode from `deployMode`, and call `guardrails` and `stress` with your settings. Then
14
+ /// write your own tests for every rule, action and bound of your mode with the helpers below.
15
+ abstract contract ModeHarness is Test {
16
+ uint256 internal constant SUPPLY = 1_000_000_000 ether;
17
+ uint8 internal constant BUY = 1;
18
+ uint8 internal constant SELL = 2;
19
+ uint8 internal constant SEND = 4;
20
+ /// @notice The gas a mode may use for one trade in the harness: 80 % of the coin's budget (`MODE_GAS`, 250,000),
21
+ /// so the mode keeps headroom. Over the budget the coin skips the mode for that trade.
22
+ uint256 internal constant MODE_GAS_LIMIT = 200_000;
23
+ bytes32 internal constant MODE_SKIPPED = keccak256("ModeSkipped(uint8)");
24
+
25
+ IStickyFactory3 internal factory;
26
+ /// @notice Your mode, listed on the fork.
27
+ address internal mode;
28
+ /// @notice The coin of the last launch.
29
+ IStickyCoin3Live internal coin;
30
+ /// @notice The coin's V3 curve and its V2 pair.
31
+ address internal pool;
32
+ address internal pair;
33
+
34
+ // fresh labels only: well known test addresses are EIP-7702 sweepers on BNB Chain
35
+ address internal dev = makeAddr("kit.dev");
36
+ address internal alice = makeAddr("kit.alice");
37
+ address internal bob = makeAddr("kit.bob");
38
+ address internal carol = makeAddr("kit.carol");
39
+ address internal dave = makeAddr("kit.dave");
40
+ address internal erin = makeAddr("kit.erin");
41
+
42
+ uint256 private salt_;
43
+
44
+ /// @notice Your mode, built for the live factory: `return address(new MyMode(Live.FACTORY));`
45
+ function deployMode() internal virtual returns (address);
46
+
47
+ function setUp() public virtual {
48
+ string memory rpc = vm.envOr("FORK_RPC", string(""));
49
+ if (bytes(rpc).length == 0) rpc = vm.envOr("BSC_RPC_URL", string(""));
50
+ require(bytes(rpc).length != 0, "set BSC_RPC_URL to a BNB Chain RPC (or FORK_RPC to a local fork)");
51
+ uint256 blk = vm.envOr("FORK_BLOCK", uint256(0));
52
+ if (blk == 0) vm.createSelectFork(rpc);
53
+ else vm.createSelectFork(rpc, blk);
54
+ require(block.chainid == 56, "the RPC is not BNB Chain");
55
+
56
+ factory = IStickyFactory3(Live.FACTORY);
57
+ mode = deployMode();
58
+ require(mode.code.length != 0, "deployMode returned no contract");
59
+ vm.prank(factory.owner());
60
+ factory.setMode(mode, true);
61
+
62
+ address[6] memory people = [dev, alice, bob, carol, dave, erin];
63
+ for (uint256 i; i < people.length; i++) {
64
+ vm.deal(people[i], 100 ether);
65
+ }
66
+ }
67
+
68
+ // ------------------------------------------------ launching
69
+
70
+ /// @notice Launches a coin with your mode and these settings (empty: the mode's defaults). No first buy.
71
+ function launch(bytes memory data) internal returns (IStickyCoin3Live) {
72
+ return launch(data, 0);
73
+ }
74
+
75
+ /// @notice The same with the creator's first buy of `devBuy` BNB (it runs through the mode like any buy).
76
+ function launch(bytes memory data, uint256 devBuy) internal returns (IStickyCoin3Live) {
77
+ IStickyFactory3.LaunchArgs memory a = launchArgs(data);
78
+ uint256 value = factory.seedBnb() + devBuy;
79
+ vm.prank(dev, dev);
80
+ coin = IStickyCoin3Live(factory.launch{value: value}(a));
81
+ pool = coin.pool();
82
+ pair = coin.pool2();
83
+ return coin;
84
+ }
85
+
86
+ /// @notice The factory refuses these settings (your quote() reverts, or the share of buys is over the cap).
87
+ function expectLaunchRevert(bytes memory data) internal {
88
+ IStickyFactory3.LaunchArgs memory a = launchArgs(data);
89
+ uint256 seed = factory.seedBnb();
90
+ vm.prank(dev, dev);
91
+ vm.expectRevert();
92
+ factory.launch{value: seed}(a);
93
+ }
94
+
95
+ function launchArgs(bytes memory data) internal returns (IStickyFactory3.LaunchArgs memory a) {
96
+ a.name = "Kit Test";
97
+ a.symbol = "KIT";
98
+ a.metadata = "";
99
+ a.rows = new uint256[](0);
100
+ a.salt = keccak256(abi.encode("mode kit", salt_++));
101
+ a.keepBps = 10_000;
102
+ a.mode = mode;
103
+ a.modeData = data;
104
+ }
105
+
106
+ // ------------------------------------------------ trading the way terminals do
107
+
108
+ /// @notice A buy through PancakeSwap's V3 router (the coin's curve), from a wallet. Returns the coins out.
109
+ function routerBuy(address who, uint256 bnb) internal returns (uint256) {
110
+ vm.prank(who, who);
111
+ return ISwapRouterV3(Live.ROUTER_V3).exactInputSingle{value: bnb}(
112
+ ISwapRouterV3.ExactInputSingleParams(Live.WBNB, address(coin), 10_000, who, block.timestamp, bnb, 0, 0)
113
+ );
114
+ }
115
+
116
+ /// @notice A sell through the V3 router. Returns the BNB (as WBNB) out.
117
+ function routerSell(address who, uint256 coins) internal returns (uint256 out) {
118
+ vm.startPrank(who, who);
119
+ coin.approve(Live.ROUTER_V3, coins);
120
+ out = ISwapRouterV3(Live.ROUTER_V3).exactInputSingle(
121
+ ISwapRouterV3.ExactInputSingleParams(address(coin), Live.WBNB, 10_000, who, block.timestamp, coins, 0, 0)
122
+ );
123
+ vm.stopPrank();
124
+ }
125
+
126
+ /// @notice A buy through the V2 router (the coin's V2 pair).
127
+ function v2Buy(address who, uint256 bnb) internal {
128
+ address[] memory path = new address[](2);
129
+ (path[0], path[1]) = (Live.WBNB, address(coin));
130
+ vm.prank(who, who);
131
+ IRouterV2(Live.ROUTER_V2).swapExactETHForTokensSupportingFeeOnTransferTokens{value: bnb}(
132
+ 0, path, who, block.timestamp
133
+ );
134
+ }
135
+
136
+ /// @notice A sell through the V2 router.
137
+ function v2Sell(address who, uint256 coins) internal {
138
+ address[] memory path = new address[](2);
139
+ (path[0], path[1]) = (address(coin), Live.WBNB);
140
+ vm.startPrank(who, who);
141
+ coin.approve(Live.ROUTER_V2, coins);
142
+ IRouterV2(Live.ROUTER_V2).swapExactTokensForETHSupportingFeeOnTransferTokens(coins, 0, path, who, block.timestamp);
143
+ vm.stopPrank();
144
+ }
145
+
146
+ /// @notice An exact buy of `coins` straight out of the curve, for tests that need round numbers. The coin only counts
147
+ /// a pool transfer as a trade when the price moved, so this nudges the pool's price first.
148
+ function curveBuy(address who, uint256 coins) internal {
149
+ _nudge();
150
+ vm.prank(pool, who);
151
+ coin.transfer(who, coins);
152
+ }
153
+
154
+ /// @notice An exact sell of `coins` into the curve.
155
+ function curveSell(address who, uint256 coins) internal {
156
+ _nudge();
157
+ vm.prank(who, who);
158
+ coin.transfer(pool, coins);
159
+ }
160
+
161
+ function send(address from, address to, uint256 coins) internal {
162
+ vm.prank(from, from);
163
+ coin.transfer(to, coins);
164
+ }
165
+
166
+ /// @notice A holder's move (your `_play`): `abi.encode(uint8 action, ...)`.
167
+ function play(address who, bytes memory data) internal {
168
+ vm.prank(who, who);
169
+ coin.play(data);
170
+ }
171
+
172
+ /// @notice Anyone's nudge (your `_poke`), sent by a wallet that holds nothing.
173
+ function poke(bytes memory data) internal {
174
+ address k = makeAddr("kit.keeper");
175
+ vm.prank(k, k);
176
+ coin.poke(data);
177
+ }
178
+
179
+ function wait(uint256 secs) internal {
180
+ vm.warp(vm.getBlockTimestamp() + secs);
181
+ vm.roll(block.number + secs * 100 / 45 + 1);
182
+ }
183
+
184
+ function _nudge() private {
185
+ bytes32 s = vm.load(pool, bytes32(0));
186
+ vm.store(pool, bytes32(0), bytes32(uint256(s) + 1));
187
+ }
188
+
189
+ // ------------------------------------------------ units
190
+
191
+ /// @notice `b` basis points of the supply, in coins.
192
+ function bps(uint256 b) internal pure returns (uint256) {
193
+ return SUPPLY * b / 10_000;
194
+ }
195
+
196
+ /// @notice `p` millionths of the supply, in coins.
197
+ function ppm(uint256 p) internal pure returns (uint256) {
198
+ return SUPPLY * p / 1e6;
199
+ }
200
+
201
+ function held(address who) internal view returns (uint256 a) {
202
+ (a,,,) = coin.heldOf(who);
203
+ }
204
+
205
+ // ------------------------------------------------ the guardrails every mode keeps
206
+
207
+ /// @notice A launch with `data` and trades on every path a terminal takes: V3 and V2 buys (one by the creator),
208
+ /// sends, V3 and V2 sells, an hour apart. Checks that sends and sells take nothing, the coin never skipped the
209
+ /// mode, the pot is backed, and the mode stays under 200,000 gas on a buy, a sell and a send.
210
+ function guardrails(bytes memory data) internal {
211
+ launch(data);
212
+ _record();
213
+ routerBuy(alice, 0.05 ether);
214
+ routerBuy(bob, 0.03 ether);
215
+ v2Buy(carol, 0.02 ether);
216
+ routerBuy(dev, 0.01 ether);
217
+ wait(1 hours);
218
+ routerBuy(dave, 0.02 ether);
219
+
220
+ uint256 a = coin.balanceOf(alice);
221
+ uint256 e = coin.balanceOf(erin);
222
+ uint256 s = a / 10;
223
+ send(alice, erin, s);
224
+ assertGe(coin.balanceOf(erin), e + s, "a send arrives in full");
225
+ assertGe(coin.balanceOf(alice) + s, a, "a send takes nothing more than the coins sent");
226
+
227
+ uint256 pot = coin.gamePot();
228
+ uint256 b = coin.balanceOf(bob);
229
+ assertGt(routerSell(bob, b / 2), 0, "a V3 sell pays out");
230
+ assertLe(coin.gamePot(), pot, "a sell puts nothing into the pot");
231
+ assertGe(coin.balanceOf(bob) + b / 2, b, "a sell takes nothing more than the coins sold");
232
+ v2Sell(carol, coin.balanceOf(carol) / 2);
233
+
234
+ assertBooks();
235
+ assertNoSkips();
236
+ assertModeGas(BUY, dave, ppm(1000));
237
+ assertModeGas(SELL, alice, ppm(1000));
238
+ assertModeGas(SEND, alice, ppm(1000));
239
+ }
240
+
241
+ /// @notice `n` trades by five wallets, buys, sells and sends of changing sizes over changing times, then the same
242
+ /// checks. Finds the loop that grows, the counter that overflows, the branch that reverts.
243
+ function stress(bytes memory data, uint256 n) internal {
244
+ launch(data);
245
+ _record();
246
+ address[5] memory w = [alice, bob, carol, dave, erin];
247
+ for (uint256 i; i < n; i++) {
248
+ uint256 r = uint256(keccak256(abi.encode("stress", i)));
249
+ address who = w[r % 5];
250
+ uint256 kind = (r >> 8) % 6;
251
+ uint256 bal = coin.balanceOf(who);
252
+ if (kind <= 2 || bal == 0) {
253
+ uint256 bnb = 0.001 ether + (r >> 16) % 0.03 ether;
254
+ if (kind == 1) v2Buy(who, bnb);
255
+ else routerBuy(who, bnb);
256
+ } else if (kind == 3) {
257
+ routerSell(who, bal * (10 + (r >> 24) % 80) / 100);
258
+ } else if (kind == 4) {
259
+ v2Sell(who, bal * (10 + (r >> 24) % 80) / 100);
260
+ } else {
261
+ address to = w[(r >> 32) % 5];
262
+ if (to == who) to = w[((r >> 32) + 1) % 5];
263
+ send(who, to, bal / 5);
264
+ }
265
+ if ((r >> 40) % 4 == 0) wait(60 + (r >> 48) % 4 hours);
266
+ }
267
+ assertBooks();
268
+ assertNoSkips();
269
+ assertModeGas(BUY, alice, ppm(1000));
270
+ assertModeGas(SELL, bob, ppm(1000));
271
+ assertModeGas(SEND, carol, ppm(1000));
272
+ }
273
+
274
+ /// @notice The gas your mode uses for one trade of this kind, in the coin's current state (the mode's state is
275
+ /// rolled back after). `who` trades `coins`.
276
+ function modeGas(uint8 kind, address who, uint256 coins) internal returns (uint256 used) {
277
+ IStickyMode.Trade memory t = IStickyMode.Trade({
278
+ kind: kind,
279
+ from: kind == BUY ? pool : who,
280
+ to: kind == BUY ? who : (kind == SELL ? pool : erin),
281
+ who: who,
282
+ amount: coins,
283
+ got: coins,
284
+ dev: 0,
285
+ sniper: 0,
286
+ pot: coin.gamePot(),
287
+ buys: 1_000
288
+ });
289
+ uint256 snap = vm.snapshotState();
290
+ vm.prank(address(coin));
291
+ uint256 g = gasleft();
292
+ IStickyMode(mode).onTrade(t);
293
+ used = g - gasleft();
294
+ vm.revertToState(snap);
295
+ }
296
+
297
+ function assertModeGas(uint8 kind, address who, uint256 coins) internal {
298
+ uint256 used = modeGas(kind, who, coins);
299
+ assertLe(used, MODE_GAS_LIMIT, "the mode uses more than 200,000 gas on a trade");
300
+ }
301
+
302
+ /// @notice The game pot is backed by coins the coin holds.
303
+ function assertBooks() internal view {
304
+ assertGe(coin.balanceOf(address(coin)), coin.gamePot(), "the pot is not backed by coins");
305
+ }
306
+
307
+ /// @notice Since the launch (or the last check) the coin never skipped the mode: no revert, no out of gas, no
308
+ /// malformed answer on any trade.
309
+ function assertNoSkips() internal {
310
+ Vm.Log[] memory logs = vm.getRecordedLogs();
311
+ for (uint256 i; i < logs.length; i++) {
312
+ if (logs[i].emitter == address(coin) && logs[i].topics.length != 0 && logs[i].topics[0] == MODE_SKIPPED) {
313
+ assertTrue(false, "the coin skipped the mode on a trade: it reverted, ran out of gas or answered garbage");
314
+ }
315
+ }
316
+ _record();
317
+ }
318
+
319
+ function _record() private {
320
+ vm.recordLogs();
321
+ }
322
+ }
package/lib/abi.mjs ADDED
@@ -0,0 +1,117 @@
1
+ // A small ABI encoder (abi.encode), enough for mode settings, plays and pokes: uint<N>, int<N>, bool, address,
2
+ // bytes<N>, bytes, string and arrays of them. No dependencies, so the kit runs with node alone.
3
+
4
+ const WORD = 64; // hex chars
5
+
6
+ const pad = (hex) => hex.padStart(WORD, "0");
7
+ const padRight = (hex) => hex.padEnd(Math.ceil(hex.length / WORD) * WORD || WORD, "0");
8
+
9
+ export function isDynamic(type) {
10
+ return type === "bytes" || type === "string" || type.endsWith("[]");
11
+ }
12
+
13
+ export function isStatic(type) {
14
+ return !isDynamic(type) && /^(u?int(8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128|136|144|152|160|168|176|184|192|200|208|216|224|232|240|248|256)?|bool|address|bytes([1-9]|[12][0-9]|3[0-2]))$/.test(type);
15
+ }
16
+
17
+ export function isType(type) {
18
+ if (type.endsWith("[]")) return isType(type.slice(0, -2));
19
+ return type === "bytes" || type === "string" || isStatic(type);
20
+ }
21
+
22
+ function bits(type) {
23
+ const m = /^u?int(\d+)?$/.exec(type);
24
+ return m ? Number(m[1] ?? 256) : 256;
25
+ }
26
+
27
+ /** A value as a BigInt: 123, "123", "0x7b", "1.5" with decimals 18, "1e18". */
28
+ export function toBig(v, decimals = 0) {
29
+ if (typeof v === "bigint") return v;
30
+ if (typeof v === "boolean") return v ? 1n : 0n;
31
+ if (typeof v === "number") {
32
+ if (!Number.isFinite(v)) throw new Error(`not a number: ${v}`);
33
+ v = Number.isInteger(v) && decimals === 0 ? BigInt(v).toString() : String(v);
34
+ }
35
+ const s = String(v).trim().replace(/_/g, "");
36
+ if (/^-?0x[0-9a-f]+$/i.test(s)) return s.startsWith("-") ? -BigInt(s.slice(1)) : BigInt(s);
37
+ const e = /^(-?)(\d+)(?:\.(\d+))?(?:e\+?(\d+))?$/i.exec(s);
38
+ if (!e) throw new Error(`not a number: ${v}`);
39
+ const [, neg, int, frac = "", exp = "0"] = e;
40
+ // the value is int.frac x 10^shift: the integer digits followed by the fraction padded to `shift` digits
41
+ const shift = Number(exp) + decimals;
42
+ if (frac.length > shift) throw new Error(`${v} has more decimals than ${shift}`);
43
+ const n = BigInt(int + frac.padEnd(shift, "0"));
44
+ return neg ? -n : n;
45
+ }
46
+
47
+ function encStatic(type, v) {
48
+ if (type === "bool") {
49
+ const b = v === true || v === "true" || v === 1 || v === "1" || v === 1n;
50
+ const f = v === false || v === "false" || v === 0 || v === "0" || v === 0n;
51
+ if (!b && !f) throw new Error(`bool expects true or false, got ${v}`);
52
+ return pad(b ? "1" : "0");
53
+ }
54
+ if (type === "address") {
55
+ const s = String(v);
56
+ if (!/^0x[0-9a-fA-F]{40}$/.test(s)) throw new Error(`not an address: ${v}`);
57
+ return pad(s.slice(2).toLowerCase());
58
+ }
59
+ const bm = /^bytes(\d+)$/.exec(type);
60
+ if (bm) {
61
+ const n = Number(bm[1]);
62
+ const s = String(v);
63
+ if (!/^0x[0-9a-fA-F]*$/.test(s) || (s.length - 2) / 2 !== n) throw new Error(`${type} expects ${n} bytes of hex, got ${v}`);
64
+ return padRight(s.slice(2).toLowerCase());
65
+ }
66
+ const n = toBig(v);
67
+ const b = bits(type);
68
+ if (type.startsWith("uint")) {
69
+ if (n < 0n || n >= 1n << BigInt(b)) throw new Error(`${v} does not fit ${type}`);
70
+ return pad(n.toString(16));
71
+ }
72
+ const lim = 1n << BigInt(b - 1);
73
+ if (n < -lim || n >= lim) throw new Error(`${v} does not fit ${type}`);
74
+ return pad((n < 0n ? (1n << 256n) + n : n).toString(16));
75
+ }
76
+
77
+ function encDynamic(type, v) {
78
+ if (type === "bytes" || type === "string") {
79
+ let hex;
80
+ if (type === "string") hex = Buffer.from(String(v), "utf8").toString("hex");
81
+ else {
82
+ const s = String(v);
83
+ if (!/^0x([0-9a-fA-F]{2})*$/.test(s)) throw new Error(`bytes expects hex, got ${v}`);
84
+ hex = s.slice(2).toLowerCase();
85
+ }
86
+ return pad((hex.length / 2).toString(16)) + (hex.length ? padRight(hex) : "");
87
+ }
88
+ const inner = type.slice(0, -2);
89
+ const arr = Array.isArray(v) ? v : JSON.parse(String(v));
90
+ if (!Array.isArray(arr)) throw new Error(`${type} expects an array`);
91
+ return pad(arr.length.toString(16)) + encodeTuple(arr.map(() => inner), arr);
92
+ }
93
+
94
+ function encodeTuple(types, values) {
95
+ if (types.length !== values.length) throw new Error(`expected ${types.length} values, got ${values.length}`);
96
+ const heads = [];
97
+ const tails = [];
98
+ let tailLen = 0;
99
+ const headLen = types.length * 32;
100
+ for (let i = 0; i < types.length; i++) {
101
+ if (isDynamic(types[i])) {
102
+ const t = encDynamic(types[i], values[i]);
103
+ heads.push(pad((headLen + tailLen).toString(16)));
104
+ tails.push(t);
105
+ tailLen += t.length / 2;
106
+ } else {
107
+ heads.push(encStatic(types[i], values[i]));
108
+ }
109
+ }
110
+ return heads.join("") + tails.join("");
111
+ }
112
+
113
+ /** abi.encode(values...) of these types, as 0x hex. */
114
+ export function encode(types, values) {
115
+ for (const t of types) if (!isType(t)) throw new Error(`the kit cannot encode the type ${t}`);
116
+ return "0x" + encodeTuple(types, values);
117
+ }