diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 12cbef2f7..952b2cc71 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,14 +11,14 @@ jobs: runs-on: ubuntu-22.04 services: rpc: - image: stellar/quickstart:testing@sha256:bef5c451e305c914e91964ec22e7a25b9f5276a706fe0357ac23125569d93f05 + image: stellar/quickstart:testing@sha256:5333ec87069efd7bb61f6654a801dc093bf0aad91f43a5ba84806d3efe4a6322 ports: - 8000:8000 env: ENABLE_LOGS: true NETWORK: local ENABLE_SOROBAN_RPC: true - PROTOCOL_VERSION: 21 + PROTOCOL_VERSION: 22 options: >- --health-cmd "curl --no-progress-meter --fail-with-body -X POST \"http://localhost:8000/soroban/rpc\" -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":8675309,\"method\":\"getNetwork\"}' && curl --no-progress-meter \"http://localhost:8000/friendbot\" | grep '\"invalid_field\": \"addr\"'" --health-interval 10s diff --git a/CHANGELOG.md b/CHANGELOG.md index 5180fd492..355a072a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,21 +55,37 @@ A breaking change will get clearly marked in this log. * To use a minimal build without both Axios and EventSource, use `stellar-sdk-minimal.js` for the browser build and import from `@stellar/stellar-sdk/minimal` for the Node package. - `contract.AssembledTransaction#signAuthEntries` now allows you to override `authorizeEntry`. This can be used to streamline novel workflows using cross-contract auth. (#1044) - `rpc.Server` now has a `getSACBalance` helper which lets you fetch the balance of a built-in Stellar Asset Contract token held by a contract ([#1046](https://github.com/stellar/js-stellar-sdk/pull/1046)): -```typescript -export interface BalanceResponse { - latestLedger: number; - /** present only on success, otherwise request malformed or no balance */ - balanceEntry?: { - /** a 64-bit integer */ - amount: string; - authorized: boolean; - clawback: boolean; - - lastModifiedLedgerSeq?: number; - liveUntilLedgerSeq?: number; - }; -} -``` + ```typescript + export interface BalanceResponse { + latestLedger: number; + /** present only on success, otherwise request malformed or no balance */ + balanceEntry?: { + /** a 64-bit integer */ + amount: string; + authorized: boolean; + clawback: boolean; + + lastModifiedLedgerSeq?: number; + liveUntilLedgerSeq?: number; + }; + } + ``` +- `contract.Client` now has a static `deploy` method that can be used to deploy a contract instance from an existing uploaded/"installed" Wasm hash. The first arguments to this method are the arguments for the contract's `__constructor` method. For example, using the `increment` test contract as modified in https://github.com/stellar/soroban-test-examples/pull/2/files#diff-8734809100be3803c3ce38064730b4578074d7c2dc5fb7c05ca802b2248b18afR10-R45: + ```ts + const tx = await contract.Client.deploy( + { counter: 42 }, + { + networkPassphrase, + rpcUrl, + wasmHash: uploadedWasmHash, + publicKey: someKeypair.publicKey(), + ...basicNodeSigner(someKeypair, networkPassphrase), + }, + ); + const { result: client } = await tx.signAndSend(); + const t = await client.get(); + expect(t.result, 42); + ``` ### Fixed - `contract.AssembledTransaction#nonInvokerSigningBy` now correctly returns contract addresses, in instances of cross-contract auth, rather than throwing an error. `sign` will ignore these contract addresses, since auth happens via cross-contract call ([#1044](https://github.com/stellar/js-stellar-sdk/pull/1044)). diff --git a/src/contract/assembled_transaction.ts b/src/contract/assembled_transaction.ts index 3acbc2795..803f4f686 100644 --- a/src/contract/assembled_transaction.ts +++ b/src/contract/assembled_transaction.ts @@ -323,6 +323,7 @@ export class AssembledTransaction { NoSigner: class NoSignerError extends Error { }, NotYetSimulated: class NotYetSimulatedError extends Error { }, FakeAccount: class FakeAccountError extends Error { }, + SimulationFailed: class SimulationFailedError extends Error { }, }; /** @@ -423,8 +424,8 @@ export class AssembledTransaction { } /** - * Construct a new AssembledTransaction. This is the only way to create a new - * AssembledTransaction; the main constructor is private. + * Construct a new AssembledTransaction. This is the main way to create a new + * AssembledTransaction; the constructor is private. * * This is an asynchronous constructor for two reasons: * @@ -435,29 +436,54 @@ export class AssembledTransaction { * If you don't want to simulate the transaction, you can set `simulate` to * `false` in the options. * + * If you need to create an operation other than `invokeHostFunction`, you + * can use {@link AssembledTransaction.buildWithOp} instead. + * * @example * const tx = await AssembledTransaction.build({ * ..., * simulate: false, * }) */ - static async build( - options: AssembledTransactionOptions, + static build( + options: AssembledTransactionOptions ): Promise> { - const tx = new AssembledTransaction(options); const contract = new Contract(options.contractId); - - const account = await getAccount( - options, - tx.server + return AssembledTransaction.buildWithOp( + contract.call(options.method, ...(options.args ?? [])), + options ); + } + /** + * Construct a new AssembledTransaction, specifying an Operation other than + * `invokeHostFunction` (the default used by {@link AssembledTransaction.build}). + * + * Note: `AssembledTransaction` currently assumes these operations can be + * simulated. This is not true for classic operations; only for those used by + * Soroban Smart Contracts like `invokeHostFunction` and `createCustomContract`. + * + * @example + * const tx = await AssembledTransaction.buildWithOp( + * Operation.createCustomContract({ ... }); + * { + * ..., + * simulate: false, + * } + * ) + */ + static async buildWithOp( + operation: xdr.Operation, + options: AssembledTransactionOptions + ): Promise> { + const tx = new AssembledTransaction(options); + const account = await getAccount(options, tx.server); tx.raw = new TransactionBuilder(account, { fee: options.fee ?? BASE_FEE, networkPassphrase: options.networkPassphrase, }) - .addOperation(contract.call(options.method, ...(options.args ?? []))) - .setTimeout(options.timeoutInSeconds ?? DEFAULT_TIMEOUT); + .setTimeout(options.timeoutInSeconds ?? DEFAULT_TIMEOUT) + .addOperation(operation); if (options.simulate) await tx.simulate(); @@ -556,7 +582,9 @@ export class AssembledTransaction { ); } if (Api.isSimulationError(simulation)) { - throw new Error(`Transaction simulation failed: "${simulation.error}"`); + throw new AssembledTransaction.Errors.SimulationFailed( + `Transaction simulation failed: "${simulation.error}"` + ); } if (Api.isSimulationRestore(simulation)) { diff --git a/src/contract/client.ts b/src/contract/client.ts index 2db5b4040..f4109808d 100644 --- a/src/contract/client.ts +++ b/src/contract/client.ts @@ -1,10 +1,46 @@ -import { xdr } from "@stellar/stellar-base"; +import { + Operation, + xdr, + Address, +} from "@stellar/stellar-base"; import { Spec } from "./spec"; import { Server } from '../rpc'; import { AssembledTransaction } from "./assembled_transaction"; import type { ClientOptions, MethodOptions } from "./types"; import { processSpecEntryStream } from './utils'; +const CONSTRUCTOR_FUNC = "__constructor"; + +async function specFromWasm(wasm: Buffer) { + const wasmModule = await WebAssembly.compile(wasm); + const xdrSections = WebAssembly.Module.customSections( + wasmModule, + "contractspecv0" + ); + if (xdrSections.length === 0) { + throw new Error("Could not obtain contract spec from wasm"); + } + const bufferSection = Buffer.from(xdrSections[0]); + const specEntryArray = processSpecEntryStream(bufferSection); + const spec = new Spec(specEntryArray); + return spec; +} + +async function specFromWasmHash( + wasmHash: Buffer | string, + options: Server.Options & { rpcUrl: string }, + format: "hex" | "base64" = "hex" +): Promise { + if (!options || !options.rpcUrl) { + throw new TypeError("options must contain rpcUrl"); + } + const { rpcUrl, allowHttp } = options; + const serverOpts: Server.Options = { allowHttp }; + const server = new Server(rpcUrl, serverOpts); + const wasm = await server.getContractWasmByHash(wasmHash, format); + return specFromWasm(wasm); +} + /** * Generate a class from the contract spec that where each contract method * gets included with an identical name. @@ -20,15 +56,58 @@ import { processSpecEntryStream } from './utils'; * @param {ClientOptions} options see {@link ClientOptions} */ export class Client { + static async deploy( + /** Constructor/Initialization Args for the contract's `__constructor` method */ + args: Record | null, + /** Options for initalizing a Client as well as for calling a method, with extras specific to deploying. */ + options: MethodOptions & + Omit & { + /** The hash of the Wasm blob, which must already be installed on-chain. */ + wasmHash: Buffer | string; + /** Salt used to generate the contract's ID. Passed through to {@link Operation.createCustomContract}. Default: random. */ + salt?: Buffer | Uint8Array; + /** The format used to decode `wasmHash`, if it's provided as a string. */ + format?: "hex" | "base64"; + } + ): Promise> { + const { wasmHash, salt, format, fee, timeoutInSeconds, simulate, ...clientOptions } = options; + const spec = await specFromWasmHash(wasmHash, clientOptions, format); + + const operation = Operation.createCustomContract({ + address: new Address(options.publicKey!), + wasmHash: typeof wasmHash === "string" + ? Buffer.from(wasmHash, format ?? "hex") + : (wasmHash as Buffer), + salt, + constructorArgs: args + ? spec.funcArgsToScVals(CONSTRUCTOR_FUNC, args) + : [] + }); + + return AssembledTransaction.buildWithOp(operation, { + fee, + timeoutInSeconds, + simulate, + ...clientOptions, + contractId: "ignored", + method: CONSTRUCTOR_FUNC, + parseResultXdr: (result) => + new Client(spec, { ...clientOptions, contractId: Address.fromScVal(result).toString() }) + }) as unknown as AssembledTransaction; + } + constructor( public readonly spec: Spec, - public readonly options: ClientOptions, + public readonly options: ClientOptions ) { this.spec.funcs().forEach((xdrFn) => { const method = xdrFn.name().toString(); + if (method === CONSTRUCTOR_FUNC) { + return; + } const assembleTransaction = ( args?: Record, - methodOptions?: MethodOptions, + methodOptions?: MethodOptions ) => AssembledTransaction.build({ method, @@ -87,14 +166,7 @@ export class Client { * @throws {Error} If the contract spec cannot be obtained from the provided wasm binary. */ static async fromWasm(wasm: Buffer, options: ClientOptions): Promise { - const wasmModule = await WebAssembly.compile(wasm); - const xdrSections = WebAssembly.Module.customSections(wasmModule, "contractspecv0"); - if (xdrSections.length === 0) { - throw new Error('Could not obtain contract spec from wasm'); - } - const bufferSection = Buffer.from(xdrSections[0]); - const specEntryArray = processSpecEntryStream(bufferSection); - const spec = new Spec(specEntryArray); + const spec = await specFromWasm(wasm); return new Client(spec, options); } @@ -130,6 +202,4 @@ export class Client { }; txFromXDR = (xdrBase64: string): AssembledTransaction => AssembledTransaction.fromXDR(this.options, xdrBase64, this.spec); - } - diff --git a/test/e2e/src/test-constructor-args.js b/test/e2e/src/test-constructor-args.js new file mode 100644 index 000000000..a5623337a --- /dev/null +++ b/test/e2e/src/test-constructor-args.js @@ -0,0 +1,58 @@ +const { expect } = require("chai"); +const { contract } = require("../../../lib"); +const { installContract, rpcUrl, networkPassphrase } = require("./util"); +const { basicNodeSigner } = require("../../../lib/contract"); + +const INIT_VALUE = 42; + +describe("contract with constructor args", function () { + before(async function () { + const { wasmHash, keypair } = await installContract("increment"); + this.context = { wasmHash, keypair }; + }); + + it("can be instantiated when deployed", async function () { + const tx = await contract.Client.deploy( + { counter: INIT_VALUE }, + { + networkPassphrase, + rpcUrl, + allowHttp: true, + wasmHash: this.context.wasmHash, + publicKey: this.context.keypair.publicKey(), + ...basicNodeSigner(this.context.keypair, networkPassphrase), + }, + ); + const { result: client } = await tx.signAndSend(); + const t = await client.get(); + expect(t.result, INIT_VALUE); + }); + + it("fails with useful message if not given arguments", async function () { + const tx = await contract.Client.deploy(null, { + networkPassphrase, + rpcUrl, + allowHttp: true, + wasmHash: this.context.wasmHash, + publicKey: this.context.keypair.publicKey(), + ...basicNodeSigner(this.context.keypair, networkPassphrase), + }); + await expect(tx.signAndSend()) + .to.be.rejectedWith( + // placeholder error type + contract.AssembledTransaction.Errors.SimulationFailed, + ) + .then((error) => { + // Further assertions on the error object + expect(error).to.be.instanceOf( + contract.AssembledTransaction.Errors.SimulationFailed, + `error is not of type 'NeedMoreArgumentsError'; instead it is of type '${error?.constructor.name}'`, + ); + + if (error) { + // Using regex to check the error message + expect(error.message).to.match(/MismatchingParameterLen/); + } + }); + }); +}); diff --git a/test/e2e/src/test-contract-client-constructor.js b/test/e2e/src/test-contract-client-constructor.js index fc842907d..ea615895d 100644 --- a/test/e2e/src/test-contract-client-constructor.js +++ b/test/e2e/src/test-contract-client-constructor.js @@ -27,9 +27,7 @@ async function clientFromConstructor( const inspected = run( `./target/bin/stellar contract inspect --wasm ${path} --output xdr-base64-array`, ).stdout; - const xdr = JSON.parse(inspected); - const spec = new contract.Spec(xdr); let wasmHash = contracts[name].hash; if (!wasmHash) { wasmHash = run( @@ -37,25 +35,20 @@ async function clientFromConstructor( ).stdout; } - // TODO: do this with js-stellar-sdk, instead of shelling out to the CLI - contractId = - contractId ?? - run( - `./target/bin/stellar contract deploy --source ${keypair.secret()} --wasm-hash ${wasmHash}`, - ).stdout; - - const client = new contract.Client(spec, { + const deploy = await contract.Client.deploy(null, { networkPassphrase, - contractId, rpcUrl, allowHttp: true, + wasmHash, publicKey: keypair.publicKey(), ...wallet, }); + const { result: client } = await deploy.signAndSend(); + return { keypair, client, - contractId, + contractId: client.options.contractId, }; } diff --git a/test/e2e/src/util.js b/test/e2e/src/util.js index fedf3fa15..3c6a0d5c3 100644 --- a/test/e2e/src/util.js +++ b/test/e2e/src/util.js @@ -31,6 +31,9 @@ const contracts = { hash: run(`${stellar} contract install --wasm ${basePath}/increment.wasm`) .stdout, path: `${basePath}/increment.wasm`, + constructorArgs: { + counter: 0, + }, }, swap: { hash: run(`${stellar} contract install --wasm ${basePath}/atomic_swap.wasm`) @@ -89,46 +92,65 @@ module.exports.generateFundedKeypair = generateFundedKeypair; * `contractId` again if you want to re-use the a contract instance. */ async function clientFor(name, { keypair, contractId } = {}) { - if (!contracts[name]) { - throw new Error( - `Contract ${name} not found. ` + - `Pick one of: ${Object.keys(contracts).join(", ")}`, - ); - } - const internalKeypair = keypair ?? (await generateFundedKeypair()); - const wallet = contract.basicNodeSigner(internalKeypair, networkPassphrase); + const signer = contract.basicNodeSigner(internalKeypair, networkPassphrase); - let wasmHash = contracts[name].hash; - if (!wasmHash) { - wasmHash = run( - `${stellar} contract install --wasm ${contracts[name].path}`, - ).stdout; + if (contractId) { + return { + client: await contract.Client.from({ + contractId, + networkPassphrase, + rpcUrl, + allowHttp: true, + publicKey: internalKeypair.publicKey(), + ...signer, + }), + contractId, + keypair, + }; } - // TODO: do this with js-stellar-sdk, instead of shelling out to the CLI - contractId = - contractId ?? - run( - `${stellar} contract deploy --source ${internalKeypair.secret()} --wasm-hash ${wasmHash}`, - ).stdout; + const { wasmHash } = await installContract(name, { + keypair: internalKeypair, + }); - const client = await contract.Client.fromWasmHash( - wasmHash, + const deploy = await contract.Client.deploy( + contracts[name].constructorArgs ?? null, { networkPassphrase, - contractId, rpcUrl, allowHttp: true, + wasmHash: wasmHash, publicKey: internalKeypair.publicKey(), - ...wallet, + ...signer, }, - "hex", ); + const { result: client } = await deploy.signAndSend(); + return { keypair: internalKeypair, client, - contractId, + contractId: client.options.contractId, }; } module.exports.clientFor = clientFor; + +async function installContract(name, { keypair } = {}) { + if (!contracts[name]) { + throw new Error( + `Contract ${name} not found. ` + + `Pick one of: ${Object.keys(contracts).join(", ")}`, + ); + } + + const internalKeypair = keypair ?? (await generateFundedKeypair()); + + let wasmHash = contracts[name].hash; + if (!wasmHash) { + wasmHash = run( + `${stellar} contract install --wasm ${contracts[name].path}`, + ).stdout; + } + return { keypair: internalKeypair, wasmHash }; +} +module.exports.installContract = installContract; diff --git a/test/e2e/test-contracts b/test/e2e/test-contracts index a9f9fb608..4d031ba8b 160000 --- a/test/e2e/test-contracts +++ b/test/e2e/test-contracts @@ -1 +1 @@ -Subproject commit a9f9fb6089aefd9b404d8399737d0a8ae6c8b963 +Subproject commit 4d031ba8b1e07183014cb6c845acad0cec34c5db