From 5dea6944a05e6ecc250f6d754e99f65ff3cea764 Mon Sep 17 00:00:00 2001 From: Rithvik Vibhu Date: Sat, 19 Apr 2025 23:44:00 +0530 Subject: [PATCH] chain: add airstop soft fork --- lib/blockchain/chain.js | 18 ++ lib/protocol/networks.js | 44 ++++ test/chain-airstop-test.js | 449 ++++++++++++++++++++++++++++++++ test/chain-icann-lockup-test.js | 2 +- 4 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 test/chain-airstop-test.js diff --git a/lib/blockchain/chain.js b/lib/blockchain/chain.js index 07d634be..23496b7e 100644 --- a/lib/blockchain/chain.js +++ b/lib/blockchain/chain.js @@ -661,6 +661,16 @@ class Chain extends AsyncEmitter { 100); } + // Disable airdrop claims if + // - airstop is activated, and + // - this is an airdrop, not a faucet claim + if (state.hasAirstop && !proof.isAddress()) { + throw new VerifyError(block, + 'invalid', + 'bad-airdrop-disabled', + 100); + } + if (prev.height + 1 >= this.network.goosigStop) { const key = proof.getKey(); @@ -739,6 +749,10 @@ class Chain extends AsyncEmitter { if (await this.isActive(prev, deployments.icannlockup)) state.nameFlags |= rules.nameFlags.VERIFY_COVENANTS_LOCKUP; + // Disable airdrop claims. + if (await this.isActive(prev, deployments.airstop)) + state.hasAirstop = true; + return state; } @@ -762,6 +776,9 @@ class Chain extends AsyncEmitter { if (!this.state.hasICANNLockup() && state.hasICANNLockup()) this.logger.warning('ICANN lockup has been activated.'); + if (!this.state.hasAirstop && state.hasAirstop) + this.logger.warning('Airdrop claims has been disabled.'); + this.state = state; } @@ -4115,6 +4132,7 @@ class DeploymentState { this.flags = Script.flags.MANDATORY_VERIFY_FLAGS; this.lockFlags = common.MANDATORY_LOCKTIME_FLAGS; this.nameFlags = rules.MANDATORY_VERIFY_COVENANT_FLAGS; + this.hasAirstop = false; } hasHardening() { diff --git a/lib/protocol/networks.js b/lib/protocol/networks.js index 6f376545..feb10ab2 100644 --- a/lib/protocol/networks.js +++ b/lib/protocol/networks.js @@ -480,6 +480,16 @@ main.deployments = { required: false, force: false }, + airstop: { + name: 'airstop', + bit: 2, + startTime: 1744934400, // April 18th, 2025 + timeout: 1760745600, // October 18th, 2025 + threshold: -1, + window: -1, + required: false, + force: false + }, testdummy: { name: 'testdummy', bit: 28, @@ -501,6 +511,7 @@ main.deployments = { main.deploys = [ main.deployments.hardening, main.deployments.icannlockup, + main.deployments.airstop, main.deployments.testdummy ]; @@ -731,6 +742,16 @@ testnet.deployments = { required: false, force: false }, + airstop: { + name: 'airstop', + bit: 2, + startTime: 1744934400, // April 18th, 2025 + timeout: 1760745600, // October 18th, 2025 + threshold: -1, + window: -1, + required: false, + force: false + }, testdummy: { name: 'testdummy', bit: 28, @@ -746,6 +767,7 @@ testnet.deployments = { testnet.deploys = [ testnet.deployments.hardening, testnet.deployments.icannlockup, + testnet.deployments.airstop, testnet.deployments.testdummy ]; @@ -885,6 +907,16 @@ regtest.deployments = { required: false, force: false }, + airstop: { + name: 'airstop', + bit: 2, + startTime: 1744934400, // April 18th, 2025 + timeout: 1760745600, // October 18th, 2025 + threshold: -1, + window: -1, + required: false, + force: false + }, testdummy: { name: 'testdummy', bit: 28, @@ -900,6 +932,7 @@ regtest.deployments = { regtest.deploys = [ regtest.deployments.hardening, regtest.deployments.icannlockup, + regtest.deployments.airstop, regtest.deployments.testdummy ]; @@ -1043,6 +1076,16 @@ simnet.deployments = { required: false, force: false }, + airstop: { + name: 'airstop', + bit: 2, + startTime: 1744934400, // April 18th, 2025 + timeout: 1760745600, // October 18th, 2025 + threshold: -1, + window: -1, + required: false, + force: false + }, testdummy: { name: 'testdummy', bit: 28, @@ -1058,6 +1101,7 @@ simnet.deployments = { simnet.deploys = [ simnet.deployments.hardening, simnet.deployments.icannlockup, + simnet.deployments.airstop, simnet.deployments.testdummy ]; diff --git a/test/chain-airstop-test.js b/test/chain-airstop-test.js new file mode 100644 index 00000000..523cbc6e --- /dev/null +++ b/test/chain-airstop-test.js @@ -0,0 +1,449 @@ +'use strict'; + +const fs = require('fs'); +const { resolve } = require('path'); +const assert = require('bsert'); +const Chain = require('../lib/blockchain/chain'); +const chainCommon = require('../lib/blockchain/common'); +const BlockStore = require('../lib/blockstore/level'); +const Miner = require('../lib/mining/miner'); +const Network = require('../lib/protocol/network'); +const AirdropProof = require('../lib/primitives/airdropproof'); +const { thresholdStates } = chainCommon; + +const network = Network.get('regtest'); + +const AIRDROP_PROOF_FILE = resolve(__dirname, 'data', 'airdrop-proof.base64'); +const FAUCET_PROOF_FILE = resolve(__dirname, 'data', 'faucet-proof.base64'); +const read = file => Buffer.from(fs.readFileSync(file, 'binary'), 'base64'); + +// Sent to: +// { +// pub: '02a8959cc6491aed3fb96b3b684400311f2779fb092b026a4b170b35c175d48cec', +// hash: '95cb6129c6b98179866094b2717bfbe27d9c1921', +// addr: 'hs1qjh9kz2wxhxqhnpnqjje8z7lmuf7ecxfp6kxlly' +// } + +// Same as airdrop-test.js +const rawProof = read(AIRDROP_PROOF_FILE); +const rawFaucetProof = read(FAUCET_PROOF_FILE); // hs1qmjpjjgpz7dmg37paq9uksx4yjp675690dafg3q + +const airdropProof = AirdropProof.decode(rawProof); +const faucetproof = AirdropProof.decode(rawFaucetProof); + +const SOFT_FORK_NAME = 'airstop'; + +function createNode() { + const blocks = new BlockStore({ + memory: true, + network + }); + + const chain = new Chain({ + memory: true, + blocks, + network + }); + + const miner = new Miner({ chain }); + + return { chain, blocks, miner }; +} + +describe('BIP-9 - Airstop (integration)', function () { + const checkBIP9Info = (info, expected) => { + expected = expected || {}; + expected.startTime = expected.startTime || network.deployments[SOFT_FORK_NAME].startTime; + expected.timeout = expected.timeout || network.deployments[SOFT_FORK_NAME].timeout; + + assert(info, 'BIP9 info should be returned'); + assert.strictEqual(info.status, expected.status); + assert.strictEqual(info.bit, network.deployments[SOFT_FORK_NAME].bit); + assert.strictEqual(info.startTime, expected.startTime); + assert.strictEqual(info.timeout, expected.timeout); + }; + + const checkBIP9Statistcs = (stats, expected) => { + expected = expected || {}; + + assert.strictEqual(stats.period, expected.period || network.minerWindow); + assert.strictEqual(stats.threshold, expected.threshold || network.activationThreshold); + assert.strictEqual(stats.elapsed, expected.elapsed); + assert.strictEqual(stats.count, expected.count); + assert.strictEqual(stats.possible, expected.possible); + }; + + describe('Success (integration)', function () { + const node = createNode(); + + before(async () => { + await node.blocks.open(); + await node.chain.open(); + await node.miner.open(); + }); + + after(async () => { + await node.miner.close(); + await node.chain.close(); + await node.blocks.close(); + }); + + it('should be able to mine airdrop & faucet proofs', async () => { + await tryClaimingAirdropProofs(node, [airdropProof, faucetproof]); + }); + + it('should be in DEFINED state', async () => { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + + assert.strictEqual(state, chainCommon.thresholdStates.DEFINED); + checkBIP9Info(bip9info, { status: 'defined' }); + }); + + it('should start the soft-fork', async () => { + await mineNBlocks(network.minerWindow - 2, node); + + // We are now at the threshold of the window. + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + assert.strictEqual(state, thresholdStates.DEFINED); + + checkBIP9Info(bip9info, { status: 'defined' }); + } + + // go into new window and change the state to started. + await mineBlock(node); + + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + assert.strictEqual(state, thresholdStates.STARTED); + checkBIP9Info(bip9info, { status: 'started' }); + + checkBIP9Statistcs(bip9info.statistics, { + elapsed: 0, + count: 0, + possible: true + }); + } + }); + + it('should still be able to mine airdrop & faucet proofs', async () => { + await tryClaimingAirdropProofs(node, [airdropProof, faucetproof]); + }); + + it('should lock in the soft-fork', async () => { + // Reach the height just before the start of the next window + await mineNBlocks(network.minerWindow - 1, node, { signalFork: true }); + + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + assert.strictEqual(state, thresholdStates.STARTED); + checkBIP9Info(bip9info, { status: 'started' }); + + checkBIP9Statistcs(bip9info.statistics, { + elapsed: network.minerWindow - 1, + count: network.minerWindow - 1, + possible: true + }); + } + + // After this the deployment goes to LOCKED_IN state. + await mineBlock(node, { signalFork: true }); + + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + + assert.strictEqual(state, thresholdStates.LOCKED_IN); + checkBIP9Info(bip9info, { status: 'locked_in' }); + + assert(!bip9info.statistics); + } + }); + + it('should still be able to mine airdrop & faucet proofs', async () => { + await tryClaimingAirdropProofs(node, [airdropProof, faucetproof]); + }); + + it('should activate the soft-fork', async () => { + // Advance to ACTIVE state. + await mineNBlocks(network.minerWindow, node); + + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + + assert.strictEqual(state, thresholdStates.ACTIVE); + checkBIP9Info(bip9info, { status: 'active' }); + + assert(!bip9info.statistics); + } + }); + + it('should not be able to mine airdrop proof anymore', async () => { + await assert.rejects( + tryClaimingAirdropProofs(node, [airdropProof]), + { + code: 'invalid', + reason: 'bad-airdrop-disabled' + } + ); + }); + + it('should still be able to mine faucet proof', async () => { + await tryClaimingAirdropProofs(node, [faucetproof]); + }); + }); + + describe('Failure (integration)', function () { + const node = createNode(); + + before(async () => { + await node.blocks.open(); + await node.chain.open(); + await node.miner.open(); + }); + + after(async () => { + await node.miner.close(); + await node.chain.close(); + await node.blocks.close(); + }); + + it('should be able to mine airdrop & faucet proofs', async () => { + await tryClaimingAirdropProofs(node, [airdropProof, faucetproof]); + }); + + it('should be in DEFINED state', async () => { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + + assert.strictEqual(state, chainCommon.thresholdStates.DEFINED); + checkBIP9Info(bip9info, { status: 'defined' }); + }); + + it('should start the soft-fork', async () => { + await mineNBlocks(network.minerWindow - 2, node); + + // We are now at the threshold of the window. + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + assert.strictEqual(state, thresholdStates.DEFINED); + + checkBIP9Info(bip9info, { status: 'defined' }); + } + + // go into new window and change the state to started. + await mineBlock(node); + + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + assert.strictEqual(state, thresholdStates.STARTED); + checkBIP9Info(bip9info, { status: 'started' }); + + checkBIP9Statistcs(bip9info.statistics, { + elapsed: 0, + count: 0, + possible: true + }); + } + }); + + it('should still be able to mine airdrop & faucet proofs', async () => { + await tryClaimingAirdropProofs(node, [airdropProof, faucetproof]); + }); + + it('should fail to lock in the soft-fork', async () => { + // Reach the height just before the start of the next window + await mineNBlocks(network.minerWindow - 1, node, { signalFork: false }); + + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + assert.strictEqual(state, thresholdStates.STARTED); + checkBIP9Info(bip9info, { status: 'started' }); + + checkBIP9Statistcs(bip9info.statistics, { + elapsed: network.minerWindow - 1, + count: 0, + possible: false + }); + } + + // After this the deployment stays in STARTED state. + await mineBlock(node, { signalFork: false }); + + { + const state = await getForkDeploymentState(node.chain); + const bip9info = await getBIP9Info(network, node.chain); + + assert.strictEqual(state, thresholdStates.STARTED); + checkBIP9Info(bip9info, { status: 'started' }); + + checkBIP9Statistcs(bip9info.statistics, { + elapsed: 0, + count: 0, + possible: true + }); + } + }); + + it('should still be able to mine airdrop & faucet proofs', async () => { + await tryClaimingAirdropProofs(node, [airdropProof, faucetproof]); + }); + }); +}); + +/** + * Attempts to mine and add a block with all provided proofs + * and then revert the chain to the previous state. + * + * Throws errors if chain fails to add the block. + * + * @param {object} node + * @param {Chain} node.chain + * @param {Miner} node.miner + * @param {AirdropProof[]} proofs + * @returns {Promise} + */ +async function tryClaimingAirdropProofs(node, proofs) { + assert.ok(Array.isArray(proofs) && proofs.length > 0); + + const job = await node.miner.createJob(); + for (const proof of proofs) { + job.addAirdrop(proof); + } + job.refresh(); + + const block = await job.mineAsync(); + + assert(block.txs.length === 1); + + const [cb] = block.txs; + + assert(cb.inputs.length === proofs.length + 1); + assert(cb.outputs.length === proofs.length + 1); + + const [, input] = cb.inputs; + assert(input); + assert(input.prevout.isNull()); + assert(input.witness.length === 1); + + assert(await node.chain.add(block)); + + // Block with proof accepted, so + // Revert chain to remove the block. + await node.chain.reset(node.chain.height - 1); + + return true; +} + +/** + * Mine N new blocks + * @param {number} n number of blocks to mine + * @param {object} node + * @param {Chain} node.chain + * @param {Miner} node.miner + * @param {object} opts + * @param {boolean} opts.signalFork whether to signal the fork + */ +async function mineNBlocks(n, node, opts = {}) { + for (let i = 0; i < n; i++) + await mineBlock(node, opts); +} + +/** + * Mine a new block + * @param {object} node + * @param {Chain} node.chain + * @param {Miner} node.miner + * @param {object} opts + * @param {boolean} opts.signalFork whether to signal the fork + */ +async function mineBlock(node, opts = {}) { + assert(node); + const chain = node.chain; + const miner = node.miner; + + const signalFork = opts.signalFork || false; + + const job = await miner.cpu.createJob(chain.tip); + + // opt out of all + job.attempt.version = 0; + + if (signalFork) + job.attempt.version |= (1 << network.deployments[SOFT_FORK_NAME].bit); + + job.refresh(); + + const block = await job.mineAsync(); + await chain.add(block); + + return block; +} + +/** + * Get deployment state (number) + * @param {Chain} chain + * @returns {Promise} + */ +async function getForkDeploymentState(chain) { + const prev = chain.tip; + const state = await chain.getState(prev, network.deployments[SOFT_FORK_NAME]); + return state; +} + +/** + * Get BIP9 info for the fork + * + * adapted from lib/node/rpc.js#getSoftforks() + * + * @param {Network} network + * @param {Chain} chain + */ +async function getBIP9Info(network, chain) { + const tip = chain.tip; + const deployment = network.deploys.find(d => d.name === SOFT_FORK_NAME); + + const state = await chain.getState(tip, deployment); + let status; + + switch (state) { + case chainCommon.thresholdStates.DEFINED: + status = 'defined'; + break; + case chainCommon.thresholdStates.STARTED: + status = 'started'; + break; + case chainCommon.thresholdStates.LOCKED_IN: + status = 'locked_in'; + break; + case chainCommon.thresholdStates.ACTIVE: + status = 'active'; + break; + case chainCommon.thresholdStates.FAILED: + status = 'failed'; + break; + default: + assert(false, 'Bad state.'); + break; + } + + let statistics = undefined; + if (status === 'started') + statistics = await chain.getBIP9Stats(tip, deployment); + + return { + status: status, + bit: deployment.bit, + startTime: deployment.startTime, + timeout: deployment.timeout, + statistics + }; +} diff --git a/test/chain-icann-lockup-test.js b/test/chain-icann-lockup-test.js index 56c68d8c..45ca8e6a 100644 --- a/test/chain-icann-lockup-test.js +++ b/test/chain-icann-lockup-test.js @@ -849,7 +849,7 @@ describe('BIP9 - ICANN lockup (integration)', function() { }); } - // After this it should go to the ACTIVE state. + // After this it should go to the LOCKED_IN state. await mineBlock(node); {