Skip to content

Commit

Permalink
feat: genesisadd CLI command (#3678)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mat-if authored Mar 27, 2023
1 parent 37c0e1c commit 0c7b044
Show file tree
Hide file tree
Showing 5 changed files with 560 additions and 7 deletions.
203 changes: 203 additions & 0 deletions ironfish-cli/src/commands/chain/genesisadd.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<GenesisBlockAllocation> = {
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<string>()
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 }
}
35 changes: 35 additions & 0 deletions ironfish/src/genesis/__fixtures__/genesis.test.slow.ts.fixture
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
146 changes: 146 additions & 0 deletions ironfish/src/genesis/addGenesisTransaction.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading

0 comments on commit 0c7b044

Please sign in to comment.