diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..532170d8 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,33 @@ +--- +name: Build, lint, test +run-name: Test JS CLI (started by @${{ github.triggering_actor }}) + +on: + pull_request: + types: + - opened + - synchronize + - reopened + +jobs: + build_test_lint: + name: Build, test, and lint + permissions: + contents: read + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.10.0 + - name: Install + run: yarn --network-timeout 180000 + - name: Typecheck + run: yarn run test:types + - name: Build + run: yarn run build + - name: Add TSS server host mappings + run: | + echo "127.0.0.1 alice-tss-server bob-tss-server" | sudo tee -a /etc/hosts + - name: Test + run: yarn run test diff --git a/CHANGELOG.md b/CHANGELOG.md index c2cac619..43910832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,12 @@ Version header format: `[version] Name - year-month-day (entropy-core compatibil ## [UNRELEASED] ### Added - +- new: './src/flows/balance/balance.ts' - service file separated out of main flow containing the pure functions to perform balance requests for one or multiple addresses +- new: './tests/balance.test.ts' - new unit tests file for balance pure functions ### Fixed - +- keyring retrieval method was incorrectly returning the default keyring when no keyring was found, which is not the intended flow ### Changed - +- conditional when initializing entropy object to only error if no seed AND admin account is not found in the account data, new unit test caught bug with using OR condition ### Broke ### Meta/Dev diff --git a/package.json b/package.json index 293366de..9e4fc0cf 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "homepage": "https://github.com/entropyxyz/cli#readme", "dependencies": { - "@entropyxyz/sdk": "^0.2.1", + "@entropyxyz/sdk": "^0.2.2-0", "@polkadot/util": "^12.6.2", "@types/node": "^20.12.12", "ansi-colors": "^4.1.3", @@ -67,6 +67,7 @@ "husky": "^9.0.11", "lint-staged": "^15.2.2", "pinst": "^3.0.0", + "readline": "^1.3.0", "regenerator-runtime": "^0.14.1", "tap-spec": "^5.0.0", "tape": "^5.7.5", diff --git a/src/common/initializeEntropy.ts b/src/common/initializeEntropy.ts index 0aba9401..abb63c87 100644 --- a/src/common/initializeEntropy.ts +++ b/src/common/initializeEntropy.ts @@ -20,7 +20,8 @@ const keyrings = { export function getKeyring (address) { if (!address && keyrings.default) return keyrings.default if (address && keyrings[address]) return keyrings[address] - return keyrings.default +// explicitly return undefined so there is no confusion around what is selected + return undefined } interface InitializeEntropyOpts { @@ -38,7 +39,8 @@ export const initializeEntropy = async ({ keyMaterial, password, endpoint }: Ini await wasmGlobalsReady() const { accountData, password: successfulPassword } = await getAccountDataAndPassword(keyMaterial, password) - if (!accountData.seed || !accountData.admin) { +// check if there is no admin account and no seed so that we can throw an error + if (!accountData.seed && !accountData.admin) { throw new Error("Data format is not recognized as either encrypted or unencrypted") } @@ -63,7 +65,7 @@ export const initializeEntropy = async ({ keyMaterial, password, endpoint }: Ini } let selectedAccount - const storedKeyring = getKeyring(accountData.admin.address) + const storedKeyring = getKeyring(accountData?.admin?.address) if(!storedKeyring) { const keyring = new Keyring({ ...accountData, debug: true }) diff --git a/src/flows/balance/balance.ts b/src/flows/balance/balance.ts new file mode 100644 index 00000000..d2a5ce3b --- /dev/null +++ b/src/flows/balance/balance.ts @@ -0,0 +1,36 @@ +import Entropy from "@entropyxyz/sdk"; +import { BalanceInfo } from "./types"; + +const hexToBigInt = (hexString: string) => BigInt(hexString) + +export async function getBalance (entropy: Entropy, address: string): Promise { + try { + const accountInfo = (await entropy.substrate.query.system.account(address)) as any + + return parseInt(hexToBigInt(accountInfo.data.free).toString()) + } catch (error) { + // console.error(`There was an error getting balance for [acct = ${address}]`, error); + throw new Error(error.message) + } +} + +export async function getBalances (entropy: Entropy, addresses: string[]): Promise { + const balanceInfo: BalanceInfo = {} + try { + await Promise.all(addresses.map(async address => { + try { + const balance = await getBalance(entropy, address) + + balanceInfo[address] = { balance } + } catch (error) { + // console.error(`Error retrieving balance for ${address}`, error); + balanceInfo[address] = { error: error.message } + } + })) + + return balanceInfo + } catch (error) { + // console.error(`There was an error getting balances for [${addresses}]`, error); + throw new Error(error.message) + } +} \ No newline at end of file diff --git a/src/flows/balance/cli.ts b/src/flows/balance/cli.ts index 78a441e0..2b8c7d83 100644 --- a/src/flows/balance/cli.ts +++ b/src/flows/balance/cli.ts @@ -1,8 +1,7 @@ import { initializeEntropy } from '../../common/initializeEntropy' import * as config from '../../config' import { debug } from '../../common/utils' - -const hexToBigInt = (hexString: string) => BigInt(hexString) +import { getBalance } from './balance' export async function cliGetBalance ({ address, password, endpoint }) { const storedConfig = await config.get() @@ -17,10 +16,8 @@ export async function cliGetBalance ({ address, password, endpoint }) { } const entropy = await initializeEntropy({ keyMaterial: account.data, password, endpoint }) + const balance = await getBalance(entropy, address) - const accountInfo = (await entropy.substrate.query.system.account(address)) as any - debug('accountInfo', accountInfo) - - return hexToBigInt(accountInfo.data.free).toString() + return balance } diff --git a/src/flows/balance/index.ts b/src/flows/balance/index.ts index 1ed0b5cf..13bd3eb0 100644 --- a/src/flows/balance/index.ts +++ b/src/flows/balance/index.ts @@ -1,9 +1,9 @@ import { initializeEntropy } from "../../common/initializeEntropy" import { print, debug, getSelectedAccount } from "../../common/utils" +import { getBalance } from "./balance"; -const hexToBigInt = (hexString: string) => BigInt(hexString) - - +// TO-DO setup flow method to provide options to allow users to select account, +// use external address, or get balances for all accounts in config export async function checkBalance ({ accounts, selectedAccount: selectedAccountAddress }, options) { const { endpoint } = options debug('endpoint', endpoint); @@ -11,8 +11,6 @@ export async function checkBalance ({ accounts, selectedAccount: selectedAccount const selectedAccount = getSelectedAccount(accounts, selectedAccountAddress) const entropy = await initializeEntropy({ keyMaterial: selectedAccount.data, endpoint }); const accountAddress = selectedAccountAddress - // @ts-ignore - const accountInfo = (await entropy.substrate.query.system.account(accountAddress)) as any - const freeBalance = hexToBigInt(accountInfo.data.free) + const freeBalance = await getBalance(entropy, accountAddress) print(`Address ${accountAddress} has a balance of: ${freeBalance.toLocaleString('en-US')} BITS`) } diff --git a/src/flows/balance/types.ts b/src/flows/balance/types.ts new file mode 100644 index 00000000..5bcc820c --- /dev/null +++ b/src/flows/balance/types.ts @@ -0,0 +1,8 @@ +export type BalanceInfoWithError = { + balance?: number + error?: Error +} + +export interface BalanceInfo { + [address: string]: BalanceInfoWithError +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 6a9fd63a..6811e13c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,9 +5,9 @@ export interface EntropyAccountConfig { } export interface EntropyAccountData { - debug: boolean + debug?: boolean seed: string - admin: EntropyAccount + admin?: EntropyAccount registration?: EntropyAccount deviceKey?: EntropyAccount programDev?: EntropyAccount diff --git a/tests/balance.test.ts b/tests/balance.test.ts new file mode 100644 index 00000000..8f1896b2 --- /dev/null +++ b/tests/balance.test.ts @@ -0,0 +1,85 @@ +import test from 'tape' +import Entropy, { wasmGlobalsReady } from '@entropyxyz/sdk' +// WIP: I'm seeing problems importing this? +import Keyring from '@entropyxyz/sdk/dist/keys/index' +import { + makeSeed, + promiseRunner, + sleep, + spinNetworkUp, + spinNetworkDown +} from './testing-utils' + +import { getBalance, getBalances } from '../src/flows/balance/balance' +import { initializeEntropy } from 'src/common/initializeEntropy' + +const networkType = 'two-nodes' + +// TODO: export charlieStashSeed +const richAddress = '5Ck5SLSHYac6WFt5UZRSsdJjwmpSZq85fd5TRNAdZQVzEAPT' + +test('getBalance + getBalances', async (t) => { + /* Setup */ + const run = promiseRunner(t) + await run('wasm', wasmGlobalsReady()) + await run('network up', spinNetworkUp(networkType)) + // this gets called after all tests are run + t.teardown(async () => { + await entropy.close() + await spinNetworkDown(networkType).catch((error) => + console.error('Error while spinning network down', error.message) + ) + }) + await sleep(process.env.GITHUB_WORKSPACE ? 30_000 : 5_000) + + const newSeed = makeSeed() + const entropy = await initializeEntropy({ keyMaterial: { seed: newSeed, debug: true }, endpoint: 'ws://127.0.0.1:9944', }) + const newAddress = entropy.keyring.accounts.registration.address + + await run('entropy ready', entropy.ready) + + /* getBalance */ + const newAddressBalance = await run( + 'getBalance (newSeed)', + getBalance(entropy, newAddress) + ) + + t.equal(newAddressBalance, 0, 'newSeed balance = 0') + + const richAddressBalance = await run( + 'getBalance (richAddress)', + getBalance(entropy, richAddress) + ) + + t.true(richAddressBalance > BigInt(10e10), 'richAddress balance >>> 0') + + /* getBalances */ + + const balances = await run( + 'getBalances', + getBalances(entropy, [newAddress, richAddress]) + ) + + t.deepEqual( + balances, + { + [newAddress]: {balance: newAddressBalance}, + [richAddress]: {balance: richAddressBalance} + }, + 'getBalances works' + ) + + const badAddresses = ['5Cz6BfUaxxXCA3jninzxdan4JdmC1NVpgkiRPYhXbhr', '5Cz6BfUaxxXCA3jninzxdan4JdmC1NVpgkiRPYhXbhrfnD'] + const balancesWithNoGoodAddress = await run( + 'getBalances::one good address', + getBalances(entropy, badAddresses) + ) + + badAddresses.forEach(addr => { + t.true(!!balancesWithNoGoodAddress[addr].error, `error field is populated for ${addr}`) + }) + // TODO: + // - test getBalances with 1 good address, 1 bung seed + + t.end() +}) diff --git a/tests/testing-utils/index.ts b/tests/testing-utils/index.ts new file mode 100644 index 00000000..a83525a2 --- /dev/null +++ b/tests/testing-utils/index.ts @@ -0,0 +1,93 @@ +import { spinNetworkUp, spinNetworkDown, } from "@entropyxyz/sdk/dev/testing-utils.mjs" +// import { spinNetworkUp, spinNetworkDown, } from "@entropyxyz/sdk/testing" +import * as readline from 'readline' +import { randomBytes } from 'crypto' + +export { + spinNetworkUp, + spinNetworkDown, +} + +/* Helper for wrapping promises which makes it super clear in logging if the promise + * resolves or threw. + * + * @param {any} t - an instance to tape runner + * @param {boolean} keepThrowing - toggle throwing + */ +export function promiseRunner(t: any, keepThrowing = false) { + // NOTE: this function swallows errors + return async function run( + message: string, + promise: Promise + ): Promise { + if (promise.constructor !== Promise) { + t.pass(message) + return Promise.resolve(promise) + } + + const startTime = Date.now() + return promise + .then((result) => { + const time = (Date.now() - startTime) / 1000 + const pad = Array(40 - message.length) + .fill('-') + .join('') + t.pass(`${message} ${pad} ${time}s`) + return result + }) + .catch((err) => { + t.error(err, message) + if (keepThrowing) throw err + }) + } +} + +const SLEEP_DONE = '▓' +const SLEEP_TODO = '░' + +export function sleep(durationInMs: number) { + return new Promise((resolve) => { + let count = 0 + + readline.cursorTo(process.stdout, 2) + + const steps = Math.min(Math.round(durationInMs / 1000), 80) + const stepLength = durationInMs / steps + + console.log('') // write blank link to overwrite + const interval = setInterval(step, stepLength) + + function step() { + count++ + + if (count >= steps) { + clearInterval(interval) + + undoLastLine() + console.log(`sleep (${durationInMs / 1000}s)`) + resolve('DONE') + return + } + + undoLastLine() + console.log( + [ + 'sleep ', + ...Array(count).fill(SLEEP_DONE), + ...Array(steps - count).fill(SLEEP_TODO), + '\n', + ].join('') + ) + } + }) +} +function undoLastLine() { + readline.moveCursor(process.stdout, 0, -1) + readline.cursorTo(process.stdout, 0) + readline.clearLine(process.stdout, 0) + readline.cursorTo(process.stdout, 4) // indent +} + +export function makeSeed () { + return '0x' + randomBytes(32).toString('hex') +} diff --git a/tsconfig.json b/tsconfig.json index 149992c7..a0d38874 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "baseUrl": "./", "target": "ES6", "skipLibCheck": true, "noEmit": true, @@ -10,7 +11,10 @@ ], "tsBuildInfoFile": ".tsbuildinfo", "moduleResolution": "node", - "module": "Node16" + "module": "Node16", + "paths": { + "@entropyxyz/sdk/*": ["node_modules/@entropyxyz/sdk/*"] + } }, "exclude": [ "node_modules", diff --git a/yarn.lock b/yarn.lock index 714516b7..9cd746ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17,10 +17,10 @@ resolved "https://registry.yarnpkg.com/@entropyxyz/entropy-protocol-web/-/entropy-protocol-web-0.2.0.tgz#b9478438386fefb4b821dbac95ec81d251d2dd55" integrity sha512-lLa/lLNJnwH1R8fJvLlUn1kw7d4Rbnt9LjhUC69HKxkU69J+bw/EY6fAjBnpVbgNmqCnYpf/DBLtMyOayZeNDQ== -"@entropyxyz/sdk@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@entropyxyz/sdk/-/sdk-0.2.1.tgz#0e55eb29b0b4952c083689f6d045b5d7ef670827" - integrity sha512-tH5N2jQFiFKwM/s2P771Ux+up1fx+TGNalUIbGYdAqleJuV4yYlyzrvunU42Cjw6ikFDr1BQKvXOpWeMUMpbSA== +"@entropyxyz/sdk@^0.2.2-0": + version "0.2.2-0" + resolved "https://registry.yarnpkg.com/@entropyxyz/sdk/-/sdk-0.2.2-0.tgz#6c70b8db69bcd934aab3cc2c4d961e175dfc7ff6" + integrity sha512-qQsobti5I9/wEIslVEaLuXe/QalcMbp7bf/QePzN0tUqCzcYXuwz4kTIJEaQq1vdu0peDTJ3BeM2WVBve+iK6w== dependencies: "@entropyxyz/entropy-protocol-nodejs" "^0.2.0" "@entropyxyz/entropy-protocol-web" "^0.2.0" @@ -3031,6 +3031,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +readline@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/readline/-/readline-1.3.0.tgz#c580d77ef2cfc8752b132498060dc9793a7ac01c" + integrity sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg== + regenerator-runtime@^0.14.1: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"