-
Notifications
You must be signed in to change notification settings - Fork 573
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
5 changed files
with
560 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} |
Oops, something went wrong.