Skip to content

Commit

Permalink
Add Server/ContractClient methods to construct from contractId (#960)
Browse files Browse the repository at this point in the history
* Add ContractClient.from to instantiate from a contractId
* Add a getContractWasm method to Server
* Add test for getContractWasm server method
* Add getContractWasmByHash and analogous method in client
* Allow passing wasmHash as string for getting wasm bytecode
* Update stellar-base reference to full release

---------

Co-authored-by: George <[email protected]>
  • Loading branch information
BlaineHeffron and Shaptic authored May 14, 2024
1 parent e4db91c commit b5729cc
Show file tree
Hide file tree
Showing 11 changed files with 528 additions and 24 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ A breaking change will get clearly marked in this log.

## Unreleased

### Added
* Added a from method in `ContractClient` which takes the `ContractClientOptions` and instantiates the `ContractClient` by utilizing the `contractId` to retrieve the contract wasm from the blockchain. The custom section is then extracted and used to create a `ContractSpec` which is then used to create the client.
* Similarly adds `fromWasm` and `fromWasmHash` methods in `ContractClient` which can be used to initialize a `ContractClient` if you already have the wasm bytes or the wasm hash along with the `ContractClientOptions`.
* Added `getContractWasmByContractId` and `getContractWasmByHash` methods in `Server` which can be used to retrieve the wasm bytecode of a contract via its `contractId` and wasm hash respectively.

## [v12.0.0-rc.2](https://github.com/stellar/js-stellar-sdk/compare/v11.3.0...v12.0.0-rc.2)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"webpack-cli": "^5.0.1"
},
"dependencies": {
"@stellar/stellar-base": "11.1.0",
"@stellar/stellar-base": "^12.0.0-rc.1",
"axios": "^1.6.8",
"bignumber.js": "^9.1.2",
"eventsource": "^2.0.2",
Expand Down
65 changes: 65 additions & 0 deletions src/contract_client/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ContractSpec, xdr } from "..";
import { Server } from '../soroban';
import { AssembledTransaction } from "./assembled_transaction";
import type { ContractClientOptions, MethodOptions } from "./types";
import { processSpecEntryStream } from './utils';

export class ContractClient {
/**
Expand Down Expand Up @@ -45,6 +47,68 @@ export class ContractClient {
});
}

/**
* Generates a ContractClient instance from the provided ContractClientOptions and the contract's wasm hash.
* The wasmHash can be provided in either hex or base64 format.
*
* @param wasmHash The hash of the contract's wasm binary, in either hex or base64 format.
* @param options The ContractClientOptions object containing the necessary configuration, including the rpcUrl.
* @param format The format of the provided wasmHash, either "hex" or "base64". Defaults to "hex".
* @returns A Promise that resolves to a ContractClient instance.
* @throws {TypeError} If the provided options object does not contain an rpcUrl.
*/
static async fromWasmHash(wasmHash: Buffer | string,
options: ContractClientOptions,
format: "hex" | "base64" = "hex"
): Promise<ContractClient> {
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 ContractClient.fromWasm(wasm, options);
}

/**
* Generates a ContractClient instance from the provided ContractClientOptions and the contract's wasm binary.
*
* @param wasm The contract's wasm binary as a Buffer.
* @param options The ContractClientOptions object containing the necessary configuration.
* @returns A Promise that resolves to a ContractClient instance.
* @throws {Error} If the contract spec cannot be obtained from the provided wasm binary.
*/
static async fromWasm(wasm: Buffer, options: ContractClientOptions): Promise<ContractClient> {
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 ContractSpec(specEntryArray);
return new ContractClient(spec, options);
}

/**
* Generates a ContractClient instance from the provided ContractClientOptions, which must include the contractId and rpcUrl.
*
* @param options The ContractClientOptions object containing the necessary configuration, including the contractId and rpcUrl.
* @returns A Promise that resolves to a ContractClient instance.
* @throws {TypeError} If the provided options object does not contain both rpcUrl and contractId.
*/
static async from(options: ContractClientOptions): Promise<ContractClient> {
if (!options || !options.rpcUrl || !options.contractId) {
throw new TypeError('options must contain rpcUrl and contractId');
}
const { rpcUrl, contractId, allowHttp } = options;
const serverOpts: Server.Options = { allowHttp };
const server = new Server(rpcUrl, serverOpts);
const wasm = await server.getContractWasmByContractId(contractId);
return ContractClient.fromWasm(wasm, options);
}

txFromJSON = <T>(json: string): AssembledTransaction<T> => {
const { method, ...tx } = JSON.parse(json);
return AssembledTransaction.fromJSON(
Expand All @@ -58,3 +122,4 @@ export class ContractClient {
);
};
}

27 changes: 20 additions & 7 deletions src/contract_client/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { xdr, cereal } from "..";

/**
* The default timeout for waiting for a transaction to be included in a block.
*/
Expand All @@ -12,7 +14,7 @@ export async function withExponentialBackoff<T>(
keepWaitingIf: (result: T) => boolean,
timeoutInSeconds: number,
exponentialFactor = 1.5,
verbose = false,
verbose = false
): Promise<T[]> {
const attempts: T[] = [];

Expand All @@ -34,7 +36,7 @@ export async function withExponentialBackoff<T>(
console.info(
`Waiting ${waitTime}ms before trying again (bringing the total wait time to ${totalWaitTime}ms so far, of total ${
timeoutInSeconds * 1000
}ms)`,
}ms)`
);
}
await new Promise((res) => setTimeout(res, waitTime));
Expand All @@ -56,8 +58,8 @@ export async function withExponentialBackoff<T>(
} prev attempts. Most recent: ${JSON.stringify(
attempts[attempts.length - 1],
null,
2,
)}`,
2
)}`
);
}
}
Expand All @@ -77,8 +79,19 @@ export const contractErrorPattern = /Error\(Contract, #(\d+)\)/;
/**
* A TypeScript type guard that checks if an object has a `toString` method.
*/
export function implementsToString(
obj: unknown,
): obj is { toString(): string } {
export function implementsToString(obj: unknown): obj is { toString(): string } {
return typeof obj === "object" && obj !== null && "toString" in obj;
}

/**
* Reads a binary stream of ScSpecEntries into an array for processing by ContractSpec
*/
export function processSpecEntryStream(buffer: Buffer) {
const reader = new cereal.XdrReader(buffer);
const res: xdr.ScSpecEntry[] = [];
while (!reader.eof){
// @ts-ignore
res.push(xdr.ScSpecEntry.read(reader));
}
return res;
}
87 changes: 87 additions & 0 deletions src/soroban/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,93 @@ export class Server {
);
}

/**
* Retrieves the WASM bytecode for a given contract.
*
* This method allows you to fetch the WASM bytecode associated with a contract
* deployed on the Soroban network. The WASM bytecode represents the executable
* code of the contract.
*
* @param {string} contractId the contract ID containing the
* WASM bytecode to retrieve
*
* @returns {Promise<Buffer>} a Buffer containing the WASM bytecode
*
* @throws {Error} If the contract or its associated WASM bytecode cannot be
* found on the network.
*
* @example
* const contractId = "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5";
* server.getContractWasmByContractId(contractId).then(wasmBuffer => {
* console.log("WASM bytecode length:", wasmBuffer.length);
* // ... do something with the WASM bytecode ...
* }).catch(err => {
* console.error("Error fetching WASM bytecode:", err);
* });
*/
public async getContractWasmByContractId(
contractId: string
): Promise<Buffer> {
const contractLedgerKey = new Contract(contractId).getFootprint();
const response = await this.getLedgerEntries(contractLedgerKey);
if (!response.entries.length || !response.entries[0]?.val) {
return Promise.reject({code: 404, message: `Could not obtain contract hash from server`});
}

const wasmHash = response.entries[0].val
.contractData()
.val()
.instance()
.executable()
.wasmHash();

return this.getContractWasmByHash(wasmHash);
}

/**
* Retrieves the WASM bytecode for a given contract hash.
*
* This method allows you to fetch the WASM bytecode associated with a contract
* deployed on the Soroban network using the contract's WASM hash. The WASM bytecode
* represents the executable code of the contract.
*
* @param {Buffer} wasmHash the WASM hash of the contract
*
* @returns {Promise<Buffer>} a Buffer containing the WASM bytecode
*
* @throws {Error} If the contract or its associated WASM bytecode cannot be
* found on the network.
*
* @example
* const wasmHash = Buffer.from("...");
* server.getContractWasmByHash(wasmHash).then(wasmBuffer => {
* console.log("WASM bytecode length:", wasmBuffer.length);
* // ... do something with the WASM bytecode ...
* }).catch(err => {
* console.error("Error fetching WASM bytecode:", err);
* });
*/
public async getContractWasmByHash(
wasmHash: Buffer | string,
format: undefined | "hex" | "base64" = undefined
): Promise<Buffer> {
const wasmHashBuffer = typeof wasmHash === "string" ? Buffer.from(wasmHash, format) : wasmHash as Buffer;

const ledgerKeyWasmHash = xdr.LedgerKey.contractCode(
new xdr.LedgerKeyContractCode({
hash: wasmHashBuffer,
})
);

const responseWasm = await this.getLedgerEntries(ledgerKeyWasmHash);
if (!responseWasm.entries.length || !responseWasm.entries[0]?.val) {
return Promise.reject({ code: 404, message: "Could not obtain contract wasm from server" });
}
const wasmBuffer = responseWasm.entries[0].val.contractCode().code();

return wasmBuffer;
}

/**
* Reads the current value of arbitrary ledger entries directly.
*
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/initialize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ else
(cd "$dirname/../.." && cargo install_soroban)
fi

NETWORK_STATUS=$(curl -s -X POST "$SOROBAN_RPC_URL" -H "Content-Type: application/json" -d '{ "jsonrpc": "2.0", "id": 8675309, "method": "getHealth" }' | sed 's/.*"status":"\(.*\)".*/\1/')
NETWORK_STATUS=$(curl -s -X POST "$SOROBAN_RPC_URL" -H "Content-Type: application/json" -d '{ "jsonrpc": "2.0", "id": 8675309, "method": "getHealth" }' | sed -n 's/.*"status":\s*"\([^"]*\)".*/\1/p')

echo Network
echo " RPC: $SOROBAN_RPC_URL"
Expand Down
110 changes: 110 additions & 0 deletions test/e2e/src/test-contract-client-constructor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
const test = require('ava')
const { spawnSync } = require('node:child_process')
const { Address } = require('../../..')
const { contracts, networkPassphrase, rpcUrl, friendbotUrl } = require('./util')
const { ContractSpec } = require('../../..')
const { Keypair } = require('../../..')

const {
ContractClient,
basicNodeSigner,
} = require('../../../lib/contract_client')

async function generateFundedKeypair() {
const keypair = Keypair.random()
await fetch(`${friendbotUrl}/friendbot?addr=${keypair.publicKey()}`)
return keypair
};

/**
* Generates a ContractClient for the contract with the given name.
* Also generates a new account to use as as the keypair of this contract. This
* account is funded by friendbot. You can pass in an account to re-use the
* same account with multiple contract clients.
*
* By default, will re-deploy the contract every time. Pass in the same
* `contractId` again if you want to re-use the a contract instance.
*/
async function clientFromConstructor(contract, { keypair = generateFundedKeypair(), contractId } = {}) {
if (!contracts[contract]) {
throw new Error(
`Contract ${contract} not found. ` +
`Pick one of: ${Object.keys(contracts).join(", ")}`
)
}
keypair = await keypair // eslint-disable-line no-param-reassign
const wallet = basicNodeSigner(keypair, networkPassphrase)

const {path} = contracts[contract];
const xdr = JSON.parse(spawnSync("./target/bin/soroban", ["contract", "inspect", "--wasm", path, "--output", "xdr-base64-array"], { shell: true, encoding: "utf8" }).stdout.trim())

const spec = new ContractSpec(xdr);
let wasmHash = contracts[contract].hash;
if (!wasmHash) {
wasmHash = spawnSync("./target/bin/soroban", ["contract", "install", "--wasm", path], { shell: true, encoding: "utf8" }).stdout.trim()
}

// TODO: do this with js-stellar-sdk, instead of shelling out to the CLI
contractId = contractId ?? spawnSync("./target/bin/soroban", [ // eslint-disable-line no-param-reassign
"contract",
"deploy",
"--source",
keypair.secret(),
"--wasm-hash",
wasmHash,
], { shell: true, encoding: "utf8" }).stdout.trim();

const client = new ContractClient(spec, {
networkPassphrase,
contractId,
rpcUrl,
allowHttp: true,
publicKey: keypair.publicKey(),
...wallet,
});
return {
keypair,
client,
contractId,
}
}

/**
* Generates a ContractClient given the contractId using the from method.
*/
async function clientForFromTest(contractId, publicKey, keypair) {
keypair = await keypair; // eslint-disable-line no-param-reassign
const wallet = basicNodeSigner(keypair, networkPassphrase);
const options = {
networkPassphrase,
contractId,
rpcUrl,
allowHttp: true,
publicKey,
...wallet,
};
return ContractClient.from(options);
}

test.before(async t => {
const { client, keypair, contractId } = await clientFromConstructor('customTypes')
const publicKey = keypair.publicKey()
const addr = Address.fromString(publicKey)
t.context = { client, publicKey, addr, contractId, keypair } // eslint-disable-line no-param-reassign
});

test('hello from constructor', async t => {
const { result } = await t.context.client.hello({ hello: 'tests' })
t.is(result, 'tests')
})

test('from', async (t) => {
// objects with different constructors will not pass deepEqual check
function constructorWorkaround(object) {
return JSON.parse(JSON.stringify(object));
}

const clientFromFrom = await clientForFromTest(t.context.contractId, t.context.publicKey, t.context.keypair);
t.deepEqual(constructorWorkaround(clientFromFrom), constructorWorkaround(t.context.client));
t.deepEqual(t.context.client.spec.entries, clientFromFrom.spec.entries);
});
5 changes: 3 additions & 2 deletions test/e2e/src/test-custom-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ test.before(async t => {
const { client, keypair, contractId } = await clientFor('customTypes')
const publicKey = keypair.publicKey()
const addr = Address.fromString(publicKey)
t.context = { client, publicKey, addr, contractId } // eslint-disable-line no-param-reassign
t.context = { client, publicKey, addr, contractId, keypair } // eslint-disable-line no-param-reassign
});

test('hello', async t => {
const { result } = await t.context.client.hello({ hello: 'tests' })
t.is(result, 'tests')
})

test("view method with empty keypair", async (t) => {
const { client: client2 } = await clientFor('customTypes', {
keypair: undefined,
Expand Down Expand Up @@ -144,6 +143,8 @@ test('u128', async t => {
t.is((await t.context.client.u128({ u128: 1n })).result, 1n)
})



test('multi_args', async t => {
t.is((await t.context.client.multi_args({ a: 1, b: true })).result, 1)
t.is((await t.context.client.multi_args({ a: 1, b: false })).result, 0)
Expand Down
Loading

0 comments on commit b5729cc

Please sign in to comment.