I've been using the fuels-ts SDK to test a simple counter contract. I've found numerous areas we can improve UX and some possible bugs as well.
High Level Assesment
Overall the typescript SDK was fairly easy to navigate but some critical features or methods are either not documented or are finicky and likely buggy.
We should aim to improve the docs, provide nicer helpers for the developer, be more strict on hex values and data
The typegen is very handy but can do a lot more for the developer, namely providing a more fully fledged contract factory and deployInstance functionality with the bin, storage-slots, abi and config time constants pre-filled at compile time (which will vasty simplify boilerplate for the developer).
Areas of Improvement: Docs
Docs should make more mention of the graphql docs: graphql-docs.fuel.network/, even though this set of docs is extremely important to building frontends with Fuel.
No mention of DeployContractOptions in the docs or how to use Storage Variables (which is incredibly important to deploying contracts in Fuel.
More docs on converting Bech32 -> Hex and Hex -> Bech32 addresses (we need to make this loud and clear in the docs)
The docs include a Version section, but the data is not correctly filled in, all versions are set to zero 0.0.0. This version section should be removed if its not being used correctly. Image attached.
Areas of Improvement: General DevEx
The NativeAssetId should be renamed to BaseAssetId for more clarity.
Expected hex data should always be prefixed, otherwise throw/panic (the libraries should always throw if a hex string is presented without a prefix). We should not be converting loose hex values into hex prefixed strings (this can cause serious errors when applied to non-hex values incorrectly).
All tooling, examples, docs and tests should reflect proper hex prefixing
Get -> simulate: contract.functions[..name..].get()should be contract.functions[..name..].simulate() to be more inline with the Rust SDK and that simulate is more clear and descriptive than get is here.
There is currently no easy function to run a fuel-core node, create a bunch of wallets and faucet them, this is an extremely common practice for contract testing. I've provided a method below for doing this.
Areas of Improvement: Typegen
npx fuels typegen should support a factory type, right now it only supports ContractABI types with a less useful Factory -> instance type. It should come batteries included with a deployInstance method.
npx fuels typegen should include the binary, and make this static data in the factory type and prefil this data in the deployInstance method.
Typegen should also include the default contractStorage and the default configuration time constants in the contract factory type.
Bugs
Panic(ContractNotInInputs) when deploying a contract with storageSlots set in the deployInstance
Usage example
Below is the cleanest example of testing smart contracts with the typescript SDK.
As you can see, if the typegen was smarter and included the bin and storage-slots into the factory, the primary deployment method could be reduced to a one line command (greatly simplifying DevEx).
Second, if you uncomment the storageSlots and storage ReadFileSync, you will see the Panic that occurs afterward. This does not happen in the Rust SDK with similar code.
You will notice the launch_node_get_wallets function greatly simplifies the boilerplate and is extremely useful for typescript testing of smart contracts. This can also be extended for more complex frontend setups as well (which have a node backend or setup services).
import { ContractFactory } from 'fuels';
import { readFileSync } from 'fs';
import { FuelCounterAbi__factory } from '../types';
import { launch_node_get_wallets } from './lib/helpers';
async function main() {
// Setup a test environment.
const { cleanup, wallet } = await launch_node_get_wallets();
// Contract data.
const bytecode = readFileSync(`./out/debug/fuel-counter.bin`);
// const storage = JSON.parse(readFileSync(`./out/debug/fuel-counter-storage_slots.json`).toString());
// Contract factory.
const factory = new ContractFactory(bytecode, FuelCounterAbi__factory.abi, wallet);
const instance = await factory.deployContract({
// storageSlots: storage, // PANIC this throws.
});
const contract = FuelCounterAbi__factory.connect(instance.id, wallet);
// The above code should eventually be simplified to -> deploy(config time consts, storage slots, wallet, config = {}) like so:
// The abi, bin, storage slots and default config time constants could all be packed into the type directly by the typegen
// const contract = await FuelCounter__factory.deploy({}, [], wallet);
// Increment counter.
await contract
.functions
.increment(1)
.call();
// Check the value.
console.assert((await contract
.functions
.get() // should be .simulate().
.get())
.value
.toNumber() == 1);
// Cleanup the environment.
await cleanup();
}
main()
.catch(console.error);
Possible implementations
Launch Node and Get Wallets (with fuel_core helpers)
This code launches the fuel-core instance and provides some handy chain configuration helpers to faucet a bunch of wallets. Then the method returns a bunch of test wallets ready to go, and pre-configured with the correct provider.
The cleanup method closes out the child fuel-core process at the end of the async function.
import { Provider, NativeAssetId, WalletUnlocked, Wallet } from 'fuels';
const { spawn } = require('child_process');
const fs = require('fs').promises;
// Run fuel-core and stop it when the process has ended.
export function fuel_core(
chainConfig: String,
params:String[] = ['--db-type', 'in-memory']
): Promise<any> {
return new Promise(resolve => {
// The string to search for GraphQL service start.
const graph_ql_start = 'graphql_api::service';
// Spawn a fuel-core instance with a specific chainConfig.
const child = spawn('fuel-core', [
'run',
'--chain', chainConfig,
...params
]);
// Set the std encoding to UTF8.
child.stderr.setEncoding('utf8');
// Cleanup function where fuel-core is stopped.
const cleanup = function() {
child.kill('SIGINT');
};
// Look for a specific graphql start point in the output.
child.stderr.on('data', function(chunk:any) {
// Look for the graphql service start.
if (chunk.indexOf(graph_ql_start) !== -1) {
// Resolve with the cleanup method.
resolve(cleanup);
}
});
// Process exit.
process.on('exit', cleanup);
// Catches ctrl+c event.
process.on('SIGINT', cleanup);
// Catches "kill pid" (for example: nodemon restart).
process.on('SIGUSR1', cleanup);
process.on('SIGUSR2', cleanup);
// Catches uncaught exceptions.
process.on('uncaughtException', cleanup);
});
}
// Build a test environment.
export async function launch_node_get_wallets(inputConfig = {}): Promise<{
cleanup: Function,
wallet: WalletUnlocked,
wallets: WalletUnlocked[],
provider: Provider }> {
// Handle config object.
const config = Object.assign({
numWallets: 10,
chainConfig: null,
chainConfigPath: '.chainConfig.json',
fuelCoreParams: undefined,
providerURL: 'http://127.0.0.1:4000/graphql',
}, inputConfig);
// Create a number of wallets.
const signers = (new Array(config.numWallets))
.fill(0)
.map(() => Wallet.generate())
.map(v => v.signer());
// Default chian config.
const defaultChainConfig = {
"chain_name": "local_testnet",
"block_gas_limit": 5000000000,
"initial_state": {
"coins": signers.map(signer => {
return {
"owner": signer.address.toHexString(),
"amount": "0xFFFFFFFFFFFFFFFF",
"asset_id": NativeAssetId,
};
}),
},
"transaction_parameters": {
"contract_max_size": 16777216,
"max_inputs": 255,
"max_outputs": 255,
"max_witnesses": 255,
"max_gas_per_tx": 500000000,
"max_script_length": 1048576,
"max_script_data_length": 1048576,
"max_static_contracts": 255,
"max_storage_slots": 255,
"max_predicate_length": 1048576,
"max_predicate_data_length": 1048576,
"gas_price_factor": 1000000000,
"gas_per_byte": 4,
"max_message_data_length": 1048576
},
"consensus": {
"PoA": {
"signing_key": "0x94ffcc53b892684acefaebc8a3d4a595e528a8cf664eeb3ef36f1020b0809d0d"
}
}
};
// Write a temporary chain configuration file.
await fs.writeFile(
config.chainConfigPath,
JSON.stringify(config.chainConfig ? config.chainConfig : defaultChainConfig),
'utf8'
);
// Run the fuel-core node.
const close = await fuel_core(config.chainConfigPath, config.fuelCoreParams);
// Create a provider object.
const provider = new Provider(config.providerURL);
// Connect all the wallets.
const wallets = signers.map(signer => Wallet.fromPrivateKey(signer.privateKey, provider));
// Define cleanup method.
async function cleanup() {
await fs.unlink(config.chainConfigPath);
close();
}
// Return the environment.
return {
cleanup,
wallet: wallets[0],
wallets,
provider,
};
}
Motivation
Abstract
I've been using the
fuels-ts
SDK to test a simple counter contract. I've found numerous areas we can improve UX and some possible bugs as well.High Level Assesment
deployInstance
functionality with thebin
,storage-slots
,abi
andconfig time constants
pre-filled at compile time (which will vasty simplify boilerplate for the developer).Areas of Improvement: Docs
graphql
docs: graphql-docs.fuel.network/, even though this set of docs is extremely important to building frontends with Fuel.DeployContractOptions
in the docs or how to use Storage Variables (which is incredibly important to deploying contracts in Fuel.Version
section, but the data is not correctly filled in, all versions are set to zero0.0.0
. This version section should be removed if its not being used correctly. Image attached.Areas of Improvement: General DevEx
NativeAssetId
should be renamed toBaseAssetId
for more clarity.contract.functions[..name..].get()
should becontract.functions[..name..].simulate()
to be more inline with the Rust SDK and thatsimulate
is more clear and descriptive thanget
is here.fuel-core
node, create a bunch of wallets and faucet them, this is an extremely common practice for contract testing. I've provided a method below for doing this.Areas of Improvement: Typegen
npx fuels typegen
should support a factory type, right now it only supports ContractABI types with a less useful Factory -> instance type. It should come batteries included with adeployInstance
method.npx fuels typegen
should include the binary, and make this static data in the factory type and prefil this data in thedeployInstance
method.Bugs
Panic(ContractNotInInputs)
when deploying a contract withstorageSlots
set in thedeployInstance
Usage example
Below is the cleanest example of testing smart contracts with the typescript SDK.
As you can see, if the typegen was smarter and included the
bin
andstorage-slots
into the factory, the primary deployment method could be reduced to a one line command (greatly simplifying DevEx).Second, if you uncomment the storageSlots and storage ReadFileSync, you will see the Panic that occurs afterward. This does not happen in the Rust SDK with similar code.
You will notice the
launch_node_get_wallets
function greatly simplifies the boilerplate and is extremely useful for typescript testing of smart contracts. This can also be extended for more complex frontend setups as well (which have a node backend or setup services).Possible implementations
Launch Node and Get Wallets (with fuel_core helpers)
This code launches the
fuel-core
instance and provides some handy chain configuration helpers to faucet a bunch of wallets. Then the method returns a bunch of test wallets ready to go, and pre-configured with the correct provider.The
cleanup
method closes out the childfuel-core
process at the end of the async function.