first commit

This commit is contained in:
Chris Troutner
2026-08-09 09:23:07 -07:00
commit f2dc7d5c2e
18 changed files with 3037 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
deployments/
*.log
.DS_Store
coverage/
.nyc_output/
config/deploy.mainnet.json
+112
View File
@@ -0,0 +1,112 @@
# psffpp-payments
CashScript payout contracts for PSFFPP (Permissionless Software Foundation File Pinning Protocol) deposits on Bitcoin Cash.
Users send BCH to a single **PoolContract** address. Once per payout epoch (blockheight-gated), the pool splits 20% to treasury and 80% into three **ShareContract** UTXOs (one per node). A K-of-M (2-of-3) operator quorum must sign before any share pays out.
Docs: [CashScript](https://cashscript.org/) · Design: circular-economy trust-minimized federation (M=3, K=2).
## Scope (v1)
| In scope | Out of scope |
|----------|--------------|
| `ShareContract.cash`, `PoolContract.cash` | Cron (consolidate/split/broadcast) |
| Compile + mock tests | Timecard web GUI / sig collection |
| Address + deploy record scripts | Treasury multisig, withdrawal caps |
## Epoch model
Each Pool deploy bakes a single `splitBlockheight`. After that height, `split()` may run once the pool is consolidated. Later months are **not** gated by the same contract: deploy a new Pool+Shares with the next height, then K-of-M `migrate()` any remaining pool value into the new Pool.
## Contracts
### ShareContract
Constructor: `node0`, `node1`, `node2` pubkeys + this node's `nodePkh`.
- `claim(s0, s1, s2)` — requires ≥2 valid ECDSA sigs against registered pubs; pays exactly to `nodePkh`; terminating (no self-replication). Unused sigs must be empty (`0x`).
### PoolContract
Constructor: three pubs, `treasuryPkh`, three share **locking bytecodes**, `minConsolidation` (100_000), `splitBlockheight`.
- `consolidate()` — permissionless; outputs must self-replicate (same P2SH locking bytecode), pure BCH, ≥ `minConsolidation`.
- `split()` — permissionless after `tx.time >= splitBlockheight`; 4 outputs: treasury (20% + rounding remainder) + three equal shares locked in the baked ShareContracts. Prefer a companion fee-paying input so the full pool value is apportioned.
- `migrate(s0, s1, s2)` — 2-of-3; destinations are authorized by the signatures (no baked next-pool bytecode).
## Setup
```bash
npm install
npm run compile
npm test
```
Requires Node.js ≥ 22. Matches `cashc` / `cashscript` `^0.13`.
## Config
```bash
cp config/deploy.mainnet.example.json config/deploy.mainnet.json
```
Fill in:
- Three compressed operator **pubkeys** (66 hex chars)
- Three node payout **pkh** values (40 hex chars) — usually `hash160(pubkey)` for each operators payout key
- Treasury **pkh**
- `splitBlockheight` (absolute BCH block height for this epoch)
- `minConsolidation` (default 100000)
`config/deploy.mainnet.json` is gitignored. Never put private keys in config.
## Deploy order (mainnet)
Contracts are immutable. Order matters:
1. Freeze operator pubs / PKHs / treasury PKH / `splitBlockheight`.
2. `npm run compile`
3. `npm run addresses -- config/deploy.mainnet.json` — prints three Share addresses + Pool address.
4. `npm run deploy -- config/deploy.mainnet.json` — writes `deployments/<timestamp>.json` (addresses, locking bytecodes, artifact fingerprints). Does **not** move funds.
5. Publish **Pool** P2SH32 address as the deposit address.
6. **Smoke checklist** (small intentional funding only):
- Deposit a small amount to the Pool
- Run `consolidate` if multiple UTXOs
- Confirm `split` fails before the gate
- After height: `split` with a fee-paying companion input
- `claim` a share with 2-of-3 ECDSA signatures
- Optionally test `migrate` to a next-epoch Pool
7. Only then accept production deposits
### Signing (ECDSA)
```js
import { SignatureTemplate, HashType, SignatureAlgorithm } from 'cashscript'
const tmpl = new SignatureTemplate(
privateKey,
HashType.SIGHASH_ALL | HashType.SIGHASH_UTXOS,
SignatureAlgorithm.ECDSA
)
```
Pass `Uint8Array.of()` for unused signature slots.
### Next epoch
1. Deploy new Shares + Pool with the next `splitBlockheight`.
2. K-of-M `migrate` remaining old-pool value to the new Pool address.
3. Claim any unclaimed old Share UTXOs separately (still valid under the old Share contracts).
## Security notes
- Limit outputs first in every function.
- Consolidate compares against `tx.inputs[this.activeInputIndex].lockingBytecode` (P2SH wrapper), not redeem script.
- Empty `tokenCategory` on covenant outputs (pure BCH).
- Use `tx.time >=` (never `>`).
- Fee stalls on claim/migrate require rebuilding the tx and re-collecting all K signatures.
- If ≥K operators collude they can approve any signed migrate destination — trust-minimized federation, not permissionless.
## License
MIT
File diff suppressed because one or more lines are too long
+90
View File
@@ -0,0 +1,90 @@
{
"contractName": "ShareContract",
"constructorInputs": [
{
"name": "node0",
"type": "pubkey"
},
{
"name": "node1",
"type": "pubkey"
},
{
"name": "node2",
"type": "pubkey"
},
{
"name": "nodePkh",
"type": "bytes20"
}
],
"abi": [
{
"name": "claim",
"inputs": [
{
"name": "s0",
"type": "sig"
},
{
"name": "s1",
"type": "sig"
},
{
"name": "s2",
"type": "sig"
}
]
}
],
"bytecode": "OP_TXOUTPUTCOUNT OP_1 OP_NUMEQUALVERIFY OP_0 OP_5 OP_ROLL OP_ROT OP_CHECKSIG OP_IF OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_4 OP_ROLL OP_ROT OP_CHECKSIG OP_IF OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_3 OP_ROLL OP_ROT OP_CHECKSIG OP_IF OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_2 OP_GREATERTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTBYTECODE 76a914 OP_ROT OP_CAT 88ac OP_CAT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_0 OP_EQUALVERIFY OP_0 OP_0 OP_BEGIN OP_DUP OP_TXINPUTCOUNT OP_LESSTHAN OP_DUP OP_TOALTSTACK OP_IF OP_2DUP OP_UTXOVALUE OP_ADD OP_ROT OP_DROP OP_SWAP OP_DUP OP_1ADD OP_NIP OP_ENDIF OP_FROMALTSTACK OP_NOT OP_UNTIL OP_DROP OP_0 OP_OUTPUTVALUE OP_GREATERTHANOREQUAL OP_VERIFY OP_0 OP_OUTPUTVALUE OP_0 OP_GREATERTHAN",
"source": "pragma cashscript ^0.13.0;\n\n////////////////////////////////////////////////////////////////////////////////\n// ShareContract — one instance per node (M=3, K=2)\n//\n// Holds one node's monthly share UTXO until K-of-M operators approve claim.\n//\n// Operation: claim\n// inputs:\n// 0 ShareContract [BCH] (this share UTXO)\n// 1? feePayer [BCH] (optional P2PKH fee input)\n// outputs:\n// 0 nodePayout [BCH] (P2PKH to this node's nodePkh)\n////////////////////////////////////////////////////////////////////////////////\n\ncontract ShareContract(\n pubkey node0,\n pubkey node1,\n pubkey node2,\n bytes20 nodePkh\n) {\n function claim(sig s0, sig s1, sig s2) {\n // CRITICAL: limit outputs first\n require(tx.outputs.length == 1, \"claim: expected exactly 1 output\");\n\n // K-of-M (K=2): empty sig (0x) returns false; never pass invalid non-empty sigs (NULLFAIL)\n int validCount = 0;\n if (checkSig(s0, node0)) {\n validCount = validCount + 1;\n }\n if (checkSig(s1, node1)) {\n validCount = validCount + 1;\n }\n if (checkSig(s2, node2)) {\n validCount = validCount + 1;\n }\n require(validCount >= 2, \"claim: need at least 2-of-3 signatures\");\n\n // Terminating spend to this node's payout address (no self-replication)\n require(\n tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(nodePkh),\n \"claim: output must pay nodePkh\"\n );\n require(tx.outputs[0].tokenCategory == 0x, \"claim: output must be pure BCH\");\n\n // Value conservation (fee = inputs - output)\n int inSum = 0;\n for (int i = 0; i < tx.inputs.length; i = i + 1) {\n inSum = inSum + tx.inputs[i].value;\n }\n require(tx.outputs[0].value <= inSum, \"claim: value not conserved\");\n require(tx.outputs[0].value > 0, \"claim: zero payout\");\n }\n}\n",
"fingerprint": "2937963007e829c882ed2cfd37948691c196d68c52c90a9d75f265a7b3b17a45",
"debug": {
"bytecode": "c4519d00557a7bac63768b7768547a7bac63768b7768537a7bac63768b776852a26900cd0376a9147b7e0288ac7e8800d1008800006576c39f766b636ec6937b757c768b77686c91667500cca26900cc00a0",
"sourceMap": "24:16:24:33;:37::38;:8::76:1;27:25:27:26:0;28:21:28:23;;:25::30;:12::31:1;:33:30:9:0;29:25:29:35;:::39:1;:12::40;28:33:30:9;31:21:31:23:0;;:25::30;:12::31:1;:33:33:9:0;32:25:32:35;:::39:1;:12::40;31:33:33:9;34:21:34:23:0;;:25::30;:12::31:1;:33:36:9:0;35:25:35:35;:::39:1;:12::40;34:33:36:9;37:30:37:31:0;:16:::1;:8::75;41:23:41:24:0;:12::41:1;:45::78:0;:70::77;:45::78:1;;;40:8:43:10;44:27:44:28:0;:16::43:1;:47::49:0;:8::85:1;47:20:47:21:0;48:21:48:22;:8:50:9;:24:48:25;:28::44;:24:::1;;;:57:50:9:0;49:20:49:39;:28::46:1;:20;:12::47;;;48:50:48:51:0;:::55:1;:46;:57:50:9;;:8;;;51:27:51:28:0;:16::35:1;:::44;:8::76;52:27:52:28:0;:16::35:1;:38::39:0;:8::63:1",
"logs": [],
"requires": [
{
"ip": 6,
"line": 24,
"message": "claim: expected exactly 1 output"
},
{
"ip": 37,
"line": 37,
"message": "claim: need at least 2-of-3 signatures"
},
{
"ip": 45,
"line": 40,
"message": "claim: output must pay nodePkh"
},
{
"ip": 49,
"line": 44,
"message": "claim: output must be pure BCH"
},
{
"ip": 76,
"line": 51,
"message": "claim: value not conserved"
},
{
"ip": 81,
"line": 52,
"message": "claim: zero payout"
}
],
"sourceTags": "61:63:fu;64:67:lc;68:68:sc"
},
"compiler": {
"name": "cashc",
"version": "0.13.2",
"options": {
"enforceFunctionParameterTypes": true,
"enforceLocktimeGuard": true
}
},
"updatedAt": "2026-08-09T16:19:40.446Z"
}
+23
View File
@@ -0,0 +1,23 @@
{
"network": "mainnet",
"minConsolidation": 100000,
"splitBlockheight": 920000,
"treasuryPkh": "0000000000000000000000000000000000000000",
"nodes": [
{
"name": "node0",
"pubkey": "02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"pkh": "1111111111111111111111111111111111111111"
},
{
"name": "node1",
"pubkey": "02bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"pkh": "2222222222222222222222222222222222222222"
},
{
"name": "node2",
"pubkey": "02cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"pkh": "3333333333333333333333333333333333333333"
}
]
}
+150
View File
@@ -0,0 +1,150 @@
pragma cashscript ^0.13.0;
////////////////////////////////////////////////////////////////////////////////
// PoolContract — single pool instance per payout epoch (M=3, K=2)
//
// Holds user deposits. Each deploy is one height-gated payout epoch.
// Next month: redeploy with a new splitBlockheight, then K-of-M migrate.
//
////////////////////////////////////////////////////////////////////////////////
contract PoolContract(
pubkey node0,
pubkey node1,
pubkey node2,
bytes20 treasuryPkh,
bytes share0,
bytes share1,
bytes share2,
int minConsolidation,
int splitBlockheight
) {
//////////////////////////////////////////////////////////////////////////////
// Operation: consolidate (permissionless)
// inputs:
// 0..N PoolContract [BCH] (one or more pool UTXOs)
// ? feePayer [BCH] (optional)
// outputs:
// 0..1 PoolContract [BCH] (self-replicating, each >= minConsolidation)
//////////////////////////////////////////////////////////////////////////////
function consolidate() {
require(tx.outputs.length <= 2, "consolidate: too many outputs");
require(tx.outputs.length >= 1, "consolidate: need at least 1 output");
int inSum = 0;
for (int i = 0; i < tx.inputs.length; i = i + 1) {
inSum = inSum + tx.inputs[i].value;
}
// Compare against the P2SH locking bytecode on this input (not redeem script).
bytes contractLock = tx.inputs[this.activeInputIndex].lockingBytecode;
int outSum = 0;
for (int j = 0; j < tx.outputs.length; j = j + 1) {
require(
tx.outputs[j].lockingBytecode == contractLock,
"consolidate: must self-replicate"
);
require(tx.outputs[j].tokenCategory == 0x, "consolidate: pure BCH only");
require(
tx.outputs[j].value >= minConsolidation,
"consolidate: output below minConsolidation"
);
outSum = outSum + tx.outputs[j].value;
}
require(outSum <= inSum, "consolidate: value not conserved");
}
//////////////////////////////////////////////////////////////////////////////
// Operation: split (permissionless, blockheight-gated)
// inputs:
// 0 PoolContract [BCH] (consolidated pool UTXO — active input)
// 1? feePayer [BCH] (optional companion fee input)
// outputs:
// 0 treasury [BCH] (20% + rounding remainder)
// 1 ShareContract node0 [BCH] (equal share of 80%)
// 2 ShareContract node1 [BCH]
// 3 ShareContract node2 [BCH]
//////////////////////////////////////////////////////////////////////////////
function split() {
require(tx.outputs.length == 4, "split: expected 4 outputs");
require(tx.time >= splitBlockheight, "split: blockheight gate not met");
// Fee companion allowed; pool value taken from this active input only.
require(tx.inputs.length <= 2, "split: at most 2 inputs");
int poolValue = tx.inputs[this.activeInputIndex].value;
int treasuryBase = poolValue * 20 / 100;
int remaining = poolValue - treasuryBase;
int share = remaining / 3;
int remainder = remaining - share * 3;
int treasuryValue = treasuryBase + remainder;
// Output 0: treasury
require(
tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(treasuryPkh),
"split: output 0 must be treasury"
);
require(tx.outputs[0].tokenCategory == 0x, "split: treasury must be pure BCH");
require(tx.outputs[0].value == treasuryValue, "split: bad treasury value");
// Outputs 1..3: equal shares locked in ShareContracts
require(tx.outputs[1].lockingBytecode == share0, "split: output 1 must be share0");
require(tx.outputs[1].tokenCategory == 0x, "split: share0 must be pure BCH");
require(tx.outputs[1].value == share, "split: bad share0 value");
require(tx.outputs[2].lockingBytecode == share1, "split: output 2 must be share1");
require(tx.outputs[2].tokenCategory == 0x, "split: share1 must be pure BCH");
require(tx.outputs[2].value == share, "split: bad share1 value");
require(tx.outputs[3].lockingBytecode == share2, "split: output 3 must be share2");
require(tx.outputs[3].tokenCategory == 0x, "split: share2 must be pure BCH");
require(tx.outputs[3].value == share, "split: bad share2 value");
// Exact poolValue apportionment (fee paid by companion input if present)
require(
treasuryValue + share + share + share == poolValue,
"split: apportionment mismatch"
);
}
//////////////////////////////////////////////////////////////////////////////
// Operation: migrate (K-of-M)
// inputs:
// 0..N PoolContract [BCH]
// ? feePayer [BCH] (optional)
// outputs:
// 0..1 next-epoch destination [BCH] (authorized by signatures)
//////////////////////////////////////////////////////////////////////////////
function migrate(sig s0, sig s1, sig s2) {
require(tx.outputs.length <= 2, "migrate: too many outputs");
require(tx.outputs.length >= 1, "migrate: need at least 1 output");
int validCount = 0;
if (checkSig(s0, node0)) {
validCount = validCount + 1;
}
if (checkSig(s1, node1)) {
validCount = validCount + 1;
}
if (checkSig(s2, node2)) {
validCount = validCount + 1;
}
require(validCount >= 2, "migrate: need at least 2-of-3 signatures");
int inSum = 0;
for (int i = 0; i < tx.inputs.length; i = i + 1) {
inSum = inSum + tx.inputs[i].value;
}
int outSum = 0;
for (int j = 0; j < tx.outputs.length; j = j + 1) {
require(tx.outputs[j].tokenCategory == 0x, "migrate: pure BCH only");
require(tx.outputs[j].value > 0, "migrate: zero output");
outSum = outSum + tx.outputs[j].value;
}
require(outSum <= inSum, "migrate: value not conserved");
}
}
+54
View File
@@ -0,0 +1,54 @@
pragma cashscript ^0.13.0;
////////////////////////////////////////////////////////////////////////////////
// ShareContract — one instance per node (M=3, K=2)
//
// Holds one node's monthly share UTXO until K-of-M operators approve claim.
//
// Operation: claim
// inputs:
// 0 ShareContract [BCH] (this share UTXO)
// 1? feePayer [BCH] (optional P2PKH fee input)
// outputs:
// 0 nodePayout [BCH] (P2PKH to this node's nodePkh)
////////////////////////////////////////////////////////////////////////////////
contract ShareContract(
pubkey node0,
pubkey node1,
pubkey node2,
bytes20 nodePkh
) {
function claim(sig s0, sig s1, sig s2) {
// CRITICAL: limit outputs first
require(tx.outputs.length == 1, "claim: expected exactly 1 output");
// K-of-M (K=2): empty sig (0x) returns false; never pass invalid non-empty sigs (NULLFAIL)
int validCount = 0;
if (checkSig(s0, node0)) {
validCount = validCount + 1;
}
if (checkSig(s1, node1)) {
validCount = validCount + 1;
}
if (checkSig(s2, node2)) {
validCount = validCount + 1;
}
require(validCount >= 2, "claim: need at least 2-of-3 signatures");
// Terminating spend to this node's payout address (no self-replication)
require(
tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(nodePkh),
"claim: output must pay nodePkh"
);
require(tx.outputs[0].tokenCategory == 0x, "claim: output must be pure BCH");
// Value conservation (fee = inputs - output)
int inSum = 0;
for (int i = 0; i < tx.inputs.length; i = i + 1) {
inSum = inSum + tx.inputs[i].value;
}
require(tx.outputs[0].value <= inSum, "claim: value not conserved");
require(tx.outputs[0].value > 0, "claim: zero payout");
}
}
+1661
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "psffpp-payments",
"version": "1.0.0",
"description": "CashScript payout contracts for PSFFPP circular-economy payments on Bitcoin Cash",
"type": "module",
"main": "index.js",
"scripts": {
"compile": "node scripts/compile.mjs",
"addresses": "node scripts/addresses.mjs",
"deploy": "node scripts/deploy.mjs",
"test": "npm run compile && mocha --timeout 30000 'test/**/*.test.js'",
"test:unit": "npm test"
},
"keywords": [
"bitcoin-cash",
"cashscript",
"psffpp",
"smart-contracts"
],
"author": "Permissionless Software Foundation",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.0.0",
"cashc": "^0.13.2",
"cashscript": "^0.13.2"
},
"devDependencies": {
"c8": "^10.1.3",
"mocha": "^11.7.1"
},
"engines": {
"node": ">=22"
}
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* Print deterministic P2SH32 addresses from a deploy config (read-only).
*
* Usage:
* node scripts/addresses.mjs [config/deploy.mainnet.json]
*/
import {
loadDeployConfig,
createProvider,
instantiateContracts,
buildDeploymentRecord,
root
} from './lib.mjs'
import { join } from 'node:path'
const configPath = process.argv[2] || join(root, 'config', 'deploy.mainnet.json')
const cfg = loadDeployConfig(configPath)
const provider = createProvider(cfg.network || 'mainnet')
const inst = instantiateContracts(cfg, provider)
const record = buildDeploymentRecord(cfg, inst)
console.log(JSON.stringify({
network: record.network,
poolAddress: record.poolAddress,
shares: record.nodes.map((n) => ({
name: n.name,
shareAddress: n.shareAddress,
pkh: n.pkh
})),
splitBlockheight: record.splitBlockheight,
minConsolidation: record.minConsolidation,
artifacts: record.artifacts
}, null, 2))
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { compileFile } from 'cashc'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const contractsDir = join(root, 'contracts')
const artifactsDir = join(root, 'artifacts')
mkdirSync(artifactsDir, { recursive: true })
const files = ['ShareContract.cash', 'PoolContract.cash']
for (const file of files) {
const src = join(contractsDir, file)
const artifact = compileFile(src)
const outName = file.replace(/\.cash$/, '.json')
const outPath = join(artifactsDir, outName)
writeFileSync(outPath, JSON.stringify(artifact, null, 2) + '\n')
console.log(`compiled ${file} -> artifacts/${outName}`)
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Instantiate contracts against the configured network and write a deployment record.
* Does not broadcast or move funds — funding is a normal send to poolAddress.
*
* Usage:
* node scripts/deploy.mjs [config/deploy.mainnet.json]
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import {
loadDeployConfig,
createProvider,
instantiateContracts,
buildDeploymentRecord,
root
} from './lib.mjs'
const configPath = process.argv[2] || join(root, 'config', 'deploy.mainnet.json')
const cfg = loadDeployConfig(configPath)
if ((cfg.network || 'mainnet') === 'mainnet') {
const placeholder = /^0{40}$|^1{40}$|^2{40}$|^3{40}$/i
if (placeholder.test(cfg.treasuryPkh) || cfg.nodes.some((n) => placeholder.test(n.pkh) || /^(02a+|02b+|02c+)/i.test(n.pubkey))) {
console.warn('WARNING: config still looks like example placeholders. Replace with real keys before funding.')
}
}
const provider = createProvider(cfg.network || 'mainnet')
const inst = instantiateContracts(cfg, provider)
const record = buildDeploymentRecord(cfg, inst)
const outDir = join(root, 'deployments')
mkdirSync(outDir, { recursive: true })
const stamp = record.createdAt.replace(/[:.]/g, '-')
const outPath = join(outDir, `${stamp}.json`)
writeFileSync(outPath, JSON.stringify(record, null, 2) + '\n')
console.log(JSON.stringify({
wrote: outPath,
poolAddress: record.poolAddress,
shareAddresses: record.nodes.map((n) => n.shareAddress),
splitBlockheight: record.splitBlockheight,
artifacts: record.artifacts
}, null, 2))
console.log(`
Next steps:
1. Verify addresses match: npm run addresses -- ${configPath}
2. Smoke-fund poolAddress with a small intentional amount
3. Exercise consolidate / split / claim before accepting production deposits
`)
+121
View File
@@ -0,0 +1,121 @@
import { createHash } from 'node:crypto'
import { readFileSync, existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { hexToBin, binToHex } from '@bitauth/libauth'
import { Contract, ElectrumNetworkProvider, MockNetworkProvider } from 'cashscript'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
export function loadArtifact (name) {
return JSON.parse(readFileSync(join(root, 'artifacts', `${name}.json`), 'utf8'))
}
export function artifactFingerprint (artifact) {
return createHash('sha256')
.update(JSON.stringify({ bytecode: artifact.bytecode, abi: artifact.abi, constructorInputs: artifact.constructorInputs }))
.digest('hex')
}
export function loadDeployConfig (configPath) {
const path = resolve(configPath)
if (!existsSync(path)) {
throw new Error(`Config not found: ${path}. Copy config/deploy.mainnet.example.json to config/deploy.mainnet.json and fill real values.`)
}
const cfg = JSON.parse(readFileSync(path, 'utf8'))
validateConfig(cfg)
return cfg
}
function validateConfig (cfg) {
if (!cfg.nodes || cfg.nodes.length !== 3) {
throw new Error('config.nodes must contain exactly 3 entries (M=3)')
}
for (const [i, node] of cfg.nodes.entries()) {
if (!/^[0-9a-fA-F]{66}$/.test(node.pubkey)) {
throw new Error(`nodes[${i}].pubkey must be 33-byte compressed hex (66 chars)`)
}
if (!/^[0-9a-fA-F]{40}$/.test(node.pkh)) {
throw new Error(`nodes[${i}].pkh must be 20-byte hex (40 chars)`)
}
}
if (!/^[0-9a-fA-F]{40}$/.test(cfg.treasuryPkh)) {
throw new Error('treasuryPkh must be 20-byte hex (40 chars)')
}
if (!Number.isInteger(cfg.minConsolidation) || cfg.minConsolidation < 1) {
throw new Error('minConsolidation must be a positive integer')
}
if (!Number.isInteger(cfg.splitBlockheight) || cfg.splitBlockheight < 1) {
throw new Error('splitBlockheight must be a positive integer (block height)')
}
}
export function createProvider (network) {
if (network === 'mocknet') return new MockNetworkProvider()
return new ElectrumNetworkProvider(network || 'mainnet')
}
export function instantiateContracts (cfg, provider) {
const shareArtifact = loadArtifact('ShareContract')
const poolArtifact = loadArtifact('PoolContract')
const pubs = cfg.nodes.map((n) => hexToBin(n.pubkey))
const shares = cfg.nodes.map((node) =>
new Contract(
shareArtifact,
[...pubs, hexToBin(node.pkh)],
{ provider }
)
)
const pool = new Contract(
poolArtifact,
[
...pubs,
hexToBin(cfg.treasuryPkh),
hexToBin(shares[0].lockingBytecode),
hexToBin(shares[1].lockingBytecode),
hexToBin(shares[2].lockingBytecode),
BigInt(cfg.minConsolidation),
BigInt(cfg.splitBlockheight)
],
{ provider }
)
return {
shares,
pool,
fingerprints: {
ShareContract: artifactFingerprint(shareArtifact),
PoolContract: artifactFingerprint(poolArtifact)
}
}
}
export function buildDeploymentRecord (cfg, { shares, pool, fingerprints }) {
return {
network: cfg.network || 'mainnet',
createdAt: new Date().toISOString(),
minConsolidation: cfg.minConsolidation,
splitBlockheight: cfg.splitBlockheight,
treasuryPkh: cfg.treasuryPkh.toLowerCase(),
nodes: cfg.nodes.map((n, i) => ({
name: n.name || `node${i}`,
pubkey: n.pubkey.toLowerCase(),
pkh: n.pkh.toLowerCase(),
shareAddress: shares[i].address,
shareLockingBytecode: shares[i].lockingBytecode
})),
poolAddress: pool.address,
poolLockingBytecode: pool.lockingBytecode,
artifacts: fingerprints,
constructorNotes: {
M: 3,
K: 2,
deployOrder: 'ShareContracts first, then PoolContract baking share locking bytecodes',
epochModel: 'One splitBlockheight per deploy; next month redeploy + K-of-M migrate'
}
}
}
export { root, binToHex, hexToBin }
+92
View File
@@ -0,0 +1,92 @@
import {
Contract,
HashType,
MockNetworkProvider,
SignatureAlgorithm,
SignatureTemplate
} from 'cashscript'
import {
generatePrivateKey,
hash160,
hexToBin,
privateKeyToP2pkhCashAddress
} from '@bitauth/libauth'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
export const EMPTY_SIG = Uint8Array.of()
export const SIGHASH = HashType.SIGHASH_ALL | HashType.SIGHASH_UTXOS
export function loadArtifact (name) {
return JSON.parse(readFileSync(join(root, 'artifacts', `${name}.json`), 'utf8'))
}
export function ecdsaTemplate (privateKey) {
return new SignatureTemplate(privateKey, SIGHASH, SignatureAlgorithm.ECDSA)
}
export function makeOperator (label = 'node') {
const priv = generatePrivateKey(() => crypto.getRandomValues(new Uint8Array(32)))
const tmpl = ecdsaTemplate(priv)
const pub = tmpl.getPublicKey()
const pkh = hash160(pub)
const addr = privateKeyToP2pkhCashAddress({
privateKey: priv,
prefix: 'bchtest',
throwErrors: true
}).address
return { label, priv, tmpl, pub, pkh, addr }
}
export function makeTreasury () {
return makeOperator('treasury')
}
export function makeProvider () {
return new MockNetworkProvider()
}
export function createShares (provider, nodes) {
const artifact = loadArtifact('ShareContract')
return nodes.map((node) =>
new Contract(
artifact,
[nodes[0].pub, nodes[1].pub, nodes[2].pub, node.pkh],
{ provider }
)
)
}
export function createPool (provider, {
nodes,
treasuryPkh,
shareContracts,
minConsolidation = 100_000n,
splitBlockheight = 1000n
}) {
const artifact = loadArtifact('PoolContract')
return new Contract(
artifact,
[
nodes[0].pub,
nodes[1].pub,
nodes[2].pub,
treasuryPkh,
hexToBin(shareContracts[0].lockingBytecode),
hexToBin(shareContracts[1].lockingBytecode),
hexToBin(shareContracts[2].lockingBytecode),
minConsolidation,
splitBlockheight
],
{ provider }
)
}
export function claimArgs (nodes, signers) {
return nodes.map((node) => (signers.includes(node) ? node.tmpl : EMPTY_SIG))
}
export { hexToBin }
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import {
TransactionBuilder,
randomUtxo
} from 'cashscript'
import {
createPool,
createShares,
makeOperator,
makeProvider,
makeTreasury
} from './helpers.js'
describe('PoolContract.consolidate', function () {
let provider, nodes, treasury, shares, pool
beforeEach(function () {
provider = makeProvider()
nodes = [makeOperator('n0'), makeOperator('n1'), makeOperator('n2')]
treasury = makeTreasury()
shares = createShares(provider, nodes)
pool = createPool(provider, {
nodes,
treasuryPkh: treasury.pkh,
shareContracts: shares,
minConsolidation: 100_000n,
splitBlockheight: 1000n
})
})
it('merges multiple pool UTXOs into one self-replicating output', async function () {
const u0 = provider.addUtxo(pool.address, randomUtxo({ satoshis: 120_000n }))
const u1 = provider.addUtxo(pool.address, randomUtxo({ satoshis: 130_000n }))
const fee = 5_000n
const out = 120_000n + 130_000n - fee
const tx = await new TransactionBuilder({ provider })
.addInput(u0, pool.unlock.consolidate())
.addInput(u1, pool.unlock.consolidate())
.addOutput({ to: pool.address, amount: out })
.send()
assert.ok(tx.txid)
})
it('rejects escape to a foreign locking bytecode', async function () {
const u0 = provider.addUtxo(pool.address, randomUtxo({ satoshis: 200_000n }))
const builder = new TransactionBuilder({ provider })
.addInput(u0, pool.unlock.consolidate())
.addOutput({ to: nodes[0].addr, amount: 199_000n })
await assert.rejects(() => builder.send(), /must self-replicate|Failed to evaluate|require/i)
})
it('rejects outputs below minConsolidation', async function () {
const u0 = provider.addUtxo(pool.address, randomUtxo({ satoshis: 150_000n }))
const builder = new TransactionBuilder({ provider })
.addInput(u0, pool.unlock.consolidate())
.addOutput({ to: pool.address, amount: 50_000n })
await assert.rejects(() => builder.send(), /below minConsolidation|Failed to evaluate|require/i)
})
it('rejects more than two outputs', async function () {
const u0 = provider.addUtxo(pool.address, randomUtxo({ satoshis: 400_000n }))
const builder = new TransactionBuilder({ provider })
.addInput(u0, pool.unlock.consolidate())
.addOutput({ to: pool.address, amount: 130_000n })
.addOutput({ to: pool.address, amount: 130_000n })
.addOutput({ to: pool.address, amount: 139_000n })
await assert.rejects(() => builder.send(), /too many outputs|Failed to evaluate|require/i)
})
})
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import {
TransactionBuilder,
randomUtxo
} from 'cashscript'
import {
claimArgs,
createPool,
createShares,
makeOperator,
makeProvider,
makeTreasury
} from './helpers.js'
describe('PoolContract.migrate', function () {
let provider, nodes, treasury, shares, pool, nextPool
beforeEach(function () {
provider = makeProvider()
nodes = [makeOperator('n0'), makeOperator('n1'), makeOperator('n2')]
treasury = makeTreasury()
shares = createShares(provider, nodes)
pool = createPool(provider, {
nodes,
treasuryPkh: treasury.pkh,
shareContracts: shares,
minConsolidation: 100_000n,
splitBlockheight: 1000n
})
nextPool = createPool(provider, {
nodes,
treasuryPkh: treasury.pkh,
shareContracts: shares,
minConsolidation: 100_000n,
splitBlockheight: 2000n
})
})
it('allows 2-of-3 to migrate value to the next-epoch pool', async function () {
const utxo = provider.addUtxo(pool.address, randomUtxo({ satoshis: 500_000n }))
const fee = 1000n
const amount = 500_000n - fee
const tx = await new TransactionBuilder({ provider })
.addInput(utxo, pool.unlock.migrate(...claimArgs(nodes, [nodes[0], nodes[1]])))
.addOutput({ to: nextPool.address, amount })
.send()
assert.ok(tx.txid)
})
it('rejects migration with only 1-of-3 signatures', async function () {
const utxo = provider.addUtxo(pool.address, randomUtxo({ satoshis: 500_000n }))
const fee = 1000n
const amount = 500_000n - fee
const builder = new TransactionBuilder({ provider })
.addInput(utxo, pool.unlock.migrate(...claimArgs(nodes, [nodes[0]])))
.addOutput({ to: nextPool.address, amount })
await assert.rejects(() => builder.send(), /need at least 2-of-3|Failed to evaluate|require/i)
})
it('allows 2-of-3 to send to an arbitrary signed destination', async function () {
const utxo = provider.addUtxo(pool.address, randomUtxo({ satoshis: 250_000n }))
const fee = 1000n
const amount = 250_000n - fee
const tx = await new TransactionBuilder({ provider })
.addInput(utxo, pool.unlock.migrate(...claimArgs(nodes, [nodes[1], nodes[2]])))
.addOutput({ to: nodes[0].addr, amount })
.send()
assert.ok(tx.txid)
})
})
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict'
import {
TransactionBuilder,
randomUtxo
} from 'cashscript'
import {
createPool,
createShares,
makeOperator,
makeProvider,
makeTreasury
} from './helpers.js'
function apportion (poolValue) {
const treasuryBase = (poolValue * 20n) / 100n
const remaining = poolValue - treasuryBase
const share = remaining / 3n
const remainder = remaining - share * 3n
return {
treasuryValue: treasuryBase + remainder,
share
}
}
describe('PoolContract.split', function () {
const splitHeight = 1000n
let provider, nodes, treasury, shares, pool
beforeEach(function () {
provider = makeProvider()
provider.setBlockHeight(Number(splitHeight))
nodes = [makeOperator('n0'), makeOperator('n1'), makeOperator('n2')]
treasury = makeTreasury()
shares = createShares(provider, nodes)
pool = createPool(provider, {
nodes,
treasuryPkh: treasury.pkh,
shareContracts: shares,
minConsolidation: 100_000n,
splitBlockheight: splitHeight
})
})
it('rejects split before the gated blockheight', async function () {
provider.setBlockHeight(Number(splitHeight) - 1)
const poolValue = 1_000_000n
const feePriv = makeOperator('fee')
const poolUtxo = provider.addUtxo(pool.address, randomUtxo({ satoshis: poolValue }))
const feeUtxo = provider.addUtxo(feePriv.addr, randomUtxo({ satoshis: 5_000n }))
const { treasuryValue, share } = apportion(poolValue)
const builder = new TransactionBuilder({ provider })
.addInput(poolUtxo, pool.unlock.split())
.addInput(feeUtxo, feePriv.tmpl.unlockP2PKH())
.addOutput({ to: treasury.addr, amount: treasuryValue })
.addOutput({ to: shares[0].address, amount: share })
.addOutput({ to: shares[1].address, amount: share })
.addOutput({ to: shares[2].address, amount: share })
.setLocktime(Number(splitHeight) - 1)
await assert.rejects(() => builder.send(), /blockheight gate|Failed to evaluate|require|non-final|locktime/i)
})
it('splits 20% treasury + equal 80%/3 shares after the gate', async function () {
const poolValue = 1_000_000n
const feePriv = makeOperator('fee')
const poolUtxo = provider.addUtxo(pool.address, randomUtxo({ satoshis: poolValue }))
const feeUtxo = provider.addUtxo(feePriv.addr, randomUtxo({ satoshis: 5_000n }))
const { treasuryValue, share } = apportion(poolValue)
assert.equal(treasuryValue + share * 3n, poolValue)
const tx = await new TransactionBuilder({ provider })
.addInput(poolUtxo, pool.unlock.split())
.addInput(feeUtxo, feePriv.tmpl.unlockP2PKH())
.addOutput({ to: treasury.addr, amount: treasuryValue })
.addOutput({ to: shares[0].address, amount: share })
.addOutput({ to: shares[1].address, amount: share })
.addOutput({ to: shares[2].address, amount: share })
.setLocktime(Number(splitHeight))
.send()
assert.ok(tx.txid)
// remaining = 800000; share = 266666; remainder = 2 → treasury = 200002
assert.equal(share, 266_666n)
assert.equal(treasuryValue, 200_002n)
})
it('rejects wrong share locking bytecode destinations', async function () {
const poolValue = 1_000_000n
const feePriv = makeOperator('fee')
const poolUtxo = provider.addUtxo(pool.address, randomUtxo({ satoshis: poolValue }))
const feeUtxo = provider.addUtxo(feePriv.addr, randomUtxo({ satoshis: 5_000n }))
const { treasuryValue, share } = apportion(poolValue)
const builder = new TransactionBuilder({ provider })
.addInput(poolUtxo, pool.unlock.split())
.addInput(feeUtxo, feePriv.tmpl.unlockP2PKH())
.addOutput({ to: treasury.addr, amount: treasuryValue })
.addOutput({ to: nodes[0].addr, amount: share })
.addOutput({ to: shares[1].address, amount: share })
.addOutput({ to: shares[2].address, amount: share })
.setLocktime(Number(splitHeight))
await assert.rejects(() => builder.send(), /output 1 must be share0|Failed to evaluate|require/i)
})
it('folds integer-division remainder into the treasury', async function () {
// 100001 * 20/100 = 20000; remaining 80001; share 26667; rem 80001-80001=0?
// 80001 / 3 = 26667, rem = 80001 - 80001 = 0
// Use 100003: remaining = 80003 - wait 100003*20/100=20000, rem=80003, share=26667, r=80003-80001=2
const poolValue = 100_003n
const feePriv = makeOperator('fee')
const poolUtxo = provider.addUtxo(pool.address, randomUtxo({ satoshis: poolValue }))
const feeUtxo = provider.addUtxo(feePriv.addr, randomUtxo({ satoshis: 5_000n }))
const { treasuryValue, share } = apportion(poolValue)
assert.equal(share, 26_667n)
assert.equal(treasuryValue, 20_002n)
const tx = await new TransactionBuilder({ provider })
.addInput(poolUtxo, pool.unlock.split())
.addInput(feeUtxo, feePriv.tmpl.unlockP2PKH())
.addOutput({ to: treasury.addr, amount: treasuryValue })
.addOutput({ to: shares[0].address, amount: share })
.addOutput({ to: shares[1].address, amount: share })
.addOutput({ to: shares[2].address, amount: share })
.setLocktime(Number(splitHeight))
.send()
assert.ok(tx.txid)
})
})
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import {
TransactionBuilder,
randomUtxo
} from 'cashscript'
import {
claimArgs,
createShares,
makeOperator,
makeProvider
} from './helpers.js'
describe('ShareContract.claim', function () {
let provider, nodes, shares, share0, utxo
beforeEach(function () {
provider = makeProvider()
nodes = [makeOperator('n0'), makeOperator('n1'), makeOperator('n2')]
shares = createShares(provider, nodes)
share0 = shares[0]
utxo = provider.addUtxo(share0.address, randomUtxo({ satoshis: 100_000n }))
})
it('accepts 2-of-3 signatures paying the correct nodePkh', async function () {
const fee = 1000n
const amount = 100_000n - fee
const tx = await new TransactionBuilder({ provider })
.addInput(utxo, share0.unlock.claim(...claimArgs(nodes, [nodes[0], nodes[1]])))
.addOutput({ to: nodes[0].addr, amount })
.send()
assert.ok(tx.txid)
})
it('rejects 1-of-3 signatures', async function () {
const fee = 1000n
const amount = 100_000n - fee
const builder = new TransactionBuilder({ provider })
.addInput(utxo, share0.unlock.claim(...claimArgs(nodes, [nodes[0]])))
.addOutput({ to: nodes[0].addr, amount })
await assert.rejects(() => builder.send(), /need at least 2-of-3|Failed to evaluate|require/i)
})
it('rejects payout to the wrong address', async function () {
const fee = 1000n
const amount = 100_000n - fee
const builder = new TransactionBuilder({ provider })
.addInput(utxo, share0.unlock.claim(...claimArgs(nodes, [nodes[0], nodes[1]])))
.addOutput({ to: nodes[1].addr, amount })
await assert.rejects(() => builder.send(), /output must pay nodePkh|Failed to evaluate|require/i)
})
it('rejects extra outputs', async function () {
const builder = new TransactionBuilder({ provider })
.addInput(utxo, share0.unlock.claim(...claimArgs(nodes, [nodes[0], nodes[1]])))
.addOutput({ to: nodes[0].addr, amount: 50_000n })
.addOutput({ to: nodes[0].addr, amount: 49_000n })
await assert.rejects(() => builder.send(), /expected exactly 1 output|Failed to evaluate|require/i)
})
it('accepts signatures from nodes 1 and 2 (skipping node 0)', async function () {
const fee = 1000n
const amount = 100_000n - fee
const tx = await new TransactionBuilder({ provider })
.addInput(utxo, share0.unlock.claim(...claimArgs(nodes, [nodes[1], nodes[2]])))
.addOutput({ to: nodes[0].addr, amount })
.send()
assert.ok(tx.txid)
})
})