Skip to content

Commit

Permalink
Add a new static method to contract.Client to deploy & construct co…
Browse files Browse the repository at this point in the history
…ntracts (#1086)

This will allow using a wasm hash and constructor args to create 
an assembled transaction that will deploy and initialize a contract. 
Generated types in the CLI are to follow.

Co-authored-by: Elliot Voris <[email protected]>
Co-authored-by: Chad Ostrowski <[email protected]>
Co-authored-by: Elliot Voris <[email protected]>
  • Loading branch information
3 people authored Nov 13, 2024
1 parent cfea8de commit 40d1497
Show file tree
Hide file tree
Showing 8 changed files with 267 additions and 80 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 31 additions & 15 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
52 changes: 40 additions & 12 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export class AssembledTransaction<T> {
NoSigner: class NoSignerError extends Error { },
NotYetSimulated: class NotYetSimulatedError extends Error { },
FakeAccount: class FakeAccountError extends Error { },
SimulationFailed: class SimulationFailedError extends Error { },
};

/**
Expand Down Expand Up @@ -423,8 +424,8 @@ export class AssembledTransaction<T> {
}

/**
* 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:
*
Expand All @@ -435,29 +436,54 @@ export class AssembledTransaction<T> {
* 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<T>(
options: AssembledTransactionOptions<T>,
static build<T>(
options: AssembledTransactionOptions<T>
): Promise<AssembledTransaction<T>> {
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<T>(
operation: xdr.Operation,
options: AssembledTransactionOptions<T>
): Promise<AssembledTransaction<T>> {
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();

Expand Down Expand Up @@ -556,7 +582,9 @@ export class AssembledTransaction<T> {
);
}
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)) {
Expand Down
96 changes: 83 additions & 13 deletions src/contract/client.ts
Original file line number Diff line number Diff line change
@@ -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<Spec> {
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.
Expand All @@ -20,15 +56,58 @@ import { processSpecEntryStream } from './utils';
* @param {ClientOptions} options see {@link ClientOptions}
*/
export class Client {
static async deploy<T = Client>(
/** Constructor/Initialization Args for the contract's `__constructor` method */
args: Record<string, any> | null,
/** Options for initalizing a Client as well as for calling a method, with extras specific to deploying. */
options: MethodOptions &
Omit<ClientOptions, "contractId"> & {
/** 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<AssembledTransaction<T>> {
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<T>;
}

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<string, any>,
methodOptions?: MethodOptions,
methodOptions?: MethodOptions
) =>
AssembledTransaction.build({
method,
Expand Down Expand Up @@ -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<Client> {
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);
}

Expand Down Expand Up @@ -130,6 +202,4 @@ export class Client {
};

txFromXDR = <T>(xdrBase64: string): AssembledTransaction<T> => AssembledTransaction.fromXDR(this.options, xdrBase64, this.spec);

}

58 changes: 58 additions & 0 deletions test/e2e/src/test-constructor-args.js
Original file line number Diff line number Diff line change
@@ -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/);
}
});
});
});
17 changes: 5 additions & 12 deletions test/e2e/src/test-contract-client-constructor.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,35 +27,28 @@ 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(
`./target/bin/stellar contract install --wasm ${path}`,
).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,
};
}

Expand Down
Loading

0 comments on commit 40d1497

Please sign in to comment.