@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
365 lines (325 loc) • 14.8 kB
text/typescript
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 },
)
}