UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

365 lines (325 loc) • 14.8 kB
import { type Address, ContractFunctionExecutionError, ContractFunctionRevertedError, decodeEventLog, type Hex, parseAbi, TransactionExecutionError, type TransactionReceipt, } from "viem" import { estimateContractGas, getTransaction } from "viem/actions" import { getAction } from "viem/utils" import type { ChangeOwnershipParameters } from "../actions/wallet/changeOwnership" import type { CreateEntityParameters } from "../actions/wallet/createEntity" import type { DeleteEntityParameters } from "../actions/wallet/deleteEntity" import type { ExtendEntityParameters } from "../actions/wallet/extendEntity" import type { PatchEntityParameters } from "../actions/wallet/patchEntity" import type { ArkivClient } from "../clients/baseClient" import type { WalletArkivClient } from "../clients/createWalletClient" import { ARKIV_ADDRESS, GAS_HEADROOM_PERCENT, MAX_TRANSACTION_GAS } from "../consts" import { ENTITY_EVENTS_ABI } from "../entity/events" import { resolveExpiry } from "../entity/expiry" import { encodeCreationFlags } from "../entity/flags" import { randomSalt, resolveSalt } from "../entity/key" import { createOperation, deleteOperation, EXECUTE_ABI as EXECUTE_FUNCTION_ABI, extendExpiryOperation, type Operation, patchOperation, transferOwnershipOperation, } from "../entity/operations" import { EmptyPatchError, EntityMutationError } from "../errors" // Internal seam: the ABI encoders are not part of the package's public surface. import { encodeCreate, encodePatch } from "../schema/attributes" import type { TxParams } from "../types" import { getLogger } from "./logger" import { describeEntityRevert } from "./revert" const logger = getLogger("utils:arkiv-transactions") const ENTITY_ERRORS_ABI = parseAbi([ // Batch and operation framing. "error EmptyBatch()", "error InvalidOpType(uint8 operation)", "error NonCanonicalOperationData(uint8 operation)", // The attribute triple list, structural. "error AttributesNotSorted()", "error TooManyAttributes(uint256 count, uint256 maxCount)", "error EmptyMutations(bytes32 entityKey)", "error TombstoneInCreate(bytes32 name)", "error TombstoneValueNotEmpty(bytes32 name)", "error SystemAttributeNotWritable(bytes32 name)", "error InvalidValueType(bytes32 name, uint8 typeId)", // Attribute-name validation. `Ident32` is a bytes32 on the wire. "error Ident32Empty()", "error Ident32InvalidByte(uint256 position, bytes1 value)", // Entity state and authorisation. "error EntityNotFound(bytes32 entityKey)", "error EntityExpired(bytes32 entityKey, uint64 expiresAt)", "error NotOwner(bytes32 entityKey, address caller, address owner)", "error ReadOnlyEntity(bytes32 entityKey)", "error ReservedCreationFlags(uint8 creationFlags)", // Expiry. The chain caps how far ahead of the current block it may land (the SDK's MAX_LIFETIME, // which it checks before sending), so nothing lives forever. "error ExpiryDeadOnArrival(uint64 target, uint64 currentBlock)", "error ExpiryBeyondMaxLifetime(uint64 target, uint64 currentBlock, uint64 maxLifetime)", "error ExpiryNotExtended(bytes32 entityKey, uint64 newExpiresAt, uint64 currentExpiresAt)", // Ownership transfer. "error TransferToZeroAddress(bytes32 entityKey)", "error TransferToSelf(bytes32 entityKey)", ]) const EXECUTE_ABI = [...EXECUTE_FUNCTION_ABI, ...ENTITY_ERRORS_ABI] export type SendArkivTransactionResult = { receipt: TransactionReceipt /** The keys the engine minted for the entities this transaction created, in batch order. */ createdEntityKeys: Hex[] /** The block each created entity expires at, as the engine recorded it, in the same order. */ createdExpiries: bigint[] /** The block each extended entity now expires at, as the engine recorded it, in batch order. */ extendedExpiries: bigint[] } export async function sendArkivTransaction( client: ArkivClient, ops: { creates?: readonly CreateEntityParameters[] | undefined patches?: readonly PatchEntityParameters[] | undefined deletes?: readonly DeleteEntityParameters[] | undefined extensions?: readonly ExtendEntityParameters[] | undefined ownershipChanges?: readonly ChangeOwnershipParameters[] | undefined }, txParams?: TxParams, ): Promise<SendArkivTransactionResult> { if (!client.account) throw new Error("Account required") if (!client.chain) throw new Error("Chain required") const walletClient = client as WalletArkivClient const { creates, patches, deletes, extensions, ownershipChanges } = ops // Every write is checked against its schema before the block read, so one that does not fit // fails without touching the network — and a network failure cannot mask it. const encodedCreates = (creates ?? []).map((item) => ({ item, attributes: encodeCreate(item.schema, item.values), })) const patchOps = (patches ?? []).map((item) => { const mutations = encodePatch(item.schema, item) if (mutations.length === 0) { throw new EmptyPatchError(item.entityKey) } return patchOperation({ entityKey: item.entityKey, mutations }) }) // The block height, to resolve any relative expiry — which both creates and extensions carry. // Skipped entirely for a batch with neither. const currentBlock = creates?.length || extensions?.length ? await walletClient.getBlockNumber() : 0n const expiryContext = { currentBlock } const createOps = encodedCreates.map(({ item, attributes }) => { const salt = item.salt === undefined ? randomSalt() : resolveSalt(item.salt) const expiry = resolveExpiry(item.expiration, expiryContext) return createOperation({ salt, expiry, creationFlags: encodeCreationFlags(item.flags), attributes, }) }) // An extension resolves its expiry exactly as a create does — the engine applies the same // `max(expiresAt, currentBlock + minLifetime)` to both — so the same bounds are checked here // rather than left to a revert. const extendOps = (extensions ?? []).map((item) => { const expiry = resolveExpiry(item.expiration, expiryContext) return extendExpiryOperation({ entityKey: item.entityKey, expiry }) }) const operations: Operation[] = [ ...createOps, ...patchOps, ...(deletes ?? []).map((item) => deleteOperation({ entityKey: item.entityKey })), ...extendOps, ...(ownershipChanges ?? []).map((item) => transferOwnershipOperation({ entityKey: item.entityKey, newOwner: item.newOwner as Address, }), ), ] if (operations.length === 0) { throw new Error("No operations to perform") } logger("Sending execute with %d operations %o", operations.length, operations) let txHash: Hex | undefined try { const gas = txParams?.gas ?? withHeadroom( await estimateContractGas(walletClient, { address: ARKIV_ADDRESS, abi: EXECUTE_ABI, functionName: "execute", args: [operations], // A local account makes viem estimate twice, the second time capped at the first result, // which fails if a block lands in between. This is also how viem's `writeContract` does // it by default if you don't pass a custom gas limit account: { address: client.account.address, type: "json-rpc" }, ...txParams, }), ) txHash = await walletClient.writeContract({ address: ARKIV_ADDRESS, abi: EXECUTE_ABI, functionName: "execute", args: [operations], account: client.account, chain: client.chain, ...txParams, gas, }) const receipt = await walletClient.waitForTransactionReceipt({ hash: txHash }) logger("Tx receipt %o", receipt) if (receipt.status === "reverted") { // There is no guarantee that the limit we estimated here // was the actual limit used (for example a user may // have overwritten it in the wallet UI) const actualLimitUsed = (await getGasLimitOfTx(walletClient, txHash)) ?? gas // No need to run a simulation if we ran out of gas // (simulations dont have a gas limit so it would come back as an empty error response) if (receipt.gasUsed >= actualLimitUsed) { const estimated = txParams?.gas === undefined && actualLimitUsed === gas throw new EntityMutationError( `Transaction ${receipt.transactionHash} ran out of gas: it used all ${actualLimitUsed} gas it ` + "was given, so nothing was applied and the fee was spent. " + (estimated ? "The batch cost more when it ran than when it was estimated, because the state " + "changed in between. Send it again." : "The limit was set rather than estimated (passed as `gas`, or changed in the " + "wallet) and was too low for the batch. Send it again with a higher `gas`, or " + "without one to let the SDK estimate it."), { txHash: receipt.transactionHash }, ) } try { await walletClient.simulateContract({ address: ARKIV_ADDRESS, abi: EXECUTE_ABI, functionName: "execute", args: [operations], account: client.account, chain: client.chain, }) } catch (err) { const error = err as { shortMessage?: string; cause?: { details?: string } } const reason = describeEntityRevert(err) ?? error.shortMessage ?? error.cause?.details ?? "No reason provided by backend." throw new EntityMutationError( `Transaction ${receipt.transactionHash} reverted. Reason: ${reason}`, { cause: err, txHash: receipt.transactionHash }, ) } throw new EntityMutationError( `Transaction ${receipt.transactionHash} reverted. No reason provided by backend.`, { txHash: receipt.transactionHash }, ) } return { receipt, ...readAppliedOperations(receipt, { creates: createOps.length, extensions: extendOps.length, }), } } catch (error) { const described = error instanceof EntityMutationError ? undefined : describeEntityRevert(error) if (described !== undefined) { throw new EntityMutationError(`Transaction failed: ${described}`, { cause: error, txHash }) } let message = "Transaction failed" if (error instanceof TransactionExecutionError) { message += `: ${error.details}` } else if (error instanceof ContractFunctionExecutionError) { logger("Contract function execution error data:", error.shortMessage) if (error.cause instanceof ContractFunctionRevertedError) { message += `: ${error.cause.message}` } else { message += ": Execution error without revert data" } } else if (error instanceof EntityMutationError) { throw error } else if (error instanceof Error) { message += `: ${error.message}` } throw new EntityMutationError(message, { cause: error, txHash }) } } /** The gas limit a sent transaction carried, or `undefined` if the node cannot say. */ async function getGasLimitOfTx(client: WalletArkivClient, hash: Hex): Promise<bigint | undefined> { try { return (await getAction(client, getTransaction, "getTransaction")({ hash })).gas } catch { return undefined } } /** * An estimate with {@link GAS_HEADROOM_PERCENT} added: room for the state to change between the * estimate and the block the transaction lands in. Capped at {@link MAX_TRANSACTION_GAS}, so the * headroom alone never makes a batch too big to send, but never below the estimate itself. */ function withHeadroom(estimate: bigint): bigint { // Already too big to send: padding cannot help, so leave the estimate as it is. if (estimate > MAX_TRANSACTION_GAS) return estimate const padded = (estimate * (100n + GAS_HEADROOM_PERCENT)) / 100n if (padded > MAX_TRANSACTION_GAS) return MAX_TRANSACTION_GAS return padded } /** * The keys and expiries the engine recorded, read back from the events the batch emitted. */ function readAppliedOperations( receipt: TransactionReceipt, expected: { creates: number; extensions: number }, ): Omit<SendArkivTransactionResult, "receipt"> { const createdEntityKeys: Hex[] = [] const createdExpiries: bigint[] = [] const extendedExpiries: bigint[] = [] // `?? []` so a receipt without logs is reported by the count check below, which explains what is // missing and that the write succeeded, rather than as a TypeError from this loop. for (const log of receipt.logs ?? []) { // Only the operation address emits these, and a batch's transaction can carry logs from // anything else it touched. if (log.address.toLowerCase() !== ARKIV_ADDRESS.toLowerCase()) continue const decoded = decodeEntityLog(log) if (decoded === undefined) continue if (decoded.eventName === "EntityCreated") { createdEntityKeys.push(decoded.args.entityKey) createdExpiries.push(decoded.args.expiresAt) } else if (decoded.eventName === "ExpiryExtended") { extendedExpiries.push(decoded.args.expiresAt) } } // Operations apply in batch order and emit one event each, and creates lead the batch — so these // arrive already aligned with the parameters they came from. A count that disagrees means that // assumption broke, and a misaligned array would hand back a key belonging to another entity. assertCount(receipt, "create", createdEntityKeys.length, expected.creates) assertCount(receipt, "extension", extendedExpiries.length, expected.extensions) return { createdEntityKeys, createdExpiries, extendedExpiries } } /** One log as an entity event, or `undefined` for anything this SDK has no name for. */ function decodeEntityLog(log: TransactionReceipt["logs"][number]) { try { return decodeEventLog({ abi: ENTITY_EVENTS_ABI, topics: log.topics, data: log.data }) } catch { // An event a newer engine emits and this ABI does not describe. Not this function's business. return undefined } } function assertCount(receipt: TransactionReceipt, what: string, got: number, want: number): void { if (got === want) return // Deliberately explicit that the write succeeded: this throws *after* a successful transaction, // and a caller who reads it as a failure and retries would create the entities a second time. throw new EntityMutationError( `Transaction ${receipt.transactionHash} succeeded — do not retry it — but emitted ${got} ` + `${what} event(s) for ${want} ${what} operation(s). The batch was applied; the SDK cannot ` + `say which entity each operation produced, so read them back to find them.`, { txHash: receipt.transactionHash }, ) }