diff --git a/package.json b/package.json index 7603963..4d19a6b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "typescript": "^5.2.2", "undici": "^5.27.0", "upath": "^2.0.1", + "viem": "^1.20.3", "webpack": "^5.88.2", "webpack-merge": "^5.9.0", "webpack-virtual-modules": "^0.5.0" diff --git a/src/commands/add-evm-account.ts b/src/commands/add-evm-account.ts new file mode 100644 index 0000000..d12a0b9 --- /dev/null +++ b/src/commands/add-evm-account.ts @@ -0,0 +1,100 @@ +import { Flags } from '@oclif/core' +import { getContract } from '@phala/sdk' + +import PhatBaseCommand, { type ParsedFlags, type BrickProfileContract } from '../lib/PhatBaseCommand' + +export default class AddEvmAccount extends PhatBaseCommand { + static description = 'Add EVM accounts' + + static args = { + ...PhatBaseCommand.args + } + + static flags = { + ...PhatBaseCommand.flags, + evmRpcEndpoint: Flags.string({ + description: 'EVM RPC endpoint', + required: true, + }), + } + + public async run(): Promise { + const { evmRpcEndpoint } = this.parsedFlags as ParsedFlags & { + evmRpcEndpoint: string + } + + // Verify the RPC endpoint + await this.verifyRpcEndpoint(evmRpcEndpoint) + + const pair = await this.getDecodedPair({ + suri: this.parsedFlags.suri || process.env.POLKADOT_WALLET_SURI, + accountFilePath: this.parsedFlags.accountFilePath || process.env.POLKADOT_WALLET_ACCOUNT_FILE, + accountPassword: this.parsedFlags.accountPassword || process.env.POLKADOT_WALLET_ACCOUNT_PASSWORD, + }) + + // Step 1: Connect to the endpoint. + const endpoint = this.getEndpoint() + const [apiPromise, registry, cert] = await this.connect({ + endpoint, + pair, + }) + + // Step 2: Query the brick profile contract id. + this.action.start('Querying your Brick Profile contract ID') + const brickProfileContractId = await this.getBrickProfileContractId({ + endpoint, + registry, + apiPromise, + pair, + cert, + }) + this.action.succeed(`Your Brick Profile contract ID: ${brickProfileContractId}`) + + // Step 3: generate evm account + try { + this.action.start('Adding evm account') + const brickProfileAbi = await this.loadAbiByContractId( + registry, + brickProfileContractId + ) + const brickProfile = await getContract({ + client: registry, + contractId: brickProfileContractId, + abi: brickProfileAbi, + }) as BrickProfileContract + const { output } = await brickProfile.query.externalAccountCount(cert.address, { + cert, + }) + if (output.isErr) { + throw new Error(output.asErr.toString()) + } + const externalAccountCount = output.asOk.toNumber() + + const result = await brickProfile.send.generateEvmAccount( + { cert, address: pair.address, pair }, + evmRpcEndpoint + ) + await result.waitFinalized(async () => { + const { output } = await brickProfile.query.externalAccountCount(cert.address, { + cert, + }) + return output.isOk && output.asOk.toNumber() === externalAccountCount + 1 + }) + + const { output: evmAccountAddressOutput } = await brickProfile.query.getEvmAccountAddress( + cert.address, + { cert }, + externalAccountCount + ) + if (evmAccountAddressOutput.isErr) { + throw new Error(evmAccountAddressOutput.asErr.toString()) + } + const evmAddress = evmAccountAddressOutput.asOk.asOk.toHex() + this.action.succeed(`Added successfully, your evm address is: ${evmAddress}`) + process.exit(0) + } catch (error) { + this.action.fail('Failed to add evm account.') + return this.error(error as Error) + } + } +} diff --git a/src/commands/create-brick-profile.ts b/src/commands/create-brick-profile.ts new file mode 100644 index 0000000..6cff1d0 --- /dev/null +++ b/src/commands/create-brick-profile.ts @@ -0,0 +1,215 @@ +import { Flags } from '@oclif/core' +import type { Struct, u128 } from '@polkadot/types' +import { PinkContractPromise, OnChainRegistry, type CertificateData } from '@phala/sdk' +import { type KeyringPair } from '@polkadot/keyring/types' + +import PhatBaseCommand, { type ParsedFlags, type BrickProfileFactoryContract, type BrickProfileContract } from '../lib/PhatBaseCommand' +import { bindWaitPRuntimeFinalized } from '../lib/utils' + +interface PartialAccountQueryResult extends Struct { + data: { + free: u128 + } +} + +export default class CreateBrickProfile extends PhatBaseCommand { + static description = 'Create brick profile' + + static args = { + ...PhatBaseCommand.args + } + + static flags = { + ...PhatBaseCommand.flags, + evmRpcEndpoint: Flags.string({ + description: 'EVM RPC endpoint', + required: false, + }), + } + + public async run(): Promise { + const { evmRpcEndpoint } = this.parsedFlags as ParsedFlags & { + evmRpcEndpoint: string + } + + if (evmRpcEndpoint) { + await this.verifyRpcEndpoint(evmRpcEndpoint) + } + + const pair = await this.getDecodedPair({ + suri: this.parsedFlags.suri || process.env.POLKADOT_WALLET_SURI, + accountFilePath: this.parsedFlags.accountFilePath || process.env.POLKADOT_WALLET_ACCOUNT_FILE, + accountPassword: this.parsedFlags.accountPassword || process.env.POLKADOT_WALLET_ACCOUNT_PASSWORD, + }) + + // Step 1: Connect to the endpoint. + const endpoint = this.getEndpoint() + const [apiPromise, registry, cert, type] = await this.connect({ + endpoint, + pair, + }) + + // Step 2: Check balance + const account = await apiPromise.query.system.account(cert.address) + const balance = Number(account.data.free.toBigInt() / BigInt(1e12)) + if (balance < 50) { + this.action.fail(`Insufficient on-chain balance, please go to ${type.isDevelopment || type.isLocal ? 'https://phala.network/faucet' : 'https://docs.phala.network/introduction/basic-guidance/get-pha-and-transfer'} to get more than 50 PHA before continuing the process.`) + this.exit(0) + } + try { + this.action.start('Creating your brick profile') + const brickProfileFactoryContractId = await this.getBrickProfileFactoryContractId(endpoint) + const brickProfileFactoryAbi = await this.loadAbiByContractId( + registry, + brickProfileFactoryContractId + ) + const brickProfileFactoryContractKey = await registry.getContractKeyOrFail( + brickProfileFactoryContractId + ) + const brickProfileFactory: BrickProfileFactoryContract = new PinkContractPromise( + apiPromise, + registry, + brickProfileFactoryAbi, + brickProfileFactoryContractId, + brickProfileFactoryContractKey + ) + const waitForPRuntimeFinalized = bindWaitPRuntimeFinalized(registry) + // Step 3: create user profile + await waitForPRuntimeFinalized( + brickProfileFactory.send.createUserProfile( + { cert, address: pair.address, pair }, + ), + async function () { + const { output } = await brickProfileFactory.query.getUserProfileAddress(cert.address, { + cert, + }) + const created = output && output.isOk && output.asOk.isOk + if (!created) { + return false + } + const result = await registry.getContractKey( + output.asOk.asOk.toHex() + ) + if (result) { + return true + } + return false + } + ) + // Step 4: query profile + const { output } = await brickProfileFactory.query.getUserProfileAddress(cert.address, { + cert, + }) + if (output.isErr) { + throw new Error(output.asErr.toString()) + } + const brickProfileContractId = output.asOk.asOk.toHex() + const brickProfileAbi = await this.loadAbiByContractId( + registry, + brickProfileContractId + ) + const brickProfileContractKey = await registry.getContractKeyOrFail( + brickProfileContractId + ) + const brickProfile: BrickProfileContract = new PinkContractPromise( + apiPromise, + registry, + brickProfileAbi, + brickProfileContractId, + brickProfileContractKey + ) + // Step 5: unsafeConfigureJsRunner + const jsRunnerContractId = await this.getJsRunnerContractId(endpoint) + await this.unsafeConfigureJsRunner({ + registry, + contract: brickProfile, + jsRunnerContractId, + pair, + cert, + }) + // Step 6: unsafeGenerateEtherAccount + const { output: queryCount } = await brickProfile.query.externalAccountCount(cert.address, { + cert, + }) + if (queryCount.isErr) { + throw new Error(queryCount.asErr.toString()) + } + const externalAccountCount = output.asOk.toNumber() + if (externalAccountCount === 0) { + await this.unsafeGenerateEtherAccount({ + registry, + contract: brickProfile, + externalAccountCount, + evmRpcEndpoint: evmRpcEndpoint || (type.isDevelopment || type.isLocal) ? 'https://polygon-mumbai.g.alchemy.com/v2/YWlujLKt0nSn5GrgEpGCUA0C_wKV1sVQ' : 'https://polygon-mainnet.g.alchemy.com/v2/W1kyx17tiFQFT2b19mGOqppx90BLHp0a', + pair, + cert + }) + } + this.action.succeed(`Created successfully.`) + process.exit(0) + } catch (error) { + this.action.fail('Failed to create brick profile.') + return this.error(error as Error) + } + } + + async unsafeGenerateEtherAccount({ + registry, + contract, + externalAccountCount, + evmRpcEndpoint, + pair, + cert, + }: { + registry: OnChainRegistry + contract: BrickProfileContract + externalAccountCount: number + evmRpcEndpoint: string + pair: KeyringPair + cert: CertificateData + }) { + const waitForPRuntimeFinalized = bindWaitPRuntimeFinalized(registry) + await waitForPRuntimeFinalized( + contract.send.generateEvmAccount( + { cert, address: pair.address, pair }, + evmRpcEndpoint + ), + async function () { + const { output } = await contract.query.externalAccountCount(cert.address, { + cert, + }) + return output.isOk && output.asOk.toNumber() === externalAccountCount + 1 + } + ) + } + + async unsafeConfigureJsRunner({ + registry, + contract, + jsRunnerContractId, + pair, + cert, + }: { + registry: OnChainRegistry + contract: BrickProfileContract + jsRunnerContractId: string + pair: KeyringPair + cert: CertificateData + }) { + const waitForPRuntimeFinalized = bindWaitPRuntimeFinalized(registry) + await waitForPRuntimeFinalized( + contract.send.config( + { cert, address: pair.address, pair }, + jsRunnerContractId + ), + async function () { + const { output } = await contract.query.getJsRunner(cert.address, { cert }) + return ( + output.isOk && + output.asOk.isOk && + output.asOk.asOk.toHex() === jsRunnerContractId + ) + } + ) + } +} diff --git a/src/commands/list-evm-accounts.ts b/src/commands/list-evm-accounts.ts new file mode 100644 index 0000000..878c3e8 --- /dev/null +++ b/src/commands/list-evm-accounts.ts @@ -0,0 +1,87 @@ +import { + PinkContractPromise, +} from '@phala/sdk' +import chalk from 'chalk' + +import PhatBaseCommand from '../lib/PhatBaseCommand' +import type { BrickProfileContract } from '../lib/PhatBaseCommand' + +export default class ListEvmAccounts extends PhatBaseCommand { + static description = 'List EVM accounts' + + static args = { + ...PhatBaseCommand.args + } + + static flags = { + ...PhatBaseCommand.flags + } + + public async run(): Promise { + const pair = await this.getDecodedPair({ + suri: this.parsedFlags.suri || process.env.POLKADOT_WALLET_SURI, + accountFilePath: this.parsedFlags.accountFilePath || process.env.POLKADOT_WALLET_ACCOUNT_FILE, + accountPassword: this.parsedFlags.accountPassword || process.env.POLKADOT_WALLET_ACCOUNT_PASSWORD, + }) + + // Step 1: Connect to the endpoint. + const endpoint = this.getEndpoint() + const [apiPromise, registry, cert] = await this.connect({ + endpoint, + pair, + }) + + // Step 2: Query the brick profile contract id. + this.action.start('Querying your Brick Profile contract ID') + const brickProfileContractId = await this.getBrickProfileContractId({ + endpoint, + registry, + apiPromise, + pair, + cert, + }) + this.action.succeed(`Your Brick Profile contract ID: ${brickProfileContractId}`) + + // Step 3: Querying your external accounts + try { + this.action.start('Querying your external accounts') + const brickProfileAbi = await this.loadAbiByContractId( + registry, + brickProfileContractId + ) + const brickProfileContractKey = await registry.getContractKeyOrFail( + brickProfileContractId + ) + const brickProfile: BrickProfileContract = new PinkContractPromise( + apiPromise, + registry, + brickProfileAbi, + brickProfileContractId, + brickProfileContractKey + ) + const { output } = await brickProfile.query.getAllEvmAccounts(cert.address, { + cert, + }) + if (output.isErr) { + throw new Error(output.asErr.toString()) + } + if (output.asOk.isErr) { + throw new Error(output.asOk.asErr.toString()) + } + this.action.stop() + const accounts = output.asOk.asOk.map((i) => { + const obj = i.toJSON() + return { + id: obj.id, + address: obj.address, + rpcEndpoint: obj.rpc, + } + }) + accounts.map(account => this.log(`[${account.id}] ${account.address}. ${chalk.dim(account.rpcEndpoint)}`)) + process.exit(0) + } catch (error) { + this.action.fail('Failed to query your external accounts.') + return this.error(error as Error) + } + } +} diff --git a/src/commands/update.ts b/src/commands/update.ts index 70d466b..5d78f74 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,9 +1,7 @@ import fs from 'node:fs' import { Flags } from '@oclif/core' import type { Result, Struct, u16, Text, Bool } from '@polkadot/types' -import { - PinkContractPromise, -} from '@phala/sdk' +import { PinkContractPromise } from '@phala/sdk' import inquirer from 'inquirer' import PhatBaseCommand, { type ParsedFlags } from '../lib/PhatBaseCommand' diff --git a/src/lib/PhatBaseCommand.ts b/src/lib/PhatBaseCommand.ts index af41590..6e8cf72 100644 --- a/src/lib/PhatBaseCommand.ts +++ b/src/lib/PhatBaseCommand.ts @@ -13,6 +13,7 @@ import { PinkContractPromise, PinkContractQuery, type CertificateData, + type PinkContractTx, } from '@phala/sdk' import { ApiPromise } from '@polkadot/api' import { Abi } from '@polkadot/api-contract' @@ -20,7 +21,8 @@ import { waitReady } from '@polkadot/wasm-crypto' import { Keyring } from '@polkadot/keyring' import { type KeyringPair } from '@polkadot/keyring/types' import type { Result, Vec, u64, u8, Text, Struct } from '@polkadot/types' -import type { AccountId } from '@polkadot/types/interfaces' +import type { AccountId, ChainType, Hash } from '@polkadot/types/interfaces' +import { createPublicClient, http } from 'viem' import { MAX_BUILD_SIZE, @@ -43,6 +45,7 @@ export interface ParsedFlags { readonly coreSettings: string readonly pruntimeUrl: string readonly externalAccountId: string + readonly jsRunner: string } interface ParsedArgs { @@ -55,12 +58,37 @@ export interface ExternalAccountCodec extends Struct { rpc: Text } +export type BrickProfileFactoryContract = PinkContractPromise< + { + version: PinkContractQuery<[], u64[]> + owner: PinkContractQuery<[], AccountId> + userCount: PinkContractQuery<[], u64> + profileCodeHash: PinkContractQuery<[], Hash> + getUserProfileAddress: PinkContractQuery<[], Result> + }, + { + setProfileCodeHash: PinkContractTx<[string]> + createUserProfile: PinkContractTx<[]> + } +> + + export type BrickProfileContract = PinkContractPromise< { + getJsRunner: PinkContractQuery<[], Result> getAllEvmAccounts: PinkContractQuery< [], Result, any> > + workflowCount: PinkContractQuery<[], u64> + externalAccountCount: PinkContractQuery<[], u64> + getEvmAccountAddress: PinkContractQuery< + [number | u64], + Result + > + }, + { + generateEvmAccount: PinkContractTx<[string | Text]> } > @@ -105,7 +133,7 @@ export default abstract class PhatBaseCommand extends BaseCommand { required: false, }), brickProfileFactory: Flags.string({ - description: 'Brick profile factory contract address', + description: 'Brick profile factory contract id', required: false, default: '', }), @@ -133,6 +161,11 @@ export default abstract class PhatBaseCommand extends BaseCommand { char: 'b', default: true, }), + jsRunner: Flags.string({ + description: 'JS runner contract id', + required: false, + default: '', + }), } public parsedFlags!: ParsedFlags @@ -193,6 +226,20 @@ export default abstract class PhatBaseCommand extends BaseCommand { return brickProfileFactoryContractId } + async getJsRunnerContractId(endpoint: string) { + let jsRunnerContractId = this.parsedFlags.jsRunner + if (!jsRunnerContractId) { + if (endpoint === 'wss://poc6.phala.network/ws') { + jsRunnerContractId = '0x15fd4cc6e96b1637d46bd896f586e5de7c6835d8922d9d43f3c1dd5b84883d79' + } else if (endpoint === 'wss://api.phala.network/ws') { + jsRunnerContractId = '0xd0b2ee3ac67b363734c5105a275b5de964ecc4a304d98c2cc49a8d417331ade2' + } else { + jsRunnerContractId = await this.promptJsRunner() + } + } + return jsRunnerContractId + } + async getBrickProfileContractId({ endpoint, registry, @@ -239,7 +286,7 @@ export default abstract class PhatBaseCommand extends BaseCommand { }: { endpoint: string pair: KeyringPair - }): Promise<[ApiPromise, OnChainRegistry, CertificateData]> { + }): Promise<[ApiPromise, OnChainRegistry, CertificateData, ChainType]> { this.action.start(`Connecting to the endpoint: ${endpoint}`) const registry = await getClient({ transport: endpoint, @@ -251,7 +298,7 @@ export default abstract class PhatBaseCommand extends BaseCommand { if (type.isDevelopment || type.isLocal) { this.log(chalk.yellow(`\nYou are connecting to a testnet.\n`)) } - return [registry.api, registry, cert] + return [registry.api, registry, cert, type] } async getRollupAbi() { @@ -381,7 +428,7 @@ export default abstract class PhatBaseCommand extends BaseCommand { } async promptRpc( - message = 'Please enter your client RPC URL' + message = 'Please enter your EVM RPC URL' ): Promise { const { rpc } = await inquirer.prompt([ { @@ -419,6 +466,19 @@ export default abstract class PhatBaseCommand extends BaseCommand { return brickProfileFactory } + async promptJsRunner( + message = 'Please enter the js runner contract ID' + ): Promise { + const { jsRunner } = await inquirer.prompt([ + { + name: 'jsRunner', + type: 'input', + message, + }, + ]) + return jsRunner + } + async getDecodedPair({ suri, accountFilePath, accountPassword }: { suri?: string, accountFilePath?: string, accountPassword?: string }): Promise { await waitReady() const keyring = new Keyring({ type: 'sr25519' }) @@ -557,4 +617,18 @@ export default abstract class PhatBaseCommand extends BaseCommand { return abi } + async verifyRpcEndpoint(endpoint: string) { + try { + this.action.start(`Verifying the RPC endpoint: ${endpoint}`) + const client = createPublicClient({ + transport: http(endpoint) + }) + await client.getChainId() + this.action.succeed() + } catch (error) { + this.action.fail('Failed to verify the RPC endpoint.') + return this.error(error as Error) + } + } + } diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..9e0d17a --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,3 @@ +export interface WaitPRuntimeFinalized { + (awaitable: Promise, predicate?: () => Promise): Promise +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 62d3e78..eb982b2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,7 +1,42 @@ import os from 'node:os' import upath from 'upath' +import type { OnChainRegistry } from '@phala/sdk' + +import { WaitPRuntimeFinalized } from './types' export function resolveToAbsolutePath(inputPath: string): string { const regex = /^~(?=$|[/\\])/ return upath.resolve(inputPath.replace(regex, os.homedir())) } + +export function bindWaitPRuntimeFinalized( + phatRegistry: OnChainRegistry, + confirmations = 10, + pollingIntervalMs = 1000 +): WaitPRuntimeFinalized { + return async function waitPRuntimeFinalized( + awaitable: Promise, + predicate?: () => Promise + ) { + const { blocknum: initBlockNum } = await phatRegistry.phactory.getInfo({}) + const result = await awaitable + while (true) { + const { blocknum } = await phatRegistry.phactory.getInfo({}) + if (blocknum > initBlockNum + confirmations) { + if (!predicate) { + return result + } + throw new Error( + `Wait for transaction finalized in PRuntime but timeout after ${confirmations} blocks.` + ) + } + if (predicate) { + const predicateResult = await predicate() + if (predicateResult) { + return result + } + } + await new Promise((resolve) => setTimeout(resolve, pollingIntervalMs)) + } + } +} diff --git a/yarn.lock b/yarn.lock index 6c3f25c..7dc6d2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6050,6 +6050,20 @@ validate-npm-package-name@^5.0.0: dependencies: builtins "^5.0.0" +viem@^1.20.3: + version "1.20.3" + resolved "https://registry.yarnpkg.com/viem/-/viem-1.20.3.tgz#8b8360daee622295f5385949c02c86d943d14e0f" + integrity sha512-7CrmeCb2KYkeCgUmUyb1hsf+IX/PLwi+Np+Vm4YUTPeG82y3HRSgGHSaCOp3d0YtR2kXD3nv9y5kE7LBFE+wWw== + dependencies: + "@adraffy/ens-normalize" "1.10.0" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.2" + "@scure/bip32" "1.3.2" + "@scure/bip39" "1.2.1" + abitype "0.9.8" + isows "1.0.3" + ws "8.13.0" + viem@^1.5.0: version "1.19.9" resolved "https://registry.yarnpkg.com/viem/-/viem-1.19.9.tgz#a11f3ad4a3323994ebd2010dbc659d1a2b12e583"