diff --git a/src/actions/domain.ts b/src/actions/domain.ts index d69af2ed..d8a92ad5 100644 --- a/src/actions/domain.ts +++ b/src/actions/domain.ts @@ -1,6 +1,5 @@ import { BN, ContractAbi, RpcContractBuilder } from '@partisiablockchain/abi-client' import { IActionApproveMintFees, IActionDomainMintPayload, IActionDomainTransferPayload, IActionRenewDomainPayload } from '../interface' -import { builderToBytesBe } from '../transactions/helper' export const actionDomainMintPayload = (contractAbi: ContractAbi, params: IActionDomainMintPayload): Buffer => { const rpc = new RpcContractBuilder(contractAbi, 'mint') @@ -20,7 +19,7 @@ export const actionDomainMintPayload = (contractAbi: ContractAbi, params: IActio const subscriptionYearsOption = rpc.addOption() if (params.subscriptionYears) subscriptionYearsOption.addU32(params.subscriptionYears) - return builderToBytesBe(rpc) + return rpc.getBytes() } export const actionDomainMintBatchPayload = (contractAbi: ContractAbi, params: IActionDomainMintPayload[]): Buffer => { @@ -47,7 +46,7 @@ export const actionDomainMintBatchPayload = (contractAbi: ContractAbi, params: I if (param.subscriptionYears) subscriptionYearsOption.addU32(param.subscriptionYears) }) - return builderToBytesBe(rpc) + return rpc.getBytes() } export const actionApproveMintFeesPayload = (contractAbi: ContractAbi, params: IActionApproveMintFees): Buffer => { @@ -59,7 +58,7 @@ export const actionApproveMintFeesPayload = (contractAbi: ContractAbi, params: I const amount = new BN(params.amount) rpc.addU128(amount) - return builderToBytesBe(rpc) + return rpc.getBytes() } export const actionDomainRenewalPayload = (contractAbi: ContractAbi, params: IActionRenewDomainPayload): Buffer => { @@ -73,7 +72,7 @@ export const actionDomainRenewalPayload = (contractAbi: ContractAbi, params: IAc rpc.addU32(params.subscriptionYears ?? 1) - return builderToBytesBe(rpc) + return rpc.getBytes() } export const actionDomainTransferFromPayload = (contractAbi: ContractAbi, params: IActionDomainTransferPayload): Buffer => { @@ -85,5 +84,5 @@ export const actionDomainTransferFromPayload = (contractAbi: ContractAbi, params rpc.addAddress(to) rpc.addU128(new BN(tokenId)) - return builderToBytesBe(rpc) + return rpc.getBytes() } diff --git a/src/actions/record.ts b/src/actions/record.ts index 3e84e33a..a4e9ae81 100644 --- a/src/actions/record.ts +++ b/src/actions/record.ts @@ -1,13 +1,12 @@ import { AbstractBuilder, ContractAbi, RpcContractBuilder } from '@partisiablockchain/abi-client' import { IActionRecordDelete, IActionRecordMint, IActionRecordUpdate } from '../interface' -import { builderToBytesBe } from '../transactions/helper' export const actionRecordMintPayload = (contractAbi: ContractAbi, params: IActionRecordMint): Buffer => { const rpc = new RpcContractBuilder(contractAbi, 'mint_record') addCommonRecordArgs(rpc, params) addDataArg(rpc, params.data) - return builderToBytesBe(rpc) + return rpc.getBytes() } export const actionRecordMintBatchPayload = (contractAbi: ContractAbi, params: IActionRecordMint[]): Buffer => { @@ -21,7 +20,7 @@ export const actionRecordMintBatchPayload = (contractAbi: ContractAbi, params: I addDataArg(structBuilder, param.data) }) - return builderToBytesBe(rpc) + return rpc.getBytes() } export const actionRecordUpdatePayload = (contractAbi: ContractAbi, params: IActionRecordUpdate): Buffer => { @@ -29,14 +28,14 @@ export const actionRecordUpdatePayload = (contractAbi: ContractAbi, params: IAct addCommonRecordArgs(rpc, params) addDataArg(rpc, params.data) - return builderToBytesBe(rpc) + return rpc.getBytes() } export const actionRecordDeletePayload = (contractAbi: ContractAbi, params: IActionRecordDelete): Buffer => { const rpc = new RpcContractBuilder(contractAbi, 'delete_record') addCommonRecordArgs(rpc, params) - return builderToBytesBe(rpc) + return rpc.getBytes() } const addCommonRecordArgs = (rpc: AbstractBuilder, params: IActionRecordMint | IActionRecordUpdate | IActionRecordDelete) => { diff --git a/src/repositories/contract-repository.ts b/src/repositories/contract-repository.ts index b54a4e36..ea2a9d0d 100644 --- a/src/repositories/contract-repository.ts +++ b/src/repositories/contract-repository.ts @@ -100,7 +100,6 @@ export class ContractRepository implements IContractRepository { async createTransaction({ contractAddress, payload, gasCost }: TransactionParams): Promise { if (!contractAddress) throw new Error('Contract address not found') - const isMainnet = this.environment === Enviroment.mainnet const gasTable: Record = { 'low': 8_000, 'medium': 40_000, @@ -117,7 +116,7 @@ export class ContractRepository implements IContractRepository { const { createTransaction } = await import('../transactions') const backend = await this.signingBackend() - return createTransaction(this.rpc, backend, { contractAddress, payload, cost: gas, isMainnet }) + return createTransaction(this.hostUrl, backend, { contractAddress, payload, cost: gas }) } /** diff --git a/src/repositories/helpers/client.ts b/src/repositories/helpers/client.ts index dcdf883d..8cd32d4f 100644 --- a/src/repositories/helpers/client.ts +++ b/src/repositories/helpers/client.ts @@ -7,7 +7,7 @@ const jsonBodyHeaders = { "Content-Type": "application/json", } -export type RequestType = "GET" | "POST" | "PUT" +export type RequestType = "GET" | "POST" /** * Requests that never settle would otherwise pin a retry chain open forever, @@ -30,16 +30,6 @@ export function postRequest(url: string, body: unknown, timeoutMs = DEFAULT_T return handleFetch(promiseRetry(() => fetchWithTimeout(url, "POST", jsonBodyHeaders, body, timeoutMs))) } -/** - * Sends a request without retrying. Used where a retry would resubmit a - * side effect, such as broadcasting a transaction. - */ -export async function putRequestOnce(url: string, body: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { - const response = await fetchWithTimeout(url, "PUT", jsonBodyHeaders, body, timeoutMs) - - return response.ok -} - async function fetchWithTimeout(url: string, method: RequestType, headers: Record, body: unknown, timeoutMs: number): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) diff --git a/src/repositories/helpers/sharded-client.ts b/src/repositories/helpers/sharded-client.ts index ecd2c21b..25486820 100644 --- a/src/repositories/helpers/sharded-client.ts +++ b/src/repositories/helpers/sharded-client.ts @@ -1,23 +1,11 @@ import { Buffer } from "buffer" -import { getRequest, postRequest, putRequestOnce } from "./client" +import { postRequest } from "./client" export interface ShardedClientConfig { urlBaseGlobal: { url: string, shard_id: number } urlBaseShards: { url: string, shard_id: number }[] } -export interface AccountInfo { - nonce?: number -} - -export interface TransactionInfo { - identifier: string - executionSucceeded: boolean - finalized: boolean - events: { identifier: string, destinationShard: string }[] - failureCause?: { errorMessage: string } -} - export interface GlobalCoins { coins: { symbol: string @@ -25,19 +13,13 @@ export interface GlobalCoins { }[] } -export interface EventTrace { - hasError: boolean - errorMessage?: string - eventTrace: { txHash: string, shardId: number }[] -} - /** * Minimal reader-node client for the Partisia REST API. * * Replaces `partisia-blockchain-applications-rpc`, which is unmaintained (last - * published 2024-03-18) and pulled axios in for what are plain GET/POST/PUT - * calls. The endpoints and the shard derivation below match that package's - * behaviour exactly; see the notes on each method. + * published 2024-03-18) and pulled axios in for what are plain GET/POST calls. + * Transactions no longer go through here: signing, broadcasting and waiting are + * handled by the official transaction client, see `src/transactions`. */ export class ShardedClient { private readonly globalUrl: string @@ -62,28 +44,6 @@ export class ShardedClient { return int32 % this.shards.length } - shardUrl(shardId: number): string { - const shard = this.shards.find((s) => s.shard_id === shardId) - if (!shard) throw new Error(`Unknown shard ${shardId}`) - - return shard.url - } - - shardUrlForAddress(address: string): string { - return this.shardUrl(this.deriveShardId(address)) - } - - /** - * An address with no on-chain account has no nonce; the chain treats the - * first transaction from it as nonce 1, so a missing account is not an error. - */ - async getNonce(address: string, shardId?: number): Promise { - const url = this.shardUrl(shardId ?? this.deriveShardId(address)) - const account = await getRequest(`${url}/blockchain/account/${address}`) - - return account?.nonce ?? 1 - } - async fetchCoins(): Promise { const coins = await postRequest( `${this.globalUrl}/blockchain/accountPlugin/global`, @@ -93,95 +53,4 @@ export class ShardedClient { return coins } - - /** - * A transaction hash does not encode its shard, so with no `shardId` every - * shard is queried and the first that knows the transaction wins. This is - * what the rpc package did, and callers such as the event-trace walker rely - * on it. - */ - async getTransaction(transactionHash: string, shardId?: number, requireFinal = false): Promise { - const path = `/blockchain/transaction/${transactionHash}?requireFinal=${requireFinal}` - - if (shardId !== undefined) { - return getRequest(`${this.shardUrl(shardId)}${path}`) - } - - const results = await Promise.all( - this.shards.map((shard) => - getRequest(`${shard.url}${path}`).catch(() => undefined) - ) - ) - - return results.find((transaction) => transaction !== undefined) - } - - broadcastTransaction(addressFrom: string, payload: string | Buffer): Promise { - const url = this.shardUrlForAddress(addressFrom) - const transactionPayload = typeof payload === 'string' ? payload : payload.toString('base64') - - return putRequestOnce(`${url}/blockchain/transaction`, { transactionPayload }) - } - - /** - * Walk a transaction and every event it spawned, collecting failures. - * - * Mirrors `PartisiaAccountClass.getTransactionEventTrace`. Spawned events - * land on the shard named in `destinationShard` ("Shard1" -> 1), which is why - * each recursive lookup re-targets rather than reusing the parent's shard. - */ - async getTransactionEventTrace(transactionHash: string, shardId?: number): Promise { - const transaction = await this.pollTransaction(transactionHash, shardId) - - const result: EventTrace = { - hasError: !transaction.executionSucceeded, - errorMessage: transaction.executionSucceeded ? undefined : transaction.failureCause?.errorMessage, - eventTrace: transaction.events.map((event) => ({ - txHash: event.identifier, - shardId: shardIdFromDestination(event.destinationShard), - })), - } - - for (const event of transaction.events) { - await this.collectEvents(event.identifier, shardIdFromDestination(event.destinationShard), result) - } - - return result - } - - private async collectEvents(transactionHash: string, shardId: number, result: EventTrace): Promise { - const transaction = await this.pollTransaction(transactionHash, shardId) - - if (!transaction.executionSucceeded) { - result.hasError = true - result.errorMessage = transaction.failureCause?.errorMessage - } - - for (const event of transaction.events) { - const eventShardId = shardIdFromDestination(event.destinationShard) - result.eventTrace.push({ txHash: event.identifier, shardId: eventShardId }) - await this.collectEvents(event.identifier, eventShardId, result) - } - } - - /** - * The transaction is known to exist; it may not have propagated to the - * reader node yet. Retries until it is finalized. - */ - private async pollTransaction(transactionHash: string, shardId?: number, attempts = 30, intervalInMillis = 1000): Promise { - for (let attempt = 0; attempt < attempts; attempt++) { - const transaction = await this.getTransaction(transactionHash, shardId).catch(() => undefined) - - if (transaction?.finalized) return transaction - - await new Promise((resolve) => setTimeout(resolve, intervalInMillis)) - } - - throw new Error(`Transaction ${transactionHash} was not finalized in time`) - } -} - -/** "Shard1" -> 1 */ -function shardIdFromDestination(destinationShard: string): number { - return Number(destinationShard.replace('Shard', '')) } diff --git a/src/transactions/authentication.ts b/src/transactions/authentication.ts index 0785a5b9..8fb9d961 100644 --- a/src/transactions/authentication.ts +++ b/src/transactions/authentication.ts @@ -2,7 +2,6 @@ import type LedgerTransport from "@ledgerhq/hw-transport" import type PartisiaSdk from "partisia-blockchain-applications-sdk" import type { SenderAuthentication } from "@partisiablockchain/blockchain-api-transaction-client" import type { MetaMaskSdk } from "../interface" -import assert from "assert" /** * A signing backend, expressed as the official client's `SenderAuthentication`. @@ -80,7 +79,9 @@ export const metaMaskBackend = async (client: MetaMaskSdk): Promise { - return rpc.getBytes() -} - -export const getChainId = (isMainnet: boolean): string => `Partisia Blockchain${isMainnet ? '' : ' Testnet'}` - -export const buildTransactionResult = ( - client: ShardedClient, - shardId: number, - transactionHash: string -) => { - return { - transactionHash, - fetchResult: transactionResult(client, shardId, transactionHash) - } -} - -const transactionResult = async ( - client: ShardedClient, - shardId: number, - transactionHash: string -): Promise => { - const isFinalOnChain = await broadcastTransactionPoller(client, shardId, transactionHash) - - const transactionResult = isFinalOnChain - ? await client.getTransactionEventTrace(transactionHash, shardId) - : { - hasError: true, - errorMessage: 'unable to broadcast to chain', - eventTrace: [], - } - - return { - transactionHash, - ...transactionResult, - } -} - -const broadcastTransactionPoller = async ( - client: ShardedClient, - shardId: number, - transactionHash: string, - attempts = 10, - intervalInMillis = 2000 -) => { - let attempt = 0 - while (++attempt < attempts) { - try { - const transaction = await client.getTransaction(transactionHash, shardId) - if (transaction?.finalized) break - } catch (error) { - if (error instanceof Error && !error.message.includes('404')) console.error(error.message) - } finally { - await new Promise((resolve) => setTimeout(resolve, intervalInMillis)) - } - } - - return attempt < attempts -} diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 5b3c4118..4de98e8d 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -1,12 +1,23 @@ -import type { SenderAuthentication } from "@partisiablockchain/blockchain-api-transaction-client" -import type { ITransactionIntent } from "../interface" -import type { ShardedClient } from "../repositories/helpers/sharded-client" -import { buildTransactionResult, getChainId } from "./helper" -import assert from "assert" +import type { BlockchainTransactionClient, SentTransaction, TransactionTree } from "@partisiablockchain/blockchain-api-transaction-client" +import type { ITransactionIntent, ITransactionResult } from "../interface" +import type { SigningBackend } from "./authentication" export type { SigningBackend } from "./authentication" export { privateKeyBackend, ledgerBackend, metaMaskBackend, partisiaSdkBackend } from "./authentication" +/** + * The blockchain address a private key signs for. + * + * Consumers derived this from `partisia-blockchain-applications-crypto`, which + * is unmaintained and worth 300 KB of bundle; this is the same derivation over + * the official client, and lives on the lazily loaded signing path. + */ +export const privateKeyToAddress = async (privateKey: string): Promise => { + const { privateKeyBackend } = await import("./authentication") + + return (await privateKeyBackend(privateKey)).authentication.getAddress() +} + /** * The nonce comes from a reader node, which trails the chain by a moment: a * transaction signed right after another one, or from a wallet that is also in @@ -18,13 +29,22 @@ export { privateKeyBackend, ledgerBackend, metaMaskBackend, partisiaSdkBackend } */ const BROADCAST_ATTEMPTS = 3 +/** How long the chain will accept the transaction for. */ +const DEFAULT_VALIDITY_MS = 120_000 + +/** + * How long to wait for a single spawned event to be included in a block. The + * client's own default is ten minutes, which outlives any caller that is + * waiting on `fetchResult`. + */ +const DEFAULT_EVENT_TIMEOUT_MS = 30_000 + export interface CreateTransactionParams { contractAddress: string payload: Buffer cost: number - isMainnet?: boolean - /** How long the chain will accept the transaction for. */ validityInMillis?: number + eventTimeoutInMillis?: number /** Interactive signers are asked once; see `BROADCAST_ATTEMPTS`. */ attempts?: number } @@ -32,44 +52,33 @@ export interface CreateTransactionParams { /** * Sign a transaction with any of the signing backends and broadcast it. * - * `SignedTransaction` comes from the official - * `@partisiablockchain/blockchain-api-transaction-client`, which replaces the - * unmaintained `partisia-blockchain-applications-crypto`. It produces the same - * bytes: an inner part of nonce, valid-to and gas as big-endian i64s followed - * by the contract address and the length-prefixed payload, signed over the - * SHA-256 of those bytes concatenated with the length-prefixed chain id. + * Signing, broadcasting and the wait for execution are all handled by the + * official `@partisiablockchain/blockchain-api-transaction-client`, which talks + * to the reader node's `/chain` API. The chain id is read from the node rather + * than derived from the environment. */ export const createTransaction = async ( - client: ShardedClient, - { authentication, interactive }: { authentication: SenderAuthentication, interactive: boolean }, - { contractAddress, payload, cost, isMainnet = false, validityInMillis = 120_000, attempts }: CreateTransactionParams + hostUrl: string, + { authentication, interactive }: SigningBackend, + { contractAddress, payload, cost, validityInMillis = DEFAULT_VALIDITY_MS, eventTimeoutInMillis = DEFAULT_EVENT_TIMEOUT_MS, attempts }: CreateTransactionParams ): Promise => { - const { SignedTransaction } = await import("@partisiablockchain/blockchain-api-transaction-client") + const { BlockchainTransactionClient } = await import("@partisiablockchain/blockchain-api-transaction-client") - const walletAddress = authentication.getAddress() - const shardId = client.deriveShardId(walletAddress) - const chainId = getChainId(isMainnet) + const client = BlockchainTransactionClient.create(hostUrl, authentication, validityInMillis, eventTimeoutInMillis) + const transaction = { address: contractAddress, rpc: payload } const broadcastAttempts = attempts ?? (interactive ? 1 : BROADCAST_ATTEMPTS) let lastError: unknown for (let attempt = 0; attempt < broadcastAttempts; attempt++) { if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1_000 * attempt)) - const nonce = await client.getNonce(walletAddress, shardId) - const signedTransaction = await SignedTransaction.create( - authentication, - nonce, - Date.now() + validityInMillis, - cost, - chainId, - { address: contractAddress, rpc: payload } - ) - try { - const isValid = await client.broadcastTransaction(walletAddress, signedTransaction.serialize()) - assert(isValid, 'Unknown Error') + const sentTransaction = await client.signAndSend(transaction, cost) - return buildTransactionResult(client, shardId, signedTransaction.identifier()) + return { + transactionHash: sentTransaction.transactionPointer.identifier, + fetchResult: transactionResult(client, sentTransaction), + } } catch (error) { lastError = error } @@ -77,3 +86,45 @@ export const createTransaction = async ( throw lastError } + +const transactionResult = async ( + client: BlockchainTransactionClient, + sentTransaction: SentTransaction +): Promise => { + const transactionHash = sentTransaction.transactionPointer.identifier + + try { + const tree = await client.waitForSpawnedEvents(sentTransaction) + + return { + transactionHash, + hasError: tree.hasFailures(), + errorMessage: tree.getFirstFailure()?.errorMessage, + eventTrace: eventTrace(tree), + } + } catch (error) { + // A transaction that never lands in a block, or an event that never + // executes, is reported rather than thrown: callers await `fetchResult` + // for the outcome, not for the network. + return { + transactionHash, + hasError: true, + errorMessage: error instanceof Error ? error.message : 'unable to broadcast to chain', + eventTrace: [], + } + } +} + +/** + * Every event spawned by the transaction and by its events, in the order the + * client walked them. The shard is the event's destination, which is not + * necessarily the shard the parent executed on. + */ +const eventTrace = (tree: TransactionTree) => { + return [tree.transaction, ...tree.events] + .flatMap((transaction) => transaction.executionStatus?.events ?? []) + .map((event) => ({ + txHash: event.identifier, + shardId: Number(event.destinationShardId.replace('Shard', '')), + })) +}