From aff15240bca716852c22ccf6fd0c51ed53b38c18 Mon Sep 17 00:00:00 2001 From: Adam A Date: Wed, 8 May 2024 22:24:17 +0300 Subject: [PATCH] wip: refactor(agent): improve structure --- fadroma.test.ts | 2 +- fadroma.ts | 20 +- packages/agent/agent.cli.mjs | 2 +- packages/agent/commands.ts | 135 +++ packages/agent/index.node.ts | 151 +--- packages/agent/index.ts | 35 +- packages/agent/src/Agent.ts | 146 +--- packages/agent/src/Backend.ts | 2 +- packages/agent/src/Batch.ts | 2 + packages/agent/src/Block.ts | 83 +- packages/agent/src/Chain.ts | 315 +------ packages/agent/src/Compute.ts | 827 ------------------ packages/agent/src/Connection.ts | 56 +- packages/agent/src/Store.ts | 52 -- packages/agent/src/Transaction.ts | 21 +- .../Compile.node.ts} | 5 +- packages/agent/src/compute/Compile.ts | 138 +++ packages/agent/src/compute/Contract.ts | 272 ++++++ packages/agent/src/compute/Source.ts | 137 +++ packages/agent/src/compute/Upload.ts | 178 ++++ packages/agent/src/dlt/Bank.ts | 81 ++ packages/agent/src/{ => dlt}/Governance.ts | 18 +- packages/agent/src/{ => dlt}/Staking.ts | 9 +- packages/agent/src/{ => dlt}/Token.ts | 2 +- .../stub/{stub-backend.ts => StubBackend.ts} | 8 +- .../stub/{stub-chain.ts => StubChain.ts} | 56 +- .../{stub-compiler.ts => StubCompiler.ts} | 4 +- .../{stub-identity.ts => StubIdentity.ts} | 23 +- packages/agent/stub/{stub-tx.ts => StubTx.ts} | 4 +- packages/agent/stub/stub.ts | 10 +- packages/agent/test/agent-chain.test.ts | 44 +- packages/agent/test/agent-compute.test.ts | 8 +- packages/agent/tsconfig.json | 32 +- packages/deploy/deploy.ts | 432 +++++++++ packages/deploy/package.json | 5 + pnpm-lock.yaml | 10 +- stores.test.ts | 9 +- toolbox | 2 +- 38 files changed, 1726 insertions(+), 1610 deletions(-) create mode 100644 packages/agent/commands.ts delete mode 100644 packages/agent/src/Compute.ts delete mode 100644 packages/agent/src/Store.ts rename packages/agent/src/{Compute.node.ts => compute/Compile.node.ts} (91%) create mode 100644 packages/agent/src/compute/Compile.ts create mode 100644 packages/agent/src/compute/Contract.ts create mode 100644 packages/agent/src/compute/Source.ts create mode 100644 packages/agent/src/compute/Upload.ts create mode 100644 packages/agent/src/dlt/Bank.ts rename packages/agent/src/{ => dlt}/Governance.ts (51%) rename packages/agent/src/{ => dlt}/Staking.ts (53%) rename packages/agent/src/{ => dlt}/Token.ts (98%) rename packages/agent/stub/{stub-backend.ts => StubBackend.ts} (93%) rename packages/agent/stub/{stub-chain.ts => StubChain.ts} (74%) rename packages/agent/stub/{stub-compiler.ts => StubCompiler.ts} (83%) rename packages/agent/stub/{stub-identity.ts => StubIdentity.ts} (83%) rename packages/agent/stub/{stub-tx.ts => StubTx.ts} (92%) create mode 100644 packages/deploy/deploy.ts create mode 100644 packages/deploy/package.json diff --git a/fadroma.test.ts b/fadroma.test.ts index ec7f61f667..5d8712ed7e 100644 --- a/fadroma.test.ts +++ b/fadroma.test.ts @@ -3,7 +3,7 @@ along with this program. If not, see . **/ import { Suite } from '@hackbg/ensuite' export default new Suite([ - ['agent', () => import('./packages/agent/agent.test')], + ['agent', () => import('./packages/agent/index.test')], ['stores', () => import('./stores.test')], ['scrt', () => import('./packages/scrt/scrt.test')], ['cw', () => import('./packages/cw/cw.test')], diff --git a/fadroma.ts b/fadroma.ts index 05c340812d..103a980203 100644 --- a/fadroma.ts +++ b/fadroma.ts @@ -21,7 +21,7 @@ export * from './fadroma.browser' // And more! import type { ChainId, CodeHash } from '@fadroma/agent' -import { Core, Chain, Program, Deploy, Store } from '@fadroma/agent' +import { Console, bold, timestamp, Chain, Store } from '@fadroma/agent' import { getProject, ProjectPrompter } from '@fadroma/create' import Commands from '@hackbg/cmds' import { FileFormat } from '@hackbg/file' @@ -30,7 +30,7 @@ import { SyncFS } from '@hackbg/file' import { fileURLToPath } from 'node:url' import { basename } from 'node:path' -const console = new Core.Console('@hackbg/fadroma') +const console = new Console('@hackbg/fadroma') export default function main (...args: any) { console.debug('Running main...') @@ -174,7 +174,7 @@ export async function runScript (context?: { project?: Project, script?: string, if (typeof main === 'function') { return main(project, ...args||[]) } else { - console.error(`The default export of ${Core.bold(scriptPath.short)} is not a function`) + console.error(`The default export of ${bold(scriptPath.short)} is not a function`) process.exit(1) } } @@ -224,14 +224,14 @@ export function exportDeployment ( // If passed a directory, generate file name const exportPath = new SyncFS.Path(path) const exportFile = exportPath.isDirectory() - ? new SyncFS.File(exportPath, `${deployment.name}_@_${Core.timestamp()}.json`) + ? new SyncFS.File(exportPath, `${deployment.name}_@_${timestamp()}.json`) : new SyncFS.File(exportPath) // Serialize and write the deployment. const state = deployment.serialize() exportFile.setFormat(FileFormat.JSON).makeParent().save(state) console.log( 'saved', Object.keys(state).length, - 'contracts to', Core.bold(exportFile.short) + 'contracts to', bold(exportFile.short) ) } @@ -260,12 +260,12 @@ export class JSONFileUploadStore extends Store.UploadStore { const uploaded = receipt.load() as { codeId: string } if (uploaded.codeId) { this.log( - 'loading code id', Core.bold(String(uploaded.codeId)), - 'from', Core.bold(receipt.shortPath) + 'loading code id', bold(String(uploaded.codeId)), + 'from', bold(receipt.shortPath) ) super.set(codeHash, uploaded) } else { - this.log.warn('no codeId field found in', Core.bold(receipt.shortPath)) + this.log.warn('no codeId field found in', bold(receipt.shortPath)) } } return super.get(codeHash) @@ -310,9 +310,9 @@ export class JSONFileDeployStore extends Store.DeployStore { const state = receipt.load() this.log( 'loading code id', - Core.bold(name), + bold(name), 'from', - Core.bold(receipt.shortPath) + bold(receipt.shortPath) ) super.set(name, state) } diff --git a/packages/agent/agent.cli.mjs b/packages/agent/agent.cli.mjs index ea6b22c463..94336b584c 100755 --- a/packages/agent/agent.cli.mjs +++ b/packages/agent/agent.cli.mjs @@ -18,7 +18,7 @@ const CLI = await import("./agent.dist.js").catch(async e=>{ new Console().debug('Compiling TypeScript...') await import("@ganesha/esbuild") const t0 = performance.now() - const module = await import("./agent.ts") + const module = await import("./commands.ts") new Console().debug('Compiled TypeScript in', ((performance.now() - t0)/1000).toFixed(3)+'s') return module }).then(module=>module.default) diff --git a/packages/agent/commands.ts b/packages/agent/commands.ts new file mode 100644 index 0000000000..2a3c2012af --- /dev/null +++ b/packages/agent/commands.ts @@ -0,0 +1,135 @@ +import CLI from '@hackbg/cmds' +import { bold, bech32, bech32m, randomBech32m, base16 } from './src/Util' + +/** Base command line interface for Fadroma Agent. */ +export default class AgentCLI extends CLI { + + constructor (...args: ConstructorParameters) { + super(...args) + this.log.label = `` + } + + bech32 = this.command({ + name: 'random-bech32', + info: 'create a random bech32 address', + args: 'PREFIX [LENGTH]' + }, (prefix: string, length: string|number = "20") => { + if (!prefix) { + this.log.error(bold('Pass a prefix to generate a bech32 address')) + process.exit(1) + } + if (isNaN(Number(length))) { + this.log.error(bold(`"${length}" is not a number. Pass a valid length to generate a bech32 address.`)) + process.exit(1) + } + this.log + .log(`${length} byte random bech32:`, bold(randomBech32m(prefix, Number(length)))) + }) + + bech32m = this.command({ + name: 'random-bech32m', + info: 'create a random bech32m address', + args: 'PREFIX [LENGTH]' + }, (prefix: string, length: string|number = "20") => { + if (!prefix) { + this.log.error(bold('Pass a prefix to generate a bech32m address')) + process.exit(1) + } + if (isNaN(Number(length))) { + this.log.error(bold(`"${length}" is not a number. Pass a valid length to generate a bech32m address.`)) + process.exit(1) + } + this.log + .log(`${length} byte random bech32m:`, bold(randomBech32m(prefix, Number(length)))) + }) + + bech32ToHex = this.command({ + name: 'from-bech32', + info: 'convert a bech32 address to a hex string', + args: 'ADDRESS' + }, (address: string) => { + if (!address) { + this.log.error(bold('Pass an address to convert it to hexadecimal.')) + process.exit(1) + } + let prefix, words + try { + ;({ prefix, words } = bech32.decode(address)) + } catch (e) { + this.log.error(bold('Failed to decode this address.')) + this.log.error((e as any).message) + process.exit(1) + } + this.log + .info('Prefix: ', bold(prefix)) + .info('Words: ', bold(base16.encode(new Uint8Array(words)))) + .log('Original:', bold(base16.encode(new Uint8Array(bech32m.fromWords(words))))) + }) + + bech32mToHex = this.command({ + name: 'from-bech32m', + info: 'convert a bech32m address to a hex string', + args: 'ADDRESS' + }, (address: string) => { + if (!address) { + this.log.error(bold('Pass an address to convert it to hexadecimal.')) + process.exit(1) + } + let prefix, words + try { + ;({ prefix, words } = bech32m.decode(address)) + } catch (e) { + this.log.error(bold('Failed to decode this address.')) + this.log.error((e as any).message) + process.exit(1) + } + this.log + .info('Prefix: ', bold(prefix)) + .info('Words: ', bold(base16.encode(new Uint8Array(words)))) + .log('Original:', bold(base16.encode(new Uint8Array(bech32m.fromWords(words))))) + }) + + hexToBech32 = this.command({ + name: 'to-bech32', + info: 'convert a hex string to a bech32 address', + args: 'PREFIX DATA' + }, (prefix: string, data: string) => { + if (!prefix) { + this.log.error(bold('Pass a prefix and a valid hex string to generate bech32')) + process.exit(1) + } + let dataBin + try { + dataBin = base16.decode(data.toUpperCase()) + } catch (e) { + this.log.error(bold('Pass a prefix and a valid hex string to generate bech32')) + process.exit(1) + } + this.log + .info('input: ', bold(data)) + .log('bech32:', bold(bech32.encode(prefix, bech32.toWords(dataBin)))) + }) + + hexToBech32m = this.command({ + name: 'to-bech32m', + info: 'convert a hex string to a bech32m address', + args: 'PREFIX DATA' + }, (prefix: string, data: string) => { + if (!prefix) { + this.log.error(bold('Pass a prefix and a valid hex string to generate bech32m')) + process.exit(1) + } + let dataBin + try { + dataBin = base16.decode(data.toUpperCase()) + } catch (e) { + this.log.error(bold('Pass a prefix and a valid hex string to generate bech32m')) + process.exit(1) + } + this.log + .info('input: ', bold(data)) + .log('bech32m:', bold(bech32m.encode(prefix, bech32m.toWords(dataBin)))) + }) + +} + diff --git a/packages/agent/index.node.ts b/packages/agent/index.node.ts index 70401ac566..d5fb240d85 100644 --- a/packages/agent/index.node.ts +++ b/packages/agent/index.node.ts @@ -20,152 +20,7 @@ export * from './index' -import { _$_HACK_$_ } from './src/Agent' -import { LocalCompiledCode } from './src/Compute.node' +// Monkey patch for fetching from local FS. See docstring. +import { _$_HACK_$_ } from './src/compute/Upload' +import { LocalCompiledCode } from './src/compute/Compile.node' _$_HACK_$_.CompiledCode = LocalCompiledCode - -export * as Compute from './src/Compute' - -import { - Console, - base16, - bech32, - bech32m, - randomBech32, - randomBech32m, - bold, -} from './src/Util' - -import CLI from '@hackbg/cmds' - -/** Base command line interface for Fadroma Agent. */ -export default class AgentCLI extends CLI { - - constructor (...args: ConstructorParameters) { - super(...args) - this.log.label = `` - } - - bech32 = this.command({ - name: 'random-bech32', - info: 'create a random bech32 address', - args: 'PREFIX [LENGTH]' - }, (prefix: string, length: string|number = "20") => { - if (!prefix) { - this.log.error(bold('Pass a prefix to generate a bech32 address')) - process.exit(1) - } - if (isNaN(Number(length))) { - this.log.error(bold(`"${length}" is not a number. Pass a valid length to generate a bech32 address.`)) - process.exit(1) - } - this.log - .log(`${length} byte random bech32:`, bold(randomBech32m(prefix, Number(length)))) - }) - - bech32m = this.command({ - name: 'random-bech32m', - info: 'create a random bech32m address', - args: 'PREFIX [LENGTH]' - }, (prefix: string, length: string|number = "20") => { - if (!prefix) { - this.log.error(bold('Pass a prefix to generate a bech32m address')) - process.exit(1) - } - if (isNaN(Number(length))) { - this.log.error(bold(`"${length}" is not a number. Pass a valid length to generate a bech32m address.`)) - process.exit(1) - } - this.log - .log(`${length} byte random bech32m:`, bold(randomBech32m(prefix, Number(length)))) - }) - - bech32ToHex = this.command({ - name: 'from-bech32', - info: 'convert a bech32 address to a hex string', - args: 'ADDRESS' - }, (address: string) => { - if (!address) { - this.log.error(bold('Pass an address to convert it to hexadecimal.')) - process.exit(1) - } - let prefix, words - try { - ;({ prefix, words } = bech32.decode(address)) - } catch (e) { - this.log.error(bold('Failed to decode this address.')) - this.log.error(e.message) - process.exit(1) - } - this.log - .info('Prefix: ', bold(prefix)) - .info('Words: ', bold(base16.encode(new Uint8Array(words)))) - .log('Original:', bold(base16.encode(new Uint8Array(bech32m.fromWords(words))))) - }) - - bech32mToHex = this.command({ - name: 'from-bech32m', - info: 'convert a bech32m address to a hex string', - args: 'ADDRESS' - }, (address: string) => { - if (!address) { - this.log.error(bold('Pass an address to convert it to hexadecimal.')) - process.exit(1) - } - let prefix, words - try { - ;({ prefix, words } = bech32m.decode(address)) - } catch (e) { - this.log.error(bold('Failed to decode this address.')) - this.log.error(e.message) - process.exit(1) - } - this.log - .info('Prefix: ', bold(prefix)) - .info('Words: ', bold(base16.encode(new Uint8Array(words)))) - .log('Original:', bold(base16.encode(new Uint8Array(bech32m.fromWords(words))))) - }) - - hexToBech32 = this.command({ - name: 'to-bech32', - info: 'convert a hex string to a bech32 address', - args: 'PREFIX DATA' - }, (prefix: string, data: string) => { - if (!prefix) { - this.log.error(bold('Pass a prefix and a valid hex string to generate bech32')) - process.exit(1) - } - let dataBin - try { - dataBin = base16.decode(data.toUpperCase()) - } catch (e) { - this.log.error(bold('Pass a prefix and a valid hex string to generate bech32')) - process.exit(1) - } - this.log - .info('input: ', bold(data)) - .log('bech32:', bold(bech32.encode(prefix, bech32.toWords(dataBin)))) - }) - - hexToBech32m = this.command({ - name: 'to-bech32m', - info: 'convert a hex string to a bech32m address', - args: 'PREFIX DATA' - }, (prefix: string, data: string) => { - if (!prefix) { - this.log.error(bold('Pass a prefix and a valid hex string to generate bech32m')) - process.exit(1) - } - let dataBin - try { - dataBin = base16.decode(data.toUpperCase()) - } catch (e) { - this.log.error(bold('Pass a prefix and a valid hex string to generate bech32m')) - process.exit(1) - } - this.log - .info('input: ', bold(data)) - .log('bech32m:', bold(bech32m.encode(prefix, bech32m.toWords(dataBin)))) - }) - -} diff --git a/packages/agent/index.ts b/packages/agent/index.ts index dc089523d1..7f5fcbc9d2 100644 --- a/packages/agent/index.ts +++ b/packages/agent/index.ts @@ -18,19 +18,24 @@ **/ -export { Agent } from './src/Agent' -export { Backend } from './src/Backend' -export { Batch } from './src/Batch' -export { Block } from './src/Block' -export { Chain } from './src/Chain' +export { Agent } from './src/Agent' +export { Backend } from './src/Backend' +export { Batch } from './src/Batch' +export { Block } from './src/Block' +export { Chain } from './src/Chain' export { Connection, SigningConnection } from './src/Connection' -export { Identity } from './src/Identity' -export { Transaction } from './src/Transaction' -export * as Token from './src/Token' -export * as Compute from './src/Compute' -export * as Store from './src/Store' -export * as Governance from './src/Governance' -export * as Staking from './src/Staking' -export * as Stub from './stub/stub' -export * from './src/Types' -export * from './src/Util' +export { Identity } from './src/Identity' +export { Transaction } from './src/Transaction' + +export { Proposal, Vote } from './src/dlt/Governance' +export { Validator } from './src/dlt/Staking' +export * as Token from './src/dlt/Token' + +export { SourceCode, RustSourceCode } from './src/compute/Source' +export { Compiler, CompiledCode } from './src/compute/Compile' +export { UploadedCode, UploadStore } from './src/compute/Upload' +export { Contract } from './src/compute/Contract' + +export * as Stub from './stub/stub' +export * from './src/Types' +export * from './src/Util' diff --git a/packages/agent/src/Agent.ts b/packages/agent/src/Agent.ts index 53527af9d0..c3f076fc5f 100644 --- a/packages/agent/src/Agent.ts +++ b/packages/agent/src/Agent.ts @@ -1,12 +1,15 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import type { Address, Uint128, ChainId, CodeId, Token, Batch, Message } from '../index' +import type { Address, Uint128, ChainId, CodeId, Token, Batch, Message, Into } from '../index' import { assign, timed, bold, Logged, into } from './Util' import { SigningConnection } from './Connection' import { Chain } from './Chain' import { Identity } from './Identity' -import * as Compute from './Compute' +import { send } from './dlt/Bank' +import { CompiledCode } from './compute/Compile' +import { UploadedCode, upload } from './compute/Upload' +import { Contract, instantiate, execute } from './compute/Contract' /** Enables non-read-only transactions by binding an `Identity` to a `Connection`. */ export abstract class Agent extends Logged { @@ -16,172 +19,59 @@ export abstract class Agent extends Logged { & Partial> ) { super() - assign(this, properties, ["chain", "identity", "fees"]) + this.chain = properties.chain + this.identity = properties.identity + this.fees = properties.fees } - /** The connection that will broadcast the transactions. */ chain: Chain - /** The identity that will sign the transactions. */ identity: Identity - /** Default transaction fees. */ fees?: Token.FeeMap<'send'|'upload'|'init'|'exec'> - /** Get a signing connection to the RPC endpoint. */ abstract getConnection (): SigningConnection - /** Construct a transaction batch that will be broadcast by this agent. */ abstract batch (): Batch - /** Return the address of this agent. */ get address (): Address|undefined { return this.identity?.address } - async fetchBalance (tokens?: string[]|string): Promise> { throw new Error("unimplemented!") } - /** Send one or more kinds of native tokens to one or more recipients. */ async send ( outputs: Record>, options?: Omit[0], 'outputs'> ): Promise { - for (const [recipient, amounts] of Object.entries(outputs)) { - this.log.debug(`Sending to ${bold(recipient)}:`) - for (const [token, amount] of Object.entries(amounts)) { - this.log.debug(` ${amount} ${token}`) - } - } - return await timed( - ()=>this.getConnection().sendImpl({ ...options||{}, outputs }), - ({elapsed})=>`Sent in ${bold(elapsed)}` - ) + return send(this, outputs, options) } - /** Upload a contract's code, generating a new code id/hash pair. */ async upload ( - code: string|URL|Uint8Array|Partial, + code: string|URL|Uint8Array|Partial, options?: Omit[0], 'binary'>, - ): Promise { - let template: Uint8Array - if (code instanceof Uint8Array) { - template = code - } else { - const { CompiledCode } = _$_HACK_$_ - if (typeof code === 'string' || code instanceof URL) { - code = new CompiledCode({ codePath: code }) - } else { - code = new CompiledCode(code) - } - const t0 = performance.now() - code = code as Compute.CompiledCode - template = await code.fetch() - const t1 = performance.now() - t0 - this.log.log( - `Fetched in`, `${bold((t1/1000).toFixed(6))}s: code hash`, - bold(code.codeHash), `(${bold(String(code.codeData?.length))} bytes` - ) - } - this.log.debug(`Uploading ${bold((code as any).codeHash)}`) - const result = await timed( - () => this.getConnection().uploadImpl({ - ...options, - binary: template - }), - ({elapsed, result}: any) => this.log.debug( - `Uploaded in ${bold(elapsed)}:`, - `code with hash ${bold(result.codeHash)} as code id ${bold(String(result.codeId))}`, - )) - return new Compute.UploadedCode({ - ...template, ...result as any - }) as Compute.UploadedCode & { - chainId: ChainId - codeId: CodeId - } + return upload(this, code, options) } - /** Instantiate a new program from a code id, label and init message. */ async instantiate ( - contract: CodeId|Partial, - options: Partial - ): Promise, + options: Partial & { initMsg: Into } + ): Promise { - if (typeof contract === 'string') { - contract = new Compute.UploadedCode({ codeId: contract }) - } - if (isNaN(Number(contract.codeId))) { - throw new Error(`can't instantiate contract with missing code id: ${contract.codeId}`) - } - if (!contract.codeId) { - throw new Error("can't instantiate contract without code id") - } - if (!options.label) { - throw new Error("can't instantiate contract without label") - } - if (!(options.initMsg||('initMsg' in options))) { - throw new Error("can't instantiate contract without init message") - } - const { codeId, codeHash } = contract - const result = await timed( - () => into(options.initMsg).then(initMsg=>this.getConnection().instantiateImpl({ - ...options, - codeId, - codeHash, - initMsg - })), - ({ elapsed, result }) => this.log.debug( - `Instantiated in ${bold(elapsed)}:`, - `code id ${bold(String(codeId))} as `, - `${bold(options.label)} (${result.address})` - ) - ) - return new Compute.ContractInstance({ - ...options, ...result - }) as Compute.ContractInstance & { - address: Address - } + return instantiate(this, contract, options) } - /** Call a given program's transaction method. */ async execute ( - contract: Address|Partial, + contract: Address|Partial, message: Message, options?: Omit[0], 'address'|'codeHash'|'message'> ): Promise { - if (typeof contract === 'string') { - contract = new Compute.ContractInstance({ address: contract }) - } - if (!contract.address) { - throw new Error("agent.execute: no contract address") - } - const { address } = contract - let method = (typeof message === 'string') ? message : Object.keys(message||{})[0] - return timed( - () => this.getConnection().executeImpl({ - ...contract as { address, codeHash }, - message, - ...options - }), - ({ elapsed }) => this.log.debug( - `Executed in ${bold(elapsed)}:`, - `tx ${bold(method||'(???)')} of ${bold(address)}` - ) - ) + return await execute(this, contract, message, options) as T } - } - -/** The `CompiledCode` class has an alternate implementation for non-browser environments. - * This is because Next.js tries to parse the dynamic `import('node:...')` calls used by - * the `fetch` methods. (Which were made dynamic exactly to avoid such a dual-implementation - * situation in the first place - but Next is smart and adds a problem where there isn't one.) - * So, it defaults to the version that can only fetch from URL using the global fetch method; - * but the non-browser entrypoint substitutes `CompiledCode` in `_$_HACK_$_` with the - * version which can also load code from disk (`LocalCompiledCode`). Ugh. */ -export const _$_HACK_$_ = { CompiledCode: Compute.CompiledCode } diff --git a/packages/agent/src/Backend.ts b/packages/agent/src/Backend.ts index 81c7328db9..894231fdbb 100644 --- a/packages/agent/src/Backend.ts +++ b/packages/agent/src/Backend.ts @@ -3,7 +3,7 @@ along with this program. If not, see . **/ import { Logged, assign } from './Util' import { Address, ChainId } from './Types' -import * as Token from './Token' +import * as Token from './dlt/Token' import type { Chain } from './Chain' import type { Agent } from './Agent' import type { Identity } from './Identity' diff --git a/packages/agent/src/Batch.ts b/packages/agent/src/Batch.ts index 9e46e0f549..b6e8653bb8 100644 --- a/packages/agent/src/Batch.ts +++ b/packages/agent/src/Batch.ts @@ -11,6 +11,8 @@ export class Batch extends Logged { & Pick ) { super(properties) + this.chain = properties.chain + this.agent = properties.agent } /** The chain targeted by the batch. */ diff --git a/packages/agent/src/Block.ts b/packages/agent/src/Block.ts index 6d50b3ec1d..9cd2ad0eee 100644 --- a/packages/agent/src/Block.ts +++ b/packages/agent/src/Block.ts @@ -1,7 +1,7 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import { assign, hideProperties } from './Util' +import { bold } from './Util' import type { Chain, Transaction } from '../index' /** The building block of a blockchain, as obtained by @@ -9,27 +9,76 @@ import type { Chain, Transaction } from '../index' * * Contains zero or more transactions. */ export abstract class Block { - - constructor (properties: Partial = {}) { - assign(this, properties, ["chain", "height", "hash"]) - hideProperties(this, "chain") + constructor (properties: Pick) { + this.#chain = properties.chain + this.height = properties.height + this.id = properties.id + this.timestamp = properties.timestamp } - /** Connection to the chain to which this block belongs. */ - chain?: Chain + #chain: Chain + get chain () { return this.#chain } + /** Unique ID of block. */ + id: string /** Monotonically incrementing ID of block. */ - height: number - - /** Content-dependent ID of block. */ - hash: string + height: number + /** Timestamp of block */ + timestamp?: string +} - async fetchTransactions (): - Promise - async fetchTransactions (options: { byId: true }): - Promise> - async fetchTransactions (...args: unknown[]): Promise { - return [] +export async function fetchBlock (chain: Chain, ...args: Parameters): + Promise +{ + if (args[0]) { + if (typeof args[0] === 'object') { + if ('height' in args[0]) { + chain.log.debug(`Querying block by height ${args[0].height}`) + return chain.getConnection().fetchBlockImpl({ height: args[0].height as number }) + } else if ('hash' in args[0]) { + chain.log.debug(`Querying block by hash ${args[0].hash}`) + return chain.getConnection().fetchBlockImpl({ hash: args[0].hash as string }) + } + } else { + throw new Error('Invalid arguments, pass {height:number} or {hash:string}') + } } + chain.log.debug(`Querying latest block`) + return chain.getConnection().fetchBlockImpl() +} +export async function nextBlock (chain: Chain): + Promise +{ + return chain.height.then(async startingHeight=>{ + startingHeight = Number(startingHeight) + if (isNaN(startingHeight)) { + chain.log.warn('Current block height undetermined. Not waiting for next block') + return Promise.resolve(NaN) + } + chain.log.log( + `Waiting for block > ${bold(String(startingHeight))}`, + `(polling every ${chain.blockInterval}ms)` + ) + const t = + new Date() + return new Promise(async (resolve, reject)=>{ + try { + while (chain.getConnection().alive) { + await new Promise(ok=>setTimeout(ok, chain.blockInterval)) + chain.log( + `Waiting for block > ${bold(String(startingHeight))} ` + + `(${((+ new Date() - t)/1000).toFixed(3)}s elapsed)` + ) + const height = await chain.height + if (height > startingHeight) { + chain.log.log(`Block height incremented to ${bold(String(height))}, proceeding`) + return resolve(height as number) + } + } + throw new Error('endpoint dead, not waiting for next block') + } catch (e) { + reject(e) + } + }) + }) } diff --git a/packages/agent/src/Chain.ts b/packages/agent/src/Chain.ts index 1d5998b0d8..1c06fd1846 100644 --- a/packages/agent/src/Chain.ts +++ b/packages/agent/src/Chain.ts @@ -1,12 +1,29 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import { Logged, assign, bold, timed } from './Util' -import * as Compute from './Compute' +import { + Logged, assign, bold, timed +} from './Util' +import { + fetchBalance +} from './dlt/Bank' +import { + Contract, + fetchCodeInstances, + query +} from './compute/Contract' +import { + UploadedCode, + fetchCodeInfo, +} from './compute/Upload' +import { + Block, + fetchBlock, + nextBlock +} from './Block' import type { Address, Agent, - Block, ChainId, CodeId, Connection, @@ -23,7 +40,7 @@ export abstract class Chain extends Logged { & Partial> ) { super(properties) - assign(this, properties, ['chainId']) + this.chainId = properties.chainId } /** Chain ID. This is a string that uniquely identifies a chain. @@ -51,37 +68,7 @@ export abstract class Chain extends Logged { /** Wait until the block height increments, or until `this.alive` is set to false. */ get nextBlock (): Promise { - return this.height.then(async startingHeight=>{ - startingHeight = Number(startingHeight) - if (isNaN(startingHeight)) { - this.log.warn('Current block height undetermined. Not waiting for next block') - return Promise.resolve(NaN) - } - this.log.log( - `Waiting for block > ${bold(String(startingHeight))}`, - `(polling every ${this.blockInterval}ms)` - ) - const t = + new Date() - return new Promise(async (resolve, reject)=>{ - try { - while (this.getConnection().alive) { - await new Promise(ok=>setTimeout(ok, this.blockInterval)) - this.log( - `Waiting for block > ${bold(String(startingHeight))} ` + - `(${((+ new Date() - t)/1000).toFixed(3)}s elapsed)` - ) - const height = await this.height - if (height > startingHeight) { - this.log.log(`Block height incremented to ${bold(String(height))}, proceeding`) - return resolve(height as number) - } - } - throw new Error('endpoint dead, not waiting for next block') - } catch (e) { - reject(e) - } - }) - }) + return nextBlock(this) } /** Get info about the latest block. */ @@ -94,22 +81,7 @@ export abstract class Chain extends Logged { fetchBlock ({ hash }: { hash: string }): Promise fetchBlock (...args: unknown[]): Promise { - if (args[0]) { - if (typeof args[0] === 'object') { - if ('height' in args[0]) { - this.log.debug(`Querying block by height ${args[0].height}`) - return this.getConnection().fetchBlockImpl({ height: args[0].height as number }) - } else if ('hash' in args[0]) { - this.log.debug(`Querying block by hash ${args[0].hash}`) - return this.getConnection().fetchBlockImpl({ hash: args[0].hash as string }) - } - } else { - throw new Error('Invalid arguments, pass {height:number} or {hash:string}') - } - } else { - this.log.debug(`Querying latest block`) - return this.getConnection().fetchBlockImpl() - } + return fetchBlock(this, ...args as Parameters) } /** Fetch balance of 1 or many addresses in 1 or many native tokens. */ @@ -122,116 +94,28 @@ export abstract class Chain extends Logged { fetchBalance (addresses: Address[], tokens?: string): Promise>> async fetchBalance (...args: unknown[]): Promise { - throw new Error('unimplemented!') - //[>* Get balance of current identity in main token. <] - //get balance () { - //if (!this.identity?.address) { - //throw new Error('not authenticated, use .getBalance(token, address)') - //} else if (!this.defaultDenom) { - //throw new Error('no default token for this chain, use .getBalance(token, address)') - //} else { - //return this.getBalanceOf(this.identity.address) - //} - //} - /** Get the balance in a native token of a given address, - * either in this connection's gas token, - * or in another given token. */ - //getBalanceOf (address: Address|{ address: Address }, token?: string) { - //if (!address) { - //throw new Error('pass (address, token?) to getBalanceOf') - //} - //token ??= this.defaultDenom - //if (!token) { - //throw new Error('no token for balance query') - //} - //const addr = (typeof address === 'string') ? address : address.address - //if (addr === this.identity?.address) { - //this.log.debug('Querying', bold(token), 'balance') - //} else { - //this.log.debug('Querying', bold(token), 'balance of', bold(addr)) - //} - //return timed( - //this.doGetBalance.bind(this, token, addr), - //({ elapsed, result }) => this.log.debug( - //`Queried in ${elapsed}s: ${bold(address)} has ${bold(result)} ${token}` - //) - //) - //} - /** Get the balance in a given native token, of - * either this connection's identity's address, - * or of another given address. */ - //getBalanceIn (token: string, address?: Address|{ address: Address }) { - //if (!token) { - //throw new Error('pass (token, address?) to getBalanceIn') - //} - //address ??= this.identity?.address - //if (!address) { - //throw new Error('no address for balance query') - //} - //const addr = (typeof address === 'string') ? address : address.address - //if (addr === this.identity?.address) { - //this.log.debug('Querying', bold(token), 'balance') - //} else { - //this.log.debug('Querying', bold(token), 'balance of', bold(addr)) - //} - //return timed( - //this.doGetBalance.bind(this, token, addr), - //({ elapsed, result }) => this.log.debug( - //`Queried in ${elapsed}s: balance of ${bold(address)} is ${bold(result)}` - //) - //) - //} + return fetchBalance(this, ...args as Parameters) } /** Fetch info about all code IDs uploaded to the chain. */ fetchCodeInfo (): - Promise> + Promise> /** Fetch info about a single code ID. */ fetchCodeInfo (codeId: CodeId, options?: { parallel?: boolean }): - Promise + Promise /** Fetch info about multiple code IDs. */ fetchCodeInfo (codeIds: Iterable, options?: { parallel?: boolean }): - Promise> + Promise> fetchCodeInfo (...args: unknown[]): Promise { - if (args.length === 0) { - this.log.debug('Querying all codes...') - return timed( - ()=>this.getConnection().fetchCodeInfoImpl(), - ({ elapsed, result }) => this.log.debug( - `Queried in ${bold(elapsed)}: all codes` - )) - } - if (args.length === 1) { - if (args[0] instanceof Array) { - const codeIds = args[0] as Array - const { parallel } = args[1] as { parallel?: boolean } - this.log.debug(`Querying info about ${codeIds.length} code IDs...`) - return timed( - ()=>this.getConnection().fetchCodeInfoImpl({ codeIds, parallel }), - ({ elapsed, result }) => this.log.debug( - `Queried in ${bold(elapsed)}: info about ${codeIds.length} code IDs` - )) - } else { - const codeIds = [args[0] as CodeId] - const { parallel } = args[1] as { parallel?: boolean } - this.log.debug(`Querying info about code id ${args[0]}...`) - return timed( - ()=>this.getConnection().fetchCodeInfoImpl({ codeIds, parallel }), - ({ elapsed }) => this.log.debug( - `Queried in ${bold(elapsed)}: info about code id ${codeIds[0]}` - )) - } - } else { - throw new Error('fetchCodeInfo takes 0 or 1 arguments') - } + return fetchCodeInfo(this, ...args as Parameters) } /** Fetch all instances of a code ID. */ fetchCodeInstances ( codeId: CodeId - ): Promise> + ): Promise> /** Fetch all instances of a code ID, with custom client class. */ - fetchCodeInstances ( + fetchCodeInstances ( Contract: C, codeId: CodeId ): Promise>> @@ -239,69 +123,30 @@ export abstract class Chain extends Logged { fetchCodeInstances ( codeIds: Iterable, options?: { parallel?: boolean } - ): Promise>> + ): Promise>> /** Fetch all instances of multple code IDs, with custom client class. */ - fetchCodeInstances ( + fetchCodeInstances ( Contract: C, codeIds: Iterable, options?: { parallel?: boolean } ): Promise>>> /** Fetch all instances of multple code IDs, with multiple custom client classes. */ fetchCodeInstances ( - codeIds: { [id: CodeId]: typeof Compute.Contract }, + codeIds: { [id: CodeId]: typeof Contract }, options?: { parallel?: boolean } ): Promise<{ [codeId in keyof typeof codeIds]: Record> }> async fetchCodeInstances (...args: unknown[]): Promise { - let $C = Compute.Contract - let custom = false - if (typeof args[0] === 'function') { - $C = args.shift() as typeof Compute.Contract - let custom = true - } - if (!args[0]) { - throw new Error('Invalid arguments') - } - if (args[0][Symbol.iterator]) { - const result: Record> = {} - const codeIds = {} - for (const codeId of args[0] as CodeId[]) { - codeIds[codeId] = $C - } - this.log.debug(`Querying contracts with code ids ${Object.keys(codeIds).join(', ')}...`) - return timed( - ()=>this.getConnection().fetchCodeInstancesImpl({ codeIds }), - ({elapsed})=>this.log.debug(`Queried in ${elapsed}ms`)) - } - if (typeof args[0] === 'object') { - if (custom) { - throw new Error('Invalid arguments') - } - const result: Record> = {} - this.log.debug(`Querying contracts with code ids ${Object.keys(args[0]).join(', ')}...`) - const codeIds = args[0] as { [id: CodeId]: typeof Compute.Contract } - return timed( - ()=>this.getConnection().fetchCodeInstancesImpl({ codeIds }), - ({elapsed})=>this.log.debug(`Queried in ${elapsed}ms`)) - } - if ((typeof args[0] === 'number')||(typeof args[0] === 'string')) { - const id = args[0] - this.log.debug(`Querying contracts with code id ${id}...`) - const result = {} - return timed( - ()=>this.getConnection().fetchCodeInstancesImpl({ codeIds: { [id]: $C } }), - ({elapsed})=>this.log.debug(`Queried in ${elapsed}ms`)) - } - throw new Error('Invalid arguments') + return fetchCodeInstances(this, ...args as Parameters) } /** Fetch a contract's details wrapped in a `Contract` instance. */ fetchContractInfo ( address: Address - ): Promise + ): Promise /** Fetch a contract's details wrapped in a custom class instance. */ - fetchContractInfo ( + fetchContractInfo ( Contract: T, address: Address ): Promise> @@ -309,89 +154,22 @@ export abstract class Chain extends Logged { fetchContractInfo ( addresses: Address[], options?: { parallel?: boolean } - ): Promise> + ): Promise> /** Fetch multiple contracts' details wrapped in instances of a custom class. */ - fetchContractInfo ( + fetchContractInfo ( Contract: T, addresses: Address[], options?: { parallel?: boolean } ): Promise>> /** Fetch multiple contracts' details, specifying a custom class for each. */ fetchContractInfo ( - contracts: { [address: Address]: typeof Compute.Contract }, + contracts: { [address: Address]: typeof Contract }, options?: { parallel?: boolean } ): Promise<{ [address in keyof typeof contracts]: InstanceType }> async fetchContractInfo (...args: unknown[]): Promise { - let $C = Compute.Contract - let custom = false - if (typeof args[0] === 'function') { - $C = args.shift() as typeof Compute.Contract - custom = true - } - if (!args[0]) { - throw new Error('Invalid arguments') - } - const { parallel = false } = (args[1] || {}) as { parallel?: boolean } - // Fetch single contract - if (typeof args[0] === 'string') { - this.log.debug(`Fetching contract ${args[0]}`) - const contracts = await timed( - () => this.getConnection().fetchContractInfoImpl({ - contracts: { [args[0] as Address]: $C } - }), - ({ elapsed }) => this.log.debug( - `Fetched in ${bold(elapsed)}: contract ${args[0]}` - )) - if (custom) { - return new $C(contracts[args[0]]) - } else { - return contracts[args[0]] - } - } - // Fetch array of contracts - if (args[0][Symbol.iterator]) { - const addresses = args[0] as Address[] - this.log.debug(`Fetching ${addresses.length} contracts`) - const contracts = {} - for (const address of addresses) { - contracts[address] = $C - } - const results = await timed( - ()=>this.getConnection().fetchContractInfoImpl({ contracts, parallel }), - ({ elapsed }) => this.log.debug( - `Fetched in ${bold(elapsed)}: ${addresses.length} contracts` - )) - if (custom) { - return addresses.map(address=>new $C(results[address])) - } else { - return addresses.map(address=>results[address]) - } - } - // Fetch map of contracts with different classes - if (typeof args[0] === 'object') { - if (custom) { - // Can't specify class as first argument - throw new Error('Invalid arguments') - } - const addresses = Object.keys(args[0]) as Address[] - this.log.debug(`Querying info about ${addresses.length} contracts`) - const contracts = await timed( - ()=>this.getConnection().fetchContractInfoImpl({ - contracts: args[0] as { [address: Address]: typeof Compute.Contract }, - parallel - }), - ({ elapsed }) => this.log.debug( - `Queried in ${bold(elapsed)}: info about ${addresses.length} contracts` - )) - const result = {} - for (const address of addresses) { - result[address] = new args[0][address](contracts[address]) - } - return result - } - throw new Error('Invalid arguments') + return fetchCodeInstances(this, ...args as Parameters) } /** Query a contract by address. */ @@ -400,16 +178,7 @@ export abstract class Chain extends Logged { /** Query a contract object. */ query (contract: { address: Address }, message: Message): Promise - query (contract: Address|{ address: Address }, message: Message): - Promise { - return timed( - ()=>this.getConnection().queryImpl({ - ...(typeof contract === 'string') ? { address: contract } : contract, - message - }), - ({ elapsed, result }) => this.log.debug( - `Queried in ${bold(elapsed)}s: `, JSON.stringify(result) - ) - ) + query (...args: unknown[]): Promise { + return query(this, ...args as Parameters) } } diff --git a/packages/agent/src/Compute.ts b/packages/agent/src/Compute.ts deleted file mode 100644 index b69ca520c4..0000000000 --- a/packages/agent/src/Compute.ts +++ /dev/null @@ -1,827 +0,0 @@ -/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . **/ -import { - Console, - Logged, - SHA256, - assign, - base16, - bold, - hideProperties, - timestamp, -} from './Util' -import type { - Address, - Agent, - Chain, - ChainId, - CodeId, - Connection, - Into, - Label, - Message, - Name, - Store, - Token, - TxHash, -} from '../index' - -/** Represents a particular instance of a smart contract. - * - * Subclass this to add custom query and transaction methods corresponding - * to the contract's API. */ -export class Contract extends Logged { - /** Connection to the chain on which this contract is deployed. */ - chain?: Chain - /** Connection to the chain on which this contract is deployed. */ - agent?: Agent - /** Code upload from which this contract is created. */ - codeId?: CodeId - /** The code hash uniquely identifies the contents of the contract code. */ - codeHash?: CodeHash - /** The address uniquely identifies the contract instance. */ - address?: Address - /** The label is a human-friendly identifier of the contract. */ - label?: Label - /** The address of the account which instantiated the contract. */ - initBy?: Address - - constructor (properties: Partial) { - super((typeof properties === 'string')?{}:properties) - if (typeof properties === 'string') { - properties = { address: properties } - } - assign(this, properties, [ - 'chain', - 'agent', - 'codeId', - 'codeHash', - 'address', - 'label', - 'initBy' - ]) - } - - /** Execute a query on the specified instance as the specified Connection. */ - query (message: Message): Promise { - if (!this.chain) { - throw new Error("can't query instance without connection") - } - if (!this.address) { - throw new Error("can't query instance without address") - } - return this.chain.query(this as { address }, message) - } - - /** Execute a transaction on the specified instance as the specified Connection. */ - execute (message: Message, options: Parameters[2] = {}): Promise { - if (!this.chain) { - throw new Error("can't transact with instance without connection") - } - if (!this.agent?.execute) { - throw new Error("can't transact with instance without authorizing the connection") - } - if (!this.address) { - throw new Error("can't transact with instance without address") - } - return this.agent?.execute(this as { address }, message, options) - } -} - -/** Represents a contract's code in all its forms, and the contract's lifecycle - * up to and including uploading it, but not instantiating it. */ -export class ContractCode extends Logged { - source?: SourceCode - compiler?: Compiler - compiled?: CompiledCode - uploader?: Agent|Address - uploaded?: UploadedCode - deployer?: Agent|Address - - constructor (properties?: Partial) { - super(properties) - assign(this, properties, [ - 'source', 'compiler', 'compiled', 'uploader', 'uploaded', 'deployer' - ]) - } - - /** Compile this contract. - * - * If a valid binary is present and a rebuild is not requested, - * this does not compile it again, but reuses the binary. */ - async compile ({ - compiler = this.compiler, - rebuild = false, - ...buildOptions - }: { - compiler?: Compiler - rebuild?: boolean - } = {}): Promise[1] & { - codeHash: CodeHash - }> { - if (this.compiled?.canUpload && !rebuild) { - return Promise.resolve( - this.compiled as typeof this["compiled"] & { codeHash: CodeHash } - ) - } - if (!compiler) { - throw new Error("can't compile: no compiler") - } - if (!this.source) { - throw new Error(`can't compile: no source`) - } - if (!this.source.canCompile) { - throw new Error(`can't compile: ${this.source.canCompileInfo??'unspecified reason'}`) - } - const compiled = await compiler.build(this.source, buildOptions) - if (!compiled.canUpload) { - throw new Error("build failed") - } - return this.compiled = compiled as typeof compiled & { codeHash: CodeHash } - } - - /** Upload this contract. - * - * If a valid binary is not present, compile it first. - * - * If a valid code ID is present and reupload is not requested, - * this does not upload it again, but reuses the code ID. - * - * If a valid binary is not present, but valid source is present, - * this compiles the source code first to obtain a binary. */ - async upload ({ - compiler = this.compiler, - rebuild = false, - uploader = this.uploader, - reupload = rebuild, - ...uploadOptions - }: Parameters[0] & Parameters[1] & { - uploader?: Address|{ upload: Agent["upload"] } - reupload?: boolean, - } = {}): Promise { - if (this.uploaded?.canInstantiate && !reupload && !rebuild) { - return this.uploaded as typeof uploaded & { codeId: CodeId } - } - if (!uploader || (typeof uploader === 'string')) { - throw new Error("can't upload: no uploader agent") - } - const compiled = await this.compile({ compiler, rebuild }) - const uploaded = await uploader.upload(compiled, uploadOptions) - if (!uploaded.canInstantiate) { - throw new Error("upload failed") - } - return this.uploaded = uploaded - } -} - -/** Represents a contract's code, in binary form, uploaded to a given chain. */ -export class UploadedCode { - /** Code hash uniquely identifying the compiled code. */ - codeHash?: CodeHash - /** ID of chain on which this contract is uploaded. */ - chainId?: ChainId - /** Code ID representing the identity of the contract's code on a specific chain. */ - codeId?: CodeId - /** TXID of transaction that performed the upload. */ - uploadTx?: TxHash - /** address of agent that performed the upload. */ - uploadBy?: Address - /** address of agent that performed the upload. */ - uploadGas?: string|number - - constructor (properties: Partial = {}) { - assign(this, properties, [ - 'codeHash', 'chainId', 'codeId', 'uploadTx', 'uploadBy', 'uploadGas', - ]) - } - - get [Symbol.toStringTag] () { - return [ - this.codeId || 'no code id', - this.chainId || 'no chain id', - this.codeHash || '(no code hash)' - ].join('; ') - } - - serialize (): { - codeHash?: CodeHash - chainId?: ChainId - codeId?: CodeId - uploadTx?: TxHash - uploadBy?: Address - uploadGas?: string|number - uploadInfo?: string - [key: string]: unknown - } { - let { codeHash, chainId, codeId, uploadTx, uploadBy, uploadGas } = this - if ((typeof this.uploadBy === 'object')) { - uploadBy = (uploadBy as any).identity?.address - } - return { codeHash, chainId, codeId, uploadTx, uploadBy: uploadBy as string, uploadGas } - } - - get canInstantiate (): boolean { - return !!(this.chainId && this.codeId) - } - - get canInstantiateInfo (): string|undefined { - return ( - (!this.chainId) ? "can't instantiate: no chain id" : - (!this.codeId) ? "can't instantiate: no code id" : - undefined - ) - } -} - -/** A contract that is part of a deploment. - * - needed for deployment-wide deduplication - * - generates structured label */ -export class DeploymentUnit extends ContractCode { - /** Name of this unit. */ - name?: string - /** Deployment to which this unit belongs. */ - deployment?: Deployment - /** Code hash uniquely identifying the compiled code. */ - codeHash?: CodeHash - /** Code ID representing the identity of the contract's code on a specific chain. */ - chainId?: ChainId - /** Code ID representing the identity of the contract's code on a specific chain. */ - codeId?: CodeId - - constructor ( - properties: ConstructorParameters[0] & Partial = {} - ) { - super(properties) - assign(this, properties, [ - 'name', 'deployment', 'isTemplate', 'codeHash', 'chainId', 'codeId', - ] as any) - } - - serialize () { - const { name, codeHash, chainId, codeId } = this - return { name, codeHash, chainId, codeId } - } -} - -export class ContractTemplate extends DeploymentUnit { - readonly isTemplate = true - /** Create a new instance of this contract. */ - contract ( - name: Name, parameters?: Partial - ): ContractInstance { - return new ContractInstance({ ...this, name, ...parameters||{} }) - } - /** Create multiple instances of this contract. */ - contracts ( - instanceParameters: Record[1]> = {} - ): Record { - const instances: Record = {} - for (const [name, parameters] of Object.entries(instanceParameters)) { - instances[name] = this.contract(name, parameters) - } - return instances - } -} - -export class ContractInstance extends DeploymentUnit { - readonly isTemplate = false - /** Full label of the instance. Unique for a given chain. */ - label?: Label - /** Address of this contract instance. Unique per chain. */ - address?: Address - /** Contents of init message. */ - initMsg?: Into - /** Address of agent that performed the init tx. */ - initBy?: Address - /** Native tokens to send to the new contract. */ - initSend?: Token.ICoin[] - /** Fee to use for init. */ - initFee?: unknown - /** Instantiation memo. */ - initMemo?: string - /** ID of transaction that performed the init. */ - initTx?: TxHash - /** Contents of init message. */ - initGas?: unknown - - constructor ( - properties?: ConstructorParameters[0] & Partial - ) { - super(properties) - assign(this, properties, [ - 'label', 'address', - 'initMsg', 'initBy', 'initSend', 'initFee', 'initMemo', 'initTx', 'initGas' - ]) - } - - async deploy ({ - deployer = this.deployer, - redeploy = false, - uploader = this.uploader||deployer, - reupload = false, - compiler = this.compiler, - rebuild = false, - ...initOptions - }: Parameters[0] & Parameters[1] & { - deployer?: Address|{ instantiate: Agent["instantiate"] } - redeploy?: boolean - } = {}): Promise { - if (this.isValid() && !redeploy && !reupload && !rebuild) { - return this - } - if (!deployer || (typeof deployer === 'string')) { - throw new Error("can't deploy: no deployer agent") - } - const uploaded = await this.upload({ - compiler, rebuild, uploader, reupload - }) - const instance = await deployer.instantiate(uploaded, this) - if (!instance.isValid()) { - throw new Error("init failed") - } - return instance - } - - serialize () { - const { label, address, initMsg, initBy, initSend, initFee, initMemo, initTx, initGas } = this - return { - ...super.serialize(), - label, address, initMsg, initBy, initSend, initFee, initMemo, initTx, initGas - } - } - - /** Returns a client to this contract instance. */ - connect (agent?: Agent): - Contract - connect ( - agent?: Agent, $C: C = Contract as C - ) { - return new $C({ - ...this, - agent, - }) - } - - isValid (): this is ContractInstance & { address: Address } { - return !!this.address - } -} - -export type DeploymentState = Partial> - -/** A collection of contracts. */ -export class Deployment extends Map { - log = new Console('Deployment') - - name: string = timestamp() - - static fromSnapshot ({ name, units = {} }: DeploymentState) { - const deployment = new this({ name }) - for (const [key, value] of Object.entries(units)) { - deployment.set(key, value) - } - return deployment - } - - constructor (properties: Partial = {}) { - super() - assign(this, properties, [ 'name' ]) - this.name ??= timestamp() - this.log.label = `deployment(${bold(this.name)})` - } - - serialize () { - const units: Record> = {} - for (const [key, value] of this.entries()) { - units[key] = value.serialize() - } - return { name: this.name, units: Object.fromEntries(this.entries()) } - } - - set (name: string, unit: DeploymentUnit): this { - if (!(unit instanceof DeploymentUnit)) { - throw new Error('a Deployment can only contain instances of DeploymentUnit') - } - return super.set(name, unit) - } - - /** Define a template, representing code that can be compiled - * and uploaded, but will not be automatically instantiated. - * This can then be used to define multiple instances of - * the same code. */ - template (name: string, properties?: - ( - |({ language: 'rust' } & Partial) - |({ language?: undefined } & Partial) - )& - Partial & - Partial - ): ContractTemplate { - const source = - properties?.language === 'rust' ? new RustSourceCode(properties) - : new SourceCode(properties) - const compiled = new CompiledCode(properties) - const uploaded = new UploadedCode(properties) - const unit = new ContractTemplate({ - deployment: this, name, source, compiled, uploaded - }) - hideProperties(unit, 'name', 'deployment', 'isTemplate') - this.set(name, unit) - return unit - } - - /** Define a contract that will be automatically compiled, uploaded, - * and instantiated as part of this deployment. */ - contract (name: string, properties?: - ( - |({ language: 'rust' } & Partial) - |({ language?: undefined } & Partial) - )& - Partial & - Partial & - Partial - ): ContractInstance { - const source = - properties?.language === 'rust' ? new RustSourceCode(properties) - : new SourceCode(properties) - const compiled = new CompiledCode(properties) - const uploaded = new UploadedCode(properties) - const unit = new ContractInstance({ - deployment: this, name, source, compiled, uploaded, ...properties - }) - hideProperties(unit, 'name', 'deployment', 'isTemplate') - this.set(name, unit) - return unit - } - - addContract (...args: Parameters) { - this.contract( - //@ts-ignore - ...args - ) - return this - } - - addContracts (...args: Parameters) { - this.template( - //@ts-ignore - ...args - ) - return this - } - - async build ({ units = [...this.keys()], ...options }: Parameters[0] & { - units?: Name[] - } = {}): - Promise> - { - const toCompile: Array = [] - - if (units && units.length > 0) { - for (const name of units) { - const unit = this.get(name) - if (!unit) { - throw new Error(`requested to build unknown unit "${unit}"`) - } - if (!unit.source?.canCompile && unit.compiled?.canUpload) { - this.log.warn(`Missing source for ${bold(name)} (${unit.compiled.codeHash})`) - } else { - toCompile.push(unit as DeploymentUnit & { source: SourceCode }) - } - } - } - - const compiled = await options.compiler!.buildMany(toCompile.map(unit=>unit.source!)) - - const byCodeHash: Record = {} - - for (const index in compiled) { - const output = compiled[index] - if (!output.codeHash) { - throw new Error('build output did not contain codeHash') - } - toCompile[index].compiled = output - byCodeHash[output.codeHash] = output as CompiledCode & { codeHash: CodeHash } - } - - return byCodeHash - } - - async upload ({ units, ...options }: Parameters[0] & { - units?: Name[] - uploadStore?: Store.UploadStore - } = {}): - Promise> - { - const uploading: Array> = [] - for (const [name, unit] of this.entries()) { - uploading.push(unit.upload(options)) - } - const uploaded: Record = {} - for (const output of await Promise.all(uploading)) { - uploaded[output.codeId] = output - } - return uploaded - } - - async deploy ({ units, ...options }: Parameters[0] & { - units?: Name[], - deployStore?: Store.DeployStore - } = {}): - Promise> - { - const deploying: Array> = [] - for (const [name, unit] of this.entries()) { - if (unit instanceof ContractInstance) { - deploying.push(unit.deploy(options)) - } - } - const deployed: Record = {} - for (const output of await Promise.all(deploying)) { - deployed[output.address] = output - } - return deployed - } -} - -/** The default Git ref when not specified. */ -export const HEAD = 'HEAD' - -/** A code hash, uniquely identifying a particular smart contract implementation. */ -export type CodeHash = string - -export abstract class Compiler extends Logged { - /** Whether to enable build caching. - * When set to false, this compiler will rebuild even when - * binary and checksum are both present in wasm/ directory */ - caching: boolean = true - - /** Unique identifier of this compiler implementation. */ - abstract id: string - - /** Compile a source. - * `@hackbg/fadroma` implements dockerized and non-dockerized - * variants using its `build.impl.mjs` script. */ - abstract build (source: string|Partial, ...args: unknown[]): - Promise - - /** Build multiple sources. - * Default implementation of buildMany is sequential. - * Compiler classes may override this to optimize. */ - async buildMany (inputs: Partial[]): Promise { - const templates: CompiledCode[] = [] - for (const source of inputs) templates.push(await this.build(source)) - return templates - } -} - -/** An object representing a given source code. */ -export class SourceCode extends Logged { - /** URL pointing to Git upstream containing the canonical source code. */ - sourceOrigin?: string|URL - /** Pointer to the source commit. */ - sourceRef?: string - /** Path to local checkout of the source code (with .git directory if sourceRef is set). */ - sourcePath?: string - /** Whether the code contains uncommitted changes. */ - sourceDirty?: boolean - - constructor (properties: Partial = {}) { - super(properties) - assign(this, properties, [ - 'sourcePath', 'sourceOrigin', 'sourceRef', 'sourceDirty' - ]) - } - - get [Symbol.toStringTag] () { - return [ - this.sourcePath ? this.sourcePath : `(missing source)`, - this.sourceOrigin && `(from ${this.sourceOrigin})`, - this.sourceRef && `(at ${this.sourceRef})`, - this.sourceDirty && `(modified)` - ].filter(Boolean).join(' ') - } - - serialize (): { - sourceOrigin?: string - sourceRef?: string - sourcePath?: string - sourceDirty?: boolean - [key: string]: unknown - } { - const { sourcePath, sourceOrigin, sourceRef, sourceDirty } = this - return { sourcePath, sourceOrigin: sourceOrigin?.toString(), sourceRef, sourceDirty } - } - - get canFetch (): boolean { - return !!this.sourceOrigin - } - - get canFetchInfo (): string|undefined { - if (!this.sourceOrigin) return "missing sourceOrigin" - } - - get canCompile (): boolean { - return !!this.sourcePath || this.canFetch - } - - get canCompileInfo (): string|undefined { - if (!this.sourcePath) return "missing sourcePath" - } -} - -export class RustSourceCode extends SourceCode { - /** Path to the crate's Cargo.toml under sourcePath */ - cargoToml?: string - /** Path to the workspace's Cargo.toml in the source tree. */ - cargoWorkspace?: string - /** Name of crate. */ - cargoCrate?: string - /** List of crate features to enable during build. */ - cargoFeatures?: string[]|Set - - constructor (properties?: Partial) { - super(properties) - assign(this, properties, [ - 'cargoToml', 'cargoWorkspace', 'cargoCrate', 'cargoFeatures' - ]) - } - - get [Symbol.toStringTag] () { - return [ - this.cargoWorkspace - ? ((this.cargoCrate ? `crate ${this.cargoCrate} from` : 'unknown crate from') - +this.cargoWorkspace) - : this.cargoToml, - super[Symbol.toStringTag], - ].filter(Boolean).join(' ') - } - - serialize (): ReturnType & { - cargoWorkspace?: string - cargoCrate?: string - cargoFeatures?: string[] - [key: string]: unknown - } { - const { - cargoToml, - cargoWorkspace, - cargoCrate, - cargoFeatures - } = this - return { - ...super.serialize(), - cargoToml, - cargoWorkspace, - cargoCrate, - cargoFeatures: cargoFeatures ? [...cargoFeatures] : undefined - } - } - - get canCompile (): boolean { - const hasWorkspace = !!this.cargoWorkspace - const hasCrateToml = !!this.cargoToml - const hasCrateName = !!this.cargoCrate - return ( - ( hasWorkspace && !hasCrateToml && hasCrateName) || - (!hasWorkspace && hasCrateToml && !hasCrateName) - ) - } - - get canCompileInfo (): string|undefined { - let result = super.canCompileInfo - let error - const hasWorkspace = !!this.cargoWorkspace - const hasCrateToml = !!this.cargoToml - const hasCrateName = !!this.cargoCrate - if (hasWorkspace) { - if (hasCrateToml) { - error = "cargoWorkspace is set, cargoToml must be unset" - } - if (!hasCrateName) { - error = "when cargoWorkspace is set, cargoCrate must also be set" - } - } else if (hasCrateToml) { - if (hasCrateName) { - error = "when cargoToml is set, cargoCrate must be unset" - } - } else { - error = "set either cargoToml or cargoWorkspace & cargoCrate" - } - if (result || error) { - return [result, error].filter(Boolean).join('; ') - } - } -} - -/** An object representing a given compiled binary. */ -export class CompiledCode { - /** Code hash uniquely identifying the compiled code. */ - codeHash?: CodeHash - /** Location of the compiled code. */ - codePath?: string|URL - /** The compiled code. */ - codeData?: Uint8Array - - constructor (properties: Partial = {}) { - assign(this, properties, [ 'codeHash', 'codePath', 'codeData' ]) - } - - get [Symbol.toStringTag] () { - return [ - this.codePath && `${this.codePath}`, - this.codeHash && `${this.codeHash}`, - this.codeData && `(${this.codeData.length} bytes)` - ].filter(Boolean).join(' ') - } - - serialize (): { - codeHash?: CodeHash - codePath?: string - [key: string]: unknown - } { - const { codeHash, codePath } = this - return { codeHash, codePath: codePath?.toString() } - } - - get canFetch (): boolean { - return !!this.codePath - } - - get canFetchInfo (): string|undefined { - if (!this.codePath) { - return "can't fetch binary: codePath is not set" - } - } - - get canUpload (): boolean { - return !!this.codeData || this.canFetch - } - - get canUploadInfo (): string|undefined { - if (!this.codeData && this.canFetch) { - return "uploading will fetch the binary from the specified path" - } - if (this.codeData && !this.codePath) { - return "uploading from buffer, codePath is unspecified" - } - } - - async fetch (): Promise { - const console = new Console(`CompiledCode(${bold(this[Symbol.toStringTag])})`) - if (this.codeData) { - console.debug("not fetching: codeData found; unset to refetch") - return this.codeData - } - if (!this.codePath) { - throw new Error("can't fetch: missing codePath") - } - this.codeData = await this.fetchImpl() - if (this.codeHash) { - const hash0 = String(this.codeHash).toLowerCase() - const hash1 = CompiledCode.toCodeHash(this.codeData) - if (hash0 !== hash1) { - throw new Error(`code hash mismatch: expected ${hash0}, computed ${hash1}`) - } - } else { - this.codeHash = CompiledCode.toCodeHash(this.codeData) - console.warn( - "\n TOFU: Computed code hash from fetched data:" + - `\n ${bold(this.codeHash)}` + - '\n Pin the expected code hash by setting the codeHash property.') - } - return this.codeData - } - - protected async fetchImpl () { - if (!this.codePath) { - throw new Error("can't fetch: codePath not set") - } - const request = await fetch(this.codePath!) - const response = await request.arrayBuffer() - return new Uint8Array(response) - } - - /** Compute the code hash if missing; throw if different. */ - async computeHash (): Promise { - const hash = CompiledCode.toCodeHash(await this.fetch()) - if (this.codeHash) { - if (this.codeHash.toLowerCase() !== hash.toLowerCase()) { - throw new Error(`computed code hash ${hash} did not match preexisting ${this.codeHash}`) - } - } else { - this.codeHash = hash - } - return this as this & { codeHash: CodeHash } - } - - static toCodeHash (data: Uint8Array): string { - return base16.encode(SHA256(data)).toLowerCase() - } -} - diff --git a/packages/agent/src/Connection.ts b/packages/agent/src/Connection.ts index bcf9afbad5..282f094673 100644 --- a/packages/agent/src/Connection.ts +++ b/packages/agent/src/Connection.ts @@ -2,22 +2,11 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ import { - Logged, - assign, - bold, - colors, - randomColor, + Logged, assign, bold, colors, randomColor, } from './Util' import type { - Address, - Block, - ChainId, - CodeId, - Compute, - Message, - Store, - Token, - Uint128, + Address, Block, Chain, ChainId, CodeId, Message, Token, Uint128, + UploadStore, UploadedCode, Contract, Into } from '../index' /** Represents a remote API endpoint. @@ -28,24 +17,33 @@ import type { export abstract class Connection extends Logged { constructor ( properties: ConstructorParameters[0] - & Pick + & Pick & Partial> ) { super(properties) - assign(this, properties, ['alive', 'url', 'api']) + this.#chain = properties.chain + this.url = properties.url + this.alive = properties.alive || true + this.api = properties.api this.log.label = [ this.constructor.name, - '(', - this[Symbol.toStringTag] ? `(${bold(this[Symbol.toStringTag])})` : null, - ')' + '(', this[Symbol.toStringTag] ? `(${bold(this[Symbol.toStringTag])})` : null, ')' ].filter(Boolean).join('') this.log.label = new.target.constructor.name const chainColor = randomColor({ luminosity: 'dark', seed: this.url }) this.log.label = colors.bgHex(chainColor).whiteBright(` ${this.url} `) } - /** This must match the containing `Chain` object's chain ID. */ - chainId: ChainId + #chain: Chain + /** Chain to which this connection points. */ + get chain (): Chain { + return this.chain + } + /** ID of chain to which this connection points. */ + get chainId (): ChainId { + return this.chain.chainId + } + /** Connection URL. * * The same chain may be accessible via different endpoints, so @@ -86,10 +84,10 @@ export abstract class Connection extends Logged { abstract fetchCodeInfoImpl (parameters?: { codeIds?: CodeId[] parallel?: boolean - }): Promise> + }): Promise> /** Chain-specific implementation of fetchCodeInstances. */ abstract fetchCodeInstancesImpl (parameters: { - codeIds: { [id: CodeId]: typeof Compute.Contract }, + codeIds: { [id: CodeId]: typeof Contract }, parallel?: boolean }): Promise<{ [codeId in keyof typeof parameters["codeIds"]]: @@ -97,9 +95,9 @@ export abstract class Connection extends Logged { }> /** Chain-specific implementation of fetchContractInfo. */ abstract fetchContractInfoImpl (parameters: { - contracts: { [address: Address]: typeof Compute.Contract }, + contracts: { [address: Address]: typeof Contract }, parallel?: boolean - }): Promise> + }): Promise> /** Chain-specific implementation of query. */ abstract queryImpl (parameters: { address: Address @@ -121,16 +119,16 @@ export abstract class SigningConnection { abstract uploadImpl (parameters: { binary: Uint8Array, reupload?: boolean, - uploadStore?: Store.UploadStore, + uploadStore?: UploadStore, uploadFee?: Token.ICoin[]|'auto', uploadMemo?: string - }): Promise> /** Chain-specific implementation of contract instantiation. */ - abstract instantiateImpl (parameters: Partial): - Promise + abstract instantiateImpl (parameters: Partial & { initMsg: Into }): + Promise /** Chain-specific implementation of contract transaction. */ abstract executeImpl (parameters: { address: Address diff --git a/packages/agent/src/Store.ts b/packages/agent/src/Store.ts deleted file mode 100644 index 41d8d744d4..0000000000 --- a/packages/agent/src/Store.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . **/ -import type { CodeHash, Name } from '../index' -import { Console } from './Util' -import * as Compute from './Compute' - -/** A deploy store collects receipts corresponding to individual instances of Deployment, - * and can create Deployment objects with the data from the receipts. */ -export class DeployStore extends Map { - log = new Console(this.constructor.name) - - constructor () { - super() - } - - selected?: Compute.DeploymentState = undefined - - get (name?: Name): Compute.DeploymentState|undefined { - if (arguments.length === 0) { - return this.selected - } - return super.get(name!) - } - - set (name: Name, state: Partial|Compute.DeploymentState): this { - if (state instanceof Compute.Deployment) state = state.serialize() - return super.set(name, state) - } -} - -export class UploadStore extends Map { - log = new Console(this.constructor.name) - - constructor () { - super() - } - - get (codeHash: CodeHash): Compute.UploadedCode|undefined { - return super.get(codeHash) - } - - set (codeHash: CodeHash, value: Partial): this { - if (!(value instanceof Compute.UploadedCode)) { - value = new Compute.UploadedCode(value) - } - if (value.codeHash && (value.codeHash !== codeHash)) { - throw new Error('tried to store upload under different code hash') - } - return super.set(codeHash, value as Compute.UploadedCode) - } -} diff --git a/packages/agent/src/Transaction.ts b/packages/agent/src/Transaction.ts index 83d4b7884b..cb8ae4d909 100644 --- a/packages/agent/src/Transaction.ts +++ b/packages/agent/src/Transaction.ts @@ -5,15 +5,20 @@ import type { Block } from './Block' import type { Chain } from './Chain' import type { Agent } from './Agent' import { Logged } from './Util' -import * as Token from './Token' +import * as Token from './dlt/Token' /** A transaction in a block on a chain. */ export class Transaction { - block? : Block - hash: string - type: unknown - data: unknown - gasLimit: Token.Native[] - gasUsed: Token.Native[] - status: 'Pending'|'Accepted'|'Rejected' + constructor (properties: Pick) { + this.#block = properties.block + this.id = properties.id + } + + #block?: Block + get block () { + return this.#block + } + + id: string + } diff --git a/packages/agent/src/Compute.node.ts b/packages/agent/src/compute/Compile.node.ts similarity index 91% rename from packages/agent/src/Compute.node.ts rename to packages/agent/src/compute/Compile.node.ts index 1006aa7989..a0defde7f4 100644 --- a/packages/agent/src/Compute.node.ts +++ b/packages/agent/src/compute/Compile.node.ts @@ -1,8 +1,9 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import { Console, bold } from './Util' -import { CompiledCode } from './Compute' + +import { Console, bold } from '../Util' +import { CompiledCode } from './Compile' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' diff --git a/packages/agent/src/compute/Compile.ts b/packages/agent/src/compute/Compile.ts new file mode 100644 index 0000000000..5f36b43c08 --- /dev/null +++ b/packages/agent/src/compute/Compile.ts @@ -0,0 +1,138 @@ +/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . **/ + +import { Console, Logged, bold, assign, base16, SHA256 } from '../Util' +import { SourceCode } from './Source' +import type { CodeHash } from '../../index' + +/** The default Git ref when not specified. */ +export const HEAD = 'HEAD' + +export abstract class Compiler extends Logged { + /** Whether to enable build caching. + * When set to false, this compiler will rebuild even when + * binary and checksum are both present in wasm/ directory */ + caching: boolean = true + + /** Unique identifier of this compiler implementation. */ + abstract id: string + + /** Compile a source. + * `@hackbg/fadroma` implements dockerized and non-dockerized + * variants using its `build.impl.mjs` script. */ + abstract build (source: string|Partial, ...args: unknown[]): + Promise + + /** Build multiple sources. + * Default implementation of buildMany is sequential. + * Compiler classes may override this to optimize. */ + async buildMany (inputs: Partial[]): Promise { + const templates: CompiledCode[] = [] + for (const source of inputs) templates.push(await this.build(source)) + return templates + } +} + +/** An object representing a given compiled binary. */ +export class CompiledCode { + /** Code hash uniquely identifying the compiled code. */ + codeHash?: CodeHash + /** Location of the compiled code. */ + codePath?: string|URL + /** The compiled code. */ + codeData?: Uint8Array + + constructor (properties: Partial = {}) { + assign(this, properties, [ 'codeHash', 'codePath', 'codeData' ]) + } + + get [Symbol.toStringTag] () { + return [ + this.codePath && `${this.codePath}`, + this.codeHash && `${this.codeHash}`, + this.codeData && `(${this.codeData.length} bytes)` + ].filter(Boolean).join(' ') + } + + serialize (): { + codeHash?: CodeHash + codePath?: string + [key: string]: unknown + } { + const { codeHash, codePath } = this + return { codeHash, codePath: codePath?.toString() } + } + + status () { + const canFetch = !!this.codePath + const canFetchInfo = (!this.codePath) ? "can't fetch binary: codePath is not set" : '' + const canUpload = !!this.codeData || canFetch + let canUploadInfo = '' + if (!this.codeData && canFetch) { + canUploadInfo = "uploading will fetch the binary from the specified path" + } + if (this.codeData && !this.codePath) { + canUploadInfo = "uploading from buffer, codePath is unspecified" + } + return { + canFetch, + canFetchInfo, + canUpload, + canUploadInfo + } + } + + async fetch (): Promise { + const console = new Console(`CompiledCode(${bold(this[Symbol.toStringTag])})`) + if (this.codeData) { + console.debug("not fetching: codeData found; unset to refetch") + return this.codeData + } + if (!this.codePath) { + throw new Error("can't fetch: missing codePath") + } + this.codeData = await this.fetchImpl() + if (this.codeHash) { + const hash0 = String(this.codeHash).toLowerCase() + const hash1 = CompiledCode.toCodeHash(this.codeData) + if (hash0 !== hash1) { + throw new Error(`code hash mismatch: expected ${hash0}, computed ${hash1}`) + } + } else { + this.codeHash = CompiledCode.toCodeHash(this.codeData) + console.warn( + "\n TOFU: Computed code hash from fetched data:" + + `\n ${bold(this.codeHash)}` + + '\n Pin the expected code hash by setting the codeHash property.') + } + return this.codeData + } + + protected async fetchImpl () { + if (!this.codePath) { + throw new Error("can't fetch: codePath not set") + } + const request = await fetch(this.codePath!) + const response = await request.arrayBuffer() + return new Uint8Array(response) + } + + /** Compute the code hash if missing; throw if different. */ + async computeHash (): Promise { + const hash = CompiledCode.toCodeHash(await this.fetch()) + if (this.codeHash) { + if (this.codeHash.toLowerCase() !== hash.toLowerCase()) { + throw new Error(`computed code hash ${hash} did not match preexisting ${this.codeHash}`) + } + } else { + this.codeHash = hash + } + return this as this & { codeHash: CodeHash } + } + + static toCodeHash (data: Uint8Array): string { + return base16.encode(SHA256(data)).toLowerCase() + } +} + diff --git a/packages/agent/src/compute/Contract.ts b/packages/agent/src/compute/Contract.ts new file mode 100644 index 0000000000..46f3607c4d --- /dev/null +++ b/packages/agent/src/compute/Contract.ts @@ -0,0 +1,272 @@ +/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . **/ + +import { + Console, Logged, SHA256, assign, base16, bold, hideProperties, into, timestamp, timed +} from '../Util' +import type { + Address, Agent, Chain, ChainId, CodeId, CodeHash, Connection, Into, Label, Message, Name, + Token, TxHash, +} from '../../index' +import { + UploadedCode +} from './Upload' + +/** Represents a particular instance of a smart contract. + * + * Subclass this to add custom query and transaction methods corresponding + * to the contract's API. */ +export class Contract extends Logged { + /** Connection to the chain on which this contract is deployed. */ + chain?: Chain + /** Connection to the chain on which this contract is deployed. */ + agent?: Agent + /** Code upload from which this contract is created. */ + codeId?: CodeId + /** The code hash uniquely identifies the contents of the contract code. */ + codeHash?: CodeHash + /** The address uniquely identifies the contract instance. */ + address?: Address + /** The label is a human-friendly identifier of the contract. */ + label?: Label + /** The address of the account which instantiated the contract. */ + initBy?: Address + + constructor (properties: Partial) { + super((typeof properties === 'string')?{}:properties) + if (typeof properties === 'string') { + properties = { address: properties } + } + assign(this, properties, [ + 'chain', + 'agent', + 'codeId', + 'codeHash', + 'address', + 'label', + 'initBy' + ]) + } + + /** Execute a query on the specified instance as the specified Connection. */ + query (message: Message): Promise { + if (!this.chain) { + throw new Error("can't query instance without connection") + } + if (!this.address) { + throw new Error("can't query instance without address") + } + return this.chain.query(this as { address: Address }, message) + } + + /** Execute a transaction on the specified instance as the specified Connection. */ + execute (message: Message, options: Parameters[2] = {}): Promise { + if (!this.chain) { + throw new Error("can't transact with instance without connection") + } + if (!this.agent?.execute) { + throw new Error("can't transact with instance without authorizing the connection") + } + if (!this.address) { + throw new Error("can't transact with instance without address") + } + return this.agent?.execute(this as { address: Address }, message, options) + } +} + +export async function fetchCodeInstances ( + chain: Chain, ...args: Parameters +) { + let $C = Contract + let custom = false + if (typeof args[0] === 'function') { + $C = args.shift() as typeof Contract + let custom = true + } + if (!args[0]) { + throw new Error('Invalid arguments') + } + + if ((args[0] as any)[Symbol.iterator]) { + const result: Record> = {} + const codeIds: Record = {} + for (const codeId of args[0] as unknown as CodeId[]) { + codeIds[codeId] = $C + } + chain.log.debug(`Querying contracts with code ids ${Object.keys(codeIds).join(', ')}...`) + return timed( + ()=>chain.getConnection().fetchCodeInstancesImpl({ codeIds }), + ({elapsed})=>chain.log.debug(`Queried in ${elapsed}ms`)) + } + + if (typeof args[0] === 'object') { + if (custom) { + throw new Error('Invalid arguments') + } + const result: Record> = {} + chain.log.debug(`Querying contracts with code ids ${Object.keys(args[0]).join(', ')}...`) + const codeIds = args[0] as { [id: CodeId]: typeof Contract } + return timed( + ()=>chain.getConnection().fetchCodeInstancesImpl({ codeIds }), + ({elapsed})=>chain.log.debug(`Queried in ${elapsed}ms`)) + } + + if ((typeof args[0] === 'number')||(typeof args[0] === 'string')) { + const id = args[0] + chain.log.debug(`Querying contracts with code id ${id}...`) + const result = {} + return timed( + ()=>chain.getConnection().fetchCodeInstancesImpl({ codeIds: { [id]: $C } }), + ({elapsed})=>chain.log.debug(`Queried in ${elapsed}ms`)) + } + + throw new Error('Invalid arguments') +} + +export async function fetchContractInfo ( + chain: Chain, ...args: Parameters +) { + let $C = Contract + let custom = false + if (typeof args[0] === 'function') { + $C = args.shift() as typeof Contract + custom = true + } + if (!args[0]) { + throw new Error('Invalid arguments') + } + const { parallel = false } = (args[1] || {}) as { parallel?: boolean } + // Fetch single contract + if (typeof args[0] === 'string') { + chain.log.debug(`Fetching contract ${args[0]}`) + const contracts = await timed( + () => chain.getConnection().fetchContractInfoImpl({ + contracts: { [args[0] as unknown as Address]: $C } + }), + ({ elapsed }) => chain.log.debug( + `Fetched in ${bold(elapsed)}: contract ${args[0]}` + )) + if (custom) { + return new $C(contracts[args[0]]) + } else { + return contracts[args[0]] + } + } + // Fetch array of contracts + if ((args[0] as any)[Symbol.iterator]) { + const addresses = args[0] as unknown as Address[] + chain.log.debug(`Fetching ${addresses.length} contracts`) + const contracts: Record = {} + for (const address of addresses) { + contracts[address] = $C + } + const results = await timed( + ()=>chain.getConnection().fetchContractInfoImpl({ contracts, parallel }), + ({ elapsed }) => chain.log.debug( + `Fetched in ${bold(elapsed)}: ${addresses.length} contracts` + )) + if (custom) { + return addresses.map(address=>new $C(results[address])) + } else { + return addresses.map(address=>results[address]) + } + } + // Fetch map of contracts with different classes + if (typeof args[0] === 'object') { + if (custom) { + // Can't specify class as first argument + throw new Error('Invalid arguments') + } + const addresses = Object.keys(args[0]) as Address[] + chain.log.debug(`Querying info about ${addresses.length} contracts`) + const contracts = await timed( + ()=>chain.getConnection().fetchContractInfoImpl({ + contracts: args[0] as { [address: Address]: typeof Contract }, + parallel + }), + ({ elapsed }) => chain.log.debug( + `Queried in ${bold(elapsed)}: info about ${addresses.length} contracts` + )) + const result: Record = {} + for (const address of addresses) { + result[address] = new args[0][address](contracts[address]) + } + return result + } + throw new Error('Invalid arguments') +} + +export async function query (chain: Chain, ...args: Parameters) { + const [contract, message] = args + return timed( + ()=>chain.getConnection().queryImpl({ + ...(typeof contract === 'string') ? { address: contract } : contract, + message + }), + ({ elapsed, result }) => chain.log.debug( + `Queried in ${bold(elapsed)}s: `, JSON.stringify(result) + ) + ) +} + +export async function instantiate (agent: Agent, ...args: Parameters) { + let [contract, options] = args + if (typeof contract === 'string') { + contract = new UploadedCode({ codeId: contract }) + } + if (isNaN(Number(contract.codeId))) { + throw new Error(`can't instantiate contract with missing code id: ${contract.codeId}`) + } + if (!contract.codeId) { + throw new Error("can't instantiate contract without code id") + } + if (!options.label) { + throw new Error("can't instantiate contract without label") + } + if (!(options.initMsg||('initMsg' in options))) { + throw new Error("can't instantiate contract without init message") + } + const { codeId, codeHash } = contract + const result = await timed( + () => into(options.initMsg).then(initMsg=>agent.getConnection().instantiateImpl({ + ...options, + codeId, + codeHash, + initMsg + })), + ({ elapsed, result }) => agent.log.debug( + `Instantiated in ${bold(elapsed)}:`, + `code id ${bold(String(codeId))} as `, + `${bold(options.label)} (${result.address})` + ) + ) + return new Contract({ + ...options, ...result + }) as Contract & { + address: Address + } +} + +export async function execute (agent: Agent, ...args: Parameters) { + let [contract, message, options] = args + if (typeof contract === 'string') { + contract = new Contract({ address: contract }) + } + if (!contract.address) { + throw new Error("agent.execute: no contract address") + } + const { address } = contract + let method = (typeof message === 'string') ? message : Object.keys(message||{})[0] + return timed( + () => agent.getConnection().executeImpl({ + ...contract as { address: Address, codeHash: CodeHash }, + message, + ...options + }), + ({ elapsed }) => agent.log.debug( + `Executed in ${bold(elapsed)}:`, + `tx ${bold(method||'(???)')} of ${bold(address)}` + ) + ) +} diff --git a/packages/agent/src/compute/Source.ts b/packages/agent/src/compute/Source.ts new file mode 100644 index 0000000000..15c0d07278 --- /dev/null +++ b/packages/agent/src/compute/Source.ts @@ -0,0 +1,137 @@ +import { Logged, assign } from '../Util' + +/** An object representing a given source code. */ +export class SourceCode extends Logged { + /** URL pointing to Git upstream containing the canonical source code. */ + sourceOrigin?: string|URL + /** Pointer to the source commit. */ + sourceRef?: string + /** Path to local checkout of the source code (with .git directory if sourceRef is set). */ + sourcePath?: string + /** Whether the code contains uncommitted changes. */ + sourceDirty?: boolean + + constructor (properties: Partial = {}) { + super(properties) + assign(this, properties, [ + 'sourcePath', 'sourceOrigin', 'sourceRef', 'sourceDirty' + ]) + } + + get [Symbol.toStringTag] () { + return [ + this.sourcePath ? this.sourcePath : `(missing source)`, + this.sourceOrigin && `(from ${this.sourceOrigin})`, + this.sourceRef && `(at ${this.sourceRef})`, + this.sourceDirty && `(modified)` + ].filter(Boolean).join(' ') + } + + serialize (): { + sourceOrigin?: string + sourceRef?: string + sourcePath?: string + sourceDirty?: boolean + [key: string]: unknown + } { + const { sourcePath, sourceOrigin, sourceRef, sourceDirty } = this + return { sourcePath, sourceOrigin: sourceOrigin?.toString(), sourceRef, sourceDirty } + } + + status () { + const canFetch = !!this.sourceOrigin + const canFetchInfo = (!this.sourceOrigin) ? "missing sourceOrigin" : undefined + const canCompile = !!this.sourcePath || canFetch + const canCompileInfo = (!this.sourcePath) ? "missing sourcePath" : undefined + return { canFetch, canFetchInfo, canCompile, canCompileInfo } + } +} + +export class RustSourceCode extends SourceCode { + /** Path to the crate's Cargo.toml under sourcePath */ + cargoToml?: string + /** Path to the workspace's Cargo.toml in the source tree. */ + cargoWorkspace?: string + /** Name of crate. */ + cargoCrate?: string + /** List of crate features to enable during build. */ + cargoFeatures?: string[]|Set + + constructor (properties?: Partial) { + super(properties) + assign(this, properties, [ + 'cargoToml', 'cargoWorkspace', 'cargoCrate', 'cargoFeatures' + ]) + } + + get [Symbol.toStringTag] () { + return [ + this.cargoWorkspace + ? ((this.cargoCrate ? `crate ${this.cargoCrate} from` : 'unknown crate from') + +this.cargoWorkspace) + : this.cargoToml, + super[Symbol.toStringTag], + ].filter(Boolean).join(' ') + } + + serialize (): ReturnType & { + cargoWorkspace?: string + cargoCrate?: string + cargoFeatures?: string[] + [key: string]: unknown + } { + const { + cargoToml, + cargoWorkspace, + cargoCrate, + cargoFeatures + } = this + return { + ...super.serialize(), + cargoToml, + cargoWorkspace, + cargoCrate, + cargoFeatures: cargoFeatures ? [...cargoFeatures] : undefined + } + } + + status () { + const status = super.status() + + const { canFetch, canFetchInfo } = status + const hasWorkspace = !!this.cargoWorkspace + const hasCrateToml = !!this.cargoToml + const hasCrateName = !!this.cargoCrate + const canCompile = ( + ( hasWorkspace && !hasCrateToml && hasCrateName) || + (!hasWorkspace && hasCrateToml && !hasCrateName) + ) + + let { canCompileInfo } = status + let error + if (hasWorkspace) { + if (hasCrateToml) { + error = "cargoWorkspace is set, cargoToml must be unset" + } + if (!hasCrateName) { + error = "when cargoWorkspace is set, cargoCrate must also be set" + } + } else if (hasCrateToml) { + if (hasCrateName) { + error = "when cargoToml is set, cargoCrate must be unset" + } + } else { + error = "set either cargoToml or cargoWorkspace & cargoCrate" + } + if (canCompileInfo || error) { + canCompileInfo = [canCompileInfo, error].filter(Boolean).join('; ') + } + + return { + canFetch, + canFetchInfo, + canCompile, + canCompileInfo + } + } +} diff --git a/packages/agent/src/compute/Upload.ts b/packages/agent/src/compute/Upload.ts new file mode 100644 index 0000000000..1442af65fa --- /dev/null +++ b/packages/agent/src/compute/Upload.ts @@ -0,0 +1,178 @@ +/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . **/ +import { + Console, Logged, SHA256, assign, base16, bold, hideProperties, into, timestamp, timed +} from '../Util' +import type { + Address, Agent, Chain, ChainId, CodeId, CodeHash, Connection, Into, Label, Message, Name, + Token, TxHash, +} from '../../index' +import { + CompiledCode +} from './Compile' + +export class UploadStore extends Map { + log = new Console(this.constructor.name) + + constructor () { + super() + } + + get (codeHash: CodeHash): UploadedCode|undefined { + return super.get(codeHash) + } + + set (codeHash: CodeHash, value: Partial): this { + if (!(value instanceof UploadedCode)) { + value = new UploadedCode(value) + } + if (value.codeHash && (value.codeHash !== codeHash)) { + throw new Error('tried to store upload under different code hash') + } + return super.set(codeHash, value as UploadedCode) + } +} + +/** Represents a contract's code, in binary form, uploaded to a given chain. */ +export class UploadedCode { + /** Code hash uniquely identifying the compiled code. */ + codeHash?: CodeHash + /** ID of chain on which this contract is uploaded. */ + chainId?: ChainId + /** Code ID representing the identity of the contract's code on a specific chain. */ + codeId?: CodeId + /** TXID of transaction that performed the upload. */ + uploadTx?: TxHash + /** address of agent that performed the upload. */ + uploadBy?: Address + /** address of agent that performed the upload. */ + uploadGas?: string|number + + constructor (properties: Partial = {}) { + assign(this, properties, [ + 'codeHash', 'chainId', 'codeId', 'uploadTx', 'uploadBy', 'uploadGas', + ]) + } + + get [Symbol.toStringTag] () { + return [ + this.codeId || 'no code id', + this.chainId || 'no chain id', + this.codeHash || '(no code hash)' + ].join('; ') + } + + serialize (): { + codeHash?: CodeHash + chainId?: ChainId + codeId?: CodeId + uploadTx?: TxHash + uploadBy?: Address + uploadGas?: string|number + uploadInfo?: string + [key: string]: unknown + } { + let { codeHash, chainId, codeId, uploadTx, uploadBy, uploadGas } = this + if ((typeof this.uploadBy === 'object')) { + uploadBy = (uploadBy as any).identity?.address + } + return { codeHash, chainId, codeId, uploadTx, uploadBy: uploadBy as string, uploadGas } + } + + get canInstantiate (): boolean { + return !!(this.chainId && this.codeId) + } + + get canInstantiateInfo (): string|undefined { + return ( + (!this.chainId) ? "can't instantiate: no chain id" : + (!this.codeId) ? "can't instantiate: no code id" : + undefined + ) + } +} + +export async function upload (agent: Agent, ...args: Parameters) { + let [code, options] = args + let template: Uint8Array + if (code instanceof Uint8Array) { + template = code + } else { + const { CompiledCode } = _$_HACK_$_ + if (typeof code === 'string' || code instanceof URL) { + code = new CompiledCode({ codePath: code }) + } else { + code = new CompiledCode(code) + } + const t0 = performance.now() + code = code as CompiledCode + template = await (code as any).fetch() + const t1 = performance.now() - t0 + agent.log.log( + `Fetched in`, `${bold((t1/1000).toFixed(6))}s: code hash`, + bold(code.codeHash), `(${bold(String(code.codeData?.length))} bytes` + ) + } + agent.log.debug(`Uploading ${bold((code as any).codeHash)}`) + const result = await timed( + () => agent.getConnection().uploadImpl({ + ...options, + binary: template + }), + ({elapsed, result}: any) => agent.log.debug( + `Uploaded in ${bold(elapsed)}:`, + `code with hash ${bold(result.codeHash)} as code id ${bold(String(result.codeId))}`, + )) + return new UploadedCode({ + ...template, ...result as any + }) as UploadedCode & { + chainId: ChainId + codeId: CodeId + } +} + +export async function fetchCodeInfo ( + chain: Chain, ...args: Parameters|[] +) { + if (args.length === 0) { + chain.log.debug('Querying all codes...') + return timed( + ()=>chain.getConnection().fetchCodeInfoImpl(), + ({ elapsed, result }) => chain.log.debug( + `Queried in ${bold(elapsed)}: all codes` + )) + } + if (args.length === 1) { + if (args[0] instanceof Array) { + const codeIds = args[0] as Array + const { parallel } = args[1] as { parallel?: boolean } + chain.log.debug(`Querying info about ${codeIds.length} code IDs...`) + return timed( + ()=>chain.getConnection().fetchCodeInfoImpl({ codeIds, parallel }), + ({ elapsed, result }) => chain.log.debug( + `Queried in ${bold(elapsed)}: info about ${codeIds.length} code IDs` + )) + } else { + const codeIds = [args[0] as CodeId] + const { parallel } = args[1] as { parallel?: boolean } + chain.log.debug(`Querying info about code id ${args[0]}...`) + return timed( + ()=>chain.getConnection().fetchCodeInfoImpl({ codeIds, parallel }), + ({ elapsed }) => chain.log.debug( + `Queried in ${bold(elapsed)}: info about code id ${codeIds[0]}` + )) + } + } else { + throw new Error('fetchCodeInfo takes 0 or 1 arguments') + } +} + +/** The `CompiledCode` class has an alternate implementation for non-browser environments. + * This is because Next.js tries to parse the dynamic `import('node:...')` calls used by + * the `fetch` methods. (Which were made dynamic exactly to avoid such a dual-implementation + * situation in the first place - but Next is smart and adds a problem where there isn't one.) + * So, it defaults to the version that can only fetch from URL using the global fetch method; + * but the non-browser entrypoint substitutes `CompiledCode` in `_$_HACK_$_` with the + * version which can also load code from disk (`LocalCompiledCode`). Ugh. */ +export const _$_HACK_$_ = { CompiledCode: CompiledCode } diff --git a/packages/agent/src/dlt/Bank.ts b/packages/agent/src/dlt/Bank.ts new file mode 100644 index 0000000000..5632358599 --- /dev/null +++ b/packages/agent/src/dlt/Bank.ts @@ -0,0 +1,81 @@ +import { timed, bold } from '../Util' +import type { Chain, Agent } from '../../index' + +export async function fetchBalance (chain: Chain, ...args: Parameters) { + throw new Error('unimplemented!') + //[>* Get balance of current identity in main token. <] + //get balance () { + //if (!chain.identity?.address) { + //throw new Error('not authenticated, use .getBalance(token, address)') + //} else if (!chain.defaultDenom) { + //throw new Error('no default token for chain chain, use .getBalance(token, address)') + //} else { + //return chain.getBalanceOf(chain.identity.address) + //} + //} + /** Get the balance in a native token of a given address, + * either in chain connection's gas token, + * or in another given token. */ + //getBalanceOf (address: Address|{ address: Address }, token?: string) { + //if (!address) { + //throw new Error('pass (address, token?) to getBalanceOf') + //} + //token ??= chain.defaultDenom + //if (!token) { + //throw new Error('no token for balance query') + //} + //const addr = (typeof address === 'string') ? address : address.address + //if (addr === chain.identity?.address) { + //chain.log.debug('Querying', bold(token), 'balance') + //} else { + //chain.log.debug('Querying', bold(token), 'balance of', bold(addr)) + //} + //return timed( + //chain.doGetBalance.bind(chain, token, addr), + //({ elapsed, result }) => chain.log.debug( + //`Queried in ${elapsed}s: ${bold(address)} has ${bold(result)} ${token}` + //) + //) + //} + /** Get the balance in a given native token, of + * either chain connection's identity's address, + * or of another given address. */ + //getBalanceIn (token: string, address?: Address|{ address: Address }) { + //if (!token) { + //throw new Error('pass (token, address?) to getBalanceIn') + //} + //address ??= chain.identity?.address + //if (!address) { + //throw new Error('no address for balance query') + //} + //const addr = (typeof address === 'string') ? address : address.address + //if (addr === chain.identity?.address) { + //chain.log.debug('Querying', bold(token), 'balance') + //} else { + //chain.log.debug('Querying', bold(token), 'balance of', bold(addr)) + //} + //return timed( + //chain.doGetBalance.bind(chain, token, addr), + //({ elapsed, result }) => chain.log.debug( + //`Queried in ${elapsed}s: balance of ${bold(address)} is ${bold(result)}` + //) + //) + //} +} + +export async function send (agent: Agent, ...args: Parameters) { + const [outputs, options] = args + for (const [recipient, amounts] of Object.entries(outputs)) { + agent.log.debug(`Sending to ${bold(recipient)}:`) + for (const [token, amount] of Object.entries(amounts)) { + agent.log.debug(` ${amount} ${token}`) + } + } + return await timed( + ()=>agent.getConnection().sendImpl({ + ...options||{}, + outputs + }), + ({elapsed})=>`Sent in ${bold(elapsed)}` + ) +} diff --git a/packages/agent/src/Governance.ts b/packages/agent/src/dlt/Governance.ts similarity index 51% rename from packages/agent/src/Governance.ts rename to packages/agent/src/dlt/Governance.ts index d4c278bc63..835eec142b 100644 --- a/packages/agent/src/Governance.ts +++ b/packages/agent/src/dlt/Governance.ts @@ -1,18 +1,19 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import { assign } from './Util' -import type { Connection, Address } from '../index' +import { assign } from '../Util' +import type { Connection, Address } from '../../index' export type ProposalResult = 'Pass'|'Fail' export class Proposal { - chain?: Connection id: bigint votes: Vote[] result: 'Pass'|'Fail' - constructor (properties: Partial = {}) { - assign(this, properties, ['chain', 'id', 'votes', 'result']) + constructor (properties: Pick) { + this.id = properties.id + this.votes = properties.votes + this.result = properties.result } } @@ -23,7 +24,10 @@ export class Vote { voter: Address power: bigint value: VoteValue - constructor (properties: Partial = {}) { - assign(this, properties, ['proposal', 'voter', 'power', 'value']) + constructor (properties: Pick) { + this.proposal = properties.proposal + this.voter = properties.voter + this.power = properties.power + this.value = properties.value } } diff --git a/packages/agent/src/Staking.ts b/packages/agent/src/dlt/Staking.ts similarity index 53% rename from packages/agent/src/Staking.ts rename to packages/agent/src/dlt/Staking.ts index 7ac48487bb..85a6641928 100644 --- a/packages/agent/src/Staking.ts +++ b/packages/agent/src/dlt/Staking.ts @@ -1,13 +1,14 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import { assign } from './Util' -import type { Connection, Address } from '../index' +import { assign } from '../Util' +import type { Connection, Address } from '../../index' export class Validator { chain?: Connection address: Address - constructor (properties: Partial = {}) { - assign(this, properties, ['chain', 'address']) + constructor (properties: Pick & Partial>) { + this.chain = properties.chain + this.address = properties.address } } diff --git a/packages/agent/src/Token.ts b/packages/agent/src/dlt/Token.ts similarity index 98% rename from packages/agent/src/Token.ts rename to packages/agent/src/dlt/Token.ts index f83e639a35..59aeba0529 100644 --- a/packages/agent/src/Token.ts +++ b/packages/agent/src/dlt/Token.ts @@ -1,7 +1,7 @@ /** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ -import type { Address, Uint128 } from './Types' +import type { Address, Uint128 } from '../Types' /** Represents some amount of native token. */ export interface ICoin { amount: Uint128, denom: string } diff --git a/packages/agent/stub/stub-backend.ts b/packages/agent/stub/StubBackend.ts similarity index 93% rename from packages/agent/stub/stub-backend.ts rename to packages/agent/stub/StubBackend.ts index c0b76a7886..370a961a8b 100644 --- a/packages/agent/stub/stub-backend.ts +++ b/packages/agent/stub/StubBackend.ts @@ -1,9 +1,9 @@ import type { Address, CodeId, ChainId, CodeHash } from '../index' import { Backend } from '../src/Backend' -import * as Token from '../src/Token' +import * as Token from '../src/dlt/Token' import { assign, randomBech32, base16, SHA256 } from '../src/Util' import { Identity } from '../src/Identity' -import { ContractInstance } from '../src/Compute' +import { Contract } from '../src/compute/Contract' export type StubAccount = { address: Address, mnemonic?: string } export type StubBalances = Record @@ -88,7 +88,7 @@ export class StubBackend extends Backend { } async instantiate (args: { initBy: Address, codeId: CodeId }): - Promise + Promise { const { codeId, initBy } = args const address = randomBech32(this.prefix) @@ -98,7 +98,7 @@ export class StubBackend extends Backend { } code.instances.add(address) this.instances.set(address, { address, codeId, initBy }) - return new ContractInstance({ address, codeId }) as ContractInstance & { address: Address } + return new Contract({ address, codeId }) as Contract & { address: Address } } async execute (...args: unknown[]): Promise { diff --git a/packages/agent/stub/stub-chain.ts b/packages/agent/stub/StubChain.ts similarity index 74% rename from packages/agent/stub/stub-chain.ts rename to packages/agent/stub/StubChain.ts index b5f29de256..5354a9a2df 100644 --- a/packages/agent/stub/stub-chain.ts +++ b/packages/agent/stub/StubChain.ts @@ -7,19 +7,21 @@ import { Identity } from '../src/Identity' import { Backend } from '../src/Backend' import { Chain } from '../src/Chain' import { Connection } from '../src/Connection' -import { Contract, UploadedCode, ContractInstance } from '../src/Compute' -import * as Token from '../src/Token' +import { Contract } from '../src/compute/Contract' +import { UploadedCode } from '../src/compute/Upload' +import * as Token from '../src/dlt/Token' -import { StubBatch, StubBlock } from './stub-tx' -import { StubAgent, StubIdentity } from './stub-identity' -import { StubBackend } from './stub-backend' +import { StubBatch, StubBlock } from './StubTx' +import { StubAgent, StubIdentity } from './StubIdentity' +import { StubBackend } from './StubBackend' export class StubChain extends Chain { constructor ( - properties: ConstructorParameters[0] - & Pick + properties: Omit[0], 'chainId'> + & Partial[0], 'chainId'>> + & Partial> = {} ) { - super(properties) + super({ chainId: 'stub', ...properties }) assign(this, properties, ['backend']) this.backend ??= new StubBackend({}) } @@ -50,32 +52,39 @@ export class StubChain extends Chain { getConnection (): StubConnection { return new StubConnection({ - backend: this.backend, - chainId: this.chainId, - url: 'stub', - api: {}, + chain: this, + url: 'stub', + api: {}, }) } } export class StubConnection extends Connection { - constructor ( - properties: ConstructorParameters[0] - & Pick - ) { + constructor (properties: ConstructorParameters[0]) { super(properties) assign(this, properties, ['backend']) - this.backend ??= new StubBackend() } - backend: StubBackend + get chain (): StubChain { + return super.chain as StubChain + } + + get backend (): StubBackend { + return this.chain.backend + } override fetchHeightImpl () { return this.fetchBlockImpl().then(({height})=>height) } - override fetchBlockImpl () { - return Promise.resolve(new StubBlock({ height: + new Date() })) + override fetchBlockImpl (): Promise { + const timestamp = new Date() + return Promise.resolve(new StubBlock({ + chain: this.chain, + id: `stub${+timestamp}`, + height: +timestamp, + timestamp: timestamp.toISOString() + })) } override fetchBalanceImpl ( @@ -93,6 +102,9 @@ export class StubConnection extends Connection { ): Promise> { + if (!args[0]) { + throw new Error('Invalid argument') + } if (!args[0].codeIds) { return Promise.resolve(Object.fromEntries( [...this.backend.uploads.entries()].map( @@ -100,7 +112,7 @@ export class StubConnection extends Connection { ) )) } else { - const results = {} + const results: Record = {} for (const id of args[0].codeIds) { results[id] = new UploadedCode(this.backend.uploads.get(id)) } @@ -122,7 +134,7 @@ export class StubConnection extends Connection { ): Promise> { - const results = {} + const results: Record = {} for (const address of Object.keys(args[0].contracts)) { const contract = this.backend.instances.get(address) if (!contract) { diff --git a/packages/agent/stub/stub-compiler.ts b/packages/agent/stub/StubCompiler.ts similarity index 83% rename from packages/agent/stub/stub-compiler.ts rename to packages/agent/stub/StubCompiler.ts index 0ac062875f..6089082383 100644 --- a/packages/agent/stub/stub-compiler.ts +++ b/packages/agent/stub/StubCompiler.ts @@ -2,8 +2,8 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ import { Console } from '../src/Util' -import type { SourceCode } from '../src/Compute' -import { Compiler, CompiledCode } from '../src/Compute' +import type { SourceCode } from '../src/compute/Source' +import { Compiler, CompiledCode } from '../src/compute/Compile' /** A compiler that does nothing. Used for testing. */ export class StubCompiler extends Compiler { diff --git a/packages/agent/stub/stub-identity.ts b/packages/agent/stub/StubIdentity.ts similarity index 83% rename from packages/agent/stub/stub-identity.ts rename to packages/agent/stub/StubIdentity.ts index ed2e065828..6bb4a72a77 100644 --- a/packages/agent/stub/stub-identity.ts +++ b/packages/agent/stub/StubIdentity.ts @@ -7,10 +7,11 @@ import { assign } from '../src/Util' import { Agent } from '../src/Agent' import { Identity } from '../src/Identity' import { SigningConnection } from '../src/Connection' -import { ContractInstance, UploadedCode } from '../src/Compute' -import { StubBatch } from './stub-tx' -import type { StubChain } from './stub-chain' -import type { StubBackend } from './stub-backend' +import { Contract } from '../src/compute/Contract' +import { UploadedCode } from '../src/compute/Upload' +import { StubBatch } from './StubTx' +import type { StubChain } from './StubChain' +import type { StubBackend } from './StubBackend' export class StubIdentity extends Identity { constructor (properties: ConstructorParameters[0] & { mnemonic?: string } = {}) { @@ -24,7 +25,7 @@ export class StubAgent extends Agent { getConnection (): StubSigningConnection { return new StubSigningConnection({ - address: this.identity.address, + address: this.identity.address!, backend: this.chain.backend }) } @@ -40,7 +41,8 @@ export class StubAgent extends Agent { export class StubSigningConnection extends SigningConnection { constructor (properties: Pick) { super() - assign(this, properties, ['backend', 'address']) + this.backend = properties.backend + this.address = properties.address } backend: StubBackend @@ -81,11 +83,14 @@ export class StubSigningConnection extends SigningConnection { async instantiateImpl ( ...args: Parameters - ): Promise { - return new ContractInstance(await this.backend.instantiate({ + ): Promise { + if (!args[0].codeId) { + throw new Error("Missing code ID") + } + return new Contract(await this.backend.instantiate({ initBy: this.address!, codeId: args[0].codeId - })) as ContractInstance & { + })) as Contract & { address: Address } } diff --git a/packages/agent/stub/stub-tx.ts b/packages/agent/stub/StubTx.ts similarity index 92% rename from packages/agent/stub/stub-tx.ts rename to packages/agent/stub/StubTx.ts index 661a2239ae..d63ec48799 100644 --- a/packages/agent/stub/stub-tx.ts +++ b/packages/agent/stub/StubTx.ts @@ -5,8 +5,8 @@ import { Block } from '../src/Block' import { Batch } from '../src/Batch' import { Transaction } from '../src/Transaction' -import type { StubChain } from './stub-chain' -import type { StubAgent } from './stub-identity' +import type { StubChain } from './StubChain' +import type { StubAgent } from './StubIdentity' export class StubBlock extends Block { async getTransactionsById (): Promise> { diff --git a/packages/agent/stub/stub.ts b/packages/agent/stub/stub.ts index 6eaec5fc90..4b5c2156cf 100644 --- a/packages/agent/stub/stub.ts +++ b/packages/agent/stub/stub.ts @@ -3,19 +3,19 @@ along with this program. If not, see . **/ export { StubBackend as Backend, -} from './stub-backend' +} from './StubBackend' export { StubChain as Chain, StubConnection as Connection, -} from './stub-chain' +} from './StubChain' export { StubAgent as Agent, StubIdentity as Identity, -} from './stub-identity' +} from './StubIdentity' export { StubBlock as Block, StubBatch as Batch, -} from './stub-tx' +} from './StubTx' export { StubCompiler as Compiler, -} from './stub-compiler' +} from './StubCompiler' diff --git a/packages/agent/test/agent-chain.test.ts b/packages/agent/test/agent-chain.test.ts index f2e1509512..2076247e05 100644 --- a/packages/agent/test/agent-chain.test.ts +++ b/packages/agent/test/agent-chain.test.ts @@ -2,12 +2,16 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ import assert, { equal, throws, rejects } from 'node:assert' -import { Chain, Connection, Backend, } from '../agent-chain' -import { Identity } from '../agent-identity' -import { Batch } from '../agent-tx' -import { Contract, ContractInstance } from '../agent-compute.browser' +import { Error } from '../src/Util' +import { + Chain, + Connection, + Backend, + Identity, + Batch, + Compute +} from '../index' import { fixture } from '@fadroma/fixtures' -import { Error } from '../agent-core' import * as Stub from '../stub/stub' import { Suite } from '@hackbg/ensuite' @@ -79,7 +83,7 @@ export async function testAuth () { await agent.chain.fetchCodeInstances('1') rejects(agent.chain.fetchCodeInstances(null as any)) await agent.chain.fetchCodeInstances(['1', '2']) - await agent.chain.fetchCodeInstances({'1': Contract, '2': Contract}) + await agent.chain.fetchCodeInstances({'1': Compute.Contract, '2': Compute.Contract}) await agent.execute('stub', {}, {}) await agent.execute('stub', 'method', {}) @@ -88,13 +92,13 @@ export async function testAuth () { await agent.execute({ address: 'stub' }, 'method', {}) await agent.execute({ address: 'stub' }, {'method':'crystal'}, {}) - throws(()=>new Stub.Connection().balance) - throws(()=>new Stub.Connection().getBalanceOf(null as any)) - throws(()=>new Stub.Connection().getBalanceOf('addr', false as any)) - assert(await new Stub.Connection().getBalanceOf('addr')) - throws(()=>new Stub.Connection().getBalanceIn(null as any)) - throws(()=>new Stub.Connection().getBalanceIn('token', null as any)) - assert(await new Stub.Connection().getBalanceIn('token', 'addr')) + //throws(()=>new Stub.Connection().balance) + //throws(()=>new Stub.Connection().getBalanceOf(null as any)) + //throws(()=>new Stub.Connection().getBalanceOf('addr', false as any)) + //assert(await new Stub.Connection().getBalanceOf('addr')) + //throws(()=>new Stub.Connection().getBalanceIn(null as any)) + //throws(()=>new Stub.Connection().getBalanceIn('token', null as any)) + //assert(await new Stub.Connection().getBalanceIn('token', 'addr')) } export async function testBatch () { @@ -112,17 +116,17 @@ export async function testBatch () { export async function testClient () { const instance = { address: 'addr', codeHash: 'code-hash-stub', codeId: '100' } - const agent = new Stub.Agent({}) + const agent = new Stub.Agent({ chain: new Stub.Chain(), identity: new Identity() }) const client = await agent.chain.fetchContractInfo('addr') assert.equal(client.agent, agent) assert.equal(client.address, 'addr') await client.query({foo: 'bar'}) await client.execute({foo: 'bar'}) await agent.chain.fetchContractInfo('addr') - assert(new Contract({ address: 'addr' })) - assert.throws(()=>new Contract({}).query({})) - assert.throws(()=>new Contract({ agent }).query({})) - assert.throws(()=>new Contract({}).execute({})) - assert.throws(()=>new Contract({ agent }).execute({})) - assert.throws(()=>new Contract({ agent: {} as any }).execute({})) + assert(new Compute.Contract({ address: 'addr' })) + assert.throws(()=>new Compute.Contract({}).query({})) + assert.throws(()=>new Compute.Contract({ agent }).query({})) + assert.throws(()=>new Compute.Contract({}).execute({})) + assert.throws(()=>new Compute.Contract({ agent }).execute({})) + assert.throws(()=>new Compute.Contract({ agent: {} as any }).execute({})) } diff --git a/packages/agent/test/agent-compute.test.ts b/packages/agent/test/agent-compute.test.ts index 38dba38158..020bbc50b9 100644 --- a/packages/agent/test/agent-compute.test.ts +++ b/packages/agent/test/agent-compute.test.ts @@ -2,9 +2,9 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . **/ import assert, { equal, deepEqual, rejects, throws } from 'node:assert' -import { Contract } from '../agent-chain' import * as Stub from '../stub/stub' import { + Contract, SourceCode, RustSourceCode, CompiledCode as BaseCompiledCode, @@ -12,14 +12,14 @@ import { ContractCode, ContractInstance, UploadedCode, -} from '../agent-compute.browser' +} from '../src/Compute' import { LocalCompiledCode as CompiledCode, -} from '../agent-compute.node' +} from '../src/Compute.node' import { UploadStore, DeployStore -} from '../agent-store' +} from '../src/Store' import { Suite } from '@hackbg/ensuite' export default new Suite([ diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index 9e99aea493..0f5633d20d 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -2,27 +2,35 @@ "files": [ "index.ts", "index.node.ts", + "commands.ts", + "src/Agent.ts", "src/Backend.ts", "src/Batch.ts", "src/Block.ts", "src/Chain.ts", - "src/Compute.node.ts", - "src/Compute.ts", "src/Connection.ts", - "src/Governance.ts", "src/Identity.ts", - "src/Staking.ts", - "src/Store.ts", - "src/Token.ts", "src/Transaction.ts", "src/Types.ts", "src/Util.ts", + + "src/compute/Source.ts", + "src/compute/Compile.ts", + "src/compute/Compile.node.ts", + "src/compute/Upload.ts", + "src/compute/Contract.ts", + + "src/dlt/Bank.ts", + "src/dlt/Governance.ts", + "src/dlt/Staking.ts", + "src/dlt/Token.ts", + "stub/stub.ts", - "stub/stub-chain.ts", - "stub/stub-compiler.ts", - "stub/stub-identity.ts", - "stub/stub-tx.ts" + "stub/StubChain.ts", + "stub/StubCompiler.ts", + "stub/StubIdentity.ts", + "stub/StubTx.ts" ], "include": [], "exclude": [ ".ubik", "*.dist.*", "node_modules" ], @@ -31,8 +39,10 @@ "module": "esnext", "moduleResolution": "node", "esModuleInterop": true, + "downlevelIteration": true, "allowSyntheticDefaultImports": true, "noEmitOnError": true, - "skipLibCheck": true + "skipLibCheck": true, + "strict": true } } diff --git a/packages/deploy/deploy.ts b/packages/deploy/deploy.ts new file mode 100644 index 0000000000..9b80ccdae5 --- /dev/null +++ b/packages/deploy/deploy.ts @@ -0,0 +1,432 @@ +/** Fadroma. Copyright (C) 2023 Hack.bg. License: GNU AGPLv3 or custom. + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . **/ + +import { Console, Logged, assign, bold, hideProperties, timestamp, } from '../Util' +import { SourceCode, RustSourceCode, } from './Source' +import { CompiledCode } from './Compile' +import { UploadStore, UploadedCode } from './Upload' +import { Contract } from './Contract' +import type { + Name, CodeId, CodeHash, Address, Agent, ChainId, Label, Into, Token, TxHash, Compiler, Message +} from '../../index' + +/** A collection of contracts. */ +export class Deployment extends Map { + log = new Console('Deployment') + + name: string = timestamp() + + static fromSnapshot ({ name, units = {} }: DeploymentState) { + const deployment = new this({ name }) + for (const [key, value] of Object.entries(units)) { + deployment.set(key, value) + } + return deployment + } + + constructor (properties: Partial = {}) { + super() + assign(this, properties, [ 'name' ]) + this.name ??= timestamp() + this.log.label = `deployment(${bold(this.name)})` + } + + serialize () { + const units: Record> = {} + for (const [key, value] of this.entries()) { + units[key] = value.serialize() + } + return { name: this.name, units: Object.fromEntries(this.entries()) } + } + + set (name: string, unit: DeploymentUnit): this { + if (!(unit instanceof DeploymentUnit)) { + throw new Error('a Deployment can only contain instances of DeploymentUnit') + } + return super.set(name, unit) + } + + /** Define a template, representing code that can be compiled + * and uploaded, but will not be automatically instantiated. + * This can then be used to define multiple instances of + * the same code. */ + template (name: string, properties?: + ( + |({ language: 'rust' } & Partial) + |({ language?: undefined } & Partial) + )& + Partial & + Partial + ): ContractTemplate { + const source = + properties?.language === 'rust' ? new RustSourceCode(properties) + : new SourceCode(properties) + const compiled = new CompiledCode(properties) + const uploaded = new UploadedCode(properties) + const unit = new ContractTemplate({ + deployment: this, name, source, compiled, uploaded + }) + hideProperties(unit, 'name', 'deployment', 'isTemplate') + this.set(name, unit) + return unit + } + + /** Define a contract that will be automatically compiled, uploaded, + * and instantiated as part of this deployment. */ + contract (name: string, properties?: + ( + |({ language: 'rust' } & Partial) + |({ language?: undefined } & Partial) + )& + Partial & + Partial & + Partial + ): ContractInstance { + const source = + properties?.language === 'rust' ? new RustSourceCode(properties) + : new SourceCode(properties) + const compiled = new CompiledCode(properties) + const uploaded = new UploadedCode(properties) + const unit = new ContractInstance({ + deployment: this, name, source, compiled, uploaded, ...properties + }) + hideProperties(unit, 'name', 'deployment', 'isTemplate') + this.set(name, unit) + return unit + } + + addContract (...args: Parameters) { + this.contract.apply(this, args) + return this + } + + addContracts (...args: Parameters) { + this.template.apply(this, args) + return this + } + + async build ({ units = [...this.keys()], ...options }: Parameters[0] & { + units?: Name[] + } = {}): + Promise> + { + const toCompile: Array = [] + + if (units && units.length > 0) { + for (const name of units) { + const unit = this.get(name) + if (!unit) { + throw new Error(`requested to build unknown unit "${unit}"`) + } + if (!unit.source?.status().canCompile && unit.compiled?.status().canUpload) { + this.log.warn(`Missing source for ${bold(name)} (${unit.compiled.codeHash})`) + } else { + toCompile.push(unit as DeploymentUnit & { source: SourceCode }) + } + } + } + + const compiled = await options.compiler!.buildMany(toCompile.map(unit=>unit.source!)) + + const byCodeHash: Record = {} + + for (const index in compiled) { + const output = compiled[index] + if (!output.codeHash) { + throw new Error('build output did not contain codeHash') + } + toCompile[Number(index)].compiled = output + byCodeHash[output.codeHash] = output as CompiledCode & { codeHash: CodeHash } + } + + return byCodeHash + } + + async upload ({ units, ...options }: Parameters[0] & { + units?: Name[] + uploadStore?: UploadStore + } = {}): + Promise> + { + const uploading: Array> = [] + for (const [name, unit] of this.entries()) { + uploading.push(unit.upload(options)) + } + const uploaded: Record = {} + for (const output of await Promise.all(uploading)) { + uploaded[output.codeId] = output + } + return uploaded + } + + async deploy ({ units, ...options }: Parameters[0] & { + units?: Name[], + deployStore?: DeployStore + } = {}): + Promise> + { + const deploying: Array> = [] + for (const [name, unit] of this.entries()) { + if (unit instanceof ContractInstance) { + deploying.push(unit.deploy(options)) + } + } + const deployed: Record = {} + for (const output of await Promise.all(deploying)) { + deployed[output.address] = output + } + return deployed + } +} + +export type DeploymentState = Partial> + +/** A deploy store collects receipts corresponding to individual instances of Deployment, + * and can create Deployment objects with the data from the receipts. */ +export class DeployStore extends Map { + log = new Console(this.constructor.name) + + constructor () { + super() + } + + selected?: DeploymentState = undefined + + get (name?: Name): DeploymentState|undefined { + if (arguments.length === 0) { + return this.selected + } + return super.get(name!) + } + + set (name: Name, state: Partial|DeploymentState): this { + if (state instanceof Deployment) state = state.serialize() + return super.set(name, state) + } +} + +/** Represents a contract's code in all its forms, and the contract's lifecycle + * up to and including uploading it, but not instantiating it. */ +export class ContractCode extends Logged { + source?: SourceCode + compiler?: Compiler + compiled?: CompiledCode + uploader?: Agent|Address + uploaded?: UploadedCode + deployer?: Agent|Address + + constructor (properties?: Partial) { + super(properties) + assign(this, properties, [ + 'source', 'compiler', 'compiled', 'uploader', 'uploaded', 'deployer' + ]) + } + + /** Compile this contract. + * + * If a valid binary is present and a rebuild is not requested, + * this does not compile it again, but reuses the binary. */ + async compile ({ + compiler = this.compiler, + rebuild = false, + ...buildOptions + }: { + compiler?: Compiler + rebuild?: boolean + } = {}): Promise[1] & { + codeHash: CodeHash + }> { + if (this.compiled?.status().canUpload && !rebuild) { + return Promise.resolve( + this.compiled as typeof this["compiled"] & { codeHash: CodeHash } + ) + } + if (!compiler) { + throw new Error("can't compile: no compiler") + } + if (!this.source) { + throw new Error(`can't compile: no source`) + } + if (!this.source.status().canCompile) { + throw new Error(`can't compile: ${this.source.status().canCompileInfo??'unspecified reason'}`) + } + const compiled = await compiler.build(this.source, buildOptions) + if (!compiled.status().canUpload) { + throw new Error("build failed") + } + return this.compiled = compiled as typeof compiled & { codeHash: CodeHash } + } + + /** Upload this contract. + * + * If a valid binary is not present, compile it first. + * + * If a valid code ID is present and reupload is not requested, + * this does not upload it again, but reuses the code ID. + * + * If a valid binary is not present, but valid source is present, + * this compiles the source code first to obtain a binary. */ + async upload ({ + compiler = this.compiler, + rebuild = false, + uploader = this.uploader, + reupload = rebuild, + ...uploadOptions + }: Parameters[0] & Parameters[1] & { + uploader?: Address|{ upload: Agent["upload"] } + reupload?: boolean, + } = {}): Promise { + if (this.uploaded?.canInstantiate && !reupload && !rebuild) { + return this.uploaded as typeof uploaded & { codeId: CodeId } + } + if (!uploader || (typeof uploader === 'string')) { + throw new Error("can't upload: no uploader agent") + } + const compiled = await this.compile({ compiler, rebuild }) + const uploaded = await uploader.upload(compiled, uploadOptions) + if (!uploaded.canInstantiate) { + throw new Error("upload failed") + } + return this.uploaded = uploaded + } +} + +/** A contract that is part of a deploment. + * - needed for deployment-wide deduplication + * - generates structured label */ +export class DeploymentUnit extends ContractCode { + /** Name of this unit. */ + name?: string + /** Deployment to which this unit belongs. */ + deployment?: Deployment + /** Code hash uniquely identifying the compiled code. */ + codeHash?: CodeHash + /** Code ID representing the identity of the contract's code on a specific chain. */ + chainId?: ChainId + /** Code ID representing the identity of the contract's code on a specific chain. */ + codeId?: CodeId + + constructor ( + properties: ConstructorParameters[0] & Partial = {} + ) { + super(properties) + assign(this, properties, [ + 'name', 'deployment', 'isTemplate', 'codeHash', 'chainId', 'codeId', + ] as any) + } + + serialize () { + const { name, codeHash, chainId, codeId } = this + return { name, codeHash, chainId, codeId } + } +} + +export class ContractTemplate extends DeploymentUnit { + readonly isTemplate = true + /** Create a new instance of this contract. */ + contract ( + name: Name, parameters?: Partial + ): ContractInstance { + return new ContractInstance({ ...this, name, ...parameters||{} }) + } + /** Create multiple instances of this contract. */ + contracts ( + instanceParameters: Record[1]> = {} + ): Record { + const instances: Record = {} + for (const [name, parameters] of Object.entries(instanceParameters)) { + instances[name] = this.contract(name, parameters) + } + return instances + } +} + +export class ContractInstance extends DeploymentUnit { + readonly isTemplate = false + /** Full label of the instance. Unique for a given chain. */ + label?: Label + /** Address of this contract instance. Unique per chain. */ + address?: Address + /** Contents of init message. */ + initMsg?: Into + /** Address of agent that performed the init tx. */ + initBy?: Address + /** Native tokens to send to the new contract. */ + initSend?: Token.ICoin[] + /** Fee to use for init. */ + initFee?: unknown + /** Instantiation memo. */ + initMemo?: string + /** ID of transaction that performed the init. */ + initTx?: TxHash + /** Contents of init message. */ + initGas?: unknown + + constructor ( + properties?: ConstructorParameters[0] & Partial + ) { + super(properties) + assign(this, properties, [ + 'label', 'address', + 'initMsg', 'initBy', 'initSend', 'initFee', 'initMemo', 'initTx', 'initGas' + ]) + } + + async deploy ({ + deployer = this.deployer, + redeploy = false, + uploader = this.uploader||deployer, + reupload = false, + compiler = this.compiler, + rebuild = false, + ...initOptions + }: Parameters[0] & Parameters[1] & { + deployer?: Address|{ instantiate: Agent["instantiate"] } + redeploy?: boolean + } = {}): Promise { + if (this.isValid() && !redeploy && !reupload && !rebuild) { + return this + } + if (!deployer || (typeof deployer === 'string')) { + throw new Error("can't deploy: no deployer agent") + } + const uploaded = await this.upload({ + compiler, rebuild, uploader, reupload + }) + const instance = await deployer.instantiate(uploaded, this) + if (!instance.isValid()) { + throw new Error("init failed") + } + return instance + } + + serialize () { + const { label, address, initMsg, initBy, initSend, initFee, initMemo, initTx, initGas } = this + return { + ...super.serialize(), + label, address, initMsg, initBy, initSend, initFee, initMemo, initTx, initGas + } + } + + /** Returns a client to this contract instance. */ + connect (agent?: Agent): + Contract + connect ( + agent?: Agent, $C: C = Contract as C + ) { + return new $C({ + ...this, + agent, + }) + } + + isValid (): this is ContractInstance & { address: Address } { + return !!this.address + } +} + diff --git a/packages/deploy/package.json b/packages/deploy/package.json new file mode 100644 index 0000000000..58c1f04a39 --- /dev/null +++ b/packages/deploy/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fadroma/deploy", + "type": "module", + "main": "deploy.ts" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4abe932dbd..33eaaf03bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -487,9 +487,6 @@ importers: packages/cw: dependencies: - '@fadroma/agent': - specifier: workspace:* - version: link:../agent '@hackbg/borshest': specifier: workspace:* version: link:../../toolbox/borshest @@ -505,6 +502,10 @@ importers: borsher: specifier: ^1.2.1 version: 1.2.1 + devDependencies: + '@fadroma/agent': + specifier: workspace:* + version: link:../agent packages/devnet: dependencies: @@ -551,6 +552,9 @@ importers: packages/namada: dependencies: + '@fadroma/agent': + specifier: workspace:* + version: link:../agent '@fadroma/cw': specifier: workspace:* version: link:../cw diff --git a/stores.test.ts b/stores.test.ts index 2116f139f3..acc95a2fb6 100644 --- a/stores.test.ts +++ b/stores.test.ts @@ -1,13 +1,16 @@ import { TestProjectDeployment } from './fixtures/fixtures' import { JSONFileUploadStore } from './fadroma' -import { Stub } from '@fadroma/agent' +import { Stub, Identity } from '@fadroma/agent' import { withTmpDir } from '@hackbg/file' export default async function testJSONFileStores () { await withTmpDir(async dir=>{ const deployment = new TestProjectDeployment() await deployment.upload({ - uploader: new Stub.Agent({}), - uploadStore: new JSONFileUploadStore(dir) + uploadStore: new JSONFileUploadStore(dir), + uploader: new Stub.Agent({ + chain: new Stub.Chain({ chainId: 'foo' }), + identity: new Identity() + }), }) }) } diff --git a/toolbox b/toolbox index 4ae3032881..d1ea1bbed7 160000 --- a/toolbox +++ b/toolbox @@ -1 +1 @@ -Subproject commit 4ae303288129c60f923cf6f1fe61dccc505e6899 +Subproject commit d1ea1bbed78fda8c6d52220b7c484fddebfff125