@gear-js/api
Version:
A JavaScript library that provides functionality to connect GEAR Component APIs.
379 lines (376 loc) • 15.2 kB
JavaScript
import '@polkadot/util';
import './utils/generate.js';
import '@polkadot/types';
import { decodeAddress } from './utils/address.js';
import '@polkadot/util-crypto';
import 'assert';
import './default/index.js';
import './metadata/programMetadata.js';
import { ReplyCode } from './utils/reply-code.js';
/**
* # Program Class
* @param _id - The ID of the program.
* @param _api - An instance of the GearApi class
* @param _account - (optional) The account or address to be used for transactions.
* @param _signerOptions - (optional) Signer options for transactions.
*/
class BaseGearProgram {
_id;
_api;
_account;
_signerOptions;
_isInitialized = false;
waitForInitialization;
_events;
_storageUnsub;
constructor(_id, _api, _account, _signerOptions) {
this._id = _id;
this._api = _api;
this._account = _account;
this._signerOptions = _signerOptions;
this.waitForInitialization = this._init();
this._events = new EventTarget();
}
/**
* ## Creates a new instance of the Program class and initializes it.
* @param programId - The program ID.
* @param api - The GearApi instance.
* @param account - (optional) The account or address to be used for transactions.
* @param signerOptions - (optional) Signer options for transactions.
* @returns An initialized Program instance.
*/
static async new(programId, api, account, signerOptions) {
const program = new BaseGearProgram(programId, api, account, signerOptions);
await program.waitForInitialization;
return program;
}
_throwOnAccountNotSet() {
if (!this._account) {
throw new Error('Account is not set');
}
}
async _init() {
if (this._isInitialized) {
return;
}
if (this._storageUnsub) {
this._storageUnsub();
this._storageUnsub = undefined;
}
const state = await this._api.query.gearProgram.programStorage(this._id);
let continueInit = true;
while (continueInit) {
if (state.isNone) {
throw new Error(`Program ${this._id} doesn't exist`);
}
if (state.unwrap().isExited) {
this._id = state.unwrap().asExited.toHex();
continue;
}
if (state.unwrap().isTerminated) {
throw new Error(`Program ${this._id} has been terminated`);
}
continueInit = false;
}
this._storageUnsub = await this._api.query.gearProgram.programStorage(this._id, (data) => {
if (data.unwrap().isExited) {
this._isInitialized = false;
this._id = data.unwrap().asExited.toHex();
this._events.dispatchEvent(new CustomEvent('programExited', { detail: { inheritorId: this._id } }));
this._init();
}
});
this._isInitialized = true;
}
/**
* ## Subscribes to a specific event emitted by the program.
* @param action - The name of the event to subscribe to (e.g., 'programExited').
* @param callback - The callback function to execute when the event is triggered. Receives the inheritor ID as a parameter.
* @returns A function to unsubscribe from the event.
*/
async on(action, callback) {
await this.waitForInitialization;
const listener = function (event) {
callback(event.detail.inheritorId);
};
this._events.addEventListener(action, listener);
return () => {
this._events.removeEventListener(action, listener);
};
}
/**
* ## Sets the account to be used for submitting transactions.
* @param account
* @param signerOptions
* @returns
*/
setAccount(account, signerOptions) {
this._account = account;
this._signerOptions = signerOptions;
return this;
}
async _submitTx(tx, eventsToBeReturned = []) {
const _events = [];
const [success, txError, blockHash] = await new Promise((resolve) => tx
.signAndSend(this._account, this._signerOptions, ({ events, status }) => {
if (status.isInBlock) {
for (const { event } of events) {
if (eventsToBeReturned.includes(event.method)) {
_events.push(event);
}
if (event.method === 'ExtrinsicSuccess') {
resolve([true, undefined, status.asInBlock.toHex()]);
}
else if (event.method === 'ExtrinsicFailed') {
resolve([false, this._api.getExtrinsicFailedError(event).docs, status.asInBlock.toHex()]);
}
}
}
})
.catch((error) => {
resolve([false, error.message]);
}));
return {
txHash: tx.hash.toHex(),
success,
eventsToReturn: _events,
error: txError,
blockHash,
blockNumber: blockHash ? (await this._api.blocks.getBlockNumber(blockHash)).toNumber() : undefined,
};
}
get accountAddress() {
if (typeof this._account == 'string') {
return decodeAddress(this._account);
}
else if ('address' in this._account) {
return decodeAddress(this._account.address);
}
else {
return this._account.toHex();
}
}
/**
* ## Gets the current program ID.
* @returns The program ID as a HexString.
*/
get id() {
return this._id;
}
/**
* Retrieves the current balance of the program.
* @returns The program's balance as a bigint.
*/
async balance() {
await this.waitForInitialization;
const { data: balance } = await this._api.query.system.account(this._id);
return balance.free.toBigInt();
}
/**
* ## Transfers funds to the program to increase its balance.
* @param value - The amount to transfer as a bigint.
*/
async topUp(value) {
await this.waitForInitialization;
this._throwOnAccountNotSet();
const tx = this._api.tx.balances.transferKeepAlive(this._id, value);
return this._submitTx(tx);
}
/**
* ## Calculates the gas required for the message.
* @param payload - The encoded payload to send, as a HexString or Uint8Array.
* @param value - The value to send with the payload (default is 0).
* @param allowOtherPanics - Whether to allow panics in other programs during gas calculation (default is false).
* @returns Gas details.
*/
async calculateGas(payload, value = 0, allowOtherPanics = false) {
await this.waitForInitialization;
this._throwOnAccountNotSet();
const { min_limit: minLimit, reserved, burned, may_be_returned: mayBeReturned, waited, } = await this._api.program.calculateGas.handle(this.accountAddress, this._id, payload, value, allowOtherPanics);
return {
minLimit: minLimit.toBigInt(),
reserved: reserved.toBigInt(),
burned: burned.toBigInt(),
mayBeReturned: mayBeReturned.toBigInt(),
waited: waited.isTrue,
};
}
/**
* ## Calculates the reply for a given payload and value.
* @param payload - The payload to send, as a HexString or Uint8Array.
* @param value - The value to send with the payload (default is 0).
* @param gasLimit - The gas limit for the reply ('max' or a specific value).
* @returns Reply details.
*/
async calculateReply(payload, value = 0, gasLimit = 'max') {
await this.waitForInitialization;
this._throwOnAccountNotSet();
const reply = await this._api.message.calculateReply({
payload,
origin: this.accountAddress,
destination: this._id,
gasLimit: gasLimit == 'max' ? this._api.blockGasLimit : gasLimit,
value,
});
return {
payload: reply.payload.toHex(),
value: reply.value.toBigInt(),
code: new ReplyCode(reply.code.toU8a(), this._api.specVersion),
};
}
async sendMessage(paramsOrPayload, value, gasLimit, keepAlive) {
await this.waitForInitialization;
this._throwOnAccountNotSet();
const isPayloadDirect = typeof paramsOrPayload === 'string' || paramsOrPayload instanceof Uint8Array;
const _payload = isPayloadDirect ? paramsOrPayload : paramsOrPayload.payload;
const _value = (isPayloadDirect ? value : paramsOrPayload.value) ?? 0;
const _keepAlive = (isPayloadDirect ? keepAlive : paramsOrPayload.keepAlive) ?? true;
let _gasLimit = (isPayloadDirect ? gasLimit : paramsOrPayload.gasLimit) ?? 'auto';
_gasLimit = await this._resolveGasLimit(_payload, _value, gasLimit, _keepAlive);
const tx = this._api.tx.gear.sendMessage(this._id, _payload, _gasLimit, _value, _keepAlive);
const txResult = await this._submitTx(tx, ['MessageQueued']);
if (txResult.success === true) {
const messageQueuedEvent = txResult.eventsToReturn[0];
let msgId;
if (messageQueuedEvent) {
msgId = messageQueuedEvent.data[0].toHex();
}
else {
throw new Error('MessageQueued event not found in transaction result');
}
return {
success: true,
txHash: txResult.txHash,
blockHash: txResult.blockHash,
blockNumber: txResult.blockNumber,
id: msgId,
response: this._waitForResponseHandler(msgId, txResult.blockNumber).bind(this),
};
}
else {
return {
success: false,
error: txResult.error,
txHash: txResult.txHash,
blockHash: txResult.blockHash,
blockNumber: txResult.blockNumber,
};
}
}
/**
* ## Sends multiple messages to the program in a single batch transaction.
* @param messages - Array of message parameter objects to send in batch. Each object follows the ISendMessageParams interface.
* @returns Transaction result with message IDs and their individual success status.
*/
async sendBatchMessages(messages) {
await this.waitForInitialization;
this._throwOnAccountNotSet();
const txs = await Promise.all(messages.map(async ({ payload, value = 0, gasLimit = 'auto', keepAlive = true }) => {
const resolvedGasLimit = await this._resolveGasLimit(payload, value, gasLimit, keepAlive);
return this._api.tx.gear.sendMessage(this._id, payload, resolvedGasLimit, value, keepAlive);
}));
const batchTx = this._api.tx.utility.batchAll(txs);
const txResult = await this._submitTx(batchTx, ['MessageQueued', 'ItemCompleted', 'ItemFailed']);
let sentMessages = [];
if (txResult.success === true) {
const mqEvents = txResult.eventsToReturn.filter(({ method }) => method == 'MessageQueued');
if (mqEvents.length == messages.length) {
sentMessages = mqEvents.map((event) => {
const id = event.data.id.toHex();
return {
success: true,
id,
response: this._waitForResponseHandler(id, txResult.blockNumber).bind(this),
};
});
}
else {
const statusEvents = txResult.eventsToReturn.filter(({ method }) => method === 'ItemCompleted' || method === 'ItemFailed');
const mqEvents = txResult.eventsToReturn.filter(({ method }) => method === 'MessageQueued');
let mqIndex = 0;
for (let i = 0; i < messages.length; i++) {
const statusEvent = statusEvents[i];
if (statusEvent.method === 'ItemCompleted') {
const id = mqEvents[mqIndex].data.id.toHex();
sentMessages.push({
success: true,
id,
response: this._waitForResponseHandler(id, txResult.blockNumber).bind(this),
});
mqIndex++;
}
else {
sentMessages.push({
success: false,
error: this._api.getExtrinsicFailedError(statusEvent).docs,
});
}
}
}
return {
success: true,
txHash: txResult.txHash,
blockHash: txResult.blockHash,
blockNumber: txResult.blockNumber,
sentMessages,
};
}
else {
return {
success: false,
error: txResult.error,
txHash: txResult.txHash,
blockHash: txResult.blockHash,
blockNumber: txResult.blockNumber,
};
}
}
_waitForResponseHandler(id, blockNumber) {
return async () => {
const { data: { message }, } = await this._api.message.getReplyEvent(this._id, id, blockNumber);
return {
id: message.id.toHex(),
payload: message.payload.toHex(),
value: message.value.toBigInt(),
replyCode: new ReplyCode(message.details.unwrap().code.toU8a(), this._api.specVersion),
};
};
}
/**
* Resolves the gas limit based on the provided value or calculates it if needed.
* @param payload - The message payload.
* @param value - The value to send with the message.
* @param gasLimit - The requested gas limit or 'auto'/'max' option.
* @param keepAlive - Whether the message uses keep-alive semantics.
* @returns Resolved gas limit as a bigint.
* @private
*/
async _resolveGasLimit(payload, value, gasLimit, keepAlive) {
try {
if (gasLimit === 'max') {
return this._api.blockGasLimit.toBigInt();
}
else if (!gasLimit || gasLimit === 'auto') {
const gas = await this.calculateGas(payload, value, keepAlive);
return gas.minLimit;
}
else {
try {
return BigInt(gasLimit);
}
catch (_) {
throw new Error(`Invalid gas limit value: ${gasLimit}`);
}
}
}
catch (error) {
throw new Error(`Failed to resolve gas limit: ${error.message}`);
}
}
}
/**
* @deprecated - use BaseGearProgram instead
*/
class Program extends BaseGearProgram {
}
export { BaseGearProgram, Program };