@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.
- package/LICENSE +21 -0
- package/README.md +124 -2
- package/bin/pickhooks-mode.mjs +272 -0
- package/contracts/IStickyMode.sol +175 -0
- package/contracts/ModeBase.sol +124 -0
- package/contracts/harness/Live.sol +80 -0
- package/contracts/harness/ModeHarness.sol +322 -0
- package/lib/abi.mjs +117 -0
- package/lib/brief.mjs +122 -0
- package/lib/check.mjs +158 -0
- package/lib/index.mjs +6 -0
- package/lib/live.mjs +14 -0
- package/lib/modejson.mjs +176 -0
- package/package.json +39 -4
- package/schema/mode.schema.json +80 -0
- package/template/env.example +2 -0
- package/template/foundry.toml +15 -0
- package/template/gitignore +5 -0
- package/template/mode.json +26 -0
- package/template/remappings.txt +2 -0
- package/template/src/HourMode.sol +70 -0
- package/template/test/HourMode.t.sol +79 -0
package/lib/brief.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The brief for a coding agent (Claude Code, Cursor, Codex ...): everything it needs to write a mode, test it and
|
|
2
|
+
// pack it, and what it must not do. `pickhooks-mode brief` prints it; paste it into the agent with your idea.
|
|
3
|
+
import { LIVE } from "./live.mjs";
|
|
4
|
+
|
|
5
|
+
export function brief() {
|
|
6
|
+
return `# Write a pickhooks mode
|
|
7
|
+
|
|
8
|
+
You are writing a mode for pickhooks (pickhooks.com, BNB Chain): the game inside a coin. Every coin launched with
|
|
9
|
+
the mode calls it after every buy, sell and send, and holders play it through the coin. You write one Solidity
|
|
10
|
+
contract and its mode.json, test it on a BNB Chain fork against the live contracts, and pack it for review.
|
|
11
|
+
pickhooks reviews it, deploys it and lists it. **Never deploy anything to mainnet yourself, never ask for a private
|
|
12
|
+
key, never send a transaction.**
|
|
13
|
+
|
|
14
|
+
## The project
|
|
15
|
+
|
|
16
|
+
\`\`\`
|
|
17
|
+
src/<Contract>.sol the mode (start from src/HourMode.sol, a whole mode in one file)
|
|
18
|
+
test/<Contract>.t.sol its tests (start from test/HourMode.t.sol)
|
|
19
|
+
mode.json what it is, its settings and actions, in the words the launcher shows
|
|
20
|
+
lib/pickhooks/ the kit: IStickyMode.sol, ModeBase.sol, harness/ (do not edit)
|
|
21
|
+
\`\`\`
|
|
22
|
+
|
|
23
|
+
Commands: \`npx @pickhooks/mode-kit check\` (mode.json, source, bytecode), \`npx @pickhooks/mode-kit test\` (forge on a
|
|
24
|
+
fork, needs BSC_RPC_URL in .env), \`npx @pickhooks/mode-kit pack\` (the file for the review).
|
|
25
|
+
|
|
26
|
+
## How a mode works
|
|
27
|
+
|
|
28
|
+
Extend \`ModeBase\` and implement:
|
|
29
|
+
|
|
30
|
+
- \`name()\`: the title, a plain string literal, the same as mode.json "title".
|
|
31
|
+
- \`quote(creator, data)\`: decode the settings (\`abi.decode(data, (...))\`, empty data means defaults), check each
|
|
32
|
+
against its bounds with \`_within(x, default, lo, hi, "what")\` (0 means the default), revert outside them, and return
|
|
33
|
+
the share of every buy (basis points) that goes into the coin's game pot. The factory refuses a launch where this
|
|
34
|
+
share and the coin's own burns and pots pass 10 % of a buy.
|
|
35
|
+
- \`_register(coin, creator, data)\`: store the settings per coin. State is always per coin: \`mapping(address => ...)\`.
|
|
36
|
+
- \`_trade(coin, t)\`: called after every buy (kind 1), sell (2) and send (4). \`t.who\` is the trader (the coin
|
|
37
|
+
resolves routers and aggregators to their signer), \`t.amount\` the coins moved, \`t.got\` what a buyer got, \`t.pot\` the
|
|
38
|
+
pot after this trade, \`t.buys\` the buy counter. Return orders, or \`Orders.none()\`.
|
|
39
|
+
- \`_play(coin, player, data)\`: a holder's move through \`coin.play(abi.encode(uint8 op, ...))\`. Optional.
|
|
40
|
+
- \`_poke(coin, caller, data)\`: anyone's nudge through \`coin.poke(...)\`, to settle or end a round. Optional.
|
|
41
|
+
|
|
42
|
+
Use the helpers of ModeBase: \`_player(coin, who)\` (false for the creator, the pools, the coin, the dead address),
|
|
43
|
+
\`_ppm(coins)\` (millionths of the supply), \`_pot(coin)\`, \`_op(data)\`, \`_isWallet(a)\`, \`_min(a, b)\`.
|
|
44
|
+
|
|
45
|
+
## Orders: the only way a mode acts
|
|
46
|
+
|
|
47
|
+
A mode never moves a coin. It answers with orders built with \`Orders\` and the coin carries them out under its rules:
|
|
48
|
+
|
|
49
|
+
| order | what the coin does |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| pay(to, amount) | pays out of the game pot, at most the pot |
|
|
52
|
+
| payHeld(to, amount, until) | the same, into custody until \`until\` |
|
|
53
|
+
| hold(who, to, amount, until, flags) | a player's own free coins into custody (only in that player's own play) |
|
|
54
|
+
| release(who) | custody back to the wallet |
|
|
55
|
+
| burnHeld(who, amount) | burns coins in custody |
|
|
56
|
+
| moveHeld(who, to, amount, until) | moves custody to another holder |
|
|
57
|
+
| burnPot(amount) | burns out of the pot |
|
|
58
|
+
| burn(who, amount) | burns a player's own free coins (only in that player's own play) |
|
|
59
|
+
| setUntil(who, until) | moves the end of a custody |
|
|
60
|
+
|
|
61
|
+
At most 16 orders per answer. Custody ends by itself at most 7 days after it began.
|
|
62
|
+
|
|
63
|
+
## What holds whatever the mode does
|
|
64
|
+
|
|
65
|
+
- Every terminal trades the coin from the first second: GMGN, Binance Wallet, PancakeSwap, any router.
|
|
66
|
+
- The mode gets 250,000 gas per trade. If it reverts, runs out of gas or answers garbage, the coin skips it for that
|
|
67
|
+
trade (\`ModeSkipped\`) and the trade stands. A mode that skips is broken: the harness fails on any skip and wants
|
|
68
|
+
every trade under 200,000 gas.
|
|
69
|
+
- Sells and sends are never taxed. The pot fills only with the mode's share of buys and leaves only by orders.
|
|
70
|
+
|
|
71
|
+
## Rules for the code (\`pickhooks-mode check\` enforces them)
|
|
72
|
+
|
|
73
|
+
No selfdestruct, delegatecall, payable, low level calls, coin transfers or approvals, calls back into the coin, new
|
|
74
|
+
contracts, owners, proxies or upgrades. No tx.origin (use \`t.who\`). Randomness only from drand through the beacon at
|
|
75
|
+
${LIVE.drandBeacon} (see the heist mode on BscScan for the pattern), never from block values. Every loop has a fixed
|
|
76
|
+
bound. Constants are named, so the review can state them.
|
|
77
|
+
|
|
78
|
+
## The review checklist
|
|
79
|
+
|
|
80
|
+
1. Every setting has bounds; quote() reverts outside them and returns the share of buys.
|
|
81
|
+
2. _trade stays well under 250,000 gas in its worst case (a full list, the last slot); loops are bounded.
|
|
82
|
+
3. Nothing in _trade can revert on a normal buy, sell or send.
|
|
83
|
+
4. Payouts never go to the creator, a pool, the coin or the dead address (_player), and never more than the trade or
|
|
84
|
+
the pot justifies.
|
|
85
|
+
5. Custody always has an end the holder can see, never more than 7 days.
|
|
86
|
+
6. A holder's free coins move only in that holder's own play.
|
|
87
|
+
7. What can be gamed is said plainly in mode.json notes: two own wallets, a bot that times trades, a creator who plays
|
|
88
|
+
against holders, and the cap that holds it.
|
|
89
|
+
8. Randomness comes from drand through the beacon.
|
|
90
|
+
9. The harness passes: \`guardrails\` with the defaults and with the edge of every setting, \`stress\`, \`expectLaunchRevert\`
|
|
91
|
+
for every bound, and a test for every rule and action.
|
|
92
|
+
10. Texts are short sentences, lower case titles, no dashes.
|
|
93
|
+
|
|
94
|
+
## mode.json
|
|
95
|
+
|
|
96
|
+
\`\`\`json
|
|
97
|
+
{
|
|
98
|
+
"kit": 1,
|
|
99
|
+
"name": "hour", // the id: lower case letters and digits
|
|
100
|
+
"title": "first of the hour", // = name() in the contract
|
|
101
|
+
"contract": "HourMode",
|
|
102
|
+
"line": "The first buy of every hour wins from the pot.",
|
|
103
|
+
"how": ["sentence", "sentence"], // how it plays, 1 to 5 sentences
|
|
104
|
+
"share": "bps", // the setting that is the share of buys (or { "fixed": 200 })
|
|
105
|
+
"settings": [{ "key": "bps", "type": "uint256", "label": "...", "unit": "bps", "default": 200, "min": 100, "max": 300 }],
|
|
106
|
+
"actions": [{ "name": "try", "op": 1, "who": "holder", "text": "...", "params": [{ "key": "coins", "type": "uint256", "decimals": 18 }] }],
|
|
107
|
+
"pokes": [],
|
|
108
|
+
"drand": false, "custody": false, "keeper": null,
|
|
109
|
+
"notes": ["what can be gamed, and the cap that holds it"],
|
|
110
|
+
"author": { "wallet": "0x...", "x": "@...", "telegram": "@..." }
|
|
111
|
+
}
|
|
112
|
+
\`\`\`
|
|
113
|
+
|
|
114
|
+
Settings are encoded in the order of the list (\`abi.encode\` of their types); an action's data is
|
|
115
|
+
\`abi.encode(uint8 op, params...)\`. \`pickhooks-mode encode\` does both.
|
|
116
|
+
|
|
117
|
+
## The author share
|
|
118
|
+
|
|
119
|
+
When pickhooks lists the mode, the author wallet in mode.json earns half of the pickhooks fee of every coin launched
|
|
120
|
+
with it, in BNB, for as long as those coins trade. The creator's share is not touched.
|
|
121
|
+
`;
|
|
122
|
+
}
|
package/lib/check.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The checks pickhooks runs before it reviews a mode by hand: mode.json, the Solidity source and the built bytecode.
|
|
2
|
+
// Errors stop a submission; warnings are what the reviewer will look at first.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { validate } from "./modejson.mjs";
|
|
7
|
+
|
|
8
|
+
/** BNB Chain refuses contracts over this many bytes of runtime code (EIP-170). */
|
|
9
|
+
export const MAX_CODE = 24_576;
|
|
10
|
+
|
|
11
|
+
/** Solidity without comments and string contents, so a word in a comment or a message never trips a check. */
|
|
12
|
+
export function stripSolidity(src) {
|
|
13
|
+
return src
|
|
14
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
|
15
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
16
|
+
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""')
|
|
17
|
+
.replace(/'(?:[^'\\\n]|\\.)*'/g, "''");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What a mode's source may not contain, and why. */
|
|
21
|
+
const FORBIDDEN = [
|
|
22
|
+
[/\bselfdestruct\s*\(/, "selfdestruct: a mode is there for every coin that uses it, for good"],
|
|
23
|
+
[/\bdelegatecall\b/, "delegatecall: the code that runs must be the code that was reviewed"],
|
|
24
|
+
[/\bcallcode\b/, "callcode: the code that runs must be the code that was reviewed"],
|
|
25
|
+
[/\btx\.origin\b/, "tx.origin: use the trade's `who` (the coin already resolves routers to their signer)"],
|
|
26
|
+
[/\bblock\.(prevrandao|difficulty)\b|\bblockhash\s*\(/, "block values as randomness: validators can steer them; draw with drand through the beacon"],
|
|
27
|
+
[/\bpayable\b|\breceive\s*\(\s*\)|\bfallback\s*\(/, "payable: a mode holds no BNB"],
|
|
28
|
+
[/\.call\s*[({]|\.staticcall\s*\(|\.send\s*\(/, "low level calls: a mode reads its coin and answers with orders, it calls nothing else"],
|
|
29
|
+
[/\.(transfer|transferFrom|approve)\s*\(/, "moving coins: a mode never moves a coin itself, it answers with orders the coin carries out"],
|
|
30
|
+
[/\.(play|poke|releaseHeld)\s*\(/, "calling back into the coin: the coin is busy while it asks the mode, the call would revert"],
|
|
31
|
+
[/\bnew\s+[A-Z]\w*\s*[({]/, "deploying contracts: a mode is one contract, reviewed as it is"],
|
|
32
|
+
[/\b(upgradeTo|upgradeToAndCall|_authorizeUpgrade|initialize|initializer)\b|\bProxy\b|\bUUPS\b/, "upgradeable: a mode is fixed, like the coins that use it"],
|
|
33
|
+
[/\b(Ownable|onlyOwner|transferOwnership|AccessControl)\b/, "an owner: nobody holds power over a listed mode, the coins keep it for good"],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** What a reviewer will look at first. */
|
|
37
|
+
const WATCH = [
|
|
38
|
+
[/\bassembly\b/, "assembly: the reviewer reads it line by line"],
|
|
39
|
+
[/\bwhile\s*\(/, "a while loop: make sure it ends in a fixed number of steps (250,000 gas per trade)"],
|
|
40
|
+
[/\bunchecked\b/, "unchecked math: say in a comment why it cannot overflow"],
|
|
41
|
+
[/\bblock\.number\b/, "block.number: BNB Chain blocks are 0.45 s and may change; prefer block.timestamp"],
|
|
42
|
+
[/\bI(?!StickyCoin3\b|StickyMode\b|Beacon\b)[A-Z]\w*\s*\(\s*\w+\s*\)\s*\./, "a call to another contract: say in mode.json notes which one and why"],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** The runtime code without Solidity's CBOR metadata at the end. */
|
|
46
|
+
export function stripMetadata(hex) {
|
|
47
|
+
const h = hex.replace(/^0x/, "");
|
|
48
|
+
if (h.length < 4) return h;
|
|
49
|
+
const len = parseInt(h.slice(-4), 16);
|
|
50
|
+
const cut = h.length - (len + 2) * 2;
|
|
51
|
+
return cut > 0 && len > 0 && len < 200 ? h.slice(0, cut) : h;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The opcodes in runtime code, walking past PUSH data so data bytes never count as opcodes. */
|
|
55
|
+
export function opcodes(hex) {
|
|
56
|
+
const h = stripMetadata(hex);
|
|
57
|
+
const found = new Set();
|
|
58
|
+
for (let i = 0; i < h.length; i += 2) {
|
|
59
|
+
const op = parseInt(h.slice(i, i + 2), 16);
|
|
60
|
+
found.add(op);
|
|
61
|
+
if (op >= 0x60 && op <= 0x7f) i += (op - 0x5f) * 2;
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BAD_OPS = [
|
|
67
|
+
[0xff, "SELFDESTRUCT"],
|
|
68
|
+
[0xf4, "DELEGATECALL"],
|
|
69
|
+
[0xf2, "CALLCODE"],
|
|
70
|
+
[0xf0, "CREATE"],
|
|
71
|
+
[0xf5, "CREATE2"],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function walk(dir, ext, out = []) {
|
|
75
|
+
if (!fs.existsSync(dir)) return out;
|
|
76
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
77
|
+
const p = path.join(dir, e.name);
|
|
78
|
+
if (e.isDirectory()) walk(p, ext, out);
|
|
79
|
+
else if (p.endsWith(ext)) out.push(p);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function sourceFiles(dir) {
|
|
85
|
+
return walk(path.join(dir, "src"), ".sol");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Checks a mode project: mode.json, every source file under src/, and the built bytecode when out/ has it. */
|
|
89
|
+
export function checkProject(dir, { strict = false } = {}) {
|
|
90
|
+
const errors = [];
|
|
91
|
+
const warnings = [];
|
|
92
|
+
const facts = {};
|
|
93
|
+
const file = path.join(dir, "mode.json");
|
|
94
|
+
if (!fs.existsSync(file)) return { ok: false, errors: [`no mode.json in ${dir}`], warnings, facts };
|
|
95
|
+
let m;
|
|
96
|
+
try {
|
|
97
|
+
m = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return { ok: false, errors: [`mode.json is not valid JSON: ${e.message}`], warnings, facts };
|
|
100
|
+
}
|
|
101
|
+
const v = validate(m, { strict });
|
|
102
|
+
errors.push(...v.errors.map((e) => `mode.json: ${e}`));
|
|
103
|
+
warnings.push(...v.warnings.map((e) => `mode.json: ${e}`));
|
|
104
|
+
|
|
105
|
+
const files = sourceFiles(dir);
|
|
106
|
+
if (!files.length) errors.push("src/ has no Solidity files");
|
|
107
|
+
let main = null;
|
|
108
|
+
const hash = crypto.createHash("sha256");
|
|
109
|
+
for (const f of files) {
|
|
110
|
+
const raw = fs.readFileSync(f, "utf8");
|
|
111
|
+
hash.update(path.relative(dir, f).replace(/\\/g, "/") + "\n" + raw);
|
|
112
|
+
const code = stripSolidity(raw);
|
|
113
|
+
const rel = path.relative(dir, f).replace(/\\/g, "/");
|
|
114
|
+
for (const [re, why] of FORBIDDEN) if (re.test(code)) errors.push(`${rel}: ${why}`);
|
|
115
|
+
for (const [re, why] of WATCH) if (re.test(code)) warnings.push(`${rel}: ${why}`);
|
|
116
|
+
for (const imp of raw.matchAll(/^\s*import\s+[^;]*?["']([^"']+)["']/gm)) {
|
|
117
|
+
const p = imp[1];
|
|
118
|
+
if (!p.startsWith("pickhooks/") && !p.startsWith("./") && !p.startsWith("../")) warnings.push(`${rel}: imports ${p}; the review reads every imported line, keep it to the kit and your own files`);
|
|
119
|
+
if (p.startsWith("pickhooks/harness/")) errors.push(`${rel}: the harness is for tests, not for the mode`);
|
|
120
|
+
}
|
|
121
|
+
if (m?.contract && new RegExp(`\\bcontract\\s+${m.contract}\\b`).test(code)) main = { rel, raw, code };
|
|
122
|
+
}
|
|
123
|
+
facts.sourceSha256 = hash.digest("hex");
|
|
124
|
+
|
|
125
|
+
if (m?.contract && !main) errors.push(`no contract ${m.contract} under src/ (mode.json says contract "${m.contract}")`);
|
|
126
|
+
if (main) {
|
|
127
|
+
if (!new RegExp(`\\bcontract\\s+${m.contract}\\s+is\\s+[^{]*\\bModeBase\\b`).test(main.code)) errors.push(`${main.rel}: ${m.contract} must extend ModeBase (registration by the factory only, the coin check on every call)`);
|
|
128
|
+
if (!/\bModeBase\s*\(/.test(main.code)) errors.push(`${main.rel}: the constructor must pass the factory to ModeBase(factory_)`);
|
|
129
|
+
const nm = /function\s+name\s*\(\s*\)[^{]*\{\s*return\s+"([^"]*)"\s*;/.exec(main.raw);
|
|
130
|
+
if (!nm) errors.push(`${main.rel}: name() must return the title as a plain string literal`);
|
|
131
|
+
else if (m.title && nm[1] !== m.title) errors.push(`${main.rel}: name() returns "${nm[1]}", mode.json says "${m.title}"`);
|
|
132
|
+
if (!/function\s+quote\s*\(/.test(main.code)) errors.push(`${main.rel}: quote() is missing (the share of buys, and the bounds of the settings)`);
|
|
133
|
+
if (m?.drand && !/\bIBeacon\b|\bbeacon\b/.test(main.code)) warnings.push(`${main.rel}: mode.json says drand, the source does not use the beacon`);
|
|
134
|
+
if ((m?.actions ?? []).length && !/function\s+_play\s*\(/.test(main.code)) errors.push(`${main.rel}: mode.json has actions, the contract has no _play`);
|
|
135
|
+
if ((m?.pokes ?? []).length && !/function\s+_poke\s*\(/.test(main.code)) errors.push(`${main.rel}: mode.json has pokes, the contract has no _poke`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// the built code, when forge built it
|
|
139
|
+
if (m?.contract && main) {
|
|
140
|
+
const art = path.join(dir, "out", path.basename(main.rel), `${m.contract}.json`);
|
|
141
|
+
if (fs.existsSync(art)) {
|
|
142
|
+
const j = JSON.parse(fs.readFileSync(art, "utf8"));
|
|
143
|
+
const hex = j.deployedBytecode?.object ?? "";
|
|
144
|
+
const size = hex.replace(/^0x/, "").length / 2;
|
|
145
|
+
facts.runtimeBytes = size;
|
|
146
|
+
facts.runtimeSha256 = crypto.createHash("sha256").update(hex.replace(/^0x/, ""), "hex").digest("hex");
|
|
147
|
+
if (size > MAX_CODE) errors.push(`the runtime code is ${size} bytes; BNB Chain takes at most ${MAX_CODE}`);
|
|
148
|
+
else if (size > 22_000) warnings.push(`the runtime code is ${size} bytes, close to the limit of ${MAX_CODE}`);
|
|
149
|
+
const ops = opcodes(hex);
|
|
150
|
+
for (const [op, n] of BAD_OPS) if (ops.has(op)) errors.push(`the bytecode contains ${n}`);
|
|
151
|
+
facts.compiler = j.metadata?.compiler?.version ?? null;
|
|
152
|
+
facts.evm = j.metadata?.settings?.evmVersion ?? null;
|
|
153
|
+
} else {
|
|
154
|
+
warnings.push(`no build in out/ yet: run forge build (or pickhooks-mode test) to check the bytecode too`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { ok: errors.length === 0, errors, warnings, facts, mode: m };
|
|
158
|
+
}
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @pickhooks/mode-kit as a library: check a mode project, validate and encode mode.json, the live addresses, the brief.
|
|
2
|
+
export { checkProject, stripSolidity, opcodes, stripMetadata, MAX_CODE } from "./check.mjs";
|
|
3
|
+
export { validate, encodeSettings, encodeAction, shareBps, MAX_SHARE_BPS } from "./modejson.mjs";
|
|
4
|
+
export { encode } from "./abi.mjs";
|
|
5
|
+
export { LIVE } from "./live.mjs";
|
|
6
|
+
export { brief } from "./brief.mjs";
|
package/lib/live.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The live pickhooks contracts a mode plugs into (BNB Chain, chain id 56). The same addresses as contracts/harness/Live.sol.
|
|
2
|
+
export const LIVE = {
|
|
3
|
+
chainId: 56,
|
|
4
|
+
factory: "0x4b41a3Ee8C80C568aE6Da7C5572867D5640C0574",
|
|
5
|
+
locker: "0xCea0C138612B3F61D472d18F575Bb314561c024F",
|
|
6
|
+
drandBeacon: "0x9c264D2934B2027Bad622417b6747E558EC465CC",
|
|
7
|
+
wbnb: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
|
|
8
|
+
routerV3: "0x1b81D678ffb9C0263b24A97847620C99d213eB14",
|
|
9
|
+
routerV2: "0x10ED43C718714eb63d5aA57B78B54704E256024E",
|
|
10
|
+
modeGas: 250_000,
|
|
11
|
+
maxOrders: 16,
|
|
12
|
+
maxCustodyDays: 7,
|
|
13
|
+
maxShareBps: 1000,
|
|
14
|
+
};
|
package/lib/modejson.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// mode.json: what a mode is, in the words and numbers the launcher, the coin page, the SDK and the review use.
|
|
2
|
+
// validate() checks it, encodeSettings() / encodeAction() turn values into the bytes the chain takes.
|
|
3
|
+
import { encode, isStatic, isType, toBig } from "./abi.mjs";
|
|
4
|
+
|
|
5
|
+
/** The share of a buy all takes of a coin together (burns, pots and the mode) may not pass: the factory's validator. */
|
|
6
|
+
export const MAX_SHARE_BPS = 1000;
|
|
7
|
+
const WHO = ["holder", "creator", "anyone", "keeper"];
|
|
8
|
+
const DASH = /[‒–—―]| - /;
|
|
9
|
+
|
|
10
|
+
function texts(m) {
|
|
11
|
+
const out = [];
|
|
12
|
+
const add = (where, s) => typeof s === "string" && out.push([where, s]);
|
|
13
|
+
add("title", m.title);
|
|
14
|
+
add("line", m.line);
|
|
15
|
+
(m.how ?? []).forEach((s, i) => add(`how[${i}]`, s));
|
|
16
|
+
(m.notes ?? []).forEach((s, i) => add(`notes[${i}]`, s));
|
|
17
|
+
(m.settings ?? []).forEach((s, i) => add(`settings[${i}].label`, s?.label));
|
|
18
|
+
for (const list of ["actions", "pokes"]) (m[list] ?? []).forEach((a, i) => add(`${list}[${i}].text`, a?.text));
|
|
19
|
+
add("keeper", m.keeper);
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkParams(where, params, errors) {
|
|
24
|
+
if (params === undefined) return;
|
|
25
|
+
if (!Array.isArray(params)) return errors.push(`${where}.params must be a list`);
|
|
26
|
+
const keys = new Set();
|
|
27
|
+
params.forEach((p, i) => {
|
|
28
|
+
const w = `${where}.params[${i}]`;
|
|
29
|
+
if (!p || typeof p !== "object") return errors.push(`${w} must be an object`);
|
|
30
|
+
if (!/^[a-z][A-Za-z0-9]*$/.test(p.key ?? "")) errors.push(`${w}.key must be a camelCase word`);
|
|
31
|
+
if (keys.has(p.key)) errors.push(`${w}.key "${p.key}" is used twice`);
|
|
32
|
+
keys.add(p.key);
|
|
33
|
+
if (!isType(p.type ?? "")) errors.push(`${w}.type "${p.type}" is not an ABI type the kit encodes`);
|
|
34
|
+
if (p.decimals !== undefined && !(Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= 36)) errors.push(`${w}.decimals must be 0 to 36`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Checks a mode.json. `strict` (for a submission) also wants the author's wallet. Returns { errors, warnings }:
|
|
40
|
+
* errors stop a submission, warnings are what a reviewer will ask about.
|
|
41
|
+
*/
|
|
42
|
+
export function validate(m, { strict = false } = {}) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
const warnings = [];
|
|
45
|
+
if (!m || typeof m !== "object") return { errors: ["mode.json is not an object"], warnings };
|
|
46
|
+
if (m.kit !== 1) errors.push(`kit must be 1 (this kit's format), got ${JSON.stringify(m.kit)}`);
|
|
47
|
+
if (!/^[a-z][a-z0-9]{1,23}$/.test(m.name ?? "")) errors.push("name must be 2 to 24 lower case letters and digits (it becomes the mode's id)");
|
|
48
|
+
if (typeof m.title !== "string" || m.title.length < 2 || m.title.length > 32) errors.push("title must be 2 to 32 characters (it must equal the contract's name())");
|
|
49
|
+
else if (m.title !== m.title.toLowerCase()) warnings.push("title: mode names are lower case on pickhooks (\"pay it forward\", \"heist\")");
|
|
50
|
+
if (!/^[A-Z][A-Za-z0-9_]*$/.test(m.contract ?? "")) errors.push("contract must be the Solidity contract's name");
|
|
51
|
+
if (typeof m.line !== "string" || m.line.length < 8 || m.line.length > 80) errors.push("line must be 8 to 80 characters: the mode in one sentence");
|
|
52
|
+
if (!Array.isArray(m.how) || m.how.length < 1 || m.how.length > 5) errors.push("how must be 1 to 5 sentences: how it plays");
|
|
53
|
+
else m.how.forEach((s, i) => (typeof s !== "string" || s.length > 220) && errors.push(`how[${i}] must be a sentence of at most 220 characters`));
|
|
54
|
+
if (!Array.isArray(m.notes) || m.notes.length < 1) errors.push("notes must say plainly what can be gamed and which cap holds it (at least one)");
|
|
55
|
+
|
|
56
|
+
// settings
|
|
57
|
+
const settings = m.settings ?? [];
|
|
58
|
+
if (!Array.isArray(settings) || settings.length > 8) errors.push("settings must be a list of at most 8");
|
|
59
|
+
const keys = new Set();
|
|
60
|
+
(Array.isArray(settings) ? settings : []).forEach((s, i) => {
|
|
61
|
+
const w = `settings[${i}]`;
|
|
62
|
+
if (!s || typeof s !== "object") return errors.push(`${w} must be an object`);
|
|
63
|
+
if (!/^[a-z][A-Za-z0-9]*$/.test(s.key ?? "")) errors.push(`${w}.key must be a camelCase word`);
|
|
64
|
+
if (keys.has(s.key)) errors.push(`${w}.key "${s.key}" is used twice`);
|
|
65
|
+
keys.add(s.key);
|
|
66
|
+
if (!isStatic(s.type ?? "")) return errors.push(`${w}.type must be a static ABI type (uint256, bool, address ...)`);
|
|
67
|
+
if (typeof s.label !== "string" || !s.label) errors.push(`${w}.label is missing`);
|
|
68
|
+
if (/int/.test(s.type)) {
|
|
69
|
+
try {
|
|
70
|
+
const [d, lo, hi] = [toBig(s.default), toBig(s.min), toBig(s.max)];
|
|
71
|
+
if (!(lo <= d && d <= hi)) errors.push(`${w}: min <= default <= max does not hold`);
|
|
72
|
+
} catch {
|
|
73
|
+
errors.push(`${w}: default, min and max must be numbers`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// the share of buys
|
|
79
|
+
if (typeof m.share === "string") {
|
|
80
|
+
const s = (Array.isArray(settings) ? settings : []).find((x) => x?.key === m.share);
|
|
81
|
+
if (!s) errors.push(`share names the setting "${m.share}", which is not in settings`);
|
|
82
|
+
else if (!/^uint/.test(s.type)) errors.push(`share: the setting "${m.share}" must be a uint`);
|
|
83
|
+
else if (Number(s.max) > MAX_SHARE_BPS) errors.push(`share: at most ${MAX_SHARE_BPS} bps (10 % of a buy, burns and pots of the coin included)`);
|
|
84
|
+
else if (Number(s.max) > 500) warnings.push("share: over 5 % of every buy is a lot; scanners show buy takes, and it leaves less room for the coin's rows");
|
|
85
|
+
} else if (m.share && typeof m.share === "object" && Number.isInteger(m.share.fixed)) {
|
|
86
|
+
if (m.share.fixed < 0 || m.share.fixed > MAX_SHARE_BPS) errors.push(`share.fixed must be 0 to ${MAX_SHARE_BPS} bps`);
|
|
87
|
+
} else errors.push('share must name the setting that is the share of every buy into the pot, or be { "fixed": bps }');
|
|
88
|
+
|
|
89
|
+
// plays and pokes
|
|
90
|
+
for (const list of ["actions", "pokes"]) {
|
|
91
|
+
const xs = m[list] ?? [];
|
|
92
|
+
if (!Array.isArray(xs)) {
|
|
93
|
+
errors.push(`${list} must be a list`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const ops = new Set();
|
|
97
|
+
xs.forEach((a, i) => {
|
|
98
|
+
const w = `${list}[${i}]`;
|
|
99
|
+
if (!a || typeof a !== "object") return errors.push(`${w} must be an object`);
|
|
100
|
+
if (!/^[a-z][a-z0-9 ]{0,23}$/.test(a.name ?? "")) errors.push(`${w}.name must be 1 to 24 lower case letters, digits and spaces`);
|
|
101
|
+
if (!(Number.isInteger(a.op) && a.op >= 0 && a.op <= 255)) errors.push(`${w}.op must be the action number 0 to 255 (the first word of the data)`);
|
|
102
|
+
if (ops.has(a.op)) errors.push(`${w}.op ${a.op} is used twice`);
|
|
103
|
+
ops.add(a.op);
|
|
104
|
+
if (!WHO.includes(a.who)) errors.push(`${w}.who must be one of ${WHO.join(", ")}`);
|
|
105
|
+
if (typeof a.text !== "string" || !a.text) errors.push(`${w}.text is missing`);
|
|
106
|
+
checkParams(w, a.params, errors);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof m.drand !== "boolean") errors.push("drand must be true or false (does the mode wait for drand rounds?)");
|
|
111
|
+
if (typeof m.custody !== "boolean") errors.push("custody must be true or false (can coins sit in custody?)");
|
|
112
|
+
if (!(m.keeper === null || typeof m.keeper === "string")) errors.push("keeper must be null or what the keeper does");
|
|
113
|
+
if ((m.drand || (m.pokes ?? []).length) && !m.keeper) warnings.push("keeper: a mode with drand or pokes needs someone to move it on; say what the keeper does");
|
|
114
|
+
|
|
115
|
+
// the author
|
|
116
|
+
const wallet = m.author?.wallet ?? "";
|
|
117
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(wallet)) (strict ? errors : warnings).push("author.wallet: the BNB Chain wallet the author share is paid to");
|
|
118
|
+
|
|
119
|
+
// house style
|
|
120
|
+
for (const [where, s] of texts(m)) if (DASH.test(s)) errors.push(`${where}: no dashes in texts, write two sentences or use a colon`);
|
|
121
|
+
|
|
122
|
+
if (m.address !== undefined && m.address !== null && !/^0x[0-9a-fA-F]{40}$/.test(m.address)) errors.push("address must be empty until pickhooks deploys the mode");
|
|
123
|
+
return { errors, warnings };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function valueOf(p, raw) {
|
|
127
|
+
if (raw === undefined) return undefined;
|
|
128
|
+
if (/int/.test(p.type) && !p.type.endsWith("[]")) return toBig(raw, p.decimals ?? 0);
|
|
129
|
+
return raw;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The settings as the factory takes them (`modeData`). `values` maps keys to values; a missing key takes its default.
|
|
134
|
+
* Nothing at all gives "0x": the mode's own defaults.
|
|
135
|
+
*/
|
|
136
|
+
export function encodeSettings(m, values = {}) {
|
|
137
|
+
const settings = m.settings ?? [];
|
|
138
|
+
for (const k of Object.keys(values)) if (!settings.some((s) => s.key === k)) throw new Error(`${m.title} has no setting "${k}"`);
|
|
139
|
+
if (!Object.keys(values).length) return "0x";
|
|
140
|
+
const out = settings.map((s) => {
|
|
141
|
+
const v = values[s.key] !== undefined ? valueOf(s, values[s.key]) : s.default;
|
|
142
|
+
if (/int/.test(s.type)) {
|
|
143
|
+
const n = toBig(v);
|
|
144
|
+
if (n < toBig(s.min) || n > toBig(s.max)) throw new Error(`${m.title}: ${s.key} is ${s.min} to ${s.max}`);
|
|
145
|
+
return n;
|
|
146
|
+
}
|
|
147
|
+
return v;
|
|
148
|
+
});
|
|
149
|
+
return encode(
|
|
150
|
+
settings.map((s) => s.type),
|
|
151
|
+
out,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A play's (or a poke's) data: abi.encode(uint8 op, params...). */
|
|
156
|
+
export function encodeAction(m, name, values = {}, list = "actions") {
|
|
157
|
+
const a = (m[list] ?? []).find((x) => x.name === name);
|
|
158
|
+
if (!a) throw new Error(`${m.title} has no ${list === "pokes" ? "poke" : "action"} "${name}"`);
|
|
159
|
+
const params = a.params ?? [];
|
|
160
|
+
for (const k of Object.keys(values)) if (!params.some((p) => p.key === k)) throw new Error(`"${name}" has no parameter "${k}"`);
|
|
161
|
+
const vals = params.map((p) => {
|
|
162
|
+
if (values[p.key] === undefined) throw new Error(`"${name}" needs ${p.key}`);
|
|
163
|
+
return valueOf(p, values[p.key]);
|
|
164
|
+
});
|
|
165
|
+
return encode(
|
|
166
|
+
["uint8", ...params.map((p) => p.type)],
|
|
167
|
+
[a.op, ...vals],
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The share of every buy this mode takes with these settings (offline; the mode's quote() is the truth). */
|
|
172
|
+
export function shareBps(m, values = {}) {
|
|
173
|
+
if (m.share && typeof m.share === "object") return m.share.fixed;
|
|
174
|
+
const s = (m.settings ?? []).find((x) => x.key === m.share);
|
|
175
|
+
return Number(values[m.share] ?? s?.default ?? 0);
|
|
176
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pickhooks/mode-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Write a game for pickhooks coins on BNB Chain, test it on a fork against the live contracts, earn half of the pickhooks fee of every coin that uses it.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pickhooks-mode": "bin/pickhooks-mode.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./lib/index.mjs",
|
|
12
|
+
"./mode.schema.json": "./schema/mode.schema.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"lib",
|
|
17
|
+
"contracts",
|
|
18
|
+
"template",
|
|
19
|
+
"schema",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/kit.test.mjs",
|
|
25
|
+
"sync": "node scripts/sync-check.mjs",
|
|
26
|
+
"release": "node scripts/publish.mjs"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"pickhooks",
|
|
30
|
+
"bnb-chain",
|
|
31
|
+
"pancakeswap",
|
|
32
|
+
"foundry",
|
|
33
|
+
"solidity",
|
|
34
|
+
"hooks",
|
|
35
|
+
"launchpad",
|
|
36
|
+
"memecoin"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://pickhooks.com/schema/mode.schema.json",
|
|
4
|
+
"title": "pickhooks mode",
|
|
5
|
+
"description": "What a pickhooks mode is, in the words the launcher and the coin page show. `pickhooks-mode check` checks more than this schema.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["kit", "name", "title", "contract", "line", "how", "share", "settings", "actions", "drand", "custody", "keeper", "notes", "author"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"kit": { "const": 1 },
|
|
10
|
+
"name": { "type": "string", "pattern": "^[a-z][a-z0-9]{1,23}$", "description": "the id: lower case letters and digits" },
|
|
11
|
+
"title": { "type": "string", "minLength": 2, "maxLength": 32, "description": "the name players see; equals name() in the contract" },
|
|
12
|
+
"contract": { "type": "string", "pattern": "^[A-Z][A-Za-z0-9_]*$" },
|
|
13
|
+
"line": { "type": "string", "minLength": 8, "maxLength": 80, "description": "the mode in one sentence" },
|
|
14
|
+
"how": { "type": "array", "minItems": 1, "maxItems": 5, "items": { "type": "string", "maxLength": 220 } },
|
|
15
|
+
"share": {
|
|
16
|
+
"description": "the setting that is the share of every buy into the game pot (basis points), or a fixed share",
|
|
17
|
+
"oneOf": [{ "type": "string" }, { "type": "object", "required": ["fixed"], "properties": { "fixed": { "type": "integer", "minimum": 0, "maximum": 1000 } } }]
|
|
18
|
+
},
|
|
19
|
+
"settings": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"maxItems": 8,
|
|
22
|
+
"items": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"required": ["key", "type", "label"],
|
|
25
|
+
"properties": {
|
|
26
|
+
"key": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]*$" },
|
|
27
|
+
"type": { "type": "string", "description": "a static ABI type: uint256, int256, bool, address, bytes32 ..." },
|
|
28
|
+
"label": { "type": "string" },
|
|
29
|
+
"unit": { "type": "string", "description": "bps, ppm, seconds, coins, wallets ..." },
|
|
30
|
+
"default": {},
|
|
31
|
+
"min": {},
|
|
32
|
+
"max": {}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"actions": { "$ref": "#/$defs/actions" },
|
|
37
|
+
"pokes": { "$ref": "#/$defs/actions" },
|
|
38
|
+
"drand": { "type": "boolean" },
|
|
39
|
+
"custody": { "type": "boolean" },
|
|
40
|
+
"keeper": { "type": ["string", "null"] },
|
|
41
|
+
"notes": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "said plainly: what can be gamed, and the cap that holds it" },
|
|
42
|
+
"author": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"wallet": { "type": "string", "description": "the BNB Chain wallet the author share is paid to" },
|
|
46
|
+
"x": { "type": "string" },
|
|
47
|
+
"telegram": { "type": "string" }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"address": { "type": ["string", "null"], "description": "set by pickhooks after the deploy" }
|
|
51
|
+
},
|
|
52
|
+
"$defs": {
|
|
53
|
+
"actions": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"items": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["name", "op", "who", "text"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"name": { "type": "string", "pattern": "^[a-z][a-z0-9 ]{0,23}$" },
|
|
60
|
+
"op": { "type": "integer", "minimum": 0, "maximum": 255, "description": "the first word of the data: abi.encode(uint8 op, ...)" },
|
|
61
|
+
"who": { "enum": ["holder", "creator", "anyone", "keeper"] },
|
|
62
|
+
"text": { "type": "string" },
|
|
63
|
+
"params": {
|
|
64
|
+
"type": "array",
|
|
65
|
+
"items": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"required": ["key", "type"],
|
|
68
|
+
"properties": {
|
|
69
|
+
"key": { "type": "string" },
|
|
70
|
+
"type": { "type": "string" },
|
|
71
|
+
"label": { "type": "string" },
|
|
72
|
+
"decimals": { "type": "integer", "minimum": 0, "maximum": 36, "description": "18 for coins: 1.5 becomes 1.5e18" }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[profile.default]
|
|
2
|
+
src = "src"
|
|
3
|
+
test = "test"
|
|
4
|
+
out = "out"
|
|
5
|
+
libs = ["lib"]
|
|
6
|
+
solc = "0.8.28"
|
|
7
|
+
optimizer = true
|
|
8
|
+
optimizer_runs = 200
|
|
9
|
+
via_ir = true
|
|
10
|
+
# the bytecode is built for cancun, as on BNB Chain mainnet
|
|
11
|
+
evm_version = "cancun"
|
|
12
|
+
|
|
13
|
+
# `pickhooks-mode test` runs this profile: prague has the BLS12-381 precompiles the drand beacon checks signatures with
|
|
14
|
+
[profile.fork]
|
|
15
|
+
evm_version = "prague"
|