Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions src/actions/domain.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -85,5 +84,5 @@ export const actionDomainTransferFromPayload = (contractAbi: ContractAbi, params
rpc.addAddress(to)
rpc.addU128(new BN(tokenId))

return builderToBytesBe(rpc)
return rpc.getBytes()
}
9 changes: 4 additions & 5 deletions src/actions/record.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -21,22 +20,22 @@ 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 => {
const rpc = new RpcContractBuilder(contractAbi, 'update_record')
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) => {
Expand Down
3 changes: 1 addition & 2 deletions src/repositories/contract-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ export class ContractRepository implements IContractRepository {
async createTransaction({ contractAddress, payload, gasCost }: TransactionParams): Promise<ITransactionIntent> {
if (!contractAddress) throw new Error('Contract address not found')

const isMainnet = this.environment === Enviroment.mainnet
const gasTable: Record<GasCost, number> = {
'low': 8_000,
'medium': 40_000,
Expand All @@ -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 })
}

/**
Expand Down
12 changes: 1 addition & 11 deletions src/repositories/helpers/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,16 +30,6 @@ export function postRequest<R>(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<boolean> {
const response = await fetchWithTimeout(url, "PUT", jsonBodyHeaders, body, timeoutMs)

return response.ok
}

async function fetchWithTimeout(url: string, method: RequestType, headers: Record<string, string>, body: unknown, timeoutMs: number): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
Expand Down
139 changes: 4 additions & 135 deletions src/repositories/helpers/sharded-client.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,25 @@
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
conversionRate: { numerator: string, denominator: string }
}[]
}

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
Expand All @@ -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<number> {
const url = this.shardUrl(shardId ?? this.deriveShardId(address))
const account = await getRequest<AccountInfo>(`${url}/blockchain/account/${address}`)

return account?.nonce ?? 1
}

async fetchCoins(): Promise<GlobalCoins> {
const coins = await postRequest<GlobalCoins>(
`${this.globalUrl}/blockchain/accountPlugin/global`,
Expand All @@ -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<TransactionInfo | undefined> {
const path = `/blockchain/transaction/${transactionHash}?requireFinal=${requireFinal}`

if (shardId !== undefined) {
return getRequest<TransactionInfo>(`${this.shardUrl(shardId)}${path}`)
}

const results = await Promise.all(
this.shards.map((shard) =>
getRequest<TransactionInfo>(`${shard.url}${path}`).catch(() => undefined)
)
)

return results.find((transaction) => transaction !== undefined)
}

broadcastTransaction(addressFrom: string, payload: string | Buffer): Promise<boolean> {
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<EventTrace> {
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<void> {
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<TransactionInfo> {
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', ''))
}
5 changes: 3 additions & 2 deletions src/transactions/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -80,7 +79,9 @@ export const metaMaskBackend = async (client: MetaMaskSdk): Promise<SigningBacke
},
},
})
assert(Buffer.from(signature, "hex").length === 65)
// Node's `assert` would drag a polyfill into every browser bundle for
// this one check.
if (Buffer.from(signature, "hex").length !== 65) throw new Error('MetaMask returned a malformed signature')

return signature
},
Expand Down
63 changes: 0 additions & 63 deletions src/transactions/helper.ts

This file was deleted.

Loading
Loading