1528 lines
54 KiB
JavaScript
1528 lines
54 KiB
JavaScript
function decodedTransactionType(transaction) {
|
|
if (!transaction || typeof transaction !== "object" || Array.isArray(transaction)) {
|
|
return 0;
|
|
}
|
|
return Number(
|
|
transaction.unsigned?.txtype ??
|
|
transaction.unsigned_transfer?.txtype ??
|
|
transaction.unsigned_create_token?.txtype ??
|
|
transaction.unsigned_create_nft?.txtype ??
|
|
transaction.unsigned_marketing?.txtype ??
|
|
transaction.unsigned_swap?.txtype ??
|
|
transaction.unsigned_loan_contract?.txtype ??
|
|
transaction.unsigned_contract_payment?.txtype ??
|
|
transaction.unsigned_collateral_claim?.txtype ??
|
|
transaction.unsigned_burn?.txtype ??
|
|
transaction.unsigned_issue_token?.txtype ??
|
|
transaction.unsigned_vanity_address?.txtype ??
|
|
0
|
|
);
|
|
}
|
|
|
|
function txTypeKey(type) {
|
|
return {
|
|
0: "genesis",
|
|
1: "reward",
|
|
2: "transfer",
|
|
3: "token_create",
|
|
4: "nft_create",
|
|
5: "marketing",
|
|
6: "swap",
|
|
7: "loan_create",
|
|
8: "loan_payment",
|
|
9: "collateral_claim",
|
|
10: "burn",
|
|
11: "token_issue",
|
|
12: "vanity"
|
|
}[Number(type)] || "transaction";
|
|
}
|
|
|
|
function minerFeeDisplay(minerEarnings) {
|
|
const fee = (Array.isArray(minerEarnings) ? minerEarnings : [])
|
|
.filter((earning) => Number(earning?.earning_type) === 0)
|
|
.reduce((total, earning) => total + BigInt(earning?.amount || 0), 0n);
|
|
return signedBaseFee("+", fee);
|
|
}
|
|
|
|
function minerTipRows(base, minerEarnings) {
|
|
return (Array.isArray(minerEarnings) ? minerEarnings : [])
|
|
.map((earning) => {
|
|
const amount = earning?.amount ?? 0;
|
|
const asset = assetWithSeries(earning?.asset || activeBaseCoin(), Number(earning?.nft_series || 0));
|
|
if (Number(earning?.earning_type) === 1) {
|
|
return dashboardRow(base, "Miner Tip", asset, "Network", signedAmount("+", amount), "--");
|
|
}
|
|
return null;
|
|
})
|
|
.filter((row) => row && row.amount !== "+0");
|
|
}
|
|
|
|
function loanSummaryFromLinkedTransactions(linkedTransactions = {}, contractHash = "") {
|
|
const direct = linkedTransactions?.[contractHash];
|
|
const directLoan = parseLoanContractSummary(direct?.transaction || direct);
|
|
if (directLoan) {
|
|
return directLoan;
|
|
}
|
|
|
|
for (const linked of Object.values(linkedTransactions || {})) {
|
|
const loan = parseLoanContractSummary(linked?.transaction || linked);
|
|
if (loan) {
|
|
return loan;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseDecodedDashboardTransactionRows(
|
|
txid,
|
|
blockHeight,
|
|
transaction,
|
|
linkedTransactions = {},
|
|
minerEarnings = []
|
|
) {
|
|
const type = decodedTransactionType(transaction);
|
|
const base = {
|
|
txid,
|
|
block_height: blockHeight || null,
|
|
status: walletStatusForBlock(blockHeight)
|
|
};
|
|
const fallback = [dashboardRow(base, txTypeName(type), activeBaseCoin(), shortenAddress(txid), "")];
|
|
|
|
try {
|
|
if (type === 1) {
|
|
const value = transaction.unsigned?.value || 0;
|
|
return [
|
|
dashboardRow(
|
|
base,
|
|
"Mining Reward",
|
|
activeBaseCoin(),
|
|
"Network",
|
|
signedAmount("+", value),
|
|
minerFeeDisplay(minerEarnings)
|
|
),
|
|
...minerTipRows(base, minerEarnings)
|
|
];
|
|
}
|
|
|
|
if (type === 2) {
|
|
const tx = transaction.unsigned_transfer;
|
|
const asset = assetWithSeries(tx.coin, tx.nft_series);
|
|
if (isActiveWalletAddress(tx.sender)) {
|
|
return [dashboardRow(base, "Transfer Out", asset, addressLabel(tx.receiver), signedAmount("-", tx.value), signedBaseFee("-", tx.txfee || 0))];
|
|
}
|
|
if (isActiveWalletAddress(tx.receiver)) {
|
|
return [dashboardRow(base, "Transfer In", asset, addressLabel(tx.sender), signedAmount("+", tx.value))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.sender), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 3) {
|
|
const tx = transaction.unsigned_create_token;
|
|
if (isActiveWalletAddress(tx.creator)) {
|
|
return [dashboardRow(base, "Token Created", tx.ticker || "Token", "Self", signedAmount("+", tx.number), signedBaseFee("-", tx.txfee || 0))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.creator), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 4) {
|
|
const tx = transaction.unsigned_create_nft;
|
|
if (isActiveWalletAddress(tx.creator)) {
|
|
return [dashboardRow(base, "NFT Created", assetWithSeries(tx.nft_name || "NFT", tx.series), "Self", `+${tx.count || 0}`, signedBaseFee("-", tx.txfee || 0))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.creator), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 5) {
|
|
const tx = transaction.unsigned_marketing;
|
|
if (isActiveWalletAddress(tx.advertiser)) {
|
|
return [dashboardRow(base, "Marketing", "Advertisement", "Network", "", signedBaseFee("-", tx.txfee || 0))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.advertiser), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 6) {
|
|
const tx = transaction.unsigned_swap;
|
|
const asset1 = assetWithSeries(tx.ticker1, tx.nft_series1);
|
|
const asset2 = assetWithSeries(tx.ticker2, tx.nft_series2);
|
|
if (isActiveWalletAddress(tx.sender1)) {
|
|
return [
|
|
dashboardRow(base, "Swap Out", asset1, addressLabel(tx.sender2), signedAmount("-", tx.value1), signedBaseFee("-", tx.txfee1 || 0)),
|
|
dashboardRow(base, "Miner Tip Paid", asset1, addressLabel(tx.sender2), signedAmount("-", tx.tip1 || 0), "--"),
|
|
dashboardRow(base, "Swap In", asset2, addressLabel(tx.sender2), signedAmount("+", tx.value2))
|
|
].filter((row) => row.amount !== "0" && row.amount !== "-0");
|
|
}
|
|
if (isActiveWalletAddress(tx.sender2)) {
|
|
return [
|
|
dashboardRow(base, "Swap Out", asset2, addressLabel(tx.sender1), signedAmount("-", tx.value2), signedBaseFee("-", tx.txfee2 || 0)),
|
|
dashboardRow(base, "Miner Tip Paid", asset2, addressLabel(tx.sender1), signedAmount("-", tx.tip2 || 0), "--"),
|
|
dashboardRow(base, "Swap In", asset1, addressLabel(tx.sender1), signedAmount("+", tx.value1))
|
|
].filter((row) => row.amount !== "0" && row.amount !== "-0");
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), "Swap", signedAmount("+", (tx.txfee1 || 0) + (tx.txfee2 || 0)))];
|
|
}
|
|
|
|
if (type === 7) {
|
|
const tx = transaction.unsigned_loan_contract;
|
|
if (isActiveWalletAddress(tx.lender)) {
|
|
return [dashboardRow(base, "Loan Created", tx.loan_coin, addressLabel(tx.borrower), signedAmount("-", tx.loan_amount), signedBaseFee("-", tx.txfee || 0))];
|
|
}
|
|
if (isActiveWalletAddress(tx.borrower)) {
|
|
return [
|
|
dashboardRow(base, "Loan Received", tx.loan_coin, addressLabel(tx.lender), signedAmount("+", tx.loan_amount)),
|
|
dashboardRow(base, "Collateral Locked", tx.collateral, addressLabel(tx.lender), signedAmount("-", tx.collateral_amount))
|
|
];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.lender), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 8) {
|
|
const tx = transaction.unsigned_contract_payment;
|
|
const loan = loanSummaryFromLinkedTransactions(linkedTransactions, tx.contract_hash);
|
|
if (isActiveWalletAddress(tx.address)) {
|
|
const asset = loan?.loanCoin || "Loan";
|
|
const counterparty = loan?.lender ? addressLabel(loan.lender) : shortenAddress(tx.contract_hash);
|
|
return [
|
|
dashboardRow(base, "Loan Payment Sent", asset, counterparty, signedAmount("-", tx.payback_amount), signedBaseFee("-", tx.txfee || 0)),
|
|
dashboardRow(base, "Miner Tip Paid", asset, counterparty, signedAmount("-", tx.tip || 0), "--")
|
|
].filter((row) => row.amount !== "0" && row.amount !== "-0");
|
|
}
|
|
if (loan?.lender && isActiveWalletAddress(loan.lender)) {
|
|
return [dashboardRow(base, "Loan Payment Received", loan.loanCoin, addressLabel(tx.address), signedAmount("+", tx.payback_amount))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 9) {
|
|
const tx = transaction.unsigned_collateral_claim;
|
|
if (isActiveWalletAddress(tx.address)) {
|
|
return [dashboardRow(base, "Collateral Claimed", "Collateral", shortenAddress(tx.contract_hash), "", signedBaseFee("-", tx.txfee || 0))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 10) {
|
|
const tx = transaction.unsigned_burn;
|
|
if (isActiveWalletAddress(tx.address)) {
|
|
return [dashboardRow(base, "Burn", assetWithSeries(tx.coin, tx.nft_series), "Network", signedAmount("-", tx.value), signedBaseFee("-", tx.txfee || 0))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 11) {
|
|
const tx = transaction.unsigned_issue_token;
|
|
if (isActiveWalletAddress(tx.creator)) {
|
|
return [dashboardRow(base, "Token Issued", tx.ticker || "Token", "Self", signedAmount("+", tx.number), signedBaseFee("-", tx.txfee || 0))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.creator), signedAmount("+", tx.txfee || 0))];
|
|
}
|
|
|
|
if (type === 12) {
|
|
const tx = transaction.unsigned_vanity_address;
|
|
if (isActiveWalletAddress(tx.address)) {
|
|
return [dashboardRow(base, "Vanity Registered", "Vanity", "Self", "", signedBaseFee("-", tx.txfee || tx.fee || 0))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(tx.address), signedAmount("+", tx.txfee || tx.fee || 0))];
|
|
}
|
|
} catch (_) {
|
|
return fallback;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function parseDashboardTransactionRows(
|
|
txid,
|
|
blockHeight,
|
|
transaction,
|
|
linkedTransactions = {},
|
|
minerEarnings = []
|
|
) {
|
|
if (transaction && typeof transaction === "object" && !Array.isArray(transaction)) {
|
|
return parseDecodedDashboardTransactionRows(
|
|
txid,
|
|
blockHeight,
|
|
transaction,
|
|
linkedTransactions,
|
|
minerEarnings
|
|
);
|
|
}
|
|
|
|
const bytes = Array.from(transaction || []);
|
|
const type = Number(bytes[0] || 0);
|
|
const base = {
|
|
txid,
|
|
block_height: blockHeight,
|
|
status: statusForBlock(blockHeight)
|
|
};
|
|
const fallback = [dashboardRow(base, txTypeName(type), activeBaseCoin(), shortenAddress(txid), "")];
|
|
|
|
try {
|
|
if (type === 1) {
|
|
const value = readU64LE(bytes, 5);
|
|
return [
|
|
dashboardRow(
|
|
base,
|
|
"Mining Reward",
|
|
activeBaseCoin(),
|
|
"Network",
|
|
signedAmount("+", value),
|
|
minerFeeDisplay(minerEarnings)
|
|
),
|
|
...minerTipRows(base, minerEarnings)
|
|
];
|
|
}
|
|
|
|
if (type === 2) {
|
|
const value = readU64LE(bytes, 5);
|
|
const asset = assetWithSeries(bytesToText(bytes.slice(13, 28)), readU32LE(bytes, 28));
|
|
const sender = bytesToAddressHex(bytes.slice(32, 54));
|
|
const receiver = bytesToAddressHex(bytes.slice(54, 76));
|
|
const fee = readU64LE(bytes, 76);
|
|
if (isActiveAddress(sender)) {
|
|
return [dashboardRow(base, "Transfer Out", asset, addressLabel(receiver), signedAmount("-", value), signedBaseFee("-", fee))];
|
|
}
|
|
if (isActiveAddress(receiver)) {
|
|
return [dashboardRow(base, "Transfer In", asset, addressLabel(sender), signedAmount("+", value))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(sender), signedAmount("+", fee))];
|
|
}
|
|
|
|
if (type === 3) {
|
|
const creator = bytesToAddressHex(bytes.slice(5, 27));
|
|
const ticker = bytesToText(bytes.slice(27, 42));
|
|
const number = readU64LE(bytes, 42);
|
|
const fee = readU64LE(bytes, 51);
|
|
if (isActiveAddress(creator)) {
|
|
return [dashboardRow(base, "Token Created", ticker || "Token", "Self", signedAmount("+", number), signedBaseFee("-", fee))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(creator), signedAmount("+", fee))];
|
|
}
|
|
|
|
if (type === 4) {
|
|
const creator = bytesToAddressHex(bytes.slice(5, 27));
|
|
const series = Number(bytes[27] || 0);
|
|
const nftName = bytesToText(bytes.slice(28, 43)) || "NFT";
|
|
const count = readU32LE(bytes, 143);
|
|
const fee = readU64LE(bytes, 247);
|
|
if (isActiveAddress(creator)) {
|
|
return [dashboardRow(base, "NFT Created", assetWithSeries(nftName, series), "Self", `+${count}`, signedBaseFee("-", fee))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(creator), signedAmount("+", fee))];
|
|
}
|
|
|
|
if (type === 5) {
|
|
const advertiser = bytesToAddressHex(bytes.slice(165, 187));
|
|
const fee = readU64LE(bytes, 187);
|
|
if (isActiveAddress(advertiser)) {
|
|
return [dashboardRow(base, "Marketing", "Advertisement", "Network", "", signedBaseFee("-", fee))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(advertiser), signedAmount("+", fee))];
|
|
}
|
|
|
|
if (type === 6) {
|
|
const ticker1 = bytesToText(bytes.slice(9, 24));
|
|
const nftSeries1 = readU32LE(bytes, 24);
|
|
const value1 = readU64LE(bytes, 28);
|
|
const ticker2 = bytesToText(bytes.slice(36, 51));
|
|
const nftSeries2 = readU32LE(bytes, 51);
|
|
const value2 = readU64LE(bytes, 55);
|
|
const sender1 = bytesToAddressHex(bytes.slice(63, 85));
|
|
const sender2 = bytesToAddressHex(bytes.slice(85, 107));
|
|
const tip1 = readU64LE(bytes, 107);
|
|
const tip2 = readU64LE(bytes, 115);
|
|
const txfee1 = readU64LE(bytes, 123);
|
|
const txfee2 = readU64LE(bytes, 131);
|
|
const asset1 = assetWithSeries(ticker1, nftSeries1);
|
|
const asset2 = assetWithSeries(ticker2, nftSeries2);
|
|
if (isActiveAddress(sender1)) {
|
|
return [
|
|
dashboardRow(base, "Swap Out", asset1, addressLabel(sender2), signedAmount("-", value1), signedBaseFee("-", txfee1)),
|
|
dashboardRow(base, "Miner Tip Paid", asset1, addressLabel(sender2), signedAmount("-", tip1), "--"),
|
|
dashboardRow(base, "Swap In", asset2, addressLabel(sender2), signedAmount("+", value2))
|
|
].filter((row) => row.amount !== "0" && row.amount !== "-0");
|
|
}
|
|
if (isActiveAddress(sender2)) {
|
|
return [
|
|
dashboardRow(base, "Swap Out", asset2, addressLabel(sender1), signedAmount("-", value2), signedBaseFee("-", txfee2)),
|
|
dashboardRow(base, "Miner Tip Paid", asset2, addressLabel(sender1), signedAmount("-", tip2), "--"),
|
|
dashboardRow(base, "Swap In", asset1, addressLabel(sender1), signedAmount("+", value1))
|
|
].filter((row) => row.amount !== "0" && row.amount !== "-0");
|
|
}
|
|
return [
|
|
dashboardRow(base, "Fee Earned", activeBaseCoin(), "Swap", signedAmount("+", txfee1 + txfee2)),
|
|
dashboardRow(base, "Tip Earned", asset1, "Swap", signedAmount("+", tip1)),
|
|
dashboardRow(base, "Tip Earned", asset2, "Swap", signedAmount("+", tip2))
|
|
].filter((row) => row.amount !== "+0");
|
|
}
|
|
|
|
if (type === 7) {
|
|
const loanCoin = bytesToText(bytes.slice(5, 20));
|
|
const loanAmount = readU64LE(bytes, 20);
|
|
const lender = bytesToAddressHex(bytes.slice(28, 50));
|
|
const collateral = bytesToText(bytes.slice(50, 71));
|
|
const collateralAmount = readU64LE(bytes, 71);
|
|
const borrower = bytesToAddressHex(bytes.slice(79, 101));
|
|
const fee = readU64LE(bytes, 120);
|
|
if (isActiveAddress(lender)) {
|
|
return [dashboardRow(base, "Loan Created", loanCoin, addressLabel(borrower), signedAmount("-", loanAmount), signedBaseFee("-", fee))];
|
|
}
|
|
if (isActiveAddress(borrower)) {
|
|
return [
|
|
dashboardRow(base, "Loan Received", loanCoin, addressLabel(lender), signedAmount("+", loanAmount)),
|
|
dashboardRow(base, "Collateral Locked", collateral, addressLabel(lender), signedAmount("-", collateralAmount))
|
|
];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(lender), signedAmount("+", fee))];
|
|
}
|
|
|
|
if (type === 8) {
|
|
const paybackAmount = readU64LE(bytes, 5);
|
|
const contractHash = bytesToHex(bytes.slice(13, 45));
|
|
const borrower = bytesToAddressHex(bytes.slice(45, 67));
|
|
const tip = readU64LE(bytes, 67);
|
|
const fee = readU64LE(bytes, 75);
|
|
const loan = loanSummaryFromLinkedTransactions(linkedTransactions, contractHash);
|
|
if (isActiveAddress(borrower)) {
|
|
const asset = loan?.loanCoin || "Loan";
|
|
const counterparty = loan?.lender ? addressLabel(loan.lender) : `${contractHash.slice(0, 8)}...${contractHash.slice(-8)}`;
|
|
return [
|
|
dashboardRow(base, "Loan Payment Sent", asset, counterparty, signedAmount("-", paybackAmount), signedBaseFee("-", fee)),
|
|
dashboardRow(base, "Miner Tip Paid", asset, counterparty, signedAmount("-", tip), "--")
|
|
].filter((row) => row.amount !== "0" && row.amount !== "-0");
|
|
}
|
|
if (loan?.lender && isActiveAddress(loan.lender)) {
|
|
return [dashboardRow(base, "Loan Payment Received", loan.loanCoin, addressLabel(borrower), signedAmount("+", paybackAmount))];
|
|
}
|
|
return [
|
|
dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(borrower), signedAmount("+", fee)),
|
|
dashboardRow(base, "Tip Earned", loan?.loanCoin || "Loan", addressLabel(borrower), signedAmount("+", tip))
|
|
].filter((row) => row.amount !== "0" && row.amount !== "+0");
|
|
}
|
|
|
|
if (type === 9) {
|
|
const contractHash = bytesToHex(bytes.slice(5, 37));
|
|
const claimant = bytesToAddressHex(bytes.slice(37, 59));
|
|
const fee = readU64LE(bytes, 59);
|
|
if (isActiveAddress(claimant)) {
|
|
return [dashboardRow(base, "Collateral Claimed", "Collateral", `${contractHash.slice(0, 8)}...${contractHash.slice(-8)}`, "", signedBaseFee("-", fee))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(claimant), signedAmount("+", fee))];
|
|
}
|
|
|
|
if (type === 10) {
|
|
const address = bytesToAddressHex(bytes.slice(5, 27));
|
|
const asset = assetWithSeries(bytesToText(bytes.slice(27, 42)), readU32LE(bytes, 42));
|
|
const value = readU64LE(bytes, 46);
|
|
const fee = readU64LE(bytes, 54);
|
|
if (isActiveAddress(address)) {
|
|
return [dashboardRow(base, "Burn", asset, "Network", signedAmount("-", value), signedBaseFee("-", fee))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(address), signedAmount("+", fee))];
|
|
}
|
|
|
|
if (type === 11) {
|
|
const creator = bytesToAddressHex(bytes.slice(5, 27));
|
|
const ticker = bytesToText(bytes.slice(27, 42));
|
|
const number = readU64LE(bytes, 42);
|
|
const fee = readU64LE(bytes, 50);
|
|
if (isActiveAddress(creator)) {
|
|
return [dashboardRow(base, "Token Issued", ticker || "Token", "Self", signedAmount("+", number), signedBaseFee("-", fee))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(creator), signedAmount("+", fee))];
|
|
}
|
|
|
|
if (type === 12) {
|
|
const address = bytesToAddressHex(bytes.slice(5, 27));
|
|
const fee = readU64LE(bytes, 49);
|
|
if (isActiveAddress(address)) {
|
|
return [dashboardRow(base, "Vanity Registered", "Vanity", "Self", "", signedBaseFee("-", fee))];
|
|
}
|
|
return [dashboardRow(base, "Fee Earned", activeBaseCoin(), addressLabel(address), signedAmount("+", fee))];
|
|
}
|
|
} catch (_) {
|
|
return fallback;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function decodeLightweightTransaction(bytesLike) {
|
|
const bytes = Array.from(bytesLike || []);
|
|
const type = Number(bytes[0] || 0);
|
|
const time = () => readU32LE(bytes, 1);
|
|
if (type === 1) {
|
|
return { unsigned: { txtype: type, timestamp: time(), value: Number(readU64LE(bytes, 5)) } };
|
|
}
|
|
if (type === 2) {
|
|
return {
|
|
unsigned_transfer: {
|
|
txtype: type,
|
|
time: time(),
|
|
value: Number(readU64LE(bytes, 5)),
|
|
coin: bytesToText(bytes.slice(13, 28)),
|
|
nft_series: readU32LE(bytes, 28),
|
|
sender: bytesToAddressHex(bytes.slice(32, 54)),
|
|
receiver: bytesToAddressHex(bytes.slice(54, 76)),
|
|
txfee: Number(readU64LE(bytes, 76))
|
|
}
|
|
};
|
|
}
|
|
if (type === 3) {
|
|
return {
|
|
unsigned_create_token: {
|
|
txtype: type,
|
|
time: time(),
|
|
creator: bytesToAddressHex(bytes.slice(5, 27)),
|
|
ticker: bytesToText(bytes.slice(27, 42)),
|
|
number: Number(readU64LE(bytes, 42)),
|
|
hard_limit: Number(bytes[50] || 0),
|
|
txfee: Number(readU64LE(bytes, 51))
|
|
}
|
|
};
|
|
}
|
|
if (type === 4) {
|
|
return {
|
|
unsigned_create_nft: {
|
|
txtype: type,
|
|
time: time(),
|
|
creator: bytesToAddressHex(bytes.slice(5, 27)),
|
|
series: Number(bytes[27] || 0),
|
|
nft_name: bytesToText(bytes.slice(28, 43)),
|
|
item_ipfs: bytesToText(bytes.slice(43, 143)),
|
|
count: readU32LE(bytes, 143),
|
|
description: bytesToText(bytes.slice(147, 247)),
|
|
txfee: Number(readU64LE(bytes, 247))
|
|
}
|
|
};
|
|
}
|
|
if (type === 5) {
|
|
return {
|
|
unsigned_marketing: {
|
|
txtype: type,
|
|
time: time(),
|
|
ad_data: bytesToText(bytes.slice(5, 165)),
|
|
advertiser: bytesToAddressHex(bytes.slice(165, 187)),
|
|
txfee: Number(readU64LE(bytes, 187))
|
|
}
|
|
};
|
|
}
|
|
if (type === 6) {
|
|
return {
|
|
unsigned_swap: {
|
|
txtype: type,
|
|
time: time(),
|
|
ticker1: bytesToText(bytes.slice(9, 24)),
|
|
nft_series1: readU32LE(bytes, 24),
|
|
value1: Number(readU64LE(bytes, 28)),
|
|
ticker2: bytesToText(bytes.slice(36, 51)),
|
|
nft_series2: readU32LE(bytes, 51),
|
|
value2: Number(readU64LE(bytes, 55)),
|
|
sender1: bytesToAddressHex(bytes.slice(63, 85)),
|
|
sender2: bytesToAddressHex(bytes.slice(85, 107)),
|
|
tip1: Number(readU64LE(bytes, 107)),
|
|
tip2: Number(readU64LE(bytes, 115)),
|
|
txfee1: Number(readU64LE(bytes, 123)),
|
|
txfee2: Number(readU64LE(bytes, 131))
|
|
}
|
|
};
|
|
}
|
|
if (type === 7) {
|
|
return {
|
|
unsigned_loan_contract: {
|
|
txtype: type,
|
|
time: time(),
|
|
loan_coin: bytesToText(bytes.slice(5, 20)),
|
|
loan_amount: Number(readU64LE(bytes, 20)),
|
|
lender: bytesToAddressHex(bytes.slice(28, 50)),
|
|
collateral: bytesToText(bytes.slice(50, 71)),
|
|
collateral_amount: Number(readU64LE(bytes, 71)),
|
|
borrower: bytesToAddressHex(bytes.slice(79, 101)),
|
|
payment_period: bytesToText(bytes.slice(101, 102)),
|
|
payment_number: Number(bytes[102] || 0),
|
|
payment_amount: Number(readU64LE(bytes, 103)),
|
|
grace_period: Number(bytes[111] || 0),
|
|
max_late_value: Number(readU64LE(bytes, 112)),
|
|
txfee: Number(readU64LE(bytes, 120))
|
|
}
|
|
};
|
|
}
|
|
if (type === 8) {
|
|
return {
|
|
unsigned_contract_payment: {
|
|
txtype: type,
|
|
time: time(),
|
|
payback_amount: Number(readU64LE(bytes, 5)),
|
|
contract_hash: bytesToHex(bytes.slice(13, 45)),
|
|
address: bytesToAddressHex(bytes.slice(45, 67)),
|
|
tip: Number(readU64LE(bytes, 67)),
|
|
txfee: Number(readU64LE(bytes, 75))
|
|
}
|
|
};
|
|
}
|
|
if (type === 9) {
|
|
return {
|
|
unsigned_collateral_claim: {
|
|
txtype: type,
|
|
time: time(),
|
|
contract_hash: bytesToHex(bytes.slice(5, 37)),
|
|
address: bytesToAddressHex(bytes.slice(37, 59)),
|
|
txfee: Number(readU64LE(bytes, 59))
|
|
}
|
|
};
|
|
}
|
|
if (type === 10) {
|
|
return {
|
|
unsigned_burn: {
|
|
txtype: type,
|
|
time: time(),
|
|
address: bytesToAddressHex(bytes.slice(5, 27)),
|
|
coin: bytesToText(bytes.slice(27, 42)),
|
|
nft_series: readU32LE(bytes, 42),
|
|
value: Number(readU64LE(bytes, 46)),
|
|
txfee: Number(readU64LE(bytes, 54))
|
|
}
|
|
};
|
|
}
|
|
if (type === 11) {
|
|
return {
|
|
unsigned_issue_token: {
|
|
txtype: type,
|
|
time: time(),
|
|
creator: bytesToAddressHex(bytes.slice(5, 27)),
|
|
ticker: bytesToText(bytes.slice(27, 42)),
|
|
number: Number(readU64LE(bytes, 42)),
|
|
txfee: Number(readU64LE(bytes, 50))
|
|
}
|
|
};
|
|
}
|
|
if (type === 12) {
|
|
return {
|
|
unsigned_vanity_address: {
|
|
txtype: type,
|
|
time: time(),
|
|
address: bytesToAddressHex(bytes.slice(5, 27)),
|
|
vanity_address: bytesToAddressHex(bytes.slice(27, 49)),
|
|
fee: Number(readU64LE(bytes, 49)),
|
|
txfee: Number(readU64LE(bytes, 49))
|
|
}
|
|
};
|
|
}
|
|
|
|
return { unknown: { txtype: type } };
|
|
}
|
|
|
|
function normalizeTransactionCache(cache) {
|
|
if (!cache || typeof cache !== "object" || Array.isArray(cache)) {
|
|
return { transactions: {}, ordered_txids: [] };
|
|
}
|
|
|
|
if (cache.transactions && typeof cache.transactions === "object" && !Array.isArray(cache.transactions)) {
|
|
return {
|
|
...cache,
|
|
transactions: { ...cache.transactions },
|
|
ordered_txids: normalizeOrderedTxids(cache)
|
|
};
|
|
}
|
|
|
|
const transactions = {};
|
|
Object.entries(cache).forEach(([txid, transaction]) => {
|
|
if (transaction && typeof transaction === "object") {
|
|
transactions[txid] = { txid, ...transaction };
|
|
}
|
|
});
|
|
return {
|
|
transactions,
|
|
ordered_txids: []
|
|
};
|
|
}
|
|
|
|
function normalizeOrderedTxids(cache) {
|
|
const txids = Array.isArray(cache?.ordered_txids)
|
|
? cache.ordered_txids
|
|
: (Array.isArray(cache?.address_history_txids) ? cache.address_history_txids : []);
|
|
const seen = new Set();
|
|
return txids.filter((txid) => {
|
|
if (!txid || seen.has(txid)) {
|
|
return false;
|
|
}
|
|
seen.add(txid);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function addOrderedTxids(cache, txids) {
|
|
cache.ordered_txids = normalizeOrderedTxids(cache);
|
|
const known = new Set(cache.ordered_txids || []);
|
|
txids.forEach((txid) => {
|
|
if (txid && !known.has(txid)) {
|
|
known.add(txid);
|
|
cache.ordered_txids.push(txid);
|
|
}
|
|
});
|
|
}
|
|
|
|
function replaceOrderedTxids(cache, txids) {
|
|
cache.ordered_txids = normalizeOrderedTxids({ ordered_txids: txids });
|
|
}
|
|
|
|
function normalizeTransactionIndex(index) {
|
|
const source = index && typeof index === "object" && !Array.isArray(index) ? index : {};
|
|
const orderedTxids = Array.isArray(source.ordered_txids) ? source.ordered_txids.filter(Boolean) : [];
|
|
const txidToFile = source.txid_to_file && typeof source.txid_to_file === "object" ? { ...source.txid_to_file } : {};
|
|
const txidToType = source.txid_to_type && typeof source.txid_to_type === "object" ? { ...source.txid_to_type } : {};
|
|
const typeToTxids = source.type_to_txids && typeof source.type_to_txids === "object" ? { ...source.type_to_txids } : {};
|
|
const nextId = Math.max(1, Number(source.next_id || 1));
|
|
|
|
return {
|
|
next_id: nextId,
|
|
ordered_txids: orderedTxids,
|
|
txid_to_file: txidToFile,
|
|
txid_to_type: txidToType,
|
|
type_to_txids: typeToTxids,
|
|
address_history_last_checked_at: source.address_history_last_checked_at,
|
|
address_history_synced_at: source.address_history_synced_at,
|
|
updated_at: source.updated_at
|
|
};
|
|
}
|
|
|
|
function transactionRecordFileName(id) {
|
|
return `${String(id).padStart(8, "0")}.json`;
|
|
}
|
|
|
|
function addIndexTransaction(index, txid, txtype) {
|
|
if (!index.txid_to_file[txid]) {
|
|
index.txid_to_file[txid] = transactionRecordFileName(index.next_id);
|
|
index.next_id += 1;
|
|
}
|
|
if (!index.ordered_txids.includes(txid)) {
|
|
index.ordered_txids.push(txid);
|
|
}
|
|
index.txid_to_type[txid] = Number(txtype || 0);
|
|
const typeKey = String(Number(txtype || 0));
|
|
const txidsForType = Array.isArray(index.type_to_txids[typeKey]) ? index.type_to_txids[typeKey] : [];
|
|
if (!txidsForType.includes(txid)) {
|
|
txidsForType.push(txid);
|
|
}
|
|
index.type_to_txids[typeKey] = txidsForType;
|
|
return index.txid_to_file[txid];
|
|
}
|
|
|
|
function transactionRecordFromRpc(record) {
|
|
const transactionBytes = Array.from(record?.transaction_bytes || []);
|
|
const txtype = Number(record?.transaction_type ?? transactionBytes[0] ?? 0);
|
|
const block = Number(record?.block_height || 0) || null;
|
|
const transaction = decodeLightweightTransaction(transactionBytes);
|
|
return {
|
|
type: txTypeKey(txtype),
|
|
txtype,
|
|
block,
|
|
status: walletStatusForBlock(block),
|
|
transaction,
|
|
miner_earnings: Array.isArray(record?.miner_earnings) ? record.miner_earnings : []
|
|
};
|
|
}
|
|
|
|
function chainStatusForBlock(blockHeight, currentHeight = latestKnownBlockHeight) {
|
|
const height = Number(blockHeight || 0);
|
|
const tip = Number(currentHeight || 0);
|
|
if (!height || !tip || tip < height) {
|
|
return CHAIN_STATUS_PENDING;
|
|
}
|
|
return tip - height >= CONFIRMED_AFTER_BLOCKS ? CHAIN_STATUS_FINALIZED : CHAIN_STATUS_PENDING;
|
|
}
|
|
|
|
function updateTransactionCacheChainStatus(cache, currentHeight = latestKnownBlockHeight) {
|
|
Object.entries(cache.transactions || {}).forEach(([, record]) => {
|
|
if (!record || typeof record !== "object") {
|
|
return;
|
|
}
|
|
record.status = walletStatusForBlock(blockValueFromRecord(record), currentHeight);
|
|
});
|
|
cache.finalized_through_height = Math.max(0, Number(currentHeight || 0) - CONFIRMED_AFTER_BLOCKS);
|
|
return cache;
|
|
}
|
|
|
|
function recentTransactionField(transaction, names, fallback = "") {
|
|
for (const name of names) {
|
|
const value = transaction?.[name];
|
|
if (value !== undefined && value !== null && String(value).trim() !== "") {
|
|
return String(value);
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function transactionRecordsForTxids(cache, txids) {
|
|
if (!cache || typeof cache !== "object") {
|
|
return [];
|
|
}
|
|
|
|
return (txids || [])
|
|
.map((txid) => {
|
|
const transaction = cache.transactions?.[txid];
|
|
if (!transaction || typeof transaction !== "object") {
|
|
return null;
|
|
}
|
|
return { txid, ...transaction };
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeRecentTransactionRecords(records, options = {}) {
|
|
const source = Array.isArray(records) ? records : [];
|
|
|
|
const normalized = source
|
|
.filter((transaction) => transaction && typeof transaction === "object")
|
|
.flatMap((transaction, recordIndex) => {
|
|
if (!transaction.transaction && Array.isArray(transaction.rows)) {
|
|
return transaction.rows.map((row, rowIndex) => ({
|
|
...row,
|
|
txid: row.txid || transaction.txid,
|
|
block_height: row.block_height ?? blockValueFromRecord(transaction),
|
|
__order: recordIndex + rowIndex / 1000
|
|
}));
|
|
}
|
|
const blockHeight = blockValueFromRecord(transaction);
|
|
return parseDashboardTransactionRows(
|
|
transaction.txid,
|
|
blockHeight,
|
|
transaction.transaction,
|
|
transaction.linked_transactions || {},
|
|
transaction.miner_earnings || []
|
|
).map((row, rowIndex) => ({
|
|
...row,
|
|
status: walletStatusForBlock(blockHeight),
|
|
__order: recordIndex + rowIndex / 1000
|
|
}));
|
|
})
|
|
.map((transaction) => ({
|
|
...transaction,
|
|
status: walletStatusForBlock(Number(recentTransactionField(transaction, ["block_height", "blockHeight", "height"], "0")))
|
|
}));
|
|
|
|
const sorted = options.preserveOrder ? normalized : normalized.sort((left, right) => {
|
|
const leftHeight = Number(recentTransactionField(left, ["block_height", "blockHeight", "height"], "0"));
|
|
const rightHeight = Number(recentTransactionField(right, ["block_height", "blockHeight", "height"], "0"));
|
|
return rightHeight - leftHeight;
|
|
});
|
|
|
|
return sorted
|
|
.slice(0, 5)
|
|
.map(({ __order: _order, ...transaction }) => transaction);
|
|
}
|
|
|
|
function normalizeCachedRecentTransactions(cache) {
|
|
if (!cache || typeof cache !== "object") {
|
|
return [];
|
|
}
|
|
|
|
if (cache.transactions && typeof cache.transactions === "object" && !Array.isArray(cache.transactions)) {
|
|
return normalizeRecentTransactionRecords(transactionRecordsForTxids(cache, cache.ordered_txids || []));
|
|
}
|
|
|
|
if (Array.isArray(cache)) {
|
|
return normalizeRecentTransactionRecords(cache);
|
|
}
|
|
|
|
if (Array.isArray(cache.transactions)) {
|
|
return normalizeRecentTransactionRecords(cache.transactions);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function renderRecentTransactions(transactions) {
|
|
clearRecentTransactionsRows();
|
|
|
|
if (!recentTransactionsBody) {
|
|
return;
|
|
}
|
|
|
|
transactions.forEach((transaction) => {
|
|
const type = recentTransactionField(transaction, ["type", "tx_type", "transactionType"], "Transaction");
|
|
const asset = recentTransactionField(transaction, ["asset", "coin", "token"], activeBaseCoin());
|
|
const fallbackCounterparty = shortenAddress(recentTransactionField(transaction, ["txid"], "Unknown"));
|
|
const counterparty = recentTransactionField(transaction, ["counterparty", "to", "from", "address"], fallbackCounterparty);
|
|
const status = recentTransactionField(transaction, ["status"], "Confirmed");
|
|
const amount = recentTransactionField(transaction, ["amount", "value", "display_amount"], "");
|
|
const statusClass = status.trim().toLowerCase() === "pending" ? "pending" : "confirmed";
|
|
|
|
const row = document.createElement("tr");
|
|
row.innerHTML = `
|
|
<td></td>
|
|
<td></td>
|
|
<td></td>
|
|
<td><span class="table-status ${statusClass}"></span></td>
|
|
<td></td>
|
|
`;
|
|
row.children[0].textContent = type;
|
|
row.children[1].textContent = asset;
|
|
row.children[2].textContent = counterparty;
|
|
row.querySelector(".table-status").textContent = status;
|
|
row.children[4].textContent = amount;
|
|
recentTransactionsBody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function renderRecentTransactionsFromCache() {
|
|
if (!walletLoaded || !registrationRegistered) {
|
|
return [];
|
|
}
|
|
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke) {
|
|
clearRecentTransactionsRows();
|
|
setRecentTransactionsState("Transaction cache requires the desktop app build.");
|
|
return [];
|
|
}
|
|
|
|
const readId = ++recentTransactionsCacheReadId;
|
|
try {
|
|
await refreshCurrentBlockHeight();
|
|
const cache = await readTransactionCacheObject();
|
|
if (readId !== recentTransactionsCacheReadId) {
|
|
return [];
|
|
}
|
|
updateTransactionCacheChainStatus(cache);
|
|
const transactions = normalizeCachedRecentTransactions(cache);
|
|
renderRecentTransactions(transactions);
|
|
setRecentTransactionsState(transactions.length ? "" : "No cached transactions yet.");
|
|
return transactions;
|
|
} catch (_) {
|
|
if (readId !== recentTransactionsCacheReadId) {
|
|
return [];
|
|
}
|
|
clearRecentTransactionsRows();
|
|
setRecentTransactionsState("No cached transactions yet.");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function readTransactionCacheObject(walletToken = activeWalletCacheToken()) {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke || !isActiveWalletCacheToken(walletToken)) {
|
|
return { transactions: {}, ordered_txids: [] };
|
|
}
|
|
|
|
const index = await readTransactionIndexObject(walletToken);
|
|
return readTransactionCacheRecords(index, index.ordered_txids, walletToken);
|
|
}
|
|
|
|
async function readTransactionCacheRecords(index, txids, walletToken = activeWalletCacheToken()) {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke || !isActiveWalletCacheToken(walletToken)) {
|
|
return { transactions: {}, ordered_txids: [] };
|
|
}
|
|
|
|
const normalizedIndex = normalizeTransactionIndex(index);
|
|
const cache = { ...normalizedIndex, transactions: {} };
|
|
const recordTxids = normalizeOrderedTxids({ ordered_txids: Array.isArray(txids) ? txids : [] });
|
|
cache.ordered_txids = recordTxids;
|
|
|
|
for (const txid of recordTxids) {
|
|
if (!isActiveWalletCacheToken(walletToken)) {
|
|
return { transactions: {}, ordered_txids: [] };
|
|
}
|
|
const fileName = normalizedIndex.txid_to_file?.[txid];
|
|
if (!fileName) {
|
|
continue;
|
|
}
|
|
try {
|
|
const recordJson = await invoke("read_local_transaction_record", { fileName });
|
|
const record = JSON.parse(recordJson || "{}");
|
|
if (record && typeof record === "object" && Object.keys(record).length) {
|
|
cache.transactions[txid] = repairCachedTransactionRecord(txid, record);
|
|
}
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
}
|
|
return cache;
|
|
}
|
|
|
|
async function readTransactionIndexObject(walletToken = activeWalletCacheToken()) {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke || !isActiveWalletCacheToken(walletToken)) {
|
|
return normalizeTransactionIndex({});
|
|
}
|
|
|
|
const indexJson = await invoke("read_local_transaction_index");
|
|
if (!isActiveWalletCacheToken(walletToken)) {
|
|
return normalizeTransactionIndex({});
|
|
}
|
|
return normalizeTransactionIndex(JSON.parse(indexJson || "{}"));
|
|
}
|
|
|
|
function activeWalletCacheToken() {
|
|
return activeWalletAddressShort || activeWalletAddressHex || activeWalletPath || "";
|
|
}
|
|
|
|
function isActiveWalletCacheToken(token) {
|
|
return Boolean(walletLoaded && token && token === activeWalletCacheToken());
|
|
}
|
|
|
|
async function refreshCurrentBlockHeight() {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
const broadcastNode = activeBroadcastNode();
|
|
if (!invoke || !broadcastNode || !walletLoaded) {
|
|
return latestKnownBlockHeight;
|
|
}
|
|
|
|
try {
|
|
latestKnownBlockHeight = await invokeWalletRpcWithBackoff(
|
|
"Block height lookup",
|
|
() => invoke("current_block_height", { broadcastNode }),
|
|
() => walletLoaded
|
|
);
|
|
} catch (_) {
|
|
latestKnownBlockHeight = 0;
|
|
}
|
|
return latestKnownBlockHeight;
|
|
}
|
|
|
|
function repairCachedTransactionRecord(txid, record) {
|
|
if (!record || typeof record !== "object") {
|
|
return record;
|
|
}
|
|
const block = blockValueFromRecord(record);
|
|
const transaction = record.transaction && !Array.isArray(record.transaction)
|
|
? record.transaction
|
|
: record.decoded_transaction;
|
|
const transactionType = Number(record.txtype ?? record.transaction_type ?? decodedTransactionType(transaction));
|
|
return {
|
|
txid,
|
|
type: record.type || txTypeKey(transactionType),
|
|
txtype: transactionType,
|
|
block,
|
|
status: walletStatusForBlock(block),
|
|
transaction: transaction || record.transaction,
|
|
linked_transactions: record.linked_transactions || {},
|
|
miner_earnings: Array.isArray(record.miner_earnings) ? record.miner_earnings : []
|
|
};
|
|
}
|
|
|
|
function linkedTransactionIdsFor(transaction) {
|
|
if (transaction && typeof transaction === "object" && !Array.isArray(transaction)) {
|
|
const contractHash = transaction.unsigned_contract_payment?.contract_hash;
|
|
return contractHash ? [contractHash] : [];
|
|
}
|
|
|
|
const bytes = Array.from(transaction || []);
|
|
const type = Number(bytes[0] || 0);
|
|
if (type === 8) {
|
|
return [bytesToHex(bytes.slice(13, 45))];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function cachedRecordNeedsRefresh(record) {
|
|
if (!record || typeof record !== "object") {
|
|
return true;
|
|
}
|
|
if (!record.transaction || Array.isArray(record.transaction)) {
|
|
return true;
|
|
}
|
|
const linkedIds = linkedTransactionIdsFor(record.transaction);
|
|
return linkedIds.some((txid) => !record.linked_transactions?.[txid]);
|
|
}
|
|
|
|
async function hydrateLinkedTransactionsForRecord(
|
|
txid,
|
|
record,
|
|
invoke,
|
|
broadcastNode,
|
|
cache = { transactions: {} },
|
|
shouldContinue = () => true,
|
|
linkedLookupDelayMs = 0,
|
|
commandName = "transaction_by_txid"
|
|
) {
|
|
if (!record || !record.transaction || !cachedRecordNeedsRefresh(record)) {
|
|
return record;
|
|
}
|
|
|
|
const linkedTransactions = { ...(record.linked_transactions || {}) };
|
|
for (const linkedTxid of linkedTransactionIdsFor(record.transaction)) {
|
|
if (linkedTransactions[linkedTxid]?.transaction) {
|
|
continue;
|
|
}
|
|
|
|
const cachedLinked = cache.transactions?.[linkedTxid] || cache.transactions?.[txid]?.linked_transactions?.[linkedTxid];
|
|
if (cachedLinked?.transaction && !Array.isArray(cachedLinked.transaction)) {
|
|
linkedTransactions[linkedTxid] = repairCachedTransactionRecord(linkedTxid, cachedLinked);
|
|
continue;
|
|
}
|
|
|
|
if (linkedLookupDelayMs > 0) {
|
|
await sleepMs(linkedLookupDelayMs);
|
|
}
|
|
if (!shouldContinue()) {
|
|
throw new Error("Linked transaction lookup cancelled");
|
|
}
|
|
|
|
const linked = await invokeWalletRpcWithBackoff(
|
|
"Linked transaction lookup",
|
|
() => invoke(commandName, {
|
|
broadcastNode,
|
|
txid: linkedTxid
|
|
}),
|
|
shouldContinue
|
|
);
|
|
|
|
linkedTransactions[linkedTxid] = repairCachedTransactionRecord(linkedTxid, {
|
|
txid: linkedTxid,
|
|
block: linked.block_height || null,
|
|
transaction: linked.transaction,
|
|
transaction_type: linked.transaction_type,
|
|
miner_earnings: Array.isArray(linked.miner_earnings) ? linked.miner_earnings : []
|
|
});
|
|
}
|
|
|
|
return repairCachedTransactionRecord(txid, {
|
|
...record,
|
|
linked_transactions: linkedTransactions
|
|
});
|
|
}
|
|
|
|
function sanitizeTransactionCacheForWrite(cache) {
|
|
const clean = {
|
|
transactions: {},
|
|
ordered_txids: normalizeOrderedTxids(cache),
|
|
finalized_through_height: cache.finalized_through_height || 0,
|
|
address_history_last_checked_at: cache.address_history_last_checked_at,
|
|
address_history_synced_at: cache.address_history_synced_at
|
|
};
|
|
|
|
Object.entries(cache.transactions || {}).forEach(([txid, record]) => {
|
|
if (!record || typeof record !== "object") {
|
|
return;
|
|
}
|
|
const normalized = repairCachedTransactionRecord(txid, record);
|
|
if (!normalized?.transaction || Array.isArray(normalized.transaction)) {
|
|
return;
|
|
}
|
|
const linkedTransactions = {};
|
|
Object.entries(normalized.linked_transactions || {}).forEach(([linkedTxid, linkedRecord]) => {
|
|
if (linkedRecord && typeof linkedRecord === "object") {
|
|
const linkedNormalized = repairCachedTransactionRecord(linkedTxid, linkedRecord);
|
|
if (linkedNormalized?.transaction && !Array.isArray(linkedNormalized.transaction)) {
|
|
linkedTransactions[linkedTxid] = linkedNormalized;
|
|
}
|
|
}
|
|
});
|
|
clean.transactions[txid] = {
|
|
...normalized,
|
|
linked_transactions: linkedTransactions
|
|
};
|
|
});
|
|
|
|
updateTransactionCacheChainStatus(clean);
|
|
return clean;
|
|
}
|
|
|
|
async function writeTransactionCacheObject(cache, walletToken = activeWalletCacheToken()) {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke || !isActiveWalletCacheToken(walletToken)) {
|
|
return;
|
|
}
|
|
|
|
const index = normalizeTransactionIndex(cache);
|
|
const indexJson = JSON.stringify({
|
|
...index,
|
|
updated_at: new Date().toISOString()
|
|
});
|
|
if (!isActiveWalletCacheToken(walletToken)) {
|
|
return;
|
|
}
|
|
|
|
await invoke("write_local_transaction_index", {
|
|
indexJson
|
|
});
|
|
}
|
|
|
|
async function writeTransactionRecordFile(fileName, record, walletToken = activeWalletCacheToken()) {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke || !isActiveWalletCacheToken(walletToken)) {
|
|
return;
|
|
}
|
|
|
|
await invoke("write_local_transaction_record", {
|
|
fileName,
|
|
recordJson: JSON.stringify(record)
|
|
});
|
|
}
|
|
|
|
async function mutateTransactionCache(mutator, walletToken = activeWalletCacheToken()) {
|
|
if (!isActiveWalletCacheToken(walletToken)) {
|
|
return { transactions: {}, ordered_txids: [] };
|
|
}
|
|
|
|
const previous = transactionCacheMutationChain;
|
|
let releaseMutation;
|
|
transactionCacheMutationChain = new Promise((resolve) => {
|
|
releaseMutation = resolve;
|
|
});
|
|
|
|
await previous;
|
|
try {
|
|
if (!isActiveWalletCacheToken(walletToken)) {
|
|
return { transactions: {}, ordered_txids: [] };
|
|
}
|
|
let cache = await readTransactionCacheObject(walletToken);
|
|
Object.entries(cache.transactions).forEach(([txid, record]) => {
|
|
cache.transactions[txid] = repairCachedTransactionRecord(txid, record);
|
|
});
|
|
updateTransactionCacheChainStatus(cache);
|
|
cache = (await mutator(cache)) || cache;
|
|
if (!isActiveWalletCacheToken(walletToken)) {
|
|
return { transactions: {}, ordered_txids: [] };
|
|
}
|
|
updateTransactionCacheChainStatus(cache);
|
|
await writeTransactionCacheObject(cache, walletToken);
|
|
return cache;
|
|
} finally {
|
|
releaseMutation();
|
|
}
|
|
}
|
|
|
|
async function fetchTransactionRecord(
|
|
invoke,
|
|
broadcastNode,
|
|
txid,
|
|
cache,
|
|
shouldContinue = () => true,
|
|
linkedLookupDelayMs = DASHBOARD_INITIAL_TX_FETCH_DELAY_MS,
|
|
commandName = "transaction_by_txid"
|
|
) {
|
|
const tx = await invokeWalletRpcWithBackoff(
|
|
"Transaction lookup",
|
|
() => invoke(commandName, {
|
|
broadcastNode,
|
|
txid
|
|
}),
|
|
shouldContinue
|
|
);
|
|
|
|
const linkedTransactions = {};
|
|
const record = repairCachedTransactionRecord(txid, {
|
|
txid,
|
|
block: tx.block_height || null,
|
|
transaction: tx.transaction,
|
|
transaction_type: tx.transaction_type,
|
|
linked_transactions: linkedTransactions,
|
|
miner_earnings: Array.isArray(tx.miner_earnings) ? tx.miner_earnings : []
|
|
});
|
|
return hydrateLinkedTransactionsForRecord(
|
|
txid,
|
|
record,
|
|
invoke,
|
|
broadcastNode,
|
|
cache,
|
|
shouldContinue,
|
|
linkedLookupDelayMs,
|
|
commandName
|
|
);
|
|
}
|
|
|
|
async function syncLatestDashboardTransactions() {
|
|
if (!walletLoaded || !registrationRegistered || currentView !== "dashboard" || latestTransactionsSyncInFlight) {
|
|
return;
|
|
}
|
|
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
const broadcastNode = activeBroadcastNode();
|
|
if (!invoke || !broadcastNode) {
|
|
return;
|
|
}
|
|
|
|
const syncId = ++latestTransactionsSyncId;
|
|
recentTransactionsCacheReadId += 1;
|
|
latestTransactionsSyncInFlight = true;
|
|
|
|
try {
|
|
const hasVisibleTransactions = latestDashboardTransactionRecords.size > 0;
|
|
if (!hasVisibleTransactions) {
|
|
setRecentTransactionsState("Checking latest transactions...", "loading");
|
|
}
|
|
if (hasVisibleTransactions) {
|
|
await refreshCurrentBlockHeight();
|
|
}
|
|
const shouldContinue = () => (
|
|
syncId === latestTransactionsSyncId &&
|
|
registrationRegistered &&
|
|
walletLoaded &&
|
|
currentView === "dashboard"
|
|
);
|
|
const latestRecords = await invokeWalletRpcWithBackoff(
|
|
"Latest address transaction lookup",
|
|
() => invoke("latest_address_transactions", {
|
|
broadcastNode,
|
|
limit: DASHBOARD_RECENT_LIMIT
|
|
}),
|
|
shouldContinue
|
|
);
|
|
if (syncId !== latestTransactionsSyncId || !registrationRegistered || currentView !== "dashboard") {
|
|
return;
|
|
}
|
|
|
|
const txids = latestRecords.map((record) => record.txid).filter(Boolean);
|
|
if (!latestRecords.length) {
|
|
latestDashboardTxids = [];
|
|
latestDashboardTransactionRecords = new Map();
|
|
clearRecentTransactionsRows();
|
|
setRecentTransactionsState("No transactions found.");
|
|
return;
|
|
}
|
|
|
|
const memoryCache = { transactions: {} };
|
|
for (const record of latestRecords) {
|
|
if (!record?.txid || !Array.isArray(record.transaction_bytes)) {
|
|
continue;
|
|
}
|
|
const chainStatus = chainStatusForBlock(record.block_height);
|
|
let transactionRecord = repairCachedTransactionRecord(record.txid, {
|
|
txid: record.txid,
|
|
block: record.block_height || null,
|
|
transaction: record.transaction_bytes,
|
|
transaction_type: record.transaction_type,
|
|
miner_earnings: Array.isArray(record.miner_earnings) ? record.miner_earnings : []
|
|
});
|
|
transactionRecord = await hydrateLinkedTransactionsForRecord(
|
|
record.txid,
|
|
transactionRecord,
|
|
invoke,
|
|
broadcastNode,
|
|
memoryCache,
|
|
shouldContinue,
|
|
0
|
|
);
|
|
if (!shouldContinue()) {
|
|
return;
|
|
}
|
|
const rows = parseDashboardTransactionRows(
|
|
record.txid,
|
|
record.block_height,
|
|
transactionRecord.transaction,
|
|
transactionRecord.linked_transactions || {},
|
|
record.miner_earnings || []
|
|
).map((row) => ({
|
|
...row,
|
|
chain_status: chainStatus
|
|
}));
|
|
memoryCache.transactions[record.txid] = {
|
|
txid: record.txid,
|
|
block_height: record.block_height,
|
|
chain_status: chainStatus,
|
|
transaction: record.transaction_bytes,
|
|
transaction_bytes: record.transaction_bytes,
|
|
transaction_type: record.transaction_type,
|
|
miner_earnings: Array.isArray(record.miner_earnings) ? record.miner_earnings : [],
|
|
linked_transactions: transactionRecord.linked_transactions || {},
|
|
rows,
|
|
parser_version: 3
|
|
};
|
|
}
|
|
|
|
latestDashboardTxids = txids;
|
|
latestDashboardTransactionRecords = new Map(
|
|
txids
|
|
.map((txid) => [txid, memoryCache.transactions[txid]])
|
|
.filter(([, record]) => record)
|
|
);
|
|
renderRecentTransactions(normalizeRecentTransactionRecords(
|
|
transactionRecordsForTxids(memoryCache, txids),
|
|
{ preserveOrder: true }
|
|
));
|
|
setRecentTransactionsState("");
|
|
if (!hasVisibleTransactions) {
|
|
refreshCurrentBlockHeight()
|
|
.then(() => {
|
|
if (
|
|
syncId === latestTransactionsSyncId &&
|
|
registrationRegistered &&
|
|
walletLoaded &&
|
|
currentView === "dashboard"
|
|
) {
|
|
const latestCache = { transactions: Object.fromEntries(latestDashboardTransactionRecords) };
|
|
updateTransactionCacheChainStatus(latestCache);
|
|
latestDashboardTransactionRecords = new Map(Object.entries(latestCache.transactions));
|
|
renderRecentTransactions(normalizeRecentTransactionRecords(
|
|
transactionRecordsForTxids(latestCache, latestDashboardTxids),
|
|
{ preserveOrder: true }
|
|
));
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
} catch (error) {
|
|
const message = String(error);
|
|
|
|
if (syncId === latestTransactionsSyncId) {
|
|
if (latestDashboardTransactionRecords.size === 0) {
|
|
clearRecentTransactionsRows();
|
|
setRecentTransactionsState(message || "Unable to load latest transactions right now.");
|
|
}
|
|
}
|
|
} finally {
|
|
if (
|
|
syncId === latestTransactionsSyncId &&
|
|
latestDashboardTransactionRecords.size === 0 &&
|
|
recentTransactionsState?.classList.contains("loading")
|
|
) {
|
|
setRecentTransactionsState("Unable to load latest transactions right now.");
|
|
}
|
|
latestTransactionsSyncInFlight = false;
|
|
}
|
|
}
|
|
|
|
function resetLatestDashboardTransactionsMemory() {
|
|
latestDashboardTxids = [];
|
|
latestDashboardTransactionRecords = new Map();
|
|
}
|
|
|
|
function shouldPollLatestDashboardTransactions() {
|
|
return walletLoaded && registrationRegistered && currentView === "dashboard";
|
|
}
|
|
|
|
function startLatestDashboardTransactionsPolling() {
|
|
if (latestDashboardTransactionsTimer || !shouldPollLatestDashboardTransactions()) {
|
|
return;
|
|
}
|
|
|
|
syncLatestDashboardTransactions();
|
|
latestDashboardTransactionsTimer = setInterval(() => {
|
|
syncLatestDashboardTransactions();
|
|
}, DASHBOARD_LATEST_REFRESH_MS);
|
|
}
|
|
|
|
function stopLatestDashboardTransactionsPolling() {
|
|
latestTransactionsSyncId += 1;
|
|
if (latestDashboardTransactionsTimer) {
|
|
clearInterval(latestDashboardTransactionsTimer);
|
|
latestDashboardTransactionsTimer = null;
|
|
}
|
|
}
|
|
|
|
function updateLatestDashboardTransactionsPolling() {
|
|
if (shouldPollLatestDashboardTransactions()) {
|
|
startLatestDashboardTransactionsPolling();
|
|
return;
|
|
}
|
|
|
|
stopLatestDashboardTransactionsPolling();
|
|
}
|
|
|
|
function shouldContinueBackgroundTransactionSync(syncId, walletToken = activeWalletCacheToken()) {
|
|
return (
|
|
syncId === backgroundTransactionSyncId &&
|
|
walletLoaded &&
|
|
registrationRegistered &&
|
|
isActiveWalletCacheToken(walletToken)
|
|
);
|
|
}
|
|
|
|
function stopBackgroundTransactionSync() {
|
|
backgroundTransactionSyncId += 1;
|
|
}
|
|
|
|
function startBackgroundTransactionSync() {
|
|
if (!walletLoaded || !registrationRegistered) {
|
|
return;
|
|
}
|
|
|
|
const walletToken = activeWalletCacheToken();
|
|
const syncId = ++backgroundTransactionSyncId;
|
|
runBackgroundTransactionSync(syncId, 0, walletToken);
|
|
}
|
|
|
|
async function runBackgroundTransactionSync(syncId, failureCount = 0, walletToken = activeWalletCacheToken()) {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
if (!invoke) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
while (shouldContinueBackgroundTransactionSync(syncId, walletToken)) {
|
|
const broadcastNode = activeBroadcastNode();
|
|
if (!broadcastNode) {
|
|
await sleepMs(BACKGROUND_SYNC_IDLE_MS);
|
|
continue;
|
|
}
|
|
|
|
const currentHeight = await refreshCurrentBlockHeight();
|
|
if (!currentHeight) {
|
|
await sleepMs(BACKGROUND_SYNC_IDLE_MS);
|
|
continue;
|
|
}
|
|
const cache = await readTransactionIndexObject(walletToken);
|
|
|
|
const skip = normalizeOrderedTxids(cache).length;
|
|
const shouldContinue = () => shouldContinueBackgroundTransactionSync(syncId, walletToken);
|
|
const records = await invokeWalletRpcWithBackoff(
|
|
"Paged address history lookup",
|
|
() => invoke("paged_address_history", {
|
|
broadcastNode,
|
|
skip,
|
|
limit: BACKGROUND_HISTORY_PAGE_SIZE
|
|
}),
|
|
shouldContinue
|
|
);
|
|
|
|
if (!shouldContinueBackgroundTransactionSync(syncId, walletToken)) {
|
|
return;
|
|
}
|
|
|
|
if (!records.length) {
|
|
cache.address_history_last_checked_at = new Date().toISOString();
|
|
cache.address_history_synced_at = new Date().toISOString();
|
|
await writeTransactionCacheObject(cache, walletToken);
|
|
await sleepMs(BACKGROUND_SYNC_IDLE_MS);
|
|
continue;
|
|
}
|
|
|
|
for (const rpcRecord of records) {
|
|
if (!shouldContinueBackgroundTransactionSync(syncId, walletToken)) {
|
|
return;
|
|
}
|
|
|
|
const txid = rpcRecord?.txid;
|
|
if (!txid || cache.txid_to_file?.[txid]) {
|
|
continue;
|
|
}
|
|
|
|
let record = transactionRecordFromRpc(rpcRecord);
|
|
record = await hydrateLinkedTransactionsForRecord(
|
|
txid,
|
|
record,
|
|
invoke,
|
|
broadcastNode,
|
|
{ transactions: {} },
|
|
shouldContinue,
|
|
BACKGROUND_TX_FETCH_DELAY_MS
|
|
);
|
|
const fileName = addIndexTransaction(cache, txid, record.txtype);
|
|
await writeTransactionRecordFile(fileName, record, walletToken);
|
|
if (typeof syncAddressBookFromTransactionRecord === "function") {
|
|
await syncAddressBookFromTransactionRecord(txid, record);
|
|
}
|
|
}
|
|
|
|
cache.address_history_synced_at = new Date().toISOString();
|
|
await writeTransactionCacheObject(cache, walletToken);
|
|
if (typeof flushAddressBookWrite === "function") {
|
|
await flushAddressBookWrite();
|
|
}
|
|
failureCount = 0;
|
|
}
|
|
} catch (_) {
|
|
if (shouldContinueBackgroundTransactionSync(syncId, walletToken)) {
|
|
const backoff = Math.min(BACKGROUND_SYNC_IDLE_MS * 2 ** failureCount, 60000);
|
|
await sleepMs(backoff);
|
|
if (shouldContinueBackgroundTransactionSync(syncId, walletToken)) {
|
|
runBackgroundTransactionSync(syncId, failureCount + 1, walletToken);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|