mirror of
https://github.com/Permissionless-Software-Foundation/psffpp-payments.git
synced 2026-09-21 16:52:04 -07:00
first commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
deployments/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
config/deploy.mainnet.json
|
||||||
@@ -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 operator’s 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
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1661
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user