diff --git a/dev/README.md b/dev/README.md index ba633b34..58a53834 100644 --- a/dev/README.md +++ b/dev/README.md @@ -44,3 +44,14 @@ git push origin main --tags ``` go create a release on github if possible. + + +## Deploying new faucet + +#### Requirements +- faucet program + - build from repo or use binary in tests/programs/faucet_program.wasm +- configuration and aux data schema +- program mod account with funds to deploy +- child funded accounts to be used as issuers of funds for faucet + - child accounts must be registered with deployed faucet program \ No newline at end of file diff --git a/src/common/logger.ts b/src/common/logger.ts index 7f112143..159dac1e 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -89,27 +89,27 @@ export class EntropyLogger { } // maps to winston:error - public error (description: string, error: Error): void { - this.writeLogMsg('error', error?.message || error, this.context, description, error.stack); + error (description: string, error: Error, context?: string): void { + this.writeLogMsg('error', error?.message || error, context, description, error.stack); } // maps to winston:info - public log (message: any, context?: string): void { + log (message: any, context?: string): void { this.writeLogMsg('info', message, context); } // maps to winston:warn - public warn (message: any, context?: string): void { + warn (message: any, context?: string): void { this.writeLogMsg('warn', message, context); } // maps to winston:debug - public debug (message: any, context?: string): void { + debug (message: any, context?: string): void { this.writeLogMsg('debug', message, context); } // maps to winston:verbose - public verbose (message: any, context?: string): void { + verbose (message: any, context?: string): void { this.writeLogMsg('verbose', message, context); } diff --git a/src/flows/entropyFaucet/constants.ts b/src/flows/entropyFaucet/constants.ts new file mode 100644 index 00000000..0630208a --- /dev/null +++ b/src/flows/entropyFaucet/constants.ts @@ -0,0 +1,10 @@ +// Testnet address used to deploy program on chain +// Used to derive various accounts registered to faucet program in order to be used for +// issuing Faucet Funds +export const FAUCET_PROGRAM_MOD_KEY = '5GWamxgW4XWcwGsrUynqnFq2oNZPqNXQhMDfgNH9xNsg2Yj7' +// Faucet program pointer +// To-DO: Look into deriving this program from owned programs of Faucet Program Mod Acct +// this is differnt from tests because the fauce that is live now was lazily deployed without schemas +// TO-DO: update this when faucet is deployed properly +export const TESTNET_PROGRAM_HASH = '0x12af0bd1f2d91f12e34aeb07ea622c315dbc3c2bdc1e25ff98c23f1e61106c77' +export const LOCAL_PROGRAM_HASH = '0x5fa0536818acaa380b0c349c8e887bf269d593a47e30c8e31de53a75d327f7b1' \ No newline at end of file diff --git a/src/flows/entropyFaucet/faucet.ts b/src/flows/entropyFaucet/faucet.ts new file mode 100644 index 00000000..455ea240 --- /dev/null +++ b/src/flows/entropyFaucet/faucet.ts @@ -0,0 +1,94 @@ +// check verifying key has the balance and proper program hash + +import Entropy from "@entropyxyz/sdk"; +import { blake2AsHex, encodeAddress } from "@polkadot/util-crypto"; +import { getBalance } from "../balance/balance"; +import { viewPrograms } from "../programs/view"; +import FaucetSigner from "./signer"; +import { FAUCET_PROGRAM_MOD_KEY, TESTNET_PROGRAM_HASH } from "./constants"; + +// only the faucet program should be on the key +async function faucetSignAndSend (call: any, entropy: Entropy, amount: number, senderAddress: string, chosenVerifyingKey: any): Promise { + const api = entropy.substrate + const faucetSigner = new FaucetSigner(api.registry, entropy, amount, chosenVerifyingKey) + + const sig = await call.signAsync(senderAddress, { + signer: faucetSigner, + }); + return new Promise((resolve, reject) => { + sig.send(({ status, dispatchError }: any) => { + // status would still be set, but in the case of error we can shortcut + // to just check it (so an error would indicate InBlock or Finalized) + if (dispatchError) { + let msg: string + if (dispatchError.isModule) { + // for module errors, we have the section indexed, lookup + const decoded = api.registry.findMetaError(dispatchError.asModule); + // @ts-ignore + const { documentation, method, section } = decoded; + + msg = `${section}.${method}: ${documentation.join(' ')}` + } else { + // Other, CannotLookup, BadOrigin, no extra info + msg = dispatchError.toString() + } + return reject(Error(msg)) + } + if (status.isFinalized) resolve(status) + }) + }) +} + +export async function getRandomFaucet (entropy: Entropy, previousVerifyingKeys: string[] = [], programModKey = FAUCET_PROGRAM_MOD_KEY) { + const modifiableKeys = await entropy.substrate.query.registry.modifiableKeys(programModKey) + const verifyingKeys = JSON.parse(JSON.stringify(modifiableKeys.toJSON())) + + // Choosing one of the 5 verifiying keys at random to be used as the faucet sender + if (verifyingKeys.length === previousVerifyingKeys.length) { + throw new Error('FaucetError: There are no more faucets to choose from') + } + let chosenVerifyingKey = verifyingKeys[Math.floor(Math.random() * verifyingKeys.length)] + if (previousVerifyingKeys.length && previousVerifyingKeys.includes(chosenVerifyingKey)) { + const filteredVerifyingKeys = verifyingKeys.filter((key: string) => !previousVerifyingKeys.includes(key)) + chosenVerifyingKey = filteredVerifyingKeys[Math.floor(Math.random() * filteredVerifyingKeys.length)] + } + const hashedKey = blake2AsHex(chosenVerifyingKey) + const faucetAddress = encodeAddress(hashedKey, 42).toString() + + return { chosenVerifyingKey, faucetAddress, verifyingKeys } +} + +export async function sendMoney ( + entropy: Entropy, + { + amount, + addressToSendTo, + faucetAddress, + chosenVerifyingKey, + faucetProgramPointer = TESTNET_PROGRAM_HASH + }: { + amount: string, + addressToSendTo: string, + faucetAddress: string, + chosenVerifyingKey: string, + faucetProgramPointer: string + } +): Promise { + // check balance of faucet address + const balance = await getBalance(entropy, faucetAddress) + if (balance <= 0) throw new Error('FundsError: Faucet Account does not have funds') + // check verifying key for only one program matching the program hash + const programs = await viewPrograms(entropy, { verifyingKey: chosenVerifyingKey }) + if (programs.length) { + if (programs.length > 1) throw new Error('ProgramsError: Faucet Account has too many programs attached, expected less') + if (programs.length === 1 && programs[0].program_pointer !== faucetProgramPointer) { + throw new Error('ProgramsError: Faucet Account does not possess Faucet program') + } + } else { + throw new Error('ProgramsError: Faucet Account has no programs attached') + } + + const transfer = entropy.substrate.tx.balances.transferAllowDeath(addressToSendTo, BigInt(amount)); + const transferStatus = await faucetSignAndSend(transfer, entropy, parseInt(amount), faucetAddress, chosenVerifyingKey ) + if (transferStatus.isFinalized) return transferStatus +} \ No newline at end of file diff --git a/src/flows/entropyFaucet/index.ts b/src/flows/entropyFaucet/index.ts index 3a38942c..c5bbe461 100644 --- a/src/flows/entropyFaucet/index.ts +++ b/src/flows/entropyFaucet/index.ts @@ -1,48 +1,45 @@ -import inquirer from "inquirer" -import { print, accountChoices } from "../../common/utils" +import Entropy from "@entropyxyz/sdk" +import { getSelectedAccount, print } from "../../common/utils" import { initializeEntropy } from "../../common/initializeEntropy" - -export async function entropyFaucet ({ accounts }, options) { +import { EntropyLogger } from '../../common/logger' +import { getRandomFaucet, sendMoney } from "./faucet" +import { TESTNET_PROGRAM_HASH } from "./constants" + +let chosenVerifyingKeys = [] +export async function entropyFaucet ({ accounts, selectedAccount: selectedAccountAddress }, options, logger: EntropyLogger) { + const FLOW_CONTEXT = 'ENTROPY_FAUCET' + let faucetAddress + let chosenVerifyingKey + let entropy: Entropy + let verifyingKeys: string[] = [] + const amount = "10000000000" const { endpoint } = options + const selectedAccount = getSelectedAccount(accounts, selectedAccountAddress) + logger.log(`selectedAccount::`, FLOW_CONTEXT) + logger.log(selectedAccount, FLOW_CONTEXT) + try { + // @ts-ignore (see TODO on aliceAccount) + entropy = await initializeEntropy({ keyMaterial: selectedAccount.data, endpoint }) + + if (!entropy.registrationManager.signer.pair) { + throw new Error("Keys are undefined") + } - const accountQuestion = { - type: "list", - name: "selectedAccount", - message: "Choose account:", - choices: accountChoices(accounts), - } - - const answers = await inquirer.prompt([accountQuestion]) - const selectedAccount = answers.selectedAccount - - const recipientAddress = selectedAccount.address - const aliceAccount = { - data: { - // type: "seed", - seed: "0xe5be9a5092b81bca64be81d212e7f2f9eba183bb7a90954f7b76361f6edb5c0a", - // admin TODO: missing this field - }, - } - - // @ts-ignore (see TODO on aliceAccount) - const entropy = await initializeEntropy({ keyMaterial: aliceAccount.data, endpoint }) - - if (!entropy.registrationManager.signer.pair) { - throw new Error("Keys are undefined") - } - - const amount = "10000000000000000" - const tx = entropy.substrate.tx.balances.transferAllowDeath( - recipientAddress, - amount - ) - - await tx.signAndSend( - entropy.registrationManager.signer.pair, - async ({ status }) => { - if (status.isInBlock || status.isFinalized) { - print(recipientAddress, "funded") - } + ({ chosenVerifyingKey, faucetAddress, verifyingKeys } = await getRandomFaucet(entropy, chosenVerifyingKeys)) + + await sendMoney(entropy, { amount, addressToSendTo: selectedAccountAddress, faucetAddress, chosenVerifyingKey, faucetProgramPointer: TESTNET_PROGRAM_HASH }) + // reset chosen keys after successful transfer + chosenVerifyingKeys = [] + print(`Account: ${selectedAccountAddress} has been successfully funded with ${parseInt(amount).toLocaleString('en-US')} BITS`) + } catch (error) { + logger.error('Error issuing funds through faucet', error, FLOW_CONTEXT) + chosenVerifyingKeys.push(chosenVerifyingKey) + if (error.message.includes('FaucetError') || chosenVerifyingKeys.length === verifyingKeys.length) { + console.error('ERR::', error.message) + return + } else { + // Check for non faucet errors (FaucetError) and retry faucet + await entropyFaucet({ accounts, selectedAccount: selectedAccountAddress }, options, logger) } - ) -} + } +} \ No newline at end of file diff --git a/src/flows/entropyFaucet/signer.ts b/src/flows/entropyFaucet/signer.ts new file mode 100644 index 00000000..bb492291 --- /dev/null +++ b/src/flows/entropyFaucet/signer.ts @@ -0,0 +1,70 @@ +import Entropy from "@entropyxyz/sdk"; +import type { Signer, SignerResult } from "@polkadot/api/types"; +import { Registry, SignerPayloadJSON } from "@polkadot/types/types"; +import { u8aToHex } from "@polkadot/util"; +import { stripHexPrefix } from "../../common/utils"; +import { blake2AsHex, decodeAddress, encodeAddress, signatureVerify } from "@polkadot/util-crypto"; + +let id = 0 +export default class FaucetSigner implements Signer { + readonly #registry: Registry + readonly #entropy: Entropy + readonly amount: number + readonly chosenVerifyingKey: any + readonly globalTest: any + + constructor ( + registry: Registry, + entropy: Entropy, + amount: number, + chosenVerifyingKey: any, + ) { + this.#registry = registry + this.#entropy = entropy + this.amount = amount + this.chosenVerifyingKey = chosenVerifyingKey + } + + async signPayload (payload: SignerPayloadJSON): Promise { + // toU8a(true) is important as it strips the scale encoding length prefix from the payload + // without it transactions will fail + // ref: https://github.com/polkadot-js/api/issues/4446#issuecomment-1013213962 + const raw = this.#registry.createType('ExtrinsicPayload', payload, { + version: payload.version, + }).toU8a(true); + + const auxData = { + spec_version: 100, + transaction_version: 6, + string_account_id: this.#entropy.keyring.accounts.registration.address, + amount: this.amount + } + + const signature = await this.#entropy.sign({ + sigRequestHash: u8aToHex(raw), + // @ts-ignore + hash: {custom: 0}, + auxiliaryData: [auxData], + signatureVerifyingKey: this.chosenVerifyingKey + }) + + let sigHex = u8aToHex(signature); + // the 02 prefix is needed for signature type edcsa (00 = ed25519, 01 = sr25519, 02 = ecdsa) + // ref: https://github.com/polkadot-js/tools/issues/175#issuecomment-767496439 + sigHex = `0x02${stripHexPrefix(sigHex)}` + + const hashedKey = blake2AsHex(this.chosenVerifyingKey) + const faucetAddress = encodeAddress(hashedKey) + const publicKey = decodeAddress(faucetAddress); + + const hexPublicKey = u8aToHex(publicKey); + + const signatureValidation = signatureVerify(u8aToHex(raw), sigHex, hexPublicKey) + + if (signatureValidation.isValid) { + return { id: id++, signature: sigHex } + } else { + throw new Error('FaucetSignerError: Signature is not valid') + } + } +} \ No newline at end of file diff --git a/src/flows/manage-accounts/new-account.ts b/src/flows/manage-accounts/new-account.ts index 2c2c6268..c8648131 100644 --- a/src/flows/manage-accounts/new-account.ts +++ b/src/flows/manage-accounts/new-account.ts @@ -1,9 +1,9 @@ import inquirer from 'inquirer' import { randomAsHex } from '@polkadot/util-crypto' -import { importQuestions } from './helpers/import-account' +import { importQuestions } from './utils/import-account' // import * as passwordFlow from '../password' import { print } from '../../common/utils' -import { createAccount } from './helpers/create-account' +import { createAccount } from './utils/create-account' import { EntropyLogger } from 'src/common/logger' export async function newAccount ({ accounts }, logger: EntropyLogger) { diff --git a/src/flows/manage-accounts/helpers/create-account.ts b/src/flows/manage-accounts/utils/create-account.ts similarity index 100% rename from src/flows/manage-accounts/helpers/create-account.ts rename to src/flows/manage-accounts/utils/create-account.ts diff --git a/src/flows/manage-accounts/helpers/import-account.ts b/src/flows/manage-accounts/utils/import-account.ts similarity index 100% rename from src/flows/manage-accounts/helpers/import-account.ts rename to src/flows/manage-accounts/utils/import-account.ts diff --git a/src/flows/programs/index.ts b/src/flows/programs/index.ts index db8eadee..3e1deae4 100644 --- a/src/flows/programs/index.ts +++ b/src/flows/programs/index.ts @@ -74,7 +74,7 @@ export async function userPrograms ({ accounts, selectedAccount: selectedAccount validate: (input) => (input ? true : "Program pointer is required!"), }]) logger.debug(`program pointer: ${programPointer}`, `${FLOW_CONTEXT}::PROGRAM_PRESENCE_CHECK`); - const program = await entropy.programs.dev.get(programPointer); + const program = await entropy.programs.dev.getProgramInfo(programPointer); print(program); } catch (error) { console.error(error.message); diff --git a/src/tui.ts b/src/tui.ts index 746640fa..c0e13380 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -24,7 +24,7 @@ export default function tui (options: EntropyTuiOptions) { // TODO: design programs in TUI (merge deploy+user programs) 'Deploy Program': flows.devPrograms, 'User Programs': flows.userPrograms, - // 'Construct an Ethereum Tx': flows.ethTransaction, + 'Entropy Faucet': flows.entropyFaucet, } // const devChoices = { diff --git a/tests/faucet.test.ts b/tests/faucet.test.ts new file mode 100644 index 00000000..d8345fd8 --- /dev/null +++ b/tests/faucet.test.ts @@ -0,0 +1,75 @@ +import test from 'tape' +import * as util from "@polkadot/util" +import { charlieStashSeed, setupTest } from './testing-utils' +import { stripHexPrefix } from '../src/common/utils' +import { readFileSync } from 'fs' +import { getRandomFaucet, sendMoney } from '../src/flows/entropyFaucet/faucet' +import { getBalance } from '../src/flows/balance/balance' +import { register } from '../src/flows/register/register' +import { transfer } from '../src/flows/entropyTransfer/transfer' +import { LOCAL_PROGRAM_HASH } from '../src/flows/entropyFaucet/constants' + +test('Faucet Tests', async t => { + const { run, entropy } = await setupTest(t, { seed: charlieStashSeed }) + const { entropy: naynayEntropy } = await setupTest(t) + + const faucetProgram = readFileSync('tests/programs/faucet_program.wasm') + + const genesisHash = await entropy.substrate.rpc.chain.getBlockHash(0) + + const userConfig = { + max_transfer_amount: 10_000_000_000, + genesis_hash: stripHexPrefix(genesisHash.toString()) + } + const configurationSchema = { + max_transfer_amount: "number", + genesis_hash: "string" + } + const auxDataSchema = { + amount: "number", + string_account_id: "string", + spec_version: "number", + transaction_version: "number", + } + + // Deploy faucet program + const faucetProgramPointer = await run('Deploy faucet program', entropy.programs.dev.deploy(faucetProgram, configurationSchema, auxDataSchema)) + + // Confirm faucetPointer matches deployed program pointer + t.equal(faucetProgramPointer, LOCAL_PROGRAM_HASH, 'Program pointer matches') + + let naynayBalance = await getBalance(naynayEntropy, naynayEntropy.keyring.accounts.registration.address) + t.equal(naynayBalance, 0, 'Naynay is broke af') + // register with faucet program + await run('Register Faucet Program for charlie stash', register( + entropy, + { + programModAddress: entropy.keyring.accounts.registration.address, + programData: [{ program_pointer: faucetProgramPointer, program_config: userConfig }] + } + )) + + const { chosenVerifyingKey, faucetAddress } = await getRandomFaucet(entropy, [], entropy.keyring.accounts.registration.address) + // adding funds to faucet address + + await run('Transfer funds to faucet address', transfer(entropy, { from: entropy.keyring.accounts.registration.pair, to: faucetAddress, amount: BigInt("100000000000000") })) + + const transferStatus = await sendMoney( + naynayEntropy, + { + amount: "10000000000", + addressToSendTo: naynayEntropy.keyring.accounts.registration.address, + faucetAddress, + chosenVerifyingKey, + faucetProgramPointer + } + ) + + t.ok(transferStatus.isFinalized, 'Transfer is good') + + naynayBalance = await getBalance(naynayEntropy, naynayEntropy.keyring.accounts.registration.address) + + t.ok(naynayBalance > 0, 'Naynay is drippin in faucet tokens') + + t.end() +}) \ No newline at end of file diff --git a/tests/manage-accounts.test.ts b/tests/manage-accounts.test.ts index 6b3521c3..01a4cdc1 100644 --- a/tests/manage-accounts.test.ts +++ b/tests/manage-accounts.test.ts @@ -7,7 +7,7 @@ import Keyring from '@entropyxyz/sdk/keys' import { randomAsHex } from '@polkadot/util-crypto' import { EntropyAccountConfig, EntropyConfig } from '../src/config/types' import { listAccounts } from '../src/flows/manage-accounts/list' -import { createAccount } from '../src/flows/manage-accounts/helpers/create-account' +import { createAccount } from '../src/flows/manage-accounts/utils/create-account' import * as config from '../src/config' import { promiseRunner, sleep } from './testing-utils' import { charlieStashAddress, charlieStashSeed } from './testing-utils/constants' @@ -40,7 +40,7 @@ test('List Accounts', async t => { t.deepEqual(accountsArray, [{ name: account.name, address: account.address, - verifyingKeys: account.data.admin.verifyingKeys + verifyingKeys: account?.data?.admin?.verifyingKeys }]) // Resetting accounts on config to test for empty list @@ -74,6 +74,6 @@ test('Create Account', async t => { const isValidAddress = isValidSubstrateAddress(newAccount.address) t.ok(isValidAddress, 'Valid address created') - t.equal(newAccount.address, admin.address, 'Generated Account matches Account created by Keyring') + t.equal(newAccount.address, admin?.address, 'Generated Account matches Account created by Keyring') t.end() }) diff --git a/tests/programs/faucet_program.wasm b/tests/programs/faucet_program.wasm new file mode 100644 index 00000000..cf1da081 Binary files /dev/null and b/tests/programs/faucet_program.wasm differ diff --git a/tests/testing-utils/index.ts b/tests/testing-utils/index.ts index 99cfde31..147b11e2 100644 --- a/tests/testing-utils/index.ts +++ b/tests/testing-utils/index.ts @@ -32,13 +32,15 @@ export function promiseRunner(t: any, keepThrowing = false) { return promise .then((result) => { const time = (Date.now() - startTime) / 1000 - const pad = Array(40 - message.length) + const noPad = message.length > 40 + const pad = noPad ? '' : Array(40 - message.length) .fill('-') .join('') t.pass(`${message} ${pad} ${time}s`) return result }) .catch((err) => { + console.log('error', err); t.error(err, message) if (keepThrowing) throw err }) diff --git a/tests/testing-utils/setup-test.ts b/tests/testing-utils/setup-test.ts index 92817e82..826758dc 100644 --- a/tests/testing-utils/setup-test.ts +++ b/tests/testing-utils/setup-test.ts @@ -13,6 +13,7 @@ interface SetupTestOpts { configPath?: string networkType?: string seed?: string, + createAccountOnly?: boolean } const NETWORK_TYPE_DEFAULT = 'two-nodes' let counter = 0 @@ -21,7 +22,8 @@ export async function setupTest (t: Test, opts?: SetupTestOpts): Promise<{ entro const { configPath = `/tmp/entropy-cli-${Date.now()}_${counter++}.json`, networkType = NETWORK_TYPE_DEFAULT, - seed = makeSeed() + seed = makeSeed(), + createAccountOnly = false } = opts || {} const run = promiseRunner(t)