@decent-stuff/dc-client
Version:
High-performance WebAssembly client for browser-based querying of Decent Cloud ledger data
361 lines (326 loc) • 14.3 kB
text/typescript
import { db, LedgerBlock, LedgerEntry } from './db';
import { canisterQueryLedgerData } from './agent';
import { parseLedgerBlocks } from './dc-client.js';
// Types for ledger transaction entries
interface Account {
owner: string;
subaccount: string | null;
}
interface TransactionV1 {
from: Account;
to: Account;
created_at_time: number;
memo: string;
amount: number;
balance_from_after: number;
balance_to_after: number;
}
interface LedgerTransactionEntry {
V1: TransactionV1;
}
class DecentCloudLedger {
/**
* Utility method to handle operations with consistent error handling
* @param operationName Name of the operation for error messages
* @param operation Function that performs the operation
* @param defaultValue Optional default value to return in case of error
* @returns Result of the operation or defaultValue in case of error
*/
private async withErrorHandling<T>(
operationName: string,
operation: () => Promise<T>,
defaultValue: T
): Promise<T>;
private async withErrorHandling<T>(
operationName: string,
operation: () => Promise<T>
): Promise<T>;
private async withErrorHandling<T>(
operationName: string,
operation: () => Promise<T>,
defaultValue?: T
): Promise<T> {
// Clear any previous database error
db.setError(null);
try {
// Execute the operation
return await operation();
} catch (error) {
console.error(`Error ${operationName}:`, error);
// Set the database error
if (error instanceof Error) {
db.setError(`Error ${operationName}: ${error.message}`);
} else {
db.setError(`Error ${operationName}: ${String(error)}`);
}
// Return default value or rethrow based on whether defaultValue is provided
if (arguments.length >= 3) {
return defaultValue as T;
}
throw error;
}
}
/**
* Initialize the ledger interface and fetch new ledger
* entries from the remote (canister) ledger.
*/
async init(): Promise<void> {
console.log("Initializing Ledger interface...");
return this.withErrorHandling(
"initializing ledger interface",
async () => {
// If your db module requires explicit initialization, call it here:
// await db.initialize();
await this.fetchLedgerBlocks();
console.log("Ledger interface initialization complete.");
}
);
}
/**
* Fetch new ledger blocks from the remote ledger canister, process them with the WASM module,
* and store the resulting ledger entries in the local database.
*
* @returns {Promise<string>} Fetch result.
*/
async fetchLedgerBlocks(): Promise<string> {
return this.withErrorHandling(
"fetching ledger blocks",
async () => {
// Step 1: Get the highest block offset stored locally.
const lastBlock = await db.getLastBlock();
// Step 2: Create a cursor for fetching data.
let cursorString: string;
let bytesBefore: [Uint8Array] | undefined;
if (lastBlock === null) {
cursorString = "position=0";
bytesBefore = undefined;
} else {
cursorString = `position=${lastBlock.fetchOffset}`;
bytesBefore = [base64ToUint8Array(lastBlock.fetchCompareBytes)];
}
console.log(
`Requesting ledger canister with cursor: ${cursorString} and bytesBefore: ${bytesBefore}`
);
const result = await canisterQueryLedgerData(cursorString, bytesBefore);
// Validate the response.
if (!result || !result.Ok || !Array.isArray(result.Ok) || result.Ok.length < 2) {
const s = `Invalid or empty response from ledger canister: ${result}`;
console.warn(s);
return s;
}
const [remotePositionStr, binaryData] = result.Ok;
if (!remotePositionStr || !binaryData || !(binaryData instanceof Uint8Array)) {
const s = `Invalid data format from canister: ${remotePositionStr}, ${binaryData}`;
console.warn(s);
return s;
}
// Parse the remote position and compare with the last stored block.
// Example of remotePositionStr:
// "position=8388608&response_bytes=143991&direction=forward&more=false"
const remotePositionMatch = remotePositionStr.match(/position=(\d+)/);
const remotePosition = remotePositionMatch ? parseInt(remotePositionMatch[1], 10) : NaN;
console.debug(`Received remote position: ${remotePosition} from str ${remotePositionStr}`);
console.debug("Received binary data:", binaryData.length, "bytes");
if (binaryData.length === 0) {
const s = `Fetch successful, no new ledger data found.`;
console.info(s);
return s;
}
// Step 3: Process the binary data using the WASM function.
console.log("Processing binary data into ledger blocks using WASM...");
const newBlocks: LedgerBlock[] = [];
const newEntries: LedgerEntry[] = [];
try {
const blocksData = await parseLedgerBlocks(binaryData, BigInt(remotePosition));
if (!Array.isArray(blocksData)) {
const s = `Invalid data format from canister: ${blocksData}`;
console.warn(s);
return s;
}
console.log(`Parsed ${blocksData.length} blocks from binary data`);
// Process each block and its entries.
for (const blockData of blocksData) {
if (!blockData || !blockData.block_header) {
console.warn("Invalid block data:", blockData);
continue;
}
const blockHeader: LedgerBlock = {
blockVersion: blockData.block_header.block_version,
blockSize: blockData.block.length,
parentBlockHash: blockData.block_header.parent_block_hash,
blockHash: blockData.block_header.block_hash,
blockOffset: blockData.block_header.offset,
fetchCompareBytes: blockData.block_header.fetch_compare_bytes,
fetchOffset: blockData.block_header.fetch_offset,
timestampNs: blockData.block_header.timestamp_ns
};
newBlocks.push(blockHeader);
for (const entry of blockData.block) {
if (!entry.label || !entry.key) {
console.warn("Invalid entry data:", entry);
continue;
}
const ledgerEntry: LedgerEntry = {
blockOffset: blockData.block_header.offset,
label: entry.label,
key: entry.key,
value: entry.value,
description: entry.description
};
newEntries.push(ledgerEntry);
}
}
} catch (error) {
const s = `Error processing blocks with WASM: ${error}`;
console.warn(s);
return s;
}
// Step 4: Store the new ledger entries in the local database.
if (newBlocks.length > 0) {
console.log(`Storing ${newBlocks.length} new blocks and ${newEntries.length} new ledger entries in IndexedDB`);
await db.bulkAddOrUpdate(newBlocks, newEntries);
}
return `Fetched ${newBlocks.length} new blocks and ${newEntries.length} new ledger entries.`;
},
"Error fetching ledger blocks. See console for details."
);
}
/**
* Retrieve all ledger entries stored in the local database.
*
* @returns {Promise<LedgerEntry[]>} An array of all ledger entries.
*/
async getAllEntries(): Promise<LedgerEntry[]> {
return this.withErrorHandling(
"retrieving all ledger entries",
async () => await db.getAllEntries(),
[]
);
}
// Get all blocks from the ledger
async getAllBlocks(): Promise<LedgerBlock[]> {
return this.withErrorHandling(
"retrieving all ledger blocks",
async () => await db.getAllBlocks(),
[]
);
}
// Check if a particular provider principal is registered as a provider
async isProviderRegistered(principal: string): Promise<boolean> {
console.log("Checking if provider is registered:", principal);
return this.withErrorHandling(
"checking if provider is registered",
async () => {
const entries = await db.getEntriesByLabelAndKey("NPRegister", principal);
console.log(`Found ${entries.length} registration entries for principal ${principal}`);
return entries.length > 0;
},
false
);
}
/**
* Get the balance of the specified account
* @param owner The owner of the account
* @param subaccount The subaccount of the account
* @returns {Promise<number>} The balance of the specified account
*/
async getAccountBalance(owner: string | null, subaccount: string | null): Promise<number> {
return this.withErrorHandling(
"retrieving balance",
async () => {
if (!owner) return 0;
const entries: unknown[] = await db.getEntriesByLabel("DCTokenTransfer");
// Iterate in reverse order to find the latest balance
for (const rawEntry of entries.reverse()) {
const entry = rawEntry as LedgerTransactionEntry;
/* We are looking for balance_from_after or balance_to_after if the account is the "from" or "to"
{
"V1": {
"from": {
"owner": "yp4qz-xtz2x-66yql-yymle-guxye-matt3-os7u7-ctzvp-oig6d-feehe-6qe",
"subaccount": null
},
"to": {
"owner": "zjbo4-sknjf-hfisk-oi4",
"subaccount": null
},
"fee": 500000000,
"fees_accounts": [
{
"owner": "zjbo4-sknjf-hfisk-oi4",
"subaccount": null
}
],
"created_at_time": 1742554245399975000,
"memo": "check-in-yp4qz-287-Website Valid",
"amount": 0,
"balance_from_after": 158055499999997,
"balance_to_after": 0
}
}
*/
if (entry.V1 === undefined || entry.V1 === null) {
continue;
}
if (entry.V1.from.owner === owner && entry.V1.from.subaccount === subaccount) {
return entry.V1.balance_from_after;
} else if (entry.V1.to.owner === owner && entry.V1.to.subaccount === subaccount) {
return entry.V1.balance_to_after;
}
}
return 0;
},
0
);
}
/**
* Retrieve entries for a specific block.
*
* @param blockOffset The offset of the block to retrieve entries for.
* @returns {Promise<LedgerEntry[]>} An array of ledger entries for the specified block.
*/
async getBlockEntries(blockOffset: number): Promise<LedgerEntry[]> {
return this.withErrorHandling(
`retrieving ledger entries for block ${blockOffset}`,
async () => await db.getBlockEntries(blockOffset),
[]
);
}
/**
* Retrieve the last fetched ledger block entry.
*
* @returns {Promise<LedgerEntry | null>} The last ledger entry or null if none exists.
*/
async getLastFetchedBlock(): Promise<LedgerBlock | null> {
return this.withErrorHandling(
"retrieving the last fetched ledger block",
async () => await db.getLastBlock(),
null
);
}
/**
* Clear the ledger storage.
*/
async clearStorage(): Promise<void> {
return this.withErrorHandling(
"clearing the ledger storage",
async () => await db.clearAllEntries()
);
}
}
/**
* Converts a base64 string to a Uint8Array
* @param b64string The base64 string to convert
* @returns The resulting Uint8Array
*/
export function base64ToUint8Array(b64string: string): Uint8Array {
const binaryString = atob(b64string);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// Export a singleton instance of the Ledger.
export const decentCloudLedger = new DecentCloudLedger();