diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7dab5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +build \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..2264cb5 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +src +tsconfig.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..18a3a23 --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# Polkadot API for Smart Contracts compiled via Solang + +This library allows to deploy and call into smart contracts that have been compiled from Solidity to wasm via Solang. + +It has two main functions: + +- deploy (instantiate) a smart contract +- execute a message call to an existing smart contract + +These functions are meant to be used very flexibly and they have the following special features: + +1. Both functions allow to either specify a keypair or a general signer in order to sign the extrinsics. The keypair is generally useful for command line tools whereas the signer is more useful in a browser context, where it can give access to a wallets or browser extensions. + +2. In certain applications it can be necessary to extend the extrinsics created by these two functions, e.g., to wrap them into a sudo call. For that reason these functions allow to specify an optional argument `modifyExtrinsic` that allows the caller to arbitrarily change or extend the created extrinsic. + +3. Contracts usually emit events and these events can be emitted by contracts (recursively) called by the original contract. In order to properly decode the binary data contained in the event, one needs to lookup the abi of the respective contract emitting the event. For that reason both functions take an optional argument `lookupAbi` that allows the caller to provide the abi for any deployed contract (identified through the contract address). + +4. They can correctly decode the return value of message calls, i.e., whether a message call reverted (including the revert message) or whether it panicked and why it panicked. + +## Deploy a smart contract + +The function `deployContract` has the following arguments: + +- `api`: the polkadot-js `ApiPromise` object + + This is the `ApiPromise` object that polkadot-js creates when connecting to an RPC node of a chain. + +- `abi`: The abi (metadata) of the smart contract + + This needs to be an object of type `Abi`. It can be constructed from the metadata JSON file (that is generated by Solang) as follows: + + ``` + const metadata = JSON.parse(metadataString); + new Abi(metadata, api.registry.getChainProperties()); + ``` + +- `signer`: Specifies the signer of the extrinsic submitted to the chain. + + This can either be a keypair or any generic signer. If it is a keypair, then it needs to have the format + + ``` + { + type: "keypair"; + keypair: KeyringPair; + } + ``` + + If it is a generic signer, then it needs to have the format + + ``` + { + type: "signer"; + address: string; // the address of the signer + signer: Signer; + } + ``` + +- `constructorArguments`: the array with the arguments to the constructor +- `constructorName`: an optional argument to specify the constructor + + This is `new` by default. Contracts that are compiled via Solidity usually only have the `new` constructor. + +- `limits`: the resource limits + + These limits specify the resources that are allowed to be used for the execution. They need to be specified as the type + + ``` + { + gas: { + refTime: string | number; + proofSize: string | number; + }; + storageDeposit?: string | number; + } + ``` + + The `gas.refTime` and `gas.proofSize` entries specify limits of the gas usage. The optional argument `storageDeposit` specifies a limit for the storage deposit. + +- `modifyExtrinsic`: allows to extend the generated extrinsic. + + This is an optional function that allows the caller to extend, modify or wrap the extrinsic created by `deployContract` before it is submitted to the chain. + +- `lookupAbi`: provide abi for deployed contracts + + This is an optional argument that allows to provide the abi of any deployed contract (specified by the address of the contract). This allows `deployContract` to properly decode events emitted during contract execuction. + +The return value of this function is an object that contains the field `type`. This fields indicate the execution status and is either one of the values: + +- `"success"`: in this case the deployment was successful and the return object also contains the entries `events` (which is the collection of (decoded) contract events that were emitted during contract execution), the `deploymentAddress` and the `transactionFee` that the signer had to pay +- `"error"`: in this case there was a general error when submitting the extrinsic and the return object contains the entry `error` (a string with an error description) +- `"reverted"`: in this case the contract reverted and the return object contains the revert message in the entry `description` +- `"panic"`: in this case the contract panicked and the return object contains the entries `errorCode` (a numerical error code) and `explanation` (an explanation of the error code) + +## Execute a Message Call + +The function `messageCall` has the following arguments: + +- `api`: the polkadot-js `ApiPromise` object + + This is the `ApiPromise` object that polkadot-js creates when connecting to an RPC node of a chain. + +- `abi`: The abi (metadata) of the smart contract + + This needs to be an object of type `Abi`. It can be constructed from the metadata JSON file (that is generated by Solang) as follows: + + ``` + const metadata = JSON.parse(metadataString); + new Abi(metadata, api.registry.getChainProperties()); + ``` + +- `contractDeploymentAddress`: the address of the contract + + This needs to be the address of a deployed contracts that has the abi specified by `abi` + +- `callerAddress`: the address of the caller of the contract + +- `getSigner`: a callback to provide the signer of the extrinsic + + This callback will only be invoked when the message is mutating and therefore requires that an extrinsic is submitted to the chain (otherwise, `messageCall` will only make an rpc call). The address associated with the signer should be the same as `callerAddress`. + +- `messageName`: the name of the message + +- `messageArguments`: the array with the arguments to the message + +- `limits`: the resource limits + + These limits specify the resources that are allowed to be used for the execution. They need to be specified as the type + + ``` + { + gas: { + refTime: string | number; + proofSize: string | number; + }; + storageDeposit?: string | number; + } + ``` + + The `gas.refTime` and `gas.proofSize` entries specify limits of the gas usage. The optional argument `storageDeposit` specifies a limit for the storage deposit. + +- `getSigner`: Specifies the signer of the extrinsic submitted to the chain. + + This can either be a keypair or any generic signer. If it is a keypair, then it needs to have the format + + ``` + { + type: "keypair"; + keypair: KeyringPair; + } + ``` + + If it is a generic signer, then it needs to have the format + + ``` + { + type: "signer"; + address: string; // the address of the signer + signer: Signer; + } + ``` + +- `modifyExtrinsic`: allows to extend the generated extrinsic. + + This is an optional function that allows the caller to extend, modify or wrap the extrinsic created by `messageCall` before it is submitted to the chain. + +- `lookupAbi`: provide abi for deployed contracts + + This is an optional argument that allows to provide the abi of any deployed contract (specified by the address of the contract). This allows `messageCall` to properly decode events emitted during contract execuction. + +The return value of this function is an object that contains two entries `execution` and `result`. The `execution` entry contains the field `type`, which can be either one of + +- `onlyQuery`: in this case only the rpc method has been executed because an error occurred before submitting an extrinsic or because the message is immutable and does not require an extrinsic +- `extrinsic`: an extrinsic has been submitted to execute the message call – in this case the `execution` object also contains the entries `contractEvents` (which is the collection of (decoded) contract events that were emitted during contract execution) and the `transactionFee` that the signer had to pay + +The `result` object has the field `type` which is either one of the following values: + +- `"success"`: in this case the deployment was message call was successful and it contains a return value in the entry `value` +- `"error"`: in this case there was a general error when submitting the extrinsic and the return object contains the entry `error` (a string with an error description) +- `"reverted"`: in this case the contract reverted and the return object contains the revert message in the entry `description` +- `"panic"`: in this case the contract panicked and the return object contains the entries `errorCode` (a numerical error code) and `explanation` (an explanation of the error code) + +## Submit an Extrinsic + +Additionally this module also exposes a function `submitExtrinsic` that allows to submit an arbitrary extrinsic and that processes the chain events in order to determine the execution status of the extrinsic. + +It has two arguments: + +- `extrinsic`: the extrinsic itself +- `signer`: as for the other two functions this specifies the signer of the extrinsic submitted to the chain. + +This function only returns after the extrinsic has been executed on-chain. The return value of this function is an object with the following entries: + +- `transactionFee`: the transaction fee that the signer had to pay for the execution +- `events`: the array of all Substrate events generated during execution +- `status`: this is an object whose `type` entry is either `"success"` or `"error"`. In the latter case this object also contains the entry `error`, which is a string of the error message diff --git a/package.json b/package.json new file mode 100644 index 0000000..aae253a --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "@pendulum-chain/api-solang", + "version": "0.0.7", + "description": "Interface to interact with smart contracts compiled via Solang", + "main": "build/index.js", + "devDependencies": { + "@types/node": "^20.6.0", + "rimraf": "^5.0.1", + "typescript": "^5.2.2" + }, + "peerDependencies": { + "@polkadot/api": "^10.9.1", + "@polkadot/api-contract": "^10.9.1", + "@polkadot/keyring": "^12.3.2", + "@polkadot/types": "^10.9.1", + "@polkadot/types-codec": "^10.9.1", + "@polkadot/util": "^12.3.2", + "@polkadot/util-crypto": "^12.3.2" + }, + "type": "commonjs", + "scripts": { + "build": "npm run clean && tsc", + "clean": "rimraf build", + "prepublishOnly": "npm run build", + "simplyPublish": "npm publish --workspace @pendulum-chain/api-solang --access public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/pendulum-chain/wasm-deploy.git" + }, + "keywords": [ + "Solang", + "wasm", + "smart", + "contracts", + "Polkadot", + "Substrate" + ], + "author": "Pendulum", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/pendulum-chain/wasm-deploy/issues" + }, + "homepage": "https://github.com/pendulum-chain/wasm-deploy#readme" +} diff --git a/src/contractRpc.ts b/src/contractRpc.ts new file mode 100644 index 0000000..a3e388c --- /dev/null +++ b/src/contractRpc.ts @@ -0,0 +1,203 @@ +import { ApiPromise } from "@polkadot/api"; +import { Abi } from "@polkadot/api-contract"; +import { + ContractExecResult, + ContractExecResultOk, + ContractInstantiateResult, + Weight, +} from "@polkadot/types/interfaces"; +import { BN_ZERO } from "@polkadot/util"; +import { TypeDef } from "@polkadot/types/types"; + +import { Address, Limits } from "./index.js"; +import { extractDispatchErrorDescription } from "./dispatchError.js"; + +export interface QueryContractOptions { + api: ApiPromise; + abi: Abi; + contractAddress: Address; + callerAddress: Address; + messageName: string; + limits: Limits; + messageArguments: unknown[]; +} + +export type PanicCode = number; + +// error explanations taken from https://docs.soliditylang.org/en/v0.8.20/control-structures.html#panic-via-assert-and-error-via-require +export function explainPanicError(errorCode: PanicCode): string { + switch (errorCode) { + case 0x00: + return "Used for generic compiler inserted panics."; + case 0x01: + return "Assert called with an argument that evaluated to false."; + case 0x11: + return "Arithmetic operation resulted in underflow or overflow outside of an unchecked { ... } block."; + case 0x12: + return "Division or modulo by zero (e.g. 5 / 0 or 23 % 0)."; + case 0x21: + return "Converted a value that is too big or negative into an enum type."; + case 0x22: + return "Accessed a storage byte array that is incorrectly encoded."; + case 0x31: + return "Called .pop() on an empty array."; + case 0x32: + return "Accessed an array, bytesN or an array slice at an out-of-bounds or negative index (i.e. x[i] where i >= x.length or i < 0)."; + case 0x41: + return "Allocated too much memory or create an array that is too large."; + case 0x51: + return "Called a zero-initialized variable of internal function type."; + default: + return "Unknown panic error"; + } +} + +export type QueryContractOutput = + | { type: "success"; value: any } + | { type: "reverted"; description: string } + | { type: "panic"; errorCode: PanicCode; explanation: string } + | { type: "error"; description?: string }; + +export interface QueryContractResult { + gasRequired: Weight; + output: QueryContractOutput; +} + +function extractContractExecutionOutput( + api: ApiPromise, + abi: Abi, + result: ContractExecResultOk, + returnType: TypeDef | null | undefined +): QueryContractOutput { + const data = result.data.toU8a(true); + if (!result.flags.isRevert) { + const value = returnType + ? abi.registry.createTypeUnsafe(returnType.lookupName || returnType.type, [data], { + isPedantic: true, + }) + : undefined; + return { type: "success", value }; + } else { + const dataView = new DataView(data.buffer); + const prefix = data.buffer.byteLength >= 4 ? dataView.getUint32(0) : 0; + switch (prefix) { + case 0x08c379a0: + return { type: "reverted", description: api.createType("String", data.slice(4)).toString() }; + + case 0x4e487b71: + try { + const errorCode = + data.buffer.byteLength >= 36 + ? dataView.getBigUint64(4, true) + + 2n ** 64n * dataView.getBigUint64(12, true) + + 2n ** 128n * dataView.getBigUint64(20, true) + + 2n ** 192n * dataView.getBigUint64(28, true) + : -1n; + + return { + type: "panic", + errorCode: Number(errorCode), + explanation: explainPanicError(Number(errorCode)), + }; + } catch {} + + default: + return { type: "error" }; + } + } +} + +export async function rpcCall({ + api, + abi, + callerAddress, + messageName, + contractAddress, + limits, + messageArguments, +}: QueryContractOptions): Promise { + let resolved = false; + return new Promise((resolve) => { + const message = abi.findMessage(messageName); + + const observable = api.rx.call.contractsApi.call( + callerAddress, + api.createType("AccountId", contractAddress), + BN_ZERO, + api.createType("WeightV2", limits.gas), + limits.storageDeposit, + message.toU8a(messageArguments) + ); + + observable.forEach((event) => { + if (resolved) { + return; + } + resolved = true; + + const { result, gasRequired } = event; + + if (result.isOk) { + resolve({ gasRequired, output: extractContractExecutionOutput(api, abi, result.asOk, message.returnType) }); + } else { + resolve({ gasRequired, output: { type: "error", description: extractDispatchErrorDescription(result.asErr) } }); + } + }); + }); +} + +export interface QueryInstantiateContractOptions { + api: ApiPromise; + abi: Abi; + callerAddress: Address; + constructorName: string; + limits: Limits; + constructorArguments: unknown[]; +} + +export async function rpcInstantiate({ + api, + abi, + callerAddress, + constructorName, + limits, + constructorArguments, +}: QueryInstantiateContractOptions): Promise { + let resolved = false; + + return new Promise((resolve) => { + const constructor = abi.findConstructor(constructorName); + const data = constructor.toU8a(constructorArguments); + const salt = new Uint8Array(); + + //const code = api.createType("Code", { Upload: abi.info.source.wasm }); + + const observable = api.rx.call.contractsApi.instantiate( + callerAddress, + BN_ZERO, + api.createType("WeightV2", limits.gas), + limits.storageDeposit, + { Upload: abi.info.source.wasm }, + data, + salt + ); + + observable.forEach((event) => { + if (resolved) { + return; + } + resolved = true; + + const { result, gasRequired } = event; + + if (result.isOk) { + resolve({ + gasRequired, + output: extractContractExecutionOutput(api, abi, result.asOk.result, constructor.returnType), + }); + } else { + resolve({ gasRequired, output: { type: "error", description: extractDispatchErrorDescription(result.asErr) } }); + } + }); + }); +} diff --git a/src/deployContract.ts b/src/deployContract.ts new file mode 100644 index 0000000..8cb2423 --- /dev/null +++ b/src/deployContract.ts @@ -0,0 +1,91 @@ +import { ApiPromise } from "@polkadot/api"; +import { CodePromise, Abi } from "@polkadot/api-contract"; +import { AccountId, Event } from "@polkadot/types/interfaces"; +import { ITuple } from "@polkadot/types-codec/types"; + +import { Limits, Address } from "./index.js"; +import { Extrinsic, GenericSigner, KeyPairSigner, getSignerAddress, submitExtrinsic } from "./submitExtrinsic.js"; +import { PanicCode, rpcInstantiate } from "./contractRpc.js"; + +export interface BasicDeployContractOptions { + api: ApiPromise; + abi: Abi; + constructorArguments: unknown[]; + constructorName?: string; + limits: Limits; + signer: KeyPairSigner | GenericSigner; + modifyExtrinsic?: (extrinsic: Extrinsic) => Extrinsic; +} + +export type BasicDeployContractResult = + | { type: "success"; events: Event[]; deploymentAddress: Address; transactionFee: bigint | undefined } + | { type: "error"; error: string } + | { type: "reverted"; description: string } + | { type: "panic"; errorCode: PanicCode; explanation: string }; + +export async function basicDeployContract({ + api, + abi, + constructorArguments, + constructorName, + limits, + signer, + modifyExtrinsic, +}: BasicDeployContractOptions): Promise { + const code = new CodePromise(api, abi, undefined); + + constructorName = constructorName ?? "new"; + try { + abi.findConstructor(constructorName); + } catch { + throw new Error(`Contract has no constructor called ${constructorName}`); + } + + const { gasRequired, output } = await rpcInstantiate({ + api, + abi, + callerAddress: getSignerAddress(signer), + constructorName, + limits, + constructorArguments, + }); + + switch (output.type) { + case "reverted": + case "panic": + return output; + + case "error": + return { type: "error", error: output.description ?? "unknown" }; + } + + const { storageDeposit: storageDepositLimit } = limits; + + let extrinsic = code.tx[constructorName]({ gasLimit: gasRequired, storageDepositLimit }, ...constructorArguments); + + if (modifyExtrinsic) { + extrinsic = modifyExtrinsic(extrinsic); + } + const { events, status, transactionFee } = await submitExtrinsic(extrinsic, signer); + + if (status.type === "error") { + return { type: "error", error: `Contract could not be deployed: ${status.error}` }; + } + + let deploymentAddress: Address | undefined = undefined; + + for (const event of events) { + const { data, section, method } = event; + + if (section === "contracts" && method === "Instantiated") { + const [, contract] = data as unknown as ITuple<[AccountId, AccountId]>; + deploymentAddress = contract.toString() as Address; + } + } + + if (deploymentAddress === undefined) { + return { type: "error", error: "Contract address not found" }; + } + + return { type: "success", deploymentAddress, events, transactionFee }; +} diff --git a/src/dispatchError.ts b/src/dispatchError.ts new file mode 100644 index 0000000..b745ba6 --- /dev/null +++ b/src/dispatchError.ts @@ -0,0 +1,14 @@ +import { DispatchError } from "@polkadot/types/interfaces"; + +export function extractDispatchErrorDescription(dispatchError: DispatchError): string { + if (dispatchError.isModule) { + try { + const module = dispatchError.asModule; + const error = dispatchError.registry.findMetaError(module); + + return `${error.section}.${error.name}: ${error.docs[0]}` ?? `${error.section}.${error.name}`; + } catch {} + } + + return dispatchError.type.toString(); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8883c4b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,225 @@ +import { ApiPromise } from "@polkadot/api"; +import { BN_ZERO } from "@polkadot/util"; +import { ContractPromise } from "@polkadot/api-contract"; +import { Event } from "@polkadot/types/interfaces"; +import { AnyJson } from "@polkadot/types-codec/types"; +import { Abi } from "@polkadot/api-contract"; + +import { PanicCode, rpcCall } from "./contractRpc.js"; +import { + Extrinsic, + GenericSigner, + KeyPairSigner, + SubmitExtrinsicStatus, + SubmitExtrinsicResult, + submitExtrinsic, +} from "./submitExtrinsic.js"; +import { addressEq } from "@polkadot/util-crypto"; +import { basicDeployContract } from "./deployContract.js"; + +export { + PanicCode, + Extrinsic, + GenericSigner, + KeyPairSigner, + SubmitExtrinsicStatus, + SubmitExtrinsicResult, + submitExtrinsic, +}; + +export type Address = string; + +export interface Limits { + gas: { + refTime: string | number; + proofSize: string | number; + }; + storageDeposit?: string | number; +} + +export interface DecodedContractEvent { + eventIdentifier: string; + args: { name: string; value: AnyJson }[]; +} + +export interface ContractEvent { + emittingContractAddress: Address; + data: Buffer; + decoded?: DecodedContractEvent; +} + +export interface DeployContractOptions { + abi: Abi; + api: ApiPromise; + signer: KeyPairSigner | GenericSigner; + constructorArguments: unknown[]; + constructorName?: string; + limits: Limits; + modifyExtrinsic?: (extrinsic: Extrinsic) => Extrinsic; + lookupAbi?: (contractAddress: Address) => Abi | undefined; +} + +export type DeployContractResult = + | { type: "success"; events: ContractEvent[]; deploymentAddress: Address; transactionFee: bigint | undefined } + | { type: "error"; error: string } + | { type: "reverted"; description: string } + | { type: "panic"; errorCode: PanicCode; explanation: string }; + +export interface MessageCallOptions { + abi: Abi; + api: ApiPromise; + contractDeploymentAddress: Address; + callerAddress: Address; + getSigner: () => Promise; + messageName: string; + messageArguments: unknown[]; + limits: Limits; + modifyExtrinsic?: (extrinsic: Extrinsic) => Extrinsic; + lookupAbi?: (contractAddress: Address) => Abi | undefined; +} + +export type MessageCallResult = { + execution: + | { type: "onlyQuery" } + | { type: "extrinsic"; contractEvents: ContractEvent[]; transactionFee: bigint | undefined }; + result: + | { type: "success"; value: any } + | { type: "error"; error: string } + | { type: "reverted"; description: string } + | { type: "panic"; errorCode: PanicCode; explanation: string }; +}; + +function decodeContractEvents( + events: Event[], + lookupAbi?: (contractAddress: Address) => Abi | undefined +): ContractEvent[] { + return events + .filter(({ section, method }) => section === "contracts" && method === "ContractEmitted") + .map(({ data }): ContractEvent => { + const dataJson = data.toHuman() as { contract: string; data: string }; + const emittingContractAddress = dataJson.contract; + const buffer = Buffer.from(dataJson.data.slice(2), "hex"); + + const abi = lookupAbi?.(emittingContractAddress); + if (abi === undefined) { + return { + emittingContractAddress, + data: buffer, + }; + } + const decodedEvent = abi.decodeEvent(buffer); + + return { + emittingContractAddress, + data: buffer, + decoded: { + args: decodedEvent.event.args.map((arg, index) => ({ + name: arg.name, + value: decodedEvent.args[index].toHuman(), + })), + eventIdentifier: decodedEvent.event.identifier, + }, + }; + }); +} + +export async function deployContract({ + signer, + api, + abi, + constructorArguments, + constructorName, + limits, + modifyExtrinsic, + lookupAbi, +}: DeployContractOptions): Promise { + const result = await basicDeployContract({ + api, + abi, + constructorArguments, + constructorName, + limits, + signer, + modifyExtrinsic, + }); + + switch (result.type) { + case "panic": + case "reverted": + case "error": + return result; + } + + const extendedLookupAbi = (contractAddress: Address): Abi | undefined => { + if (addressEq(contractAddress, result.deploymentAddress)) { + return abi; + } + + return lookupAbi?.(contractAddress); + }; + + return { ...result, events: decodeContractEvents(result.events, extendedLookupAbi) }; +} + +export async function messageCall({ + abi, + api, + contractDeploymentAddress, + messageArguments, + messageName, + limits, + callerAddress, + getSigner, + modifyExtrinsic, + lookupAbi, +}: MessageCallOptions): Promise { + const contract = new ContractPromise(api, abi, contractDeploymentAddress); + + const { gasRequired, output } = await rpcCall({ + api, + abi, + contractAddress: contractDeploymentAddress, + callerAddress, + limits, + messageName, + messageArguments, + }); + + switch (output.type) { + case "reverted": + return { execution: { type: "onlyQuery" }, result: output }; + case "panic": + return { execution: { type: "onlyQuery" }, result: output }; + case "error": + return { + execution: { type: "onlyQuery" }, + result: { type: "error", error: output.description ?? "unknown" }, + }; + } + + const message = abi.findMessage(messageName); + if (!message.isMutating) { + return { execution: { type: "onlyQuery" }, result: output }; + } + + const signer = await getSigner(); + + const typesAddress = api.registry.createType("AccountId", contractDeploymentAddress); + let extrinsic = api.tx.contracts.call( + typesAddress, + BN_ZERO, + gasRequired, + limits.storageDeposit, + contract.abi.findMessage(messageName).toU8a(messageArguments) + ); + + if (modifyExtrinsic) { + extrinsic = modifyExtrinsic(extrinsic); + } + const { events, status, transactionFee } = await submitExtrinsic(extrinsic, signer); + + return { + execution: { type: "extrinsic", contractEvents: decodeContractEvents(events, lookupAbi), transactionFee }, + result: status.type === "success" ? { type: "success", value: output.value } : status, + }; +} diff --git a/src/submitExtrinsic.ts b/src/submitExtrinsic.ts new file mode 100644 index 0000000..277a5fe --- /dev/null +++ b/src/submitExtrinsic.ts @@ -0,0 +1,98 @@ +import { Event, AccountId32, DispatchError, DispatchInfo } from "@polkadot/types/interfaces"; +import { AddressOrPair, SignerOptions, SubmittableExtrinsic } from "@polkadot/api/types"; +import { ISubmittableResult, Signer } from "@polkadot/types/types"; +import { INumber, ITuple } from "@polkadot/types-codec/types"; +import { KeyringPair } from "@polkadot/keyring/types"; + +import { extractDispatchErrorDescription } from "./dispatchError.js"; +import { Address } from "./index.js"; + +export type Extrinsic = SubmittableExtrinsic<"promise", ISubmittableResult>; + +export type SubmitExtrinsicStatus = { type: "success" } | { type: "error"; error: string }; + +export interface SubmitExtrinsicResult { + transactionFee: bigint | undefined; + events: Event[]; + status: SubmitExtrinsicStatus; +} + +export interface KeyPairSigner { + type: "keypair"; + keypair: KeyringPair; +} + +export interface GenericSigner { + type: "signer"; + address: Address; + signer: Signer; +} + +export function getSignerAddress(signer: KeyPairSigner | GenericSigner) { + switch (signer.type) { + case "keypair": + return signer.keypair.address; + + case "signer": + return signer.address; + } +} + +export async function submitExtrinsic( + extrinsic: Extrinsic, + signer: KeyPairSigner | GenericSigner +): Promise { + return await new Promise(async (resolve, reject) => { + try { + let account: AddressOrPair; + let signerOptions: Partial; + + switch (signer.type) { + case "keypair": + account = signer.keypair; + signerOptions = { nonce: -1 }; + break; + + case "signer": + account = signer.address; + signerOptions = { nonce: -1, signer: signer.signer }; + break; + } + + const unsub = await extrinsic.signAndSend(account, signerOptions, (update) => { + const { status, events: eventRecords } = update; + + const events = eventRecords.map(({ event }) => event); + if (status.isInBlock || status.isFinalized) { + let transactionFee: bigint | undefined = undefined; + let status: SubmitExtrinsicStatus | undefined = undefined; + + for (const event of events) { + const { data, section, method } = event; + + if (section === "transactionPayment" && method === "TransactionFeePaid") { + const [, actualFee] = data as unknown as ITuple<[AccountId32, INumber, INumber]>; + transactionFee = actualFee.toBigInt(); + } + + if (section === "system" && method === "ExtrinsicFailed") { + const [dispatchError] = data as unknown as ITuple<[DispatchError, DispatchInfo]>; + status = { type: "error", error: extractDispatchErrorDescription(dispatchError) }; + } + + if (section === "system" && method === "ExtrinsicSuccess") { + status = { type: "success" }; + } + } + + if (status !== undefined) { + unsub(); + resolve({ transactionFee, events, status }); + } + } + }); + } catch (error) { + reject(error); + } + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ea8869d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "outDir": "build", + "declaration": true, + "typeRoots": ["../../node_modules/@types"], + "types": ["node"], + "module": "commonjs", + + "lib": ["es2021"], + "target": "es2021", + + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "moduleResolution": "node" + }, + + "include": ["src/**/*"] +}