From 0c7b044e39163368973dfa562b6e3f2188ca9ccb Mon Sep 17 00:00:00 2001 From: mat-if <97762857+mat-if@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:53:57 -0700 Subject: [PATCH] feat: genesisadd CLI command (#3678) * feat: genesisadd CLI command * modify the note selection so it is more specific this allows us to run the script multiple times if we need to, and won't try to double spend or anything also includes some minor code-review changes, for helptext and a flag --- ironfish-cli/src/commands/chain/genesisadd.ts | 203 ++++++++++++++++++ .../__fixtures__/genesis.test.slow.ts.fixture | 35 +++ ironfish/src/genesis/addGenesisTransaction.ts | 146 +++++++++++++ ironfish/src/genesis/genesis.test.slow.ts | 182 +++++++++++++++- ironfish/src/genesis/index.ts | 1 + 5 files changed, 560 insertions(+), 7 deletions(-) create mode 100644 ironfish-cli/src/commands/chain/genesisadd.ts create mode 100644 ironfish/src/genesis/addGenesisTransaction.ts diff --git a/ironfish-cli/src/commands/chain/genesisadd.ts b/ironfish-cli/src/commands/chain/genesisadd.ts new file mode 100644 index 0000000000..477fae6281 --- /dev/null +++ b/ironfish-cli/src/commands/chain/genesisadd.ts @@ -0,0 +1,203 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { MEMO_LENGTH } from '@ironfish/rust-nodejs' +import { + addGenesisTransaction, + BlockSerde, + CurrencyUtils, + GenesisBlockAllocation, + IJSON, + isValidPublicAddress, +} from '@ironfish/sdk' +import { CliUx, Flags } from '@oclif/core' +import fs from 'fs/promises' +import { IronfishCommand } from '../../command' +import { LocalFlags } from '../../flags' + +export default class GenesisAddCommand extends IronfishCommand { + static hidden = true + + static flags = { + ...LocalFlags, + account: Flags.string({ + char: 'a', + required: true, + description: 'The name of the account to reallocate from', + }), + allocations: Flags.string({ + required: true, + description: + 'A CSV file with the format address,amountInIron,memo containing genesis block allocations', + }), + totalAmount: Flags.string({ + char: 'g', + required: true, + description: 'The total prior allocation to the given account', + }), + dry: Flags.boolean({ + default: false, + description: 'Display genesis block allocations without creating the genesis block', + }), + } + + async start(): Promise { + const { flags } = await this.parse(GenesisAddCommand) + + const node = await this.sdk.node() + await node.openDB() + + const account = node.wallet.getAccountByName(flags.account) + if (account === null) { + this.log(`Account ${flags.account} does not exist, make sure it is imported first`) + this.exit(0) + return + } + + const totalAmount = CurrencyUtils.decodeIron(flags.totalAmount) + const csv = await fs.readFile(flags.allocations, 'utf-8') + const result = parseAllocationsFile(csv) + + if (!result.ok) { + this.error(result.error) + } + + const totalSupply: bigint = result.allocations.reduce((prev, cur) => { + return prev + cur.amountInOre + }, 0n) + + if (totalSupply !== totalAmount) { + this.error( + `Allocations file contains ${CurrencyUtils.encodeIron( + totalSupply, + )} $IRON, but --totalAmount expects ${flags.totalAmount} $IRON.`, + ) + } + + const allocations: GenesisBlockAllocation[] = result.allocations + + // Log genesis block info + this.log(`Genesis block will be modified with the following values in a new transaction:`) + this.log(`Allocations:`) + const columns: CliUx.Table.table.Columns = { + identity: { + header: 'ADDRESS', + get: (row: GenesisBlockAllocation) => row.publicAddress, + }, + amount: { + header: 'AMOUNT ($IRON)', + get: (row: GenesisBlockAllocation) => { + return CurrencyUtils.encodeIron(row.amountInOre) + }, + }, + memo: { + header: 'MEMO', + get: (row: GenesisBlockAllocation) => row.memo, + }, + } + + CliUx.ux.table(allocations, columns, { + printLine: (line) => this.log(line), + }) + + // Display duplicates if they exist + const duplicates = getDuplicates(allocations) + if (duplicates.length > 0) { + this.log( + `\n/!\\ Allocations contains the following duplicate addresses. This will not cause errors, but may be a mistake. /!\\`, + ) + for (const duplicate of duplicates) { + this.log(duplicate) + } + this.log('\n') + } + + // Exit if dry run, otherwise confirm + if (flags.dry) { + this.exit(0) + } else { + const result = await CliUx.ux.confirm('\nCreate new genesis block? (y)es / (n)o') + if (!result) { + this.exit(0) + } + } + + this.log('\nBuilding a genesis block...') + const { block } = await addGenesisTransaction(node, account, allocations, this.logger) + + this.log(`\nGenesis Block`) + const serialized = BlockSerde.serialize(block) + this.log(IJSON.stringify(serialized, ' ')) + } +} + +const getDuplicates = (allocations: readonly GenesisBlockAllocation[]): string[] => { + const duplicateSet = new Set() + const nonDuplicateSet = new Set() + + for (const alloc of allocations) { + if (nonDuplicateSet.has(alloc.publicAddress)) { + duplicateSet.add(alloc.publicAddress) + } else { + nonDuplicateSet.add(alloc.publicAddress) + } + } + + return [...duplicateSet] +} + +const parseAllocationsFile = ( + fileContent: string, +): { ok: true; allocations: GenesisBlockAllocation[] } | { ok: false; error: string } => { + const allocations: GenesisBlockAllocation[] = [] + + let lineNum = 0 + for (const line of fileContent.split(/[\r\n]+/)) { + lineNum++ + if (line.trim().length === 0) { + continue + } + + const [address, amountInIron, memo, ...rest] = line.split(',').map((v) => v.trim()) + + if (rest.length > 0) { + return { + ok: false, + error: `Line ${lineNum}: (${line}) contains more than 3 values.`, + } + } + + // Check address length + if (!isValidPublicAddress(address)) { + return { + ok: false, + error: `Line ${lineNum}: (${line}) has an invalid public address.`, + } + } + + // Check amount is positive and decodes as $IRON + const amountInOre = CurrencyUtils.decodeIron(amountInIron) + if (amountInOre < 0) { + return { + ok: false, + error: `Line ${lineNum}: (${line}) contains a negative $IRON amount.`, + } + } + + // Check memo length + if (Buffer.from(memo).byteLength > MEMO_LENGTH) { + return { + ok: false, + error: `Line ${lineNum}: (${line}) contains a memo with byte length > ${MEMO_LENGTH}.`, + } + } + + allocations.push({ + publicAddress: address, + amountInOre: amountInOre, + memo: memo, + }) + } + + return { ok: true, allocations } +} diff --git a/ironfish/src/genesis/__fixtures__/genesis.test.slow.ts.fixture b/ironfish/src/genesis/__fixtures__/genesis.test.slow.ts.fixture index 1b89c652b4..7ec0c56675 100644 --- a/ironfish/src/genesis/__fixtures__/genesis.test.slow.ts.fixture +++ b/ironfish/src/genesis/__fixtures__/genesis.test.slow.ts.fixture @@ -11,5 +11,40 @@ "publicAddress": "3aade08766f374726252d244e44836524d4f89b0c46c56dcb5d3da8eedc0ac33", "createdAt": "2023-03-12T18:21:15.400Z" } + ], + "addGenesisTransaction Can create a new genesis block with an added transaction": [ + { + "version": 1, + "id": "4649f9ad-b454-49d7-946b-fc4b624e2457", + "name": "account1", + "spendingKey": "d4aada789ad4ee23fd610feef896a14bab2adb27cdfb81ad862a6c5479f6137e", + "viewKey": "341e035eb513031b7ee64c16844b163c0079e61f720e02ef50f7761e65866ad17336dc7c7a21cd59092c41a7aeb5a614fcbd9d16d28cf6c56b02daa125cc2336", + "incomingViewKey": "fe14049643a4f7a6df042b1d4612b27cdbdc46432d62d12e2c2655fa768a1001", + "outgoingViewKey": "35afb0f100570a51ab6f2497a6d3e836d5a570520edb27cd93025ed3b492ce6f", + "publicAddress": "6e00ecabbf8f50570b3462822d2f817e8bfe1d681ec5c2706cd39efdb9f3bb87", + "createdAt": "2023-03-21T21:39:39.238Z" + }, + { + "version": 1, + "id": "e55d269f-d79c-49db-84db-d1e5bae5b309", + "name": "account2", + "spendingKey": "fd210152449db354ed0fbe7ea915041728790185e89a0be2e428d453ac16245f", + "viewKey": "dbf261d0b5c1941050911d801ffba3e81877ebcd2d20aafccf2612a681eaa564dbb63e7b7aa552eca5539799811fdbc35e00b0507d34ea196b45ca2b2b1f0e9e", + "incomingViewKey": "3d46dbe6482a9e494c6187a7ba41d8b7aaace8e47e77f7c086e16e1f87c8f201", + "outgoingViewKey": "6d1423a452ffa1d10c220c92e493d368ac55f8e6317954602b74ea59a7a049d2", + "publicAddress": "82af4ab5a7c4889804ee685cc1cb0669e8a08ca480fe338a5f8d5bb26a114c5b", + "createdAt": "2023-03-21T21:39:39.239Z" + }, + { + "version": 1, + "id": "e6669000-72b0-4954-875d-6868375701ed", + "name": "account3", + "spendingKey": "f2eed26802b20071715b6a2b113c7df2ea2552cf1e45c65cfe7c2375656e397b", + "viewKey": "ea00a65b1e2bb1e53a9f92e1d13a264132ef4c791f6b0d42c8778538748b15048a9af9264eabcf4fbb470ce7ad2ac8955aeba1f72131df8058f31c6b4c99679c", + "incomingViewKey": "13bb18a8b53d8cfcc1cd69b00afcadc227a00599cd8081f4d8bd32e684e19900", + "outgoingViewKey": "b206c06a5b26c3ceae1c2189835c68cb7da06adf177f414a09d28e1b525bb5c8", + "publicAddress": "3233dd96ca906d54f76625f215ab43c1202345493dce2d85d48f71a2a2304599", + "createdAt": "2023-03-21T21:39:39.239Z" + } ] } \ No newline at end of file diff --git a/ironfish/src/genesis/addGenesisTransaction.ts b/ironfish/src/genesis/addGenesisTransaction.ts new file mode 100644 index 0000000000..555bb25904 --- /dev/null +++ b/ironfish/src/genesis/addGenesisTransaction.ts @@ -0,0 +1,146 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { + Asset, + Note as NativeNote, + Transaction as NativeTransaction, +} from '@ironfish/rust-nodejs' +import { Logger } from '../logger' +import { IronfishNode } from '../node' +import { Block, BlockHeader } from '../primitives' +import { transactionCommitment } from '../primitives/blockheader' +import { Transaction } from '../primitives/transaction' +import { CurrencyUtils } from '../utils' +import { Account } from '../wallet' +import { GenesisBlockAllocation } from './makeGenesisBlock' + +export async function addGenesisTransaction( + node: IronfishNode, + account: Account, + allocations: GenesisBlockAllocation[], + logger: Logger, +): Promise<{ block: Block }> { + logger = logger.withTag('addGenesisTransaction') + + if (!account.spendingKey) { + throw new Error('Must be a full account, not a view account') + } + + // Sum the allocations to get the total number of coins + const allocationSum = allocations.reduce((sum, cur) => sum + cur.amountInOre, 0n) + const allocationSumInIron = CurrencyUtils.encodeIron(allocationSum) + + logger.info('Generating a transaction for distributing allocations...') + + // Get a previous note owned by the given account from the existing genesis block + let note: NativeNote | null = null + let witness = null + const genesisTransactions = await node.chain.getBlockTransactions(node.chain.genesis) + for (const { transaction, initialNoteIndex } of genesisTransactions) { + let noteIndex = -1 + for (const encryptedNote of transaction.notes) { + noteIndex += 1 + // If this account can't decrypt this note, we can't use it + const decryptedNote = encryptedNote.decryptNoteForOwner(account.incomingViewKey) + if (decryptedNote == null) { + continue + } + + // If the nullifier has already been revealed, we can't use it + const nullifier = decryptedNote.nullifier( + account.viewKey, + BigInt(initialNoteIndex + noteIndex), + ) + if (await node.chain.nullifiers.contains(nullifier)) { + continue + } + + // We want the note with the exact value + if (decryptedNote.value() !== allocationSum) { + continue + } + + witness = await node.chain.notes.witness(initialNoteIndex + noteIndex) + note = decryptedNote.takeReference() + decryptedNote.returnReference() + break + } + + if (note != null) { + break + } + } + + if (note == null) { + throw new Error( + 'The given account does not have a suitable note to spend for the new allocations', + ) + } + + if (witness == null) { + throw new Error('The witness is missing, this should not happen') + } + + if (note.value() !== allocationSum) { + throw new Error('The value of the note to spend does not match the sum of the allocations') + } + + // Create the new transaction to be appended to the new genesis block + const transaction = new NativeTransaction(account.spendingKey) + logger.info(` Generating a spend for ${allocationSumInIron} coins...`) + transaction.spend(note, witness) + + for (const alloc of allocations) { + logger.info( + ` Generating an output for ${CurrencyUtils.encodeIron(alloc.amountInOre)} coins for ${ + alloc.publicAddress + }...`, + ) + const note = new NativeNote( + alloc.publicAddress, + BigInt(alloc.amountInOre), + alloc.memo, + Asset.nativeId(), + account.publicAddress, + ) + transaction.output(note) + } + + logger.info(' Posting the transaction...') + const postedTransaction = new Transaction(transaction.post(undefined, BigInt(0))) + + logger.info('Creating the modified genesis block...') + // Get the existing genesis block + const genesisBlock = await node.chain.getBlock(node.chain.genesis) + if (genesisBlock == null) { + throw new Error('An existing genesis block was not found') + } + + // Append the new transaction + genesisBlock.transactions.push(postedTransaction) + + // Add the new notes to the merkle tree + await node.chain.notes.addBatch(postedTransaction.notes) + + // Generate a new block header for the new genesis block + const noteCommitment = await node.chain.notes.rootHash() + const noteSize = await node.chain.notes.size() + const newGenesisHeader = new BlockHeader( + 1, + genesisBlock.header.previousBlockHash, + noteCommitment, + transactionCommitment(genesisBlock.transactions), + genesisBlock.header.target, + genesisBlock.header.randomness, + genesisBlock.header.timestamp, + genesisBlock.header.graffiti, + noteSize, + ) + + genesisBlock.header = newGenesisHeader + + logger.info('Block complete.') + return { block: genesisBlock } +} diff --git a/ironfish/src/genesis/genesis.test.slow.ts b/ironfish/src/genesis/genesis.test.slow.ts index 9e51514271..1c72d272ce 100644 --- a/ironfish/src/genesis/genesis.test.slow.ts +++ b/ironfish/src/genesis/genesis.test.slow.ts @@ -7,6 +7,7 @@ import { Target } from '../primitives/target' import { IJSON } from '../serde' import { createNodeTest, useAccountFixture } from '../testUtilities' import { acceptsAllTarget } from '../testUtilities/helpers/blockchain' +import { addGenesisTransaction } from './addGenesisTransaction' import { GenesisBlockInfo, makeGenesisBlock } from './makeGenesisBlock' describe('Read genesis block', () => { @@ -59,8 +60,7 @@ describe('Create genesis block', () => { const node = nodeTest.node const chain = nodeTest.chain - const amountNumber = 5n - const amountBigint = BigInt(amountNumber) + const amount = 5n // Construct parameters for the genesis block const account = await useAccountFixture(node.wallet, 'test') @@ -69,7 +69,7 @@ describe('Create genesis block', () => { target: Target.maxTarget(), allocations: [ { - amountInOre: amountNumber, + amountInOre: amount, publicAddress: account.publicAddress, memo: 'test', }, @@ -98,8 +98,8 @@ describe('Create genesis block', () => { // Check that the balance is what's expected await expect(node.wallet.getBalance(account, Asset.nativeId())).resolves.toMatchObject({ - confirmed: amountBigint, - unconfirmed: amountBigint, + confirmed: amount, + unconfirmed: amount, }) // Ensure we can construct blocks after that block @@ -136,8 +136,8 @@ describe('Create genesis block', () => { await expect( newNode.wallet.getBalance(accountNewNode, Asset.nativeId()), ).resolves.toMatchObject({ - confirmed: amountBigint, - unconfirmed: amountBigint, + confirmed: amount, + unconfirmed: amount, }) // Ensure we can construct blocks after that block @@ -150,3 +150,171 @@ describe('Create genesis block', () => { expect(newBlock).toBeTruthy() }) }) + +describe('addGenesisTransaction', () => { + const nodeTest = createNodeTest(false, { autoSeed: false }) + let targetMeetsSpy: jest.SpyInstance + let targetSpy: jest.SpyInstance + + beforeAll(() => { + targetMeetsSpy = jest.spyOn(Target, 'meets').mockImplementation(() => true) + targetSpy = jest.spyOn(Target, 'calculateTarget').mockImplementation(acceptsAllTarget) + }) + + afterAll(() => { + targetMeetsSpy.mockClear() + targetSpy.mockClear() + }) + + it('Can create a new genesis block with an added transaction', async () => { + // Initialize the database and chain + const originalNode = nodeTest.node + const originalChain = nodeTest.chain + + // Construct parameters for the genesis block + const account1Original = await useAccountFixture(originalNode.wallet, 'account1') + const account2Original = await useAccountFixture(originalNode.wallet, 'account2') + const account3Original = await useAccountFixture(originalNode.wallet, 'account3') + + const info: GenesisBlockInfo = { + timestamp: Date.now(), + target: Target.maxTarget(), + allocations: [ + { + amountInOre: 100n, + publicAddress: account1Original.publicAddress, + memo: 'account1', + }, + { + amountInOre: 100n, + publicAddress: account2Original.publicAddress, + memo: 'account2', + }, + ], + } + + // Build the original genesis block itself + const { block: originalBlock } = await makeGenesisBlock( + originalChain, + info, + originalNode.logger, + ) + + // Add the block to the chain + const originalAddBlock = await originalChain.addBlock(originalBlock) + expect(originalAddBlock.isAdded).toBeTruthy() + + const newAllocations = [ + { + amountInOre: 50n, + publicAddress: account1Original.publicAddress, + memo: 'account1', + }, + { + amountInOre: 25n, + publicAddress: account2Original.publicAddress, + memo: 'account2', + }, + { + amountInOre: 25n, + publicAddress: account3Original.publicAddress, + memo: 'account3', + }, + ] + + // Account 1: 100 in original allocation, but 50 used for the 2nd allocation + const account1Amount = 50n + // Account 2: 100 in the original allocation, and 25 in the 2nd allocation + const account2Amount = 125n + // Account 3: 25 in the 2nd allocation + const account3Amount = 25n + + // Build the modified genesis block + const { block } = await addGenesisTransaction( + originalNode, + account1Original, + newAllocations, + originalNode.logger, + ) + + // Compare the original parameters with the new one + expect(originalBlock.header.sequence).toEqual(block.header.sequence) + expect(originalBlock.header.previousBlockHash).toEqual(block.header.previousBlockHash) + expect(originalBlock.header.target).toEqual(block.header.target) + expect(originalBlock.header.randomness).toEqual(block.header.randomness) + expect(originalBlock.header.timestamp).toEqual(block.header.timestamp) + expect(originalBlock.header.graffiti).toEqual(block.header.graffiti) + expect(originalBlock.header.noteCommitment).not.toEqual(block.header.noteCommitment) + expect(originalBlock.header.noteSize).not.toEqual(block.header.noteSize) + expect(originalBlock.header.transactionCommitment).not.toEqual( + block.header.transactionCommitment, + ) + expect(originalBlock.transactions.length).not.toEqual(block.transactions.length) + + // Balance should still be zero, since generating the block should clear out + // any notes made in the process + await expect( + originalNode.wallet.getBalance(account1Original, Asset.nativeId()), + ).resolves.toMatchObject({ + confirmed: BigInt(0), + unconfirmed: BigInt(0), + }) + + // Create a new node + const { strategy, chain, node } = await nodeTest.createSetup() + + // Import accounts + const account1 = await node.wallet.importAccount(account1Original) + const account2 = await node.wallet.importAccount(account2Original) + const account3 = await node.wallet.importAccount(account3Original) + + // Next, serialize it in the same way that the genesis command serializes it + const serialized = BlockSerde.serialize(block) + const jsonedBlock = IJSON.stringify(serialized, ' ') + + // Deserialize the block and add it to the new chain + const result = IJSON.parse(jsonedBlock) as SerializedBlock + const deserializedBlock = BlockSerde.deserialize(result) + const addedBlock = await chain.addBlock(deserializedBlock) + expect(addedBlock.isAdded).toBe(true) + + await node.wallet.updateHead() + + // Check that the balance is what's expected + await expect(node.wallet.getBalance(account1, Asset.nativeId())).resolves.toMatchObject({ + confirmed: account1Amount, + unconfirmed: account1Amount, + }) + await expect(node.wallet.getBalance(account2, Asset.nativeId())).resolves.toMatchObject({ + confirmed: account2Amount, + unconfirmed: account2Amount, + }) + await expect(node.wallet.getBalance(account3, Asset.nativeId())).resolves.toMatchObject({ + confirmed: account3Amount, + unconfirmed: account3Amount, + }) + + // Ensure we can construct blocks after that block + const minersfee = await strategy.createMinersFee( + BigInt(0), + block.header.sequence + 1, + generateKey().spendingKey, + ) + const additionalBlock = await chain.newBlock([], minersfee) + expect(additionalBlock).toBeTruthy() + + // Validate parameters again to make sure they're what's expected + expect(deserializedBlock.header.sequence).toEqual(block.header.sequence) + expect(deserializedBlock.header.previousBlockHash).toEqual(block.header.previousBlockHash) + expect(deserializedBlock.header.target).toEqual(block.header.target) + expect(deserializedBlock.header.randomness).toEqual(block.header.randomness) + expect(deserializedBlock.header.timestamp).toEqual(block.header.timestamp) + expect(deserializedBlock.header.graffiti).toEqual(block.header.graffiti) + expect(deserializedBlock.header.noteCommitment).toEqual(block.header.noteCommitment) + expect(deserializedBlock.header.noteSize).toEqual(block.header.noteSize) + expect(deserializedBlock.header.transactionCommitment).toEqual( + block.header.transactionCommitment, + ) + expect(deserializedBlock.transactions.length).toEqual(block.transactions.length) + }) +}) diff --git a/ironfish/src/genesis/index.ts b/ironfish/src/genesis/index.ts index 354339927e..1268e948a9 100644 --- a/ironfish/src/genesis/index.ts +++ b/ironfish/src/genesis/index.ts @@ -1,4 +1,5 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +export * from './addGenesisTransaction' export * from './makeGenesisBlock'