iso-filecoin
Version:
Isomorphic filecoin abstractions for RPC, signatures, address, token and wallet
647 lines (583 loc) • 17.3 kB
JavaScript
import { request } from 'iso-web/http'
import { fromError } from 'zod-validation-error'
import { Message } from './message.js'
import { Signature } from './signature.js'
import { getNetworkPrefix, isZodErrorLike } from './utils.js'
export {
AbortError,
HttpError,
JsonError,
NetworkError,
RequestError,
RetryError,
TimeoutError,
} from 'iso-web/http'
/**
* @import {ChainGetTipSetByHeightParams, FilecoinAddressToEthAddressParams, GasEstimateMessageGasResponse, GasEstimateParams, JsonRpcResponse, MaybeResult, MpoolGetNonceResponse, MpoolPushResponse, Options, PushMessageParams, RpcOptions, Safety, StateAccountKeyParams, StateNetworkNameResponse, TipSet, VersionResponse, waitMsgParams, WalletBalanceResponse} from './types.js'
*/
/**
* @typedef {import('iso-web/types').RequestOptions} RequestOptions
* @typedef {import('iso-web/http').Errors | import('iso-web/http').JsonError} RequestErrors
*/
/**
* Error symbol
*/
const symbol = Symbol.for('rpc-error')
/**
* Check if a value is a RpcError
*
* @param {unknown} value
* @returns {value is RpcError}
*/
export function isRpcError(value) {
return value instanceof Error && symbol in value
}
export class RpcError extends Error {
/** @type {boolean} */
[symbol] = true
name = 'RpcError'
/** @type {unknown} */
cause
/**
*
* @param {string} message
* @param {ErrorOptions} [options]
*/
constructor(message, options = {}) {
super(message, options)
this.cause = options.cause
}
/**
* Check if a value is a RequestError
*
* @param {unknown} value
* @returns {value is RpcError}
*/
static is(value) {
return isRpcError(value) && value.name === 'RpcError'
}
}
export class JsonRpcError extends RpcError {
name = 'JsonRpcError'
/** @type {import('./types.js').JsonRpcError} */
cause
/**
*
* @param {import('./types.js').JsonRpcError} cause
*/
constructor(cause) {
super(cause.message, { cause })
this.cause = cause
}
/**
* Check if a value is a JsonRpcError
*
* @param {unknown} value
* @returns {value is JsonRpcError}
*/
static is(value) {
return isRpcError(value) && value.name === 'JsonRpcError'
}
}
export class ValidationRpcError extends RpcError {
name = 'ValidationRpcError'
/** @type {import('zod').ZodError} */
cause
/**
*
* @param {import('zod').ZodError} cause
*/
constructor(cause) {
const message = fromError(cause).message.replace('Validation error: ', '')
super(message, { cause })
this.cause = cause
}
/**
* Check if a value is a ValidationRpcError
*
* @param {unknown} value
* @returns {value is ValidationRpcError}
*/
static is(value) {
return isRpcError(value) && value.name === 'ValidationRpcError'
}
}
/**
* RPC
*/
export class RPC {
/**
*
* TODO: remove fetch from Options and use fetch from RequestOptions
* TODO: either remove token or merge this.headers with fetchOptions.headers
*
* @param {Options} options
* @param {RequestOptions} [fetchOptions]
*/
constructor(
{
api,
token,
network = 'mainnet',
fetch = globalThis.fetch.bind(globalThis),
},
fetchOptions = {}
) {
this.fetch = fetch
this.api = new URL(api)
this.network = network
this.headers = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
this.fetchOptions = fetchOptions
}
/**
* Version returns the version of the Filecoin node.
*
* @param {RequestOptions} [fetchOptions]
*/
async version(fetchOptions = {}) {
return await /** @type {typeof this.call<VersionResponse>}*/ (this.call)(
{ method: 'Filecoin.Version' },
fetchOptions
)
}
/**
* NetworkName returns the name of the network the node is synced to.
*
* @param {RequestOptions} [fetchOptions]
*/
async networkName(fetchOptions = {}) {
return await /** @type {typeof this.call<StateNetworkNameResponse>}*/ (
this.call
)({ method: 'Filecoin.StateNetworkName' }, fetchOptions)
}
/**
* GasEstimateMessageGas estimates gas values for unset message gas fields
*
* @see https://lotus.filecoin.io/reference/lotus/gas/#gasestimatemessagegas
*
* @param {GasEstimateParams} params
* @param {RequestOptions} [fetchOptions]
* @return {Promise<MaybeResult<GasEstimateMessageGasResponse, RequestErrors | JsonRpcError | RpcError | ValidationRpcError>>}
*/
async gasEstimate(params, fetchOptions = {}) {
const isFromValid = this.#isInvalidNetwork(params.msg.from)
if (isFromValid.error) {
return isFromValid
}
const isToValid = this.#isInvalidNetwork(params.msg.to)
if (isToValid.error) {
return isToValid
}
try {
const msg = new Message(params.msg)
return await /** @type {typeof this.call<GasEstimateMessageGasResponse>}*/ (
this.call
)(
{
method: 'Filecoin.GasEstimateMessageGas',
params: [msg.toLotus(), { MaxFee: params.maxFee ?? '0' }, null],
},
fetchOptions
)
} catch (error) {
if (isZodErrorLike(error)) {
return {
result: undefined,
error: new ValidationRpcError(error),
}
}
return {
result: undefined,
error: new RpcError('Failed to estimate gas', { cause: error }),
}
}
}
/**
* WalletBalance returns the balance of the given address at the current head of the chain.
*
* @see https://lotus.filecoin.io/reference/lotus/wallet/#walletbalance
*
* @param {string} address
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<WalletBalanceResponse, RequestErrors | JsonRpcError | RpcError>>}
*/
async balance(address, fetchOptions = {}) {
const isValid = this.#isInvalidNetwork(address)
if (isValid.error) {
return isValid
}
return await /** @type {typeof this.call<WalletBalanceResponse>}*/ (
this.call
)({ method: 'Filecoin.WalletBalance', params: [address] }, fetchOptions)
}
/**
* MpoolGetNonce gets next nonce for the specified sender. Note that this method may not be atomic. Use MpoolPushMessage instead.
*
* @see https://lotus.filecoin.io/reference/lotus/mpool/#mpoolgetnonce
* @param {string} address
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<MpoolGetNonceResponse, RequestErrors | JsonRpcError | RpcError>>}
*/
async nonce(address, fetchOptions = {}) {
const isValid = this.#isInvalidNetwork(address)
if (isValid.error) {
return isValid
}
return await /** @type {typeof this.call<MpoolGetNonceResponse>}*/ (
this.call
)({ method: 'Filecoin.MpoolGetNonce', params: [address] }, fetchOptions)
}
/**
* MpoolPush pushes a signed message to mempool.
*
* @see https://lotus.filecoin.io/reference/lotus/mpool/#mpoolpush
*
* @param {PushMessageParams} params
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<MpoolPushResponse, RequestErrors | JsonRpcError | RpcError | ValidationRpcError>>}
*/
async pushMessage(params, fetchOptions = {}) {
const isFromValid = this.#isInvalidNetwork(params.msg.from)
if (isFromValid.error) {
return isFromValid
}
const isToValid = this.#isInvalidNetwork(params.msg.to)
if (isToValid.error) {
return isToValid
}
try {
return await /** @type {typeof this.call<MpoolPushResponse>}*/ (
this.call
)(
{
method: 'Filecoin.MpoolPush',
params: [
{
Message: new Message(params.msg).toLotus(),
Signature: new Signature(params.signature).toLotus(),
},
],
},
fetchOptions
)
} catch (error) {
if (isZodErrorLike(error)) {
return {
result: undefined,
error: new ValidationRpcError(error),
}
}
return {
result: undefined,
error: new RpcError('Failed to push message', { cause: error }),
}
}
}
/**
* StateWaitMsg looks back in the chain for a message. If not found, it blocks until the message arrives on chain, and gets to the indicated confidence depth.
*
* Timeout is increased to 60s instead of the default 5s.
*
* @see https://lotus.filecoin.io/reference/lotus/state/#statewaitmsg
* @param {waitMsgParams} params
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<MpoolPushResponse, RequestErrors | JsonRpcError>>}
*/
async waitMsg(params, fetchOptions = {}) {
return await /** @type {typeof this.call<MpoolPushResponse>}*/ (this.call)(
{
method: 'Filecoin.StateWaitMsg',
params: [
params.cid,
params.confidence ?? 2,
params.lookback ?? 100,
false,
],
},
{ timeout: 60_000, ...fetchOptions }
)
}
/**
* Converts any Filecoin address to an EthAddress.
*
* @see https://github.com/filecoin-project/lotus/blob/471819bf1ef8a4d5c7c0476a38ce9f5e23c59bfc/api/api_full.go#L743-L768
* @param {FilecoinAddressToEthAddressParams} params
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<string, RequestErrors | JsonRpcError | RpcError>>}
*/
async filecoinAddressToEthAddress(params, fetchOptions = {}) {
const isValid = this.#isInvalidNetwork(params.address)
if (isValid.error) {
return isValid
}
return await /** @type {typeof this.call<string>} */ (this.call)(
{
method: 'Filecoin.FilecoinAddressToEthAddress',
params: [params.address, params.blockNumber ?? 'finalized'],
},
fetchOptions
)
}
/**
* Public key address of the given ID address.
*
* @see https://github.com/filecoin-project/lotus/blob/master/documentation/en/api-v0-methods.md#StateAccountKey
*
*
* @param {StateAccountKeyParams} params
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<string, RequestErrors | JsonRpcError | RpcError>>}
*/
async stateAccountKey(params, fetchOptions = {}) {
const isValid = this.#isInvalidNetwork(params.address)
if (isValid.error) {
return isValid
}
const r = await /** @type {typeof this.call<string>} */ (this.call)(
{
method: 'Filecoin.StateAccountKey',
params: [
params.address,
params.tipSetKey === undefined ? null : params.tipSetKey,
],
},
fetchOptions
)
if (r.error) {
return r
}
return {
result: r.result,
error: undefined,
}
}
/**
* Public key address of the given non-account ID address.
*
* @see https://github.com/filecoin-project/lotus/blob/master/documentation/en/api-v0-methods.md#StateLookupRobustAddress
*
*
* @param {StateAccountKeyParams} params
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<string, RequestErrors | JsonRpcError | RpcError>>}
*/
async stateLookupRobustAddress(params, fetchOptions = {}) {
const isValid = this.#isInvalidNetwork(params.address)
if (isValid.error) {
return isValid
}
const r = await /** @type {typeof this.call<string>} */ (this.call)(
{
method: 'Filecoin.StateLookupRobustAddress',
params: [
params.address,
params.tipSetKey === undefined ? null : params.tipSetKey,
],
},
fetchOptions
)
if (r.error) {
return r
}
return {
result: r.result,
error: undefined,
}
}
/**
* Retrieves the ID address of the given address for a tipset.
* If you dont have a specific tipset in mind, better to use {@link getIDAddress}.
*
* @see https://github.com/filecoin-project/lotus/blob/master/documentation/en/api-v0-methods.md#statelookupid
*
*
* @param {StateAccountKeyParams} params
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<string, RequestErrors | JsonRpcError >>}
*/
async stateLookupID(params, fetchOptions = {}) {
const r = await /** @type {typeof this.call<string>} */ (this.call)(
{
method: 'Filecoin.StateLookupID',
params: [
params.address,
params.tipSetKey === undefined ? null : params.tipSetKey,
],
},
fetchOptions
)
if (r.error) {
return r
}
return {
result: r.result,
error: undefined,
}
}
/**
* The current head of the chain.
*
* @see https://github.com/filecoin-project/filecoin-docs/blob/main/reference/json-rpc/chain.md#chainhead
*
*
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<TipSet, RequestErrors | JsonRpcError >>}
*/
async chainHead(fetchOptions = {}) {
const r = await /** @type {typeof this.call<TipSet>} */ (this.call)(
{
method: 'Filecoin.ChainHead',
},
fetchOptions
)
return r
}
/**
* Get tipset at the specified epoch (height). If there are no blocks at the specified epoch, a tipset at an earlier epoch will be returned.
*
* @see https://github.com/filecoin-project/filecoin-docs/blob/main/reference/json-rpc/chain.md#chaingettipsetbyheight
*
* @param {ChainGetTipSetByHeightParams} params
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<TipSet, RequestErrors | JsonRpcError >>}
*/
async getTipSetByHeight(params, fetchOptions = {}) {
const r = await /** @type {typeof this.call<TipSet>} */ (this.call)(
{
method: 'Filecoin.ChainGetTipSetByHeight',
params: [
params.height,
params.tipSetKey === undefined ? null : params.tipSetKey,
],
},
fetchOptions
)
return r
}
/**
* Looks back from latest height for a tipset
*
* @param {number} lookback - Chain epoch to look back to
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<TipSet, RequestErrors | JsonRpcError | RpcError>>}
*/
async lookBackTipSet(lookback, fetchOptions = {}) {
const head = await this.chainHead(fetchOptions)
if (head.error) {
return head
}
const wallTime = Math.floor(Date.now() / 1000)
const filTime = head.result.Blocks[0].Timestamp
// https://github.com/ribasushi/go-toolbox-interplanetary/blob/master/fil/time.go#L50-L61
if (wallTime < filTime - 3 || wallTime > filTime + 10 + 4 * 30) {
return {
result: undefined,
error: new RpcError('Chain is not synced'),
}
}
if (lookback === 0) {
return head
}
const height = head.result.Height - 1 - lookback
return this.getTipSetByHeight(
{
height,
tipSetKey: head.result.Cids,
},
fetchOptions
)
}
/**
* Get the ID address for an address with different safety guarantees
*
* @param {{address: string, safety?: Safety}} params
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<string, RequestErrors | JsonRpcError | RpcError>>}
*/
async getIDAddress(params, fetchOptions = {}) {
const safety = params.safety ?? 'finalized'
let tipSetKey
if (safety === 'finalized' || safety === 'safe') {
const ts = await this.lookBackTipSet(
safety === 'finalized' ? 900 : 30,
fetchOptions
)
tipSetKey = ts.result?.Cids
if (ts.error) {
return ts
}
}
if (safety === 'latest') {
tipSetKey = null
}
return this.stateLookupID(
{
address: params.address,
tipSetKey,
},
fetchOptions
)
}
/**
* Generic method to call any method on the lotus rpc api.
*
* @template R
* @param {RpcOptions} rpcOptions
* @param {RequestOptions} [fetchOptions]
* @returns {Promise<MaybeResult<R, RequestErrors | JsonRpcError>>}
*/
async call(rpcOptions, fetchOptions = {}) {
const opts = {
...this.fetchOptions,
...fetchOptions,
}
const r = await /** @type {typeof request.json.post<JsonRpcResponse>} */ (
request.json.post
)(this.api, {
...opts,
headers: this.headers,
body: {
jsonrpc: '2.0',
method: rpcOptions.method,
params: rpcOptions.params,
id: 1,
},
})
// normal request error
if (r.error) {
return {
result: undefined,
error: r.error,
}
}
// json rpc error
if (r.result.error) {
return {
result: undefined,
error: new JsonRpcError(r.result.error),
}
}
return { result: /** @type {R} */ (r.result.result), error: undefined }
}
/**
* Check address against network
*
* @param {string} address
* @returns {MaybeResult<undefined, RpcError>}
*/
#isInvalidNetwork(address) {
const prefix = getNetworkPrefix(this.network)
if (!address.startsWith(prefix)) {
return {
result: undefined,
error: new RpcError(
`Address ${address} does not belong to ${this.network}`
),
}
}
return { result: undefined, error: undefined }
}
}