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
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
"@secata-public/bitmanipulation-ts": "^3.4.0",
"bip32-path": "^0.4.2",
"partisia-blockchain-applications-crypto": "^1.0.34",
"partisia-blockchain-applications-rpc": "^1.0.13",
"partisia-blockchain-applications-sdk": "^0.1.4"
},
"devDependencies": {
Expand Down
18 changes: 15 additions & 3 deletions src/interface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-disable no-unused-vars */

import type { BN, ContractAbi, ScValueStruct } from "@partisiablockchain/abi-client"
import type { IContractInfo } from "partisia-blockchain-applications-rpc/lib/main/accountInfo"
import type PartisiaSdk from "partisia-blockchain-applications-sdk"
import type LedgerTransport from "@ledgerhq/hw-transport"
import type { BYOCSymbol } from "./providers"
Expand Down Expand Up @@ -167,8 +166,21 @@ export interface ITransactionIntent {

export type MetaNamesState = ScValueStruct

export type RawContractData = Pick<IContractInfo, 'abi' | 'serializedContract'> & { serializedContract: { avlTrees: AvlTree[] } }
export type ContractData = Pick<IContractInfo, 'abi' | 'serializedContract'> & { serializedContract: { avlTrees?: Map<number, [Buffer, Buffer][]> } };
/**
* A contract as returned by the reader node. Previously `IContractInfo` from
* `partisia-blockchain-applications-rpc`; only these two fields were ever read.
*/
export interface ContractInfo {
abi: string
serializedContract?: {
state: {
data: string
}
}
}

export type RawContractData = Pick<ContractInfo, 'abi' | 'serializedContract'> & { serializedContract: { avlTrees: AvlTree[] } }
export type ContractData = Pick<ContractInfo, 'abi' | 'serializedContract'> & { serializedContract: { avlTrees?: Map<number, [Buffer, Buffer][]> } };


export interface ContractParams {
Expand Down
9 changes: 4 additions & 5 deletions src/repositories/contract-repository.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import { AbiParser, ContractAbi, FileAbi, StateReader } from '@partisiablockchain/abi-client'
import { PartisiaAccount } from 'partisia-blockchain-applications-rpc'
import { IPartisiaRpcConfig, PartisiaAccountClass } from 'partisia-blockchain-applications-rpc/lib/main/accountInfo'
import { ByocCoin, Contract, ContractData, ContractEntry, ContractParams, GasCost, IContractRepository, ITransactionIntent, RawContractData, TransactionParams } from '../interface'
import { Enviroment } from '../providers'
import { SecretsProvider } from '../providers/secrets'
import { convertAvlTree as convertAvlTrees } from './helpers/contract'
import { AvlClient } from './helpers/avl-client'
import { getRequest, promiseRetry } from './helpers/client'
import { ShardedClient, ShardedClientConfig } from './helpers/sharded-client'


/**
* Contract repository to interact with smart contracts on Partisia
*/
export class ContractRepository implements IContractRepository {
private rpc: PartisiaAccountClass
private rpc: ShardedClient
private contractRegistry: Map<string, ContractEntry>
private hostUrl: string
protected avlClient: AvlClient

constructor(rpc: IPartisiaRpcConfig, private environment: Enviroment, private secrets: SecretsProvider, private ttl: number, protected hasProxyContract: boolean) {
constructor(rpc: ShardedClientConfig, private environment: Enviroment, private secrets: SecretsProvider, private ttl: number, protected hasProxyContract: boolean) {
this.contractRegistry = new Map()
this.rpc = PartisiaAccount(rpc)
this.rpc = new ShardedClient(rpc)
this.hostUrl = rpc.urlBaseGlobal.url
this.avlClient = new AvlClient(this.hostUrl, rpc.urlBaseShards.map((shard) => shard.shard_id))
}
Expand Down
4 changes: 2 additions & 2 deletions src/repositories/contracts/meta-names-contract-repository.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ContractAbi } from "@partisiablockchain/abi-client"
import { IPartisiaRpcConfig } from "partisia-blockchain-applications-rpc/lib/main/accountInfo"
import { Contract, ContractParams, GetStateParams, IMetaNamesContractRepository, ITransactionIntent, MetaNamesAvlTrees, MetaNamesState, TransactionParams } from "../../interface"
import { Enviroment } from "../../providers"
import { SecretsProvider } from "../../providers/secrets"
import { ContractRepository } from "../contract-repository"
import { getAddressFromProxyContractState } from "../helpers/contract"
import { ShardedClientConfig } from "../helpers/sharded-client"


/**
Expand All @@ -15,7 +15,7 @@ import { getAddressFromProxyContractState } from "../helpers/contract"
export class MetaNamesContractRepository extends ContractRepository implements IMetaNamesContractRepository {
private proxyAddress: string

constructor(contractAddress: string, rpc: IPartisiaRpcConfig, environment: Enviroment, secrets: SecretsProvider, ttl: number, hasProxyContract: boolean) {
constructor(contractAddress: string, rpc: ShardedClientConfig, environment: Enviroment, secrets: SecretsProvider, ttl: number, hasProxyContract: boolean) {
super(rpc, environment, secrets, ttl, hasProxyContract)
this.proxyAddress = contractAddress
}
Expand Down
34 changes: 27 additions & 7 deletions src/repositories/helpers/client.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,51 @@
const getHeaders = {
const jsonHeaders = {
Accept: "application/json, text/plain, */*",
}

export type RequestType = "GET"
const jsonBodyHeaders = {
...jsonHeaders,
"Content-Type": "application/json",
}

export type RequestType = "GET" | "POST" | "PUT"

/**
* Requests that never settle would otherwise pin a retry chain open forever,
* since `promiseRetry` only advances when the underlying promise settles.
*/
export const DEFAULT_TIMEOUT_MS = 30_000

function buildOptions(method: RequestType, headers: Record<string, string>, signal: AbortSignal) {
const result = { method, headers, signal }
function buildOptions(method: RequestType, headers: Record<string, string>, signal: AbortSignal, body?: unknown) {
const result: { method: RequestType, headers: Record<string, string>, signal: AbortSignal, body?: string } = { method, headers, signal }
if (body !== undefined) result.body = JSON.stringify(body)

return result
}

export function getRequest<R>(url: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<R | undefined> {
return handleFetch(promiseRetry(() => fetchWithTimeout(url, timeoutMs)))
return handleFetch(promiseRetry(() => fetchWithTimeout(url, "GET", jsonHeaders, undefined, timeoutMs)))
}

export function postRequest<R>(url: string, body: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<R | undefined> {
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, timeoutMs: number): Promise<Response> {
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)

try {
return await fetch(url, buildOptions("GET", getHeaders, controller.signal))
return await fetch(url, buildOptions(method, headers, controller.signal, body))
} finally {
clearTimeout(timer)
}
Expand Down
187 changes: 187 additions & 0 deletions src/repositories/helpers/sharded-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { Buffer } from "buffer"
import { getRequest, postRequest, putRequestOnce } 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.
*/
export class ShardedClient {
private readonly globalUrl: string
private readonly shards: { url: string, shard_id: number }[]

constructor(config: ShardedClientConfig) {
this.globalUrl = config.urlBaseGlobal.url
this.shards = config.urlBaseShards
}

/**
* Derive which shard an address lives on.
*
* Port of `PartisiaAccountClass.deriveShardId`, itself a port of the core
* dashboard's `ShardedClient.ts`: the big-endian int32 at byte offset 17 of
* the address, modulo the shard count.
*/
deriveShardId(address: string | Buffer): number {
const buf = typeof address === 'string' ? Buffer.from(address, 'hex') : address
const int32 = Math.abs(buf.readInt32BE(17))

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`,
{ path: [{ type: "field", name: "coins" }] }
)
if (!coins) throw new Error('Unable to fetch coins')

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', ''))
}
Loading
Loading