Extracts pure tx helpers, shared types, endpoint config, and the Ogmios provider. Subpath exports (./tx vs ./provider) let the forthcoming signer import pure helpers while being structurally unable to reach the network layer.
553 lines
18 KiB
TypeScript
553 lines
18 KiB
TypeScript
/**
|
|
* Custom Lucid Provider backed by Ogmios HTTP JSON-RPC + Vector submit-api.
|
|
*
|
|
* Implements the @lucid-evolution/lucid Provider interface so we can use Lucid's
|
|
* transaction building/signing without depending on Blockfrost.
|
|
*/
|
|
import fetch from 'cross-fetch';
|
|
import type {
|
|
Provider,
|
|
ProtocolParameters,
|
|
UTxO,
|
|
OutRef,
|
|
Credential,
|
|
Delegation,
|
|
EvalRedeemer,
|
|
} from '@lucid-evolution/lucid';
|
|
|
|
interface OgmiosProviderConfig {
|
|
ogmiosUrl: string;
|
|
submitUrl: string;
|
|
koiosUrl?: string;
|
|
}
|
|
|
|
export class OgmiosProvider implements Provider {
|
|
private ogmiosUrl: string;
|
|
private submitUrl: string;
|
|
private koiosUrl: string | undefined;
|
|
|
|
constructor(config: OgmiosProviderConfig) {
|
|
// Strip trailing slashes for consistent URL building
|
|
this.ogmiosUrl = config.ogmiosUrl.replace(/\/+$/, '');
|
|
this.submitUrl = config.submitUrl.replace(/\/+$/, '');
|
|
this.koiosUrl = config.koiosUrl?.replace(/\/+$/, '');
|
|
}
|
|
|
|
/**
|
|
* Send a JSON-RPC request to Ogmios
|
|
*/
|
|
private async rpc(method: string, params?: unknown): Promise<any> {
|
|
const response = await fetch(this.ogmiosUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
method,
|
|
params: params || {},
|
|
id: null,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Ogmios RPC error (${response.status}): ${text}`);
|
|
}
|
|
|
|
const json = await response.json();
|
|
if (json.error) {
|
|
throw new Error(`Ogmios RPC error: ${JSON.stringify(json.error)}`);
|
|
}
|
|
|
|
return json.result;
|
|
}
|
|
|
|
async getProtocolParameters(): Promise<ProtocolParameters> {
|
|
const result = await this.rpc('queryLedgerState/protocolParameters');
|
|
|
|
try {
|
|
// Map Ogmios v6 response to Lucid's ProtocolParameters format
|
|
// Defaults are sourced from the Vector public testnet (2026-03-31)
|
|
return {
|
|
minFeeA: result.minFeeCoefficient ?? 45,
|
|
minFeeB: result.minFeeConstant?.ada?.lovelace
|
|
?? result.minFeeConstant?.lovelace
|
|
?? (typeof result.minFeeConstant === 'number' ? result.minFeeConstant : 156253),
|
|
maxTxSize: result.maxTransactionSize?.bytes ?? result.maxTransactionSize ?? 16384,
|
|
maxValSize: result.maxValueSize?.bytes ?? result.maxValueSize ?? 5000,
|
|
keyDeposit: BigInt(result.stakeCredentialDeposit?.ada?.lovelace ?? result.stakeCredentialDeposit ?? 500000000),
|
|
poolDeposit: BigInt(result.stakePoolDeposit?.ada?.lovelace ?? result.stakePoolDeposit ?? 5000000000000),
|
|
priceMem: result.scriptExecutionPrices?.memory
|
|
? this.parseFraction(result.scriptExecutionPrices.memory)
|
|
: (result.prices?.memory ?? 0.0577),
|
|
priceStep: result.scriptExecutionPrices?.cpu
|
|
? this.parseFraction(result.scriptExecutionPrices.cpu)
|
|
: (result.prices?.steps ?? 0.0000721),
|
|
maxTxExMem: BigInt(result.maxExecutionUnitsPerTransaction?.memory ?? 16000000),
|
|
maxTxExSteps: BigInt(result.maxExecutionUnitsPerTransaction?.cpu ?? 10000000000),
|
|
coinsPerUtxoByte: BigInt(result.minUtxoDepositCoefficient ?? result.coinsPerUtxoByte ?? 4310),
|
|
collateralPercentage: result.collateralPercentage ?? 150,
|
|
maxCollateralInputs: result.maxCollateralInputs ?? 3,
|
|
costModels: this.parseCostModels(result.plutusCostModels ?? result.costModels ?? {}),
|
|
minFeeRefScriptCostPerByte: result.minFeeReferenceScripts?.base ?? 15,
|
|
drepDeposit: BigInt(result.delegateRepresentativeDeposit?.ada?.lovelace ?? 1000000000000),
|
|
govActionDeposit: BigInt(result.governanceActionDeposit?.ada?.lovelace ?? 1000000000),
|
|
} as ProtocolParameters;
|
|
} catch (err) {
|
|
console.error('[OgmiosProvider] Protocol parameter mapping failed:', err);
|
|
console.error('[OgmiosProvider] Raw Ogmios result:', JSON.stringify(result, null, 2));
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private parseFraction(value: string | number): number {
|
|
if (typeof value === 'number') return value;
|
|
const parts = value.split('/');
|
|
if (parts.length === 2) {
|
|
return Number(parts[0]) / Number(parts[1]);
|
|
}
|
|
return parseFloat(value);
|
|
}
|
|
|
|
private parseCostModels(raw: any): any {
|
|
const costModels: any = {};
|
|
if (raw['plutus:v1']) {
|
|
costModels.PlutusV1 = raw['plutus:v1'];
|
|
}
|
|
if (raw['plutus:v2']) {
|
|
costModels.PlutusV2 = raw['plutus:v2'];
|
|
}
|
|
if (raw['plutus:v3']) {
|
|
costModels.PlutusV3 = raw['plutus:v3'];
|
|
}
|
|
// Fallback: if keys are already PlutusV1/V2 format
|
|
if (raw.PlutusV1) costModels.PlutusV1 = raw.PlutusV1;
|
|
if (raw.PlutusV2) costModels.PlutusV2 = raw.PlutusV2;
|
|
if (raw.PlutusV3) costModels.PlutusV3 = raw.PlutusV3;
|
|
return costModels;
|
|
}
|
|
|
|
async getUtxos(addressOrCredential: string | Credential): Promise<UTxO[]> {
|
|
if (typeof addressOrCredential !== 'string') {
|
|
throw new Error(
|
|
'Credential-based UTxO lookup is not supported by Ogmios. ' +
|
|
'Please provide a bech32 address string.'
|
|
);
|
|
}
|
|
|
|
const result = await this.rpc('queryLedgerState/utxo', { addresses: [addressOrCredential] });
|
|
return this.ogmiosUtxosToLucid(result);
|
|
}
|
|
|
|
async getUtxosWithUnit(addressOrCredential: string | Credential, unit: string): Promise<UTxO[]> {
|
|
const utxos = await this.getUtxos(addressOrCredential);
|
|
return utxos.filter((utxo) => utxo.assets[unit]);
|
|
}
|
|
|
|
async getUtxoByUnit(unit: string): Promise<UTxO> {
|
|
const policyId = unit.slice(0, 56);
|
|
const assetName = unit.slice(56);
|
|
|
|
// Try Koios indexer first (fastest path)
|
|
if (this.koiosUrl) {
|
|
try {
|
|
const response = await fetch(`${this.koiosUrl}/api/v1/asset_utxos`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ _asset_list: [[policyId, assetName]] }),
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.length > 0) {
|
|
return this.koiosUtxoToLucid(data[0]);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Koios unreachable - caller can fall back to scanning UTxOs
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Asset ${policyId.slice(0, 8)}...${assetName ? assetName.slice(0, 8) + '...' : '(empty)'} not found. ` +
|
|
`${this.koiosUrl ? 'Koios returned no results.' : 'No Koios indexer configured.'} ` +
|
|
`The asset may not exist on-chain yet (wait for TX confirmation) or may have been burned.`
|
|
);
|
|
}
|
|
|
|
async getNetworkTip(): Promise<{ slot: number; hash: string }> {
|
|
const result = await this.rpc('queryNetwork/tip');
|
|
return {
|
|
slot: result.slot ?? 0,
|
|
hash: result.id ?? '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Query the genesis system start time from Ogmios.
|
|
* Returns POSIX milliseconds (e.g. 1756485600000 for Vector mainnet).
|
|
*/
|
|
async getSystemStartMs(): Promise<number> {
|
|
const result = await this.rpc('queryNetwork/startTime');
|
|
// Result is an ISO 8601 string, e.g. "2025-08-29T16:40:00Z"
|
|
return new Date(result).getTime();
|
|
}
|
|
|
|
async getUtxosByOutRef(outRefs: OutRef[]): Promise<UTxO[]> {
|
|
const outputReferences = outRefs.map((ref) => ({
|
|
transaction: { id: ref.txHash },
|
|
index: ref.outputIndex,
|
|
}));
|
|
|
|
const result = await this.rpc('queryLedgerState/utxo', { outputReferences });
|
|
return this.ogmiosUtxosToLucid(result);
|
|
}
|
|
|
|
async getDelegation(rewardAddress: string): Promise<Delegation> {
|
|
// Try Koios first if available
|
|
if (this.koiosUrl) {
|
|
try {
|
|
const response = await fetch(`${this.koiosUrl}/api/v1/account_info`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ _stake_addresses: [rewardAddress] }),
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.length > 0) {
|
|
return {
|
|
poolId: data[0].delegated_pool || null,
|
|
rewards: BigInt(data[0].rewards_available || 0),
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall through to default
|
|
}
|
|
}
|
|
return { poolId: null, rewards: 0n };
|
|
}
|
|
|
|
async getDatum(datumHash: string): Promise<string> {
|
|
// Try Koios if available
|
|
if (this.koiosUrl) {
|
|
try {
|
|
const response = await fetch(`${this.koiosUrl}/api/v1/datum_info`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ _datum_hashes: [datumHash] }),
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.length > 0 && data[0].bytes) {
|
|
return data[0].bytes;
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
throw new Error(`Datum not found for hash: ${datumHash}. Datum lookups require Koios indexer.`);
|
|
}
|
|
|
|
async awaitTx(txHash: string, checkInterval: number = 3000): Promise<boolean> {
|
|
// Poll for tx confirmation
|
|
const maxAttempts = 60; // ~3 minutes at 3s intervals
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
|
|
|
// Try Koios tx_status if available
|
|
if (this.koiosUrl) {
|
|
try {
|
|
const response = await fetch(`${this.koiosUrl}/api/v1/tx_status`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ _tx_hashes: [txHash] }),
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.length > 0 && data[0].num_confirmations > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
} catch {
|
|
// Continue polling
|
|
}
|
|
} else {
|
|
// Without Koios, try querying the UTxO by outRef
|
|
try {
|
|
const utxos = await this.getUtxosByOutRef([{ txHash, outputIndex: 0 }]);
|
|
if (utxos.length > 0) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
// Not yet confirmed, continue
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Evaluate a transaction (lucid-evolution Provider interface).
|
|
* Returns execution units for script validation as EvalRedeemer[].
|
|
*/
|
|
async evaluateTx(tx: string, additionalUTxOs?: UTxO[]): Promise<EvalRedeemer[]> {
|
|
const result = await this.rpc('evaluateTransaction', {
|
|
transaction: { cbor: tx },
|
|
});
|
|
// Parse result to EvalRedeemer[] format
|
|
if (Array.isArray(result)) {
|
|
return result.map((item: any) => ({
|
|
ex_units: {
|
|
mem: item.budget?.memory ?? 0,
|
|
steps: item.budget?.cpu ?? 0,
|
|
},
|
|
redeemer_index: item.validator?.index ?? 0,
|
|
redeemer_tag: item.validator?.purpose ?? 'spend',
|
|
}));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Evaluate a transaction without submitting (dry run).
|
|
* Backward-compatible alias - returns raw Ogmios response.
|
|
*/
|
|
async evaluateTransaction(txCborHex: string): Promise<any> {
|
|
return this.rpc('evaluateTransaction', {
|
|
transaction: { cbor: txCborHex },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get transaction history for an address via Koios.
|
|
*/
|
|
async getTransactionHistory(
|
|
address: string,
|
|
offset: number = 0,
|
|
limit: number = 20,
|
|
): Promise<any[]> {
|
|
if (!this.koiosUrl) {
|
|
throw new Error(
|
|
'Koios URL is not configured. Transaction history requires Koios indexer. ' +
|
|
'Set VECTOR_KOIOS_URL in your environment.'
|
|
);
|
|
}
|
|
|
|
// Step 1: Get tx hashes for the address
|
|
const txListResponse = await fetch(`${this.koiosUrl}/api/v1/address_txs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ _addresses: [address], _after_block_height: 0 }),
|
|
});
|
|
|
|
if (!txListResponse.ok) {
|
|
const text = await txListResponse.text();
|
|
throw new Error(`Koios address_txs query failed (${txListResponse.status}): ${text}`);
|
|
}
|
|
|
|
const txList: any[] = await txListResponse.json();
|
|
|
|
// Apply pagination
|
|
const paginatedTxs = txList.slice(offset, offset + limit);
|
|
|
|
if (paginatedTxs.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Step 2: Fetch full tx details
|
|
const txHashes = paginatedTxs.map((tx: any) => tx.tx_hash);
|
|
const txInfoResponse = await fetch(`${this.koiosUrl}/api/v1/tx_info`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ _tx_hashes: txHashes }),
|
|
});
|
|
|
|
if (!txInfoResponse.ok) {
|
|
// Fall back to basic tx list without details
|
|
return paginatedTxs.map((tx: any) => ({
|
|
txHash: tx.tx_hash,
|
|
blockHeight: tx.block_height || 0,
|
|
blockTime: tx.block_time
|
|
? new Date(tx.block_time * 1000).toISOString()
|
|
: '',
|
|
fee: '0',
|
|
}));
|
|
}
|
|
|
|
const txInfos: any[] = await txInfoResponse.json();
|
|
|
|
return txInfos.map((info: any) => ({
|
|
txHash: info.tx_hash,
|
|
blockHeight: info.block_height || 0,
|
|
blockTime: info.tx_timestamp
|
|
? new Date(info.tx_timestamp * 1000).toISOString()
|
|
: info.block_time
|
|
? new Date(info.block_time * 1000).toISOString()
|
|
: '',
|
|
fee: info.fee || '0',
|
|
inputs: (info.inputs || []).map((inp: any) => ({
|
|
address: inp.payment_addr?.bech32 || inp.address || '',
|
|
amount: inp.value || '0',
|
|
})),
|
|
outputs: (info.outputs || []).map((out: any) => ({
|
|
address: out.payment_addr?.bech32 || out.address || '',
|
|
amount: out.value || '0',
|
|
})),
|
|
}));
|
|
}
|
|
|
|
async submitTx(tx: string): Promise<string> {
|
|
// tx is a hex-encoded CBOR transaction
|
|
// submit-api expects raw CBOR bytes with Content-Type: application/cbor
|
|
const bytes = Buffer.from(tx, 'hex');
|
|
|
|
const response = await fetch(this.submitUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/cbor' },
|
|
body: bytes,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Transaction submission failed (${response.status}): ${errorText}`);
|
|
}
|
|
|
|
const result = await response.text();
|
|
// submit-api returns the tx hash as a JSON string
|
|
try {
|
|
const parsed = JSON.parse(result);
|
|
return typeof parsed === 'string' ? parsed : parsed.txId || parsed.txHash || tx.slice(0, 64);
|
|
} catch {
|
|
// If response is plain text tx hash
|
|
return result.replace(/"/g, '').trim();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert Ogmios UTxO response format to Lucid UTxO format
|
|
*/
|
|
private ogmiosUtxosToLucid(ogmiosUtxos: any[]): UTxO[] {
|
|
if (!Array.isArray(ogmiosUtxos)) return [];
|
|
|
|
return ogmiosUtxos.map((utxo) => {
|
|
const txHash = utxo.transaction?.id ?? utxo.txId;
|
|
const outputIndex = utxo.index ?? utxo.outputIndex;
|
|
const output = utxo.output ?? utxo;
|
|
|
|
// Parse assets
|
|
const assets: Record<string, bigint> = {};
|
|
|
|
// Handle different Ogmios response formats for value
|
|
const value = output.value ?? output.amount;
|
|
|
|
if (typeof value === 'object' && value !== null) {
|
|
// Ogmios v6 format: { ada: { lovelace: N }, policyId: { assetName: N } }
|
|
if (value.ada?.lovelace !== undefined) {
|
|
assets['lovelace'] = BigInt(value.ada.lovelace);
|
|
} else if (value.coins !== undefined) {
|
|
// Older format
|
|
assets['lovelace'] = BigInt(value.coins);
|
|
} else if (typeof value === 'bigint' || typeof value === 'number') {
|
|
assets['lovelace'] = BigInt(value);
|
|
}
|
|
|
|
// Parse native assets
|
|
for (const [key, val] of Object.entries(value)) {
|
|
if (key === 'ada' || key === 'coins' || key === 'lovelace') continue;
|
|
if (typeof val === 'object' && val !== null) {
|
|
// key is policy ID, val is { assetName: quantity }
|
|
for (const [assetName, quantity] of Object.entries(val as Record<string, any>)) {
|
|
const unit = `${key}${assetName === '' ? '' : assetName}`;
|
|
assets[unit] = BigInt(quantity);
|
|
}
|
|
}
|
|
}
|
|
} else if (typeof value === 'number' || typeof value === 'string') {
|
|
assets['lovelace'] = BigInt(value);
|
|
}
|
|
|
|
// Handle address
|
|
const address = output.address ?? '';
|
|
|
|
// Handle datum
|
|
let datumHash: string | null = null;
|
|
let datum: string | null = null;
|
|
|
|
if (output.datumHash) {
|
|
datumHash = output.datumHash;
|
|
}
|
|
if (output.datum) {
|
|
if (typeof output.datum === 'string') {
|
|
datum = output.datum;
|
|
} else if (output.datum.hash) {
|
|
datumHash = output.datum.hash;
|
|
} else if (output.datum.value || output.datum.bytes) {
|
|
datum = output.datum.value || output.datum.bytes;
|
|
}
|
|
}
|
|
|
|
// Handle script reference
|
|
let scriptRef: any = null;
|
|
if (output.script) {
|
|
const script = output.script;
|
|
// Ogmios v6 format: { language: "plutus:v3", cbor: "..." }
|
|
if (script.language && script.cbor) {
|
|
const langMap: Record<string, string> = {
|
|
'plutus:v1': 'PlutusV1',
|
|
'plutus:v2': 'PlutusV2',
|
|
'plutus:v3': 'PlutusV3',
|
|
};
|
|
const lucidType = langMap[script.language];
|
|
if (lucidType) {
|
|
scriptRef = { type: lucidType, script: script.cbor };
|
|
}
|
|
// Legacy format: { "plutus:v2": "..." }
|
|
} else if (script['plutus:v2']) {
|
|
scriptRef = { type: 'PlutusV2', script: script['plutus:v2'] };
|
|
} else if (script['plutus:v1']) {
|
|
scriptRef = { type: 'PlutusV1', script: script['plutus:v1'] };
|
|
} else if (script['plutus:v3']) {
|
|
scriptRef = { type: 'PlutusV3', script: script['plutus:v3'] };
|
|
} else if (script.native) {
|
|
scriptRef = { type: 'Native', script: JSON.stringify(script.native) };
|
|
}
|
|
}
|
|
|
|
return {
|
|
txHash,
|
|
outputIndex,
|
|
assets,
|
|
address,
|
|
datumHash,
|
|
datum,
|
|
scriptRef,
|
|
} as UTxO;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convert a Koios UTxO response to Lucid format
|
|
*/
|
|
private koiosUtxoToLucid(koiosUtxo: any): UTxO {
|
|
const assets: Record<string, bigint> = {};
|
|
assets['lovelace'] = BigInt(koiosUtxo.value || 0);
|
|
|
|
if (koiosUtxo.asset_list) {
|
|
for (const asset of koiosUtxo.asset_list) {
|
|
const unit = `${asset.policy_id}${asset.asset_name || ''}`;
|
|
assets[unit] = BigInt(asset.quantity || 0);
|
|
}
|
|
}
|
|
|
|
return {
|
|
txHash: koiosUtxo.tx_hash,
|
|
outputIndex: koiosUtxo.tx_index,
|
|
assets,
|
|
address: koiosUtxo.address,
|
|
datumHash: koiosUtxo.datum_hash || null,
|
|
datum: koiosUtxo.inline_datum?.bytes || null,
|
|
scriptRef: null,
|
|
} as UTxO;
|
|
}
|
|
}
|