Skip to content

Commit

Permalink
feat: Added support for state proof transactions
Browse files Browse the repository at this point in the history
Also added parent transaction ID to the return type of subscribed transactions
  • Loading branch information
robdmoore committed Feb 3, 2024
1 parent d863b57 commit c76d848
Show file tree
Hide file tree
Showing 7 changed files with 4,394 additions and 22 deletions.
12 changes: 8 additions & 4 deletions src/subscriber.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import * as algokit from '@algorandfoundation/algokit-utils'
import { TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer'
import { Algodv2, Indexer } from 'algosdk'
import { getSubscribedTransactions } from './subscriptions'
import { AsyncEventEmitter, AsyncEventListener } from './types/async-event-emitter'
import type { SubscriptionConfig, TransactionSubscriptionResult, TypedAsyncEventListener } from './types/subscription'
import type {
SubscribedTransaction,
SubscriptionConfig,
TransactionSubscriptionResult,
TypedAsyncEventListener,
} from './types/subscription'
import { race, sleep } from './utils'

/**
Expand Down Expand Up @@ -142,7 +146,7 @@ export class AlgorandSubscriber {
* @param listener The listener function to invoke with the subscribed event
* @returns The subscriber so `on`/`onBatch` calls can be chained
*/
on<T = TransactionResult>(eventName: string, listener: TypedAsyncEventListener<T>) {
on<T = SubscribedTransaction>(eventName: string, listener: TypedAsyncEventListener<T>) {
this.eventEmitter.on(eventName, listener as AsyncEventListener)
return this
}
Expand All @@ -159,7 +163,7 @@ export class AlgorandSubscriber {
* @param listener The listener function to invoke with the subscribed events
* @returns The subscriber so `on`/`onBatch` calls can be chained
*/
onBatch<T = TransactionResult>(eventName: string, listener: TypedAsyncEventListener<T[]>) {
onBatch<T = SubscribedTransaction>(eventName: string, listener: TypedAsyncEventListener<T[]>) {
this.eventEmitter.on(`batch:${eventName}`, listener as AsyncEventListener)
return this
}
Expand Down
14 changes: 10 additions & 4 deletions src/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import type SearchForTransactions from 'algosdk/dist/types/client/v2/indexer/sea
import sha512 from 'js-sha512'
import { algodOnCompleteToIndexerOnComplete, getBlockTransactions, getIndexerTransactionFromAlgodTransaction } from './transform'
import type { Block } from './types/block'
import type { TransactionFilter, TransactionSubscriptionParams, TransactionSubscriptionResult } from './types/subscription'
import type {
SubscribedTransaction,
TransactionFilter,
TransactionSubscriptionParams,
TransactionSubscriptionResult,
} from './types/subscription'
import { chunkArray, range } from './utils'

/**
Expand Down Expand Up @@ -37,7 +42,7 @@ export async function getSubscribedTransactions(
let algodSyncFromRoundNumber = watermark + 1
let startRound = algodSyncFromRoundNumber
let endRound = currentRound
const catchupTransactions: TransactionResult[] = []
const catchupTransactions: SubscribedTransaction[] = []
let start = +new Date()

if (currentRound - watermark > maxRoundsToSync) {
Expand Down Expand Up @@ -318,20 +323,21 @@ export async function getBlocksBulk(context: { startRound: number; maxRound: num
}

/** Process an indexer transaction and return that transaction or any of it's inner transactions that meet the indexer pre-filter requirements; patching up transaction ID and intra-round-offset on the way through */
function getFilteredIndexerTransactions(transaction: TransactionResult, filter: TransactionFilter): TransactionResult[] {
function getFilteredIndexerTransactions(transaction: TransactionResult, filter: TransactionFilter): SubscribedTransaction[] {
let parentOffset = 0
const getParentOffset = () => parentOffset++

const transactions = [transaction, ...getIndexerInnerTransactions(transaction, transaction, getParentOffset)]
return transactions.filter(indexerPreFilterInMemory(filter))
}

function getIndexerInnerTransactions(root: TransactionResult, parent: TransactionResult, offset: () => number): TransactionResult[] {
function getIndexerInnerTransactions(root: TransactionResult, parent: TransactionResult, offset: () => number): SubscribedTransaction[] {
return (parent['inner-txns'] ?? []).flatMap((t) => {
const parentOffset = offset()
return [
{
...t,
parentTransactionId: root.id,
id: `${root.id}/inner/${parentOffset + 1}`,
'intra-round-offset': root['intra-round-offset']! + parentOffset + 1,
},
Expand Down
113 changes: 102 additions & 11 deletions src/transform.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { MultisigTransactionSubSignature, TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer'
import type { MultisigTransactionSubSignature } from '@algorandfoundation/algokit-utils/types/indexer'
import { ApplicationOnComplete } from '@algorandfoundation/algokit-utils/types/indexer'
import * as msgpack from 'algo-msgpack-with-bigint'
import algosdk, { OnApplicationComplete, Transaction, TransactionType } from 'algosdk'
import { Buffer } from 'buffer'
import type { Block, BlockInnerTransaction, BlockTransaction } from './types/block'
import base32 from 'hi-base32'
import sha512 from 'js-sha512'
import type { Block, BlockInnerTransaction, BlockTransaction, StateProof, StateProofMessage } from './types/block'
import { SubscribedTransaction } from './types/subscription'

// Recursively remove all null values from object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -137,11 +141,6 @@ export function extractTransactionFromBlockTransaction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ('hgh' in blockTransaction && blockTransaction.hgh === false) txn.gh = null as any

// todo: support these
if (txn.type === 'stpf') {
throw new Error('TODO')
}

const t = Transaction.from_obj_for_encoding(txn)
return {
transaction: t,
Expand Down Expand Up @@ -171,6 +170,29 @@ export function algodOnCompleteToIndexerOnComplete(appOnComplete: OnApplicationC
: ApplicationOnComplete.update
}

function concatArrays(...arrs: ArrayLike<number>[]) {
const size = arrs.reduce((sum, arr) => sum + arr.length, 0)
const c = new Uint8Array(size)

let offset = 0
for (let i = 0; i < arrs.length; i++) {
c.set(arrs[i], offset)
offset += arrs[i].length
}

return c
}

function getTxIdFromBlockTransaction(bt: BlockTransaction) {
// Translated from algosdk.Transaction.txID()
const ALGORAND_TRANSACTION_LENGTH = 52
const encodedMessage = msgpack.encode(bt.txn, { sortKeys: true, ignoreUndefined: true })
const tag = Buffer.from('TX')
const gh = Buffer.from(concatArrays(tag, encodedMessage))
const rawTxId = Buffer.from(sha512.sha512_256.array(gh))
return base32.encode(rawTxId).slice(0, ALGORAND_TRANSACTION_LENGTH)
}

/**
* Transforms the given `algosdk.Transaction` object into an indexer transaction.
*
Expand All @@ -195,7 +217,9 @@ export function algodOnCompleteToIndexerOnComplete(appOnComplete: OnApplicationC
* @param closeAmount The amount of microAlgos that were transferred if the transaction had a close
* @returns The indexer transaction formation (`TransactionResult`)
*/
export function getIndexerTransactionFromAlgodTransaction(t: TransactionInBlock & { getChildOffset?: () => number }): TransactionResult {
export function getIndexerTransactionFromAlgodTransaction(
t: TransactionInBlock & { getChildOffset?: () => number },
): SubscribedTransaction {
const {
transaction,
createdAssetId,
Expand All @@ -221,9 +245,16 @@ export function getIndexerTransactionFromAlgodTransaction(t: TransactionInBlock
const encoder = new TextEncoder()
const decoder = new TextDecoder()

// The types in algosdk for state proofs are incorrect, so override them
const stateProof = transaction.stateProof as StateProof | undefined
const stateProofMessage = transaction.stateProofMessage as StateProofMessage | undefined
const txId = // There is a bug in algosdk that means it can't calculate transaction IDs for stpf txns
transaction.type === TransactionType.stpf ? getTxIdFromBlockTransaction(blockTransaction as BlockTransaction) : transaction.txID()

// https://github.com/algorand/indexer/blob/main/api/converter_utils.go#L249
return {
id: parentTransactionId ? `${parentTransactionId}/inner/${parentOffset! + 1}` : transaction.txID(),
id: parentTransactionId ? `${parentTransactionId}/inner/${parentOffset! + 1}` : txId,
parentTransactionId,
'asset-config-transaction':
transaction.type === TransactionType.acfg
? {
Expand Down Expand Up @@ -328,10 +359,71 @@ export function getIndexerTransactionFromAlgodTransaction(t: TransactionInBlock
'vote-participation-key': transaction.voteKey?.toString('base64'),
}
: undefined,
'state-proof-transaction':
transaction.type === TransactionType.stpf
? {
'state-proof': {
'part-proofs': {
'hash-factory': {
'hash-type': stateProof!.P.hsh.t,
},
'tree-depth': stateProof!.P.td,
path: stateProof!.P.pth.map((p) => Buffer.from(p).toString('base64')),
},
'positions-to-reveal': stateProof!.pr,
'salt-version': Number(stateProof!.v),
'sig-commit': Buffer.from(stateProof!.c).toString('base64'),
'sig-proofs': {
'hash-factory': {
'hash-type': stateProof!.S.hsh.t,
},
'tree-depth': stateProof!.S.td,
path: stateProof!.S.pth.map((p) => Buffer.from(p).toString('base64')),
},
'signed-weight': stateProof!.w,
reveals: Object.keys(stateProof!.r).map((position) => {
const r = stateProof!.r[position]
return {
'sig-slot': {
'lower-sig-weight': r.s.l ?? 0,
signature: {
'merkle-array-index': r.s.s.idx,
'falcon-signature': Buffer.from(r.s.s.sig).toString('base64'),
proof: {
'hash-factory': {
'hash-type': r.s.s.prf.hsh.t,
},
'tree-depth': r.s.s.prf.td,
path: r.s.s.prf.pth.map((p) => Buffer.from(p).toString('base64')),
},
'verifying-key': Buffer.from(r.s.s.vkey.k).toString('base64'),
},
},
position: Number(position),
participant: {
weight: r.p.w,
verifier: {
'key-lifetime': r.p.p.lf,
commitment: Buffer.from(r.p.p.cmt).toString('base64'),
},
},
}
}),
},
message: {
'block-headers-commitment': Buffer.from(stateProofMessage!.b).toString('base64'),
'first-attested-round': stateProofMessage!.f,
'latest-attested-round': stateProofMessage!.l,
'ln-proven-weight': stateProofMessage!.P,
'voters-commitment': Buffer.from(stateProofMessage!.v).toString('base64'),
},
'state-proof-type': Number(transaction.stateProofType),
}
: undefined,
'first-valid': transaction.firstRound,
'last-valid': transaction.lastRound,
'tx-type': transaction.type,
fee: transaction.fee,
fee: transaction.fee ?? 0,
sender: algosdk.encodeAddress(transaction.from.publicKey),
'confirmed-round': block.rnd,
'round-time': block.ts,
Expand Down Expand Up @@ -402,6 +494,5 @@ export function getIndexerTransactionFromAlgodTransaction(t: TransactionInBlock
//"sender-rewards"
//"global-state-delta"
//"local-state-delta"
//logs
}
}
33 changes: 33 additions & 0 deletions src/types/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,36 @@ export interface BlockValueDelta {
/** Uint64 value */
ui?: number
}

// https://github.com/algorand/go-algorand-sdk/blob/develop/types/stateproof.go
export interface StateProof {
c: Uint8Array
P: { hsh: { t: number }; pth: Uint8Array[]; td: number }
pr: number[]
r: Record<
string,
{
p: { p: { cmt: Uint8Array; lf: number }; w: number }
s: {
l?: number
s: {
idx: number
prf: { hsh: { t: number }; pth: Uint8Array[]; td: number }
sig: Uint8Array
vkey: { k: Uint8Array }
}
}
}
>
S: { hsh: { t: number }; pth: Uint8Array[]; td: number }
w: number
v?: number
}

export interface StateProofMessage {
b: Uint8Array
f: number
l: number
P: number
v: Uint8Array
}
55 changes: 52 additions & 3 deletions src/types/subscription.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,55 @@
import type { ApplicationOnComplete, TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer'
import type { TransactionType } from 'algosdk'

export type SubscribedTransaction = TransactionResult & {
parentTransactionId?: string
'state-proof-transaction'?: {
message: {
'block-headers-commitment': string
'first-attested-round': number
'latest-attested-round': number
'ln-proven-weight': number
'voters-commitment': string
}
'state-proof': {
'part-proofs': {
'hash-factory': { 'hash-type': number }
path: string[]
'tree-depth': number
}
'positions-to-reveal': number[]
reveals: {
participant: {
verifier: {
commitment: string
'key-lifetime': number
}
weight: number
}
position: number
'sig-slot': {
'lower-sig-weight': number
signature: {
'falcon-signature': string
'merkle-array-index': number
proof: {
'hash-factory': { 'hash-type': number }
path: string[]
'tree-depth': number
}
'verifying-key': string
}
}
}[]
'salt-version': number
'sig-commit': string
'sig-proofs': { 'hash-factory': { 'hash-type': number }; path: string[]; 'tree-depth': number }
'signed-weight': number
}
'state-proof-type': number
}
}

/** Parameters to control a single subscription pull/poll. */
export interface TransactionSubscriptionParams {
/** The filter to apply to find transactions of interest. */
Expand Down Expand Up @@ -93,7 +142,7 @@ export interface TransactionSubscriptionResult {
* format](https://developer.algorand.org/docs/rest-apis/indexer/#transaction)
* to represent the data.
*/
subscribedTransactions: TransactionResult[]
subscribedTransactions: SubscribedTransaction[]
}

/** Configuration for a subscription */
Expand Down Expand Up @@ -135,9 +184,9 @@ export interface SubscriptionConfigEvent<T> {
filter: TransactionFilter
/** An optional data mapper if you want the event data to take a certain shape.
*
* If not specified, then the event will receive a `TransactionResult`.
* If not specified, then the event will receive a `SubscribedTransaction`.
*/
mapper?: (transaction: TransactionResult[]) => Promise<T[]>
mapper?: (transaction: SubscribedTransaction[]) => Promise<T[]>
}

export type TypedAsyncEventListener<T> = (event: T, eventName: string | symbol) => Promise<void> | void
4 changes: 4 additions & 0 deletions tests/scenarios/transform-complex-txn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('Complex transaction with many nested inner transactions', () => {
"AAAAAAAAYaAAAAAAH/LmTQAAAAAAAAAA",
"PNZU+gAEIaZlfCPaQTne/tLHvhC5yf/+JYJqpN1uNQLOFg2mAAAAAAAAAAAAAAAAAAYExQAAAAAf8uZNAAAAAAAAAAAAAAAPdfsZdAAAAAAC7WWf",
],
"parentTransactionId": "QLYC4KMQW5RZRA7W5GYCJ4CUVWWSZKMK2V4X3XFQYSGYCJH6LI4Q",
"receiver-rewards": 0,
"round-time": 1705252440,
"sender": "AACCDJTFPQR5UQJZ337NFR56CC44T776EWBGVJG5NY2QFTQWBWTALTEN4A",
Expand Down Expand Up @@ -177,6 +178,7 @@ describe('Complex transaction with many nested inner transactions', () => {
"sender": "RS7QNBEPRRIBGI5COVRWFCRUS5NC5NX7UABZSTSFXQ6F74EP3CNLT4CNAM",
},
"confirmed-round": 35214367,
"fee": 0,
"first-valid": 35214365,
"genesis-hash": "wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=",
"genesis-id": "mainnet-v1.0",
Expand All @@ -185,6 +187,7 @@ describe('Complex transaction with many nested inner transactions', () => {
"last-valid": 35214369,
"lease": "",
"note": "",
"parentTransactionId": "QLYC4KMQW5RZRA7W5GYCJ4CUVWWSZKMK2V4X3XFQYSGYCJH6LI4Q",
"round-time": 1705252440,
"sender": "RS7QNBEPRRIBGI5COVRWFCRUS5NC5NX7UABZSTSFXQ6F74EP3CNLT4CNAM",
"tx-type": "axfer",
Expand All @@ -199,6 +202,7 @@ describe('Complex transaction with many nested inner transactions', () => {
"PNaUw7oABCHCpmV8I9qBOd6+0ofCvhDCucm/w74lwoJqwqTdrjUCzpYNwqYAAAAAAAAAAAAAAAAABgTFgAAAAB/ypo2AAAAAAAAAAAAAAA91w7sZdAAAAAAC77+9",
],
"note": "",
"parentTransactionId": "QLYC4KMQW5RZRA7W5GYCJ4CUVWWSZKMK2V4X3XFQYSGYCJH6LI4Q",
"round-time": 1705252440,
"sender": "AACCDJTFPQR5UQJZ337NFR56CC44T776EWBGVJG5NY2QFTQWBWTALTEN4A",
"tx-type": "appl",
Expand Down
Loading

0 comments on commit c76d848

Please sign in to comment.