869 lines
33 KiB
JavaScript
869 lines
33 KiB
JavaScript
import * as CardanoWasm from '@emurgo/cardano-serialization-lib-browser';
|
||
import * as CardanoLib from 'cardano-crypto.js';
|
||
|
||
|
||
class CardanoAPI {
|
||
constructor(cardanoscanApiKey = null) {
|
||
// GetBlock Ogmios JSON-RPC endpoint (for UTXOs and transactions only)
|
||
this.rpcUrl = "https://go.getblock.io/9260a43da7164b6fa58ac6a54fee2d21";
|
||
|
||
// CardanoScan API configuration
|
||
this.cardanoscanApiKey = cardanoscanApiKey;
|
||
this.cardanoscanBaseUrl = "https://api.cardanoscan.io/api/v1";
|
||
this.useCardanoScan = !!cardanoscanApiKey;
|
||
}
|
||
|
||
/**
|
||
* Convert Bech32 address to full hex bytes
|
||
* CardanoScan expects the FULL address bytes (including network tag),
|
||
* not just the payment credential hash
|
||
* @param {string} address - Bech32 address (addr1...)
|
||
* @returns {string} Full address in hex format (e.g., 0193a4ef...)
|
||
*/
|
||
addressToHex(address) {
|
||
try {
|
||
const CSL = CardanoWasm;
|
||
const addr = CSL.Address.from_bech32(address);
|
||
|
||
// Return the full address bytes as hex
|
||
// This includes the network tag (01 for mainnet base address)
|
||
// plus payment credential and staking credential
|
||
const addressBytes = addr.to_bytes();
|
||
const hexAddress = Buffer.from(addressBytes).toString('hex');
|
||
|
||
return hexAddress;
|
||
} catch (error) {
|
||
console.error('[CardanoAPI] Error converting address to hex:', error);
|
||
// If conversion fails, return the address as-is
|
||
return address;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convert Hex address to Bech32
|
||
* @param {string} hexAddress
|
||
* @returns {string} Bech32 address
|
||
*/
|
||
hexToAddress(hexAddress) {
|
||
try {
|
||
if (hexAddress.startsWith('addr')) return hexAddress;
|
||
|
||
const CSL = CardanoWasm;
|
||
const bytes = Buffer.from(hexAddress, 'hex');
|
||
const addr = CSL.Address.from_bytes(bytes);
|
||
return addr.to_bech32();
|
||
} catch (error) {
|
||
return hexAddress;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Call CardanoScan API
|
||
* @param {string} endpoint - API endpoint (e.g., '/address/balance')
|
||
* @param {object} params - Query parameters
|
||
*/
|
||
async callCardanoScan(endpoint, params = {}) {
|
||
if (!this.cardanoscanApiKey) {
|
||
throw new Error("CardanoScan API key not configured");
|
||
}
|
||
|
||
const url = new URL(this.cardanoscanBaseUrl + endpoint);
|
||
Object.keys(params).forEach(key => {
|
||
if (params[key] !== undefined && params[key] !== null) {
|
||
url.searchParams.append(key, params[key]);
|
||
}
|
||
});
|
||
|
||
try {
|
||
const response = await fetch(url.toString(), {
|
||
method: "GET",
|
||
headers: {
|
||
"apiKey": this.cardanoscanApiKey,
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`HTTP Error: ${response.status} ${response.statusText} - ${errorText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
throw new Error(`CardanoScan API Error: ${data.error}`);
|
||
}
|
||
|
||
return data;
|
||
} catch (error) {
|
||
console.error(`[CardanoScan] Error calling ${endpoint}:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generic JSON-RPC call to Ogmios
|
||
* @param {string} method - RPC method name
|
||
* @param {object} params - RPC parameters
|
||
*/
|
||
async callRpc(method, params = null) {
|
||
const payload = {
|
||
jsonrpc: "2.0",
|
||
method: method,
|
||
id: Date.now(),
|
||
};
|
||
if (params !== null && params !== undefined && Object.keys(params).length > 0) {
|
||
payload.params = params;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(this.rpcUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
console.error(`[CardanoAPI] HTTP ${response.status} response:`, errorText);
|
||
throw new Error(`HTTP Error: ${response.status} ${response.statusText} - ${errorText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
console.error(`[CardanoAPI] RPC Error:`, data.error);
|
||
throw new Error(`RPC Error: ${JSON.stringify(data.error)}`);
|
||
}
|
||
|
||
return data.result;
|
||
} catch (error) {
|
||
console.error(`[CardanoAPI] Error calling ${method}:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get balance for an address (in Lovelace)
|
||
* Uses CardanoScan API exclusively
|
||
*/
|
||
async getBalance(address) {
|
||
if (!this.useCardanoScan) {
|
||
throw new Error("CardanoScan API key not configured");
|
||
}
|
||
|
||
// Convert to hex to support all address types (Base, Enterprise, etc.)
|
||
const hexAddress = this.addressToHex(address);
|
||
|
||
const result = await this.callCardanoScan('/address/balance', { address: hexAddress });
|
||
return result.balance || "0";
|
||
}
|
||
|
||
/**
|
||
* Get transaction history for an address
|
||
* Uses CardanoScan API exclusively
|
||
* @param {string} address - Cardano address (Bech32 format)
|
||
* @param {number} pageNo - Page number (default: 1)
|
||
* @param {number} limit - Results per page (default: 20, max: 50)
|
||
* @param {string} order - Sort order: 'asc' or 'desc' (default: 'desc')
|
||
*/
|
||
async getHistory(address, pageNo = 1, limit = 20, order = 'desc') {
|
||
if (!this.useCardanoScan) {
|
||
throw new Error("CardanoScan API key not configured");
|
||
}
|
||
|
||
// Convert address to hex for transaction list endpoint
|
||
const hexAddress = this.addressToHex(address);
|
||
|
||
const result = await this.callCardanoScan('/transaction/list', {
|
||
address: hexAddress,
|
||
pageNo,
|
||
limit,
|
||
order
|
||
});
|
||
|
||
// Transform CardanoScan response to our format
|
||
const transactions = result.transactions || [];
|
||
return transactions.map(tx => ({
|
||
txHash: tx.hash,
|
||
blockHash: tx.blockHash,
|
||
fees: tx.fees,
|
||
slot: tx.slot,
|
||
epoch: tx.epoch,
|
||
blockHeight: tx.blockHeight,
|
||
absSlot: tx.absSlot,
|
||
timestamp: tx.timestamp,
|
||
index: tx.index,
|
||
inputs: tx.inputs || [],
|
||
outputs: tx.outputs || [],
|
||
netAmount: this.calculateNetAmount(tx, address)
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Get transaction details by hash with status (confirmed/pending/failed)
|
||
* Uses CardanoScan API exclusively with fallback for pending detection
|
||
* @param {string} hash - Transaction hash
|
||
* @returns {object} Transaction details with status
|
||
*/
|
||
async getTransaction(hash) {
|
||
if (!this.useCardanoScan) {
|
||
throw new Error("CardanoScan API key not configured");
|
||
}
|
||
|
||
try {
|
||
// Try to get transaction from CardanoScan (confirmed transactions)
|
||
const result = await this.callCardanoScan('/transaction', { hash });
|
||
|
||
// If we get here, transaction is confirmed on-chain
|
||
return {
|
||
...result,
|
||
status: 'confirmed',
|
||
statusLabel: 'Confirmed',
|
||
statusColor: '#28a745'
|
||
};
|
||
} catch (error) {
|
||
console.log(`[CardanoAPI] Transaction ${hash} not found in CardanoScan, checking status...`);
|
||
|
||
|
||
|
||
return {
|
||
hash: hash,
|
||
status: 'pending',
|
||
statusLabel: 'Pending or Failed',
|
||
statusColor: '#ffc107', // warning
|
||
message: 'Transaction not yet confirmed on-chain. It may still be pending in the mempool, or it may have failed/expired. Please check again in a few minutes.',
|
||
timestamp: null,
|
||
blockHeight: null,
|
||
inputs: [],
|
||
outputs: [],
|
||
fees: null,
|
||
error: error.message
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calculate net amount for an address in a transaction
|
||
* @param {object} tx - Transaction object from CardanoScan
|
||
* @param {string} address - Address to calculate for (Bech32 format)
|
||
* @returns {string} Net amount (positive for incoming, negative for outgoing)
|
||
*/
|
||
calculateNetAmount(tx, address) {
|
||
let totalIn = 0n;
|
||
let totalOut = 0n;
|
||
|
||
// Convert address to hex for comparison
|
||
const hexAddress = this.addressToHex(address);
|
||
|
||
// Sum inputs from this address
|
||
if (tx.inputs) {
|
||
for (const input of tx.inputs) {
|
||
// Compare both hex and bech32 formats
|
||
if (input.address === address || input.address === hexAddress) {
|
||
totalIn += BigInt(input.value || 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sum outputs to this address
|
||
if (tx.outputs) {
|
||
for (const output of tx.outputs) {
|
||
// Compare both hex and bech32 formats
|
||
if (output.address === address || output.address === hexAddress) {
|
||
totalOut += BigInt(output.value || 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
const net = totalOut - totalIn;
|
||
return net.toString();
|
||
}
|
||
|
||
/**
|
||
* Fetch UTXOs for an address (using Ogmios)
|
||
*/
|
||
async getUtxos(address) {
|
||
try {
|
||
const result = await this.callRpc("queryLedgerState/utxo", {
|
||
addresses: [address]
|
||
});
|
||
return this.parseUtxoResult(result);
|
||
} catch (e) {
|
||
console.warn("queryLedgerState/utxo failed, trying 'query' method...", e);
|
||
const result = await this.callRpc("query", {
|
||
query: "utxo",
|
||
arg: { addresses: [address] }
|
||
});
|
||
return this.parseUtxoResult(result);
|
||
}
|
||
}
|
||
|
||
parseUtxoResult(result) {
|
||
console.log("Parsing UTXO result:", JSON.stringify(result, null, 2));
|
||
|
||
const utxos = [];
|
||
|
||
if (Array.isArray(result)) {
|
||
for (const item of result) {
|
||
// GetBlock/Ogmios direct format
|
||
if (item.transaction && item.transaction.id !== undefined && item.index !== undefined) {
|
||
utxos.push({
|
||
txHash: item.transaction.id,
|
||
index: item.index,
|
||
value: item.value,
|
||
address: item.address
|
||
});
|
||
}
|
||
// Standard Ogmios array of pairs
|
||
else if (Array.isArray(item) && item.length === 2) {
|
||
const [input, output] = item;
|
||
utxos.push({
|
||
txHash: input.txId,
|
||
index: input.index,
|
||
value: output.value,
|
||
address: output.address
|
||
});
|
||
}
|
||
}
|
||
} else if (typeof result === 'object' && result !== null) {
|
||
//Object with "txId#index" keys
|
||
console.log("Received object result for UTXOs, parsing keys...");
|
||
|
||
for (const [key, output] of Object.entries(result)) {
|
||
const parts = key.split('#');
|
||
if (parts.length === 2) {
|
||
const [txHash, indexStr] = parts;
|
||
const index = parseInt(indexStr, 10);
|
||
|
||
utxos.push({
|
||
txHash: txHash,
|
||
index: index,
|
||
value: output.value,
|
||
address: output.address
|
||
});
|
||
}
|
||
}
|
||
|
||
console.log(`Parsed ${utxos.length} UTXOs from object format`);
|
||
}
|
||
|
||
console.log("Final parsed UTXOs:", utxos);
|
||
return utxos;
|
||
}
|
||
|
||
/**
|
||
* Get protocol parameters from Ogmios
|
||
*/
|
||
async getProtocolParameters() {
|
||
try {
|
||
// Try queryLedgerState/protocolParameters first
|
||
try {
|
||
const result = await this.callRpc("queryLedgerState/protocolParameters");
|
||
return result;
|
||
} catch (e) {
|
||
console.warn("queryLedgerState/protocolParameters failed, trying 'query' method...", e);
|
||
// Fallback to 'query' method
|
||
const result = await this.callRpc("query", {
|
||
query: "protocolParameters"
|
||
});
|
||
return result;
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to fetch protocol parameters:", e);
|
||
throw new Error("Could not fetch protocol parameters needed for transaction.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get current tip (latest block slot) from Ogmios
|
||
* Used for calculating transaction TTL
|
||
*/
|
||
async getCurrentSlot() {
|
||
try {
|
||
const tip = await this.callRpc("queryNetwork/tip");
|
||
// Handle different response formats
|
||
if (tip && tip.slot !== undefined) {
|
||
return tip.slot;
|
||
} else if (Array.isArray(tip) && tip.length > 0 && tip[0].slot !== undefined) {
|
||
return tip[0].slot;
|
||
}
|
||
throw new Error("Unable to parse current slot from tip response");
|
||
} catch (error) {
|
||
console.error("[CardanoAPI] Error fetching current slot:", error);
|
||
throw new Error("Failed to fetch current slot for TTL calculation");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Estimate transaction fee by building the transaction without submitting
|
||
* @param {string} senderAddress - Bech32 address
|
||
* @param {string} recipientAddress - Bech32 address
|
||
* @param {string|BigInt} amountLovelace - Amount in Lovelace
|
||
* @returns {object} Fee estimation details
|
||
*/
|
||
async estimateFee(senderAddress, recipientAddress, amountLovelace) {
|
||
try {
|
||
console.log("[CardanoAPI] Estimating transaction fee...");
|
||
|
||
// Fetch required data
|
||
const [protocolParams, utxos] = await Promise.all([
|
||
this.getProtocolParameters(),
|
||
this.getUtxos(senderAddress)
|
||
]);
|
||
|
||
if (utxos.length === 0) {
|
||
throw new Error("No UTXOs found. Balance is 0.");
|
||
}
|
||
|
||
// Calculate total balance
|
||
let totalBalance = 0n;
|
||
for (const utxo of utxos) {
|
||
if (utxo.value?.ada?.lovelace) {
|
||
totalBalance += BigInt(utxo.value.ada.lovelace);
|
||
}
|
||
}
|
||
|
||
// Parse protocol parameters
|
||
const minFeeA = protocolParams.minFeeCoefficient || 44;
|
||
const minFeeB = protocolParams.minFeeConstant?.ada?.lovelace || protocolParams.minFeeConstant || 155381;
|
||
|
||
// Calculate how many inputs we'll need
|
||
let inputsNeeded = 0;
|
||
let accumulatedInput = 0n;
|
||
const estimatedFeePerInput = 200000n; // Conservative estimate
|
||
const targetAmount = BigInt(amountLovelace) + estimatedFeePerInput;
|
||
|
||
for (const utxo of utxos) {
|
||
let utxoValue = 0n;
|
||
if (utxo.value?.ada?.lovelace) {
|
||
utxoValue = BigInt(utxo.value.ada.lovelace);
|
||
}
|
||
|
||
accumulatedInput += utxoValue;
|
||
inputsNeeded++;
|
||
|
||
if (accumulatedInput >= targetAmount) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Estimate transaction size based on inputs/outputs
|
||
// Formula: ~150 bytes base + ~150 bytes per input + ~50 bytes per output
|
||
const willHaveChange = (accumulatedInput - BigInt(amountLovelace)) >= 1000000n;
|
||
const outputCount = willHaveChange ? 2 : 1; // recipient + change (if any)
|
||
const estimatedSize = 150 + (inputsNeeded * 150) + (outputCount * 50);
|
||
|
||
// Calculate fee using Cardano formula: fee = a * size + b
|
||
const estimatedFee = BigInt(minFeeA) * BigInt(estimatedSize) + BigInt(minFeeB);
|
||
|
||
// Calculate change
|
||
const change = accumulatedInput - BigInt(amountLovelace) - estimatedFee;
|
||
|
||
return {
|
||
success: true,
|
||
fee: estimatedFee.toString(),
|
||
feeAda: (Number(estimatedFee) / 1000000).toFixed(6),
|
||
amount: amountLovelace.toString(),
|
||
amountAda: (Number(amountLovelace) / 1000000).toFixed(6),
|
||
totalCost: (BigInt(amountLovelace) + estimatedFee).toString(),
|
||
totalCostAda: (Number(BigInt(amountLovelace) + estimatedFee) / 1000000).toFixed(6),
|
||
change: change.toString(),
|
||
changeAda: (Number(change) / 1000000).toFixed(6),
|
||
balance: totalBalance.toString(),
|
||
balanceAda: (Number(totalBalance) / 1000000).toFixed(6),
|
||
inputsNeeded,
|
||
outputCount,
|
||
estimatedSize,
|
||
sufficientBalance: totalBalance >= (BigInt(amountLovelace) + estimatedFee)
|
||
};
|
||
} catch (error) {
|
||
console.error("[CardanoAPI] Fee estimation failed:", error);
|
||
return {
|
||
success: false,
|
||
error: error.message
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send ADA
|
||
* Uses TransactionBuilder for proper fee calculation and change handling
|
||
* @param {string} senderPrivKeyHex - Hex private key (extended or normal)
|
||
* @param {string} senderAddress - Bech32 address
|
||
* @param {string} recipientAddress - Bech32 address
|
||
* @param {string|BigInt} amountLovelace - Amount in Lovelace
|
||
* @returns {object} Transaction submission result with txId
|
||
*/
|
||
async sendAda(senderPrivKeyHex, senderAddress, recipientAddress, amountLovelace) {
|
||
console.log("[CardanoAPI] Initiating Send ADA transaction...");
|
||
console.log(` From: ${senderAddress}`);
|
||
console.log(` To: ${recipientAddress}`);
|
||
console.log(` Amount: ${amountLovelace} lovelace`);
|
||
|
||
const CSL = CardanoWasm;
|
||
if (!CSL) {
|
||
throw new Error("Cardano Serialization Library not loaded.");
|
||
}
|
||
|
||
try {
|
||
// Fetch required data in parallel
|
||
console.log("[CardanoAPI] Fetching protocol parameters, UTXOs, and current slot...");
|
||
const [protocolParams, utxos, currentSlot] = await Promise.all([
|
||
this.getProtocolParameters(),
|
||
this.getUtxos(senderAddress),
|
||
this.getCurrentSlot()
|
||
]);
|
||
|
||
if (utxos.length === 0) {
|
||
throw new Error("No UTXOs found. Your balance is 0 or address has no funds.");
|
||
}
|
||
|
||
console.log(`[CardanoAPI] Found ${utxos.length} UTXOs, current slot: ${currentSlot}`);
|
||
|
||
// Calculate total available balance
|
||
let totalBalance = 0n;
|
||
for (const utxo of utxos) {
|
||
if (utxo.value?.ada?.lovelace) {
|
||
totalBalance += BigInt(utxo.value.ada.lovelace);
|
||
} else if (utxo.value?.lovelace) {
|
||
totalBalance += BigInt(utxo.value.lovelace);
|
||
} else if (utxo.value?.coins) {
|
||
totalBalance += BigInt(utxo.value.coins);
|
||
}
|
||
}
|
||
|
||
console.log(`[CardanoAPI] Total balance: ${totalBalance} lovelace (${(Number(totalBalance) / 1000000).toFixed(2)} ADA)`);
|
||
|
||
// Estimate fee (conservative estimate: ~0.2 ADA)
|
||
const estimatedFee = 200000n;
|
||
const totalNeeded = BigInt(amountLovelace) + estimatedFee;
|
||
|
||
console.log(`[CardanoAPI] Amount to send: ${amountLovelace} lovelace (${(Number(amountLovelace) / 1000000).toFixed(2)} ADA)`);
|
||
console.log(`[CardanoAPI] Estimated fee: ${estimatedFee} lovelace (~0.2 ADA)`);
|
||
console.log(`[CardanoAPI] Total needed: ${totalNeeded} lovelace (${(Number(totalNeeded) / 1000000).toFixed(2)} ADA)`);
|
||
|
||
// Check if balance is sufficient
|
||
if (totalBalance < totalNeeded) {
|
||
const shortfall = totalNeeded - totalBalance;
|
||
throw new Error(
|
||
`Insufficient balance! ` +
|
||
`You have ${totalBalance} lovelace (${(Number(totalBalance) / 1000000).toFixed(2)} ADA), ` +
|
||
`but need ${totalNeeded} lovelace (${(Number(totalNeeded) / 1000000).toFixed(2)} ADA) ` +
|
||
`for ${amountLovelace} lovelace + ~${estimatedFee} lovelace fee. ` +
|
||
`You're short by ${shortfall} lovelace (${(Number(shortfall) / 1000000).toFixed(2)} ADA).`
|
||
);
|
||
}
|
||
|
||
console.log(`[CardanoAPI] ✅ Balance check passed`);
|
||
|
||
//Parse protocol parameters (handle nested Ogmios structure)
|
||
const minFeeA = protocolParams.minFeeCoefficient || 44;
|
||
const minFeeB = protocolParams.minFeeConstant?.ada?.lovelace ||
|
||
protocolParams.minFeeConstant || 155381;
|
||
const maxTxSize = protocolParams.maxTransactionSize?.bytes ||
|
||
protocolParams.maxTxSize || 16384;
|
||
const utxoCostPerByte = protocolParams.utxoCostPerByte?.ada?.lovelace ||
|
||
protocolParams.coinsPerUtxoByte || 4310;
|
||
const poolDeposit = protocolParams.stakePoolDeposit?.ada?.lovelace ||
|
||
protocolParams.poolDeposit || 500000000;
|
||
const keyDeposit = protocolParams.stakeCredentialDeposit?.ada?.lovelace ||
|
||
protocolParams.keyDeposit || 2000000;
|
||
|
||
console.log("[CardanoAPI] Protocol parameters:", {
|
||
minFeeA,
|
||
minFeeB,
|
||
maxTxSize,
|
||
utxoCostPerByte,
|
||
poolDeposit,
|
||
keyDeposit
|
||
});
|
||
|
||
// Configure TransactionBuilder
|
||
const txBuilderConfig = CSL.TransactionBuilderConfigBuilder.new()
|
||
.fee_algo(
|
||
CSL.LinearFee.new(
|
||
CSL.BigNum.from_str(minFeeA.toString()),
|
||
CSL.BigNum.from_str(minFeeB.toString())
|
||
)
|
||
)
|
||
.coins_per_utxo_byte(CSL.BigNum.from_str(utxoCostPerByte.toString()))
|
||
.pool_deposit(CSL.BigNum.from_str(poolDeposit.toString()))
|
||
.key_deposit(CSL.BigNum.from_str(keyDeposit.toString()))
|
||
.max_tx_size(maxTxSize)
|
||
.max_value_size(5000)
|
||
.build();
|
||
|
||
const txBuilder = CSL.TransactionBuilder.new(txBuilderConfig);
|
||
|
||
|
||
|
||
// Validate minimum UTXO value
|
||
const recipientAddr = CSL.Address.from_bech32(recipientAddress);
|
||
const outputValue = CSL.Value.new(CSL.BigNum.from_str(amountLovelace.toString()));
|
||
|
||
// Calculate approximate minimum ADA required for a standard output
|
||
// Formula: utxoCostPerByte × estimatedOutputSize
|
||
// Standard output (address + ADA value) ≈ 225 bytes
|
||
const estimatedOutputSize = 225;
|
||
const minAdaRequired = BigInt(utxoCostPerByte) * BigInt(estimatedOutputSize);
|
||
|
||
console.log(`[CardanoAPI] Minimum ADA required for output: ${minAdaRequired} lovelace (~${(Number(minAdaRequired) / 1000000).toFixed(2)} ADA)`);
|
||
|
||
// Validate amount meets minimum
|
||
if (BigInt(amountLovelace) < minAdaRequired) {
|
||
const minAdaInAda = (Number(minAdaRequired) / 1000000).toFixed(2);
|
||
const requestedInAda = (Number(amountLovelace) / 1000000).toFixed(2);
|
||
throw new Error(
|
||
`Amount too small! Cardano requires a minimum of ${minAdaRequired} lovelace (~${minAdaInAda} ADA) per UTXO. ` +
|
||
`You tried to send ${amountLovelace} lovelace (~${requestedInAda} ADA). ` +
|
||
`Please send at least ${minAdaInAda} ADA.`
|
||
);
|
||
}
|
||
|
||
console.log(`[CardanoAPI] ✅ Amount ${amountLovelace} lovelace meets minimum requirement`);
|
||
|
||
// Use TransactionOutputBuilder for better compatibility
|
||
let output;
|
||
try {
|
||
output = CSL.TransactionOutputBuilder.new()
|
||
.with_address(recipientAddr)
|
||
.next()
|
||
.with_value(outputValue)
|
||
.build();
|
||
console.log(`[CardanoAPI] Output created using TransactionOutputBuilder`);
|
||
} catch (e) {
|
||
console.log(`[CardanoAPI] Falling back to TransactionOutput.new()`);
|
||
output = CSL.TransactionOutput.new(recipientAddr, outputValue);
|
||
}
|
||
|
||
txBuilder.add_output(output);
|
||
console.log(`[CardanoAPI] Added output: ${amountLovelace} lovelace to ${recipientAddress}`);
|
||
|
||
// Add inputs (UTXOs) using TransactionUnspentOutputs
|
||
const txUnspentOutputs = CSL.TransactionUnspentOutputs.new();
|
||
|
||
for (const utxo of utxos) {
|
||
try {
|
||
// Parse UTXO amount
|
||
let lovelaceAmount = 0n;
|
||
if (utxo.value?.ada?.lovelace) {
|
||
lovelaceAmount = BigInt(utxo.value.ada.lovelace);
|
||
} else if (utxo.value?.lovelace) {
|
||
lovelaceAmount = BigInt(utxo.value.lovelace);
|
||
} else if (utxo.value?.coins) {
|
||
lovelaceAmount = BigInt(utxo.value.coins);
|
||
} else {
|
||
console.warn("[CardanoAPI] Skipping UTXO with unknown value format:", utxo);
|
||
continue;
|
||
}
|
||
|
||
// Create TransactionInput
|
||
const txHash = CSL.TransactionHash.from_bytes(
|
||
Buffer.from(utxo.txHash, "hex")
|
||
);
|
||
const txInput = CSL.TransactionInput.new(txHash, utxo.index);
|
||
|
||
// Create TransactionOutput for this UTXO
|
||
const utxoAddr = CSL.Address.from_bech32(utxo.address || senderAddress);
|
||
const utxoValue = CSL.Value.new(CSL.BigNum.from_str(lovelaceAmount.toString()));
|
||
|
||
// Use TransactionOutputBuilder for compatibility
|
||
let utxoOutput;
|
||
try {
|
||
utxoOutput = CSL.TransactionOutputBuilder.new()
|
||
.with_address(utxoAddr)
|
||
.next()
|
||
.with_value(utxoValue)
|
||
.build();
|
||
} catch (e) {
|
||
utxoOutput = CSL.TransactionOutput.new(utxoAddr, utxoValue);
|
||
}
|
||
|
||
// Create TransactionUnspentOutput
|
||
const txUnspentOutput = CSL.TransactionUnspentOutput.new(txInput, utxoOutput);
|
||
txUnspentOutputs.add(txUnspentOutput);
|
||
} catch (error) {
|
||
console.warn("[CardanoAPI] Error processing UTXO, skipping:", error, utxo);
|
||
}
|
||
}
|
||
|
||
if (txUnspentOutputs.len() === 0) {
|
||
throw new Error("No valid UTXOs could be processed");
|
||
}
|
||
|
||
console.log(`[CardanoAPI] Prepared ${txUnspentOutputs.len()} UTXOs for input selection`);
|
||
|
||
// Manual input selection for better control and correct fee calculation
|
||
const senderAddr = CSL.Address.from_bech32(senderAddress);
|
||
|
||
console.log(`[CardanoAPI] Using manual coin selection for accurate fee calculation...`);
|
||
|
||
// Derive public key and key hash once (reused for all inputs)
|
||
const privKey = CSL.PrivateKey.from_hex(senderPrivKeyHex);
|
||
const pubKey = privKey.to_public();
|
||
const keyHash = pubKey.hash();
|
||
|
||
// Add inputs manually until we have enough
|
||
let accumulatedValue = 0n;
|
||
const targetValue = BigInt(amountLovelace) + 300000n; // amount + estimated fee buffer
|
||
|
||
for (let i = 0; i < txUnspentOutputs.len(); i++) {
|
||
const utxo = txUnspentOutputs.get(i);
|
||
const utxoValue = BigInt(utxo.output().amount().coin().to_str());
|
||
|
||
// Use add_key_input with Ed25519KeyHash
|
||
txBuilder.add_key_input(
|
||
keyHash,
|
||
utxo.input(),
|
||
utxo.output().amount()
|
||
);
|
||
|
||
accumulatedValue += utxoValue;
|
||
console.log(`[CardanoAPI] Added input ${i + 1}: ${utxoValue} lovelace (total: ${accumulatedValue} lovelace)`);
|
||
|
||
// Stop when we have enough (don't add all UTXOs unnecessarily)
|
||
if (accumulatedValue >= targetValue) {
|
||
console.log(`[CardanoAPI] ✅ Sufficient inputs added (${i + 1} UTXO${i > 0 ? 's' : ''})`);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Add change output automatically
|
||
console.log(`[CardanoAPI] Adding change output...`);
|
||
|
||
// const senderAddr = CSL.Address.from_bech32(senderAddress); // Already declared above
|
||
|
||
// Ensure currentSlot is a number
|
||
const slotNumber = Number(currentSlot);
|
||
if (isNaN(slotNumber) || slotNumber <= 0) {
|
||
throw new Error(`Invalid current slot: ${currentSlot}`);
|
||
}
|
||
|
||
// Calculate TTL (time-to-live) - 2 hours from current slot
|
||
const ttl = slotNumber + 7200; // 2 hours = 7200 slots (1 slot = 1 second)
|
||
console.log(`[CardanoAPI] Calculated TTL: ${ttl} (current slot: ${slotNumber} + 7200)`);
|
||
|
||
// Set TTL on transaction builder
|
||
// In cardano-serialization-lib v15, use set_ttl_bignum for reliability
|
||
try {
|
||
// Try set_ttl_bignum first (v15+ recommended method)
|
||
if (typeof txBuilder.set_ttl_bignum === 'function') {
|
||
txBuilder.set_ttl_bignum(CSL.BigNum.from_str(ttl.toString()));
|
||
console.log(`[CardanoAPI] ✅ TTL set using set_ttl_bignum: ${ttl}`);
|
||
} else {
|
||
// Fallback to set_ttl (older versions)
|
||
txBuilder.set_ttl(CSL.BigNum.from_str(ttl.toString()));
|
||
console.log(`[CardanoAPI] ✅ TTL set using set_ttl: ${ttl}`);
|
||
}
|
||
} catch (e) {
|
||
console.error(`[CardanoAPI] Failed to set TTL:`, e);
|
||
throw new Error(`Cannot set TTL on TransactionBuilder: ${e.message}`);
|
||
}
|
||
|
||
// This calculates the fee and adds a change output if the remainder is sufficient
|
||
const changeAdded = txBuilder.add_change_if_needed(senderAddr);
|
||
console.log(`[CardanoAPI] Change added: ${changeAdded}`);
|
||
|
||
// Build the transaction body
|
||
// Note: In v15, TransactionBody may not have a ttl() getter method,
|
||
// but the TTL is properly set internally and will be included in the serialized transaction
|
||
const txBody = txBuilder.build();
|
||
|
||
console.log(`[CardanoAPI] ✅ Transaction body built successfully`);
|
||
console.log(` Fee: ${txBody.fee().to_str()} lovelace`);
|
||
|
||
//Sign the transaction
|
||
// Create the transaction hash for signing
|
||
console.log("[CardanoAPI] Creating transaction hash for signing...");
|
||
|
||
let txHash;
|
||
try {
|
||
// Try different methods to get the transaction hash
|
||
if (typeof txBody.to_hash === 'function') {
|
||
txHash = txBody.to_hash();
|
||
console.log("[CardanoAPI] Hash created using txBody.to_hash()");
|
||
} else if (typeof CSL.hash_transaction === 'function') {
|
||
// Use hash_transaction if available
|
||
txHash = CSL.hash_transaction(txBody);
|
||
console.log("[CardanoAPI] Hash created using hash_transaction()");
|
||
} else {
|
||
// Manually create hash from transaction body bytes
|
||
console.log("[CardanoAPI] Using manual hash creation from body bytes");
|
||
|
||
const bodyBytes = txBody.to_bytes();
|
||
console.log(`[CardanoAPI] Transaction body size: ${bodyBytes.length} bytes`);
|
||
|
||
|
||
console.log("[CardanoAPI] Computing Blake2b-256 hash using CardanoLib...");
|
||
const bodyBuffer = Buffer.from(bodyBytes);
|
||
const hashBytes = CardanoLib.blake2b(bodyBuffer, 32);
|
||
txHash = CSL.TransactionHash.from_bytes(hashBytes);
|
||
console.log("[CardanoAPI] ✅ Transaction hash created successfully using Blake2b-256");
|
||
}
|
||
} catch (e) {
|
||
console.error("[CardanoAPI] Failed to create transaction hash:", e);
|
||
throw new Error(`Cannot create transaction hash: ${e.message}`);
|
||
}
|
||
|
||
console.log("[CardanoAPI] Signing transaction with private key...");
|
||
|
||
const vkeyWitnesses = CSL.Vkeywitnesses.new();
|
||
let vkeyWitness;
|
||
|
||
try {
|
||
vkeyWitness = CSL.make_vkey_witness(txHash, privKey);
|
||
console.log("[CardanoAPI] ✅ Transaction signed successfully");
|
||
} catch (e) {
|
||
console.error("[CardanoAPI] Signing failed:", e);
|
||
throw new Error(`Failed to sign transaction: ${e.message}`);
|
||
}
|
||
|
||
vkeyWitnesses.add(vkeyWitness);
|
||
|
||
const witnessSet = CSL.TransactionWitnessSet.new();
|
||
witnessSet.set_vkeys(vkeyWitnesses);
|
||
|
||
console.log("[CardanoAPI] Transaction signed successfully");
|
||
|
||
// Construct final transaction
|
||
const transaction = CSL.Transaction.new(
|
||
txBody,
|
||
witnessSet,
|
||
undefined // No auxiliary data (metadata)
|
||
);
|
||
|
||
// Serialize to CBOR hex
|
||
const signedTxBytes = transaction.to_bytes();
|
||
const signedTxHex = Buffer.from(signedTxBytes).toString("hex");
|
||
|
||
console.log(`[CardanoAPI] Transaction serialized (${signedTxBytes.length} bytes)`);
|
||
console.log(`[CardanoAPI] Transaction CBOR (first 100 chars): ${signedTxHex.substring(0, 100)}...`);
|
||
|
||
// Submit transaction to network via Ogmios
|
||
console.log("[CardanoAPI] Submitting transaction to network...");
|
||
const submitResult = await this.callRpc("submitTransaction", {
|
||
transaction: { cbor: signedTxHex }
|
||
});
|
||
|
||
console.log("[CardanoAPI] Transaction submitted successfully!");
|
||
console.log(" Result:", submitResult);
|
||
|
||
// Return transaction details
|
||
return {
|
||
success: true,
|
||
txId: submitResult?.transaction?.id || "unknown",
|
||
txHash: Buffer.from(txHash.to_bytes()).toString("hex"),
|
||
fee: txBody.fee().to_str(),
|
||
submitResult: submitResult
|
||
};
|
||
|
||
} catch (error) {
|
||
console.error("[CardanoAPI] Error in sendAda:", error);
|
||
throw new Error(`Failed to send ADA: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
export default CardanoAPI; |