diff --git a/packages/core/src/Cardano/Address/PaymentAddress.ts b/packages/core/src/Cardano/Address/PaymentAddress.ts index b5eec33f790..b697246ca26 100644 --- a/packages/core/src/Cardano/Address/PaymentAddress.ts +++ b/packages/core/src/Cardano/Address/PaymentAddress.ts @@ -7,7 +7,7 @@ import { assertIsBech32WithPrefix, assertIsHexString } from '@cardano-sdk/util'; -import { HydratedTx, HydratedTxIn, TxIn, TxOut } from '../types'; +import { HydratedTx, HydratedTxIn, Tx, TxIn, TxOut } from '../types'; import { NetworkId } from '../ChainId'; import { RewardAccount } from './RewardAccount'; @@ -68,11 +68,15 @@ export const isAddressWithin = export const inputsWithAddresses = (tx: HydratedTx, ownAddresses: PaymentAddress[]): HydratedTxIn[] => tx.body.inputs.filter(isAddressWithin(ownAddresses)); +export type ResolveOptions = { + hints: Tx[]; +}; + /** * @param txIn transaction input to resolve associated txOut from * @returns txOut */ -export type ResolveInput = (txIn: TxIn) => Promise; +export type ResolveInput = (txIn: TxIn, options?: ResolveOptions) => Promise; export interface InputResolver { resolveInput: ResolveInput; diff --git a/packages/wallet/src/services/WalletUtil.ts b/packages/wallet/src/services/WalletUtil.ts index c914497356b..136b57715e7 100644 --- a/packages/wallet/src/services/WalletUtil.ts +++ b/packages/wallet/src/services/WalletUtil.ts @@ -1,6 +1,6 @@ /* eslint-disable no-bitwise */ import * as Crypto from '@cardano-sdk/crypto'; -import { Cardano } from '@cardano-sdk/core'; +import { Cardano, ChainHistoryProvider } from '@cardano-sdk/core'; import { GroupedAddress, util as KeyManagementUtil } from '@cardano-sdk/key-management'; import { Observable, firstValueFrom } from 'rxjs'; import { ObservableWallet, ScriptAddress, isScriptAddress } from '../types'; @@ -23,11 +23,79 @@ export interface WalletOutputValidatorContext { export type WalletUtilContext = WalletOutputValidatorContext & InputResolverContext; export const createInputResolver = ({ utxo }: InputResolverContext): Cardano.InputResolver => ({ - async resolveInput(input: Cardano.TxIn) { + async resolveInput(input: Cardano.TxIn, options?: Cardano.ResolveOptions) { const utxoAvailable = await firstValueFrom(utxo.available$); const availableUtxo = utxoAvailable?.find(([txIn]) => txInEquals(txIn, input)); - if (!availableUtxo) return null; - return availableUtxo[1]; + + if (availableUtxo) return availableUtxo[1]; + + if (options?.hints) { + const tx = options?.hints.find((hint) => hint.id === input.txId); + + if (tx && tx.body.outputs.length > input.index) { + return tx.body.outputs[input.index]; + } + } + return null; + } +}); + +/** + * Creates an input resolver that fetch transaction inputs from the backend. + * + * This function tries to fetch the transaction from the backend using a `ChainHistoryProvider`. It + * also caches fetched transactions to optimize subsequent input resolutions. + * + * @param provider The backend provider used to fetch transactions by their hashes if + * they are not found by the inputResolver. + * @returns An input resolver that can fetch unresolved inputs from the backend. + */ +export const createBackendInputResolver = (provider: ChainHistoryProvider): Cardano.InputResolver => { + const txCache = new Map(); + + const fetchAndCacheTransaction = async (txId: Cardano.TransactionId): Promise => { + if (txCache.has(txId)) { + return txCache.get(txId)!; + } + + const txs = await provider.transactionsByHashes({ ids: [txId] }); + if (txs.length > 0) { + txCache.set(txId, txs[0]); + return txs[0]; + } + + return null; + }; + + return { + async resolveInput(input: Cardano.TxIn, options?: Cardano.ResolveOptions) { + // Add hints to the cache + if (options?.hints) { + for (const hint of options.hints) { + txCache.set(hint.id, hint); + } + } + + const tx = await fetchAndCacheTransaction(input.txId); + if (!tx) return null; + + return tx.body.outputs.length > input.index ? tx.body.outputs[input.index] : null; + } + }; +}; + +/** + * Combines multiple input resolvers into a single resolver. + * + * @param resolvers The input resolvers to combine. + */ +export const combineInputResolvers = (...resolvers: Cardano.InputResolver[]): Cardano.InputResolver => ({ + async resolveInput(txIn: Cardano.TxIn, options?: Cardano.ResolveOptions) { + for (const resolver of resolvers) { + const resolved = await resolver.resolveInput(txIn, options); + if (resolved) return resolved; + } + return null; } }); diff --git a/packages/wallet/test/services/WalletUtil.test.ts b/packages/wallet/test/services/WalletUtil.test.ts index 2f14b5b748b..a5f87ac0651 100644 --- a/packages/wallet/test/services/WalletUtil.test.ts +++ b/packages/wallet/test/services/WalletUtil.test.ts @@ -6,12 +6,14 @@ import { util as KeyManagementUtil, KeyRole } from '@cardano-sdk/key-management'; -import { Cardano } from '@cardano-sdk/core'; +import { Cardano, ChainHistoryProvider } from '@cardano-sdk/core'; import { DrepScriptHashVoter } from '@cardano-sdk/core/dist/cjs/Cardano'; import { ObservableWallet, PersonalWallet, ScriptAddress, + combineInputResolvers, + createBackendInputResolver, createInputResolver, requiresForeignSignatures } from '../../src'; @@ -20,6 +22,19 @@ import { createStubStakePoolProvider, mockProviders as mocks } from '@cardano-sd import { dummyLogger as logger } from 'ts-log'; import { of } from 'rxjs'; +const createMockChainHistoryProvider = (txs: Cardano.HydratedTx[] = []): ChainHistoryProvider => { + const chainHistoryProvider = { + blocksByHashes: jest.fn(), + healthCheck: jest.fn(), + transactionsByAddresses: jest.fn(), + transactionsByHashes: jest.fn() + }; + chainHistoryProvider.blocksByHashes.mockResolvedValue(txs); + chainHistoryProvider.transactionsByHashes.mockResolvedValue(txs); + chainHistoryProvider.transactionsByAddresses.mockResolvedValue(txs); + return chainHistoryProvider; +}; + describe('WalletUtil', () => { describe('createInputResolver', () => { it('resolveInput resolves inputs from provided utxo set', async () => { @@ -55,6 +70,371 @@ describe('WalletUtil', () => { }) ).toBeNull(); }); + + it('resolveInput resolves inputs from provided hints', async () => { + const tx = { + body: { + outputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 50_000_000n } + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 150_000_000n } + } + ] + }, + id: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + } as Cardano.HydratedTx; + + const resolver = createInputResolver({ utxo: { available$: of([]) } }); + + expect( + await resolver.resolveInput( + { + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { hints: [tx] } + ) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 50_000_000n } + }); + + expect( + await resolver.resolveInput( + { + index: 1, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { hints: [tx] } + ) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 150_000_000n } + }); + }); + }); + + describe('createBackendInputResolver', () => { + it('resolveInput resolves inputs from provided chain history provider', async () => { + const tx = { + body: { + outputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 50_000_000n } + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 150_000_000n } + } + ] + } + } as Cardano.HydratedTx; + + const resolver = createBackendInputResolver(createMockChainHistoryProvider([tx])); + + expect( + await resolver.resolveInput({ + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 50_000_000n } + }); + + expect( + await resolver.resolveInput({ + index: 1, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 150_000_000n } + }); + }); + + it('resolveInput resolves inputs from provided hints', async () => { + const tx = { + body: { + outputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 50_000_000n } + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 150_000_000n } + } + ] + }, + id: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + } as Cardano.HydratedTx; + + const resolver = createBackendInputResolver(createMockChainHistoryProvider([])); + + expect( + await resolver.resolveInput( + { + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { hints: [tx] } + ) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 50_000_000n } + }); + + expect( + await resolver.resolveInput( + { + index: 1, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { hints: [tx] } + ) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 150_000_000n } + }); + }); + }); + + describe('combineInputResolvers', () => { + it('resolveInput resolves inputs from provided utxo set', async () => { + const utxo: Cardano.Utxo[] = [ + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ), + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { + address: Cardano.PaymentAddress('addr_test1vr8nl4u0u6fmtfnawx2rxfz95dy7m46t6dhzdftp2uha87syeufdg'), + value: { coins: 50_000_000n } + } + ] + ]; + const resolver = combineInputResolvers( + createInputResolver({ utxo: { available$: of(utxo) } }), + createBackendInputResolver(createMockChainHistoryProvider()) + ); + + expect( + await resolver.resolveInput({ + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }) + ).toEqual({ + address: 'addr_test1vr8nl4u0u6fmtfnawx2rxfz95dy7m46t6dhzdftp2uha87syeufdg', + value: { coins: 50_000_000n } + }); + expect( + await resolver.resolveInput({ + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d4') + }) + ).toBeNull(); + }); + + it('resolveInput resolves inputs from provided chain history provider', async () => { + const tx = { + body: { + outputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 50_000_000n } + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 150_000_000n } + } + ] + } + } as Cardano.HydratedTx; + + const resolver = combineInputResolvers( + createInputResolver({ utxo: { available$: of([]) } }), + createBackendInputResolver(createMockChainHistoryProvider([tx])) + ); + + expect( + await resolver.resolveInput({ + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 50_000_000n } + }); + + expect( + await resolver.resolveInput({ + index: 1, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 150_000_000n } + }); + }); + + it('can resolve inputs from own transactions, hints and from chain history provider', async () => { + const hints = [ + { + body: { + outputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 200_000_000n } + } + ] + }, + id: Cardano.TransactionId('0000bbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0FFFFFFFFFF') + } as Cardano.HydratedTx + ]; + + const tx = { + body: { + outputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 50_000_000n } + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz' + ), + value: { coins: 150_000_000n } + } + ] + } + } as Cardano.HydratedTx; + + const utxo: Cardano.Utxo[] = [ + [ + { + address: Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ), + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { + address: Cardano.PaymentAddress('addr_test1vr8nl4u0u6fmtfnawx2rxfz95dy7m46t6dhzdftp2uha87syeufdg'), + value: { coins: 50_000_000n } + } + ] + ]; + + const resolver = combineInputResolvers( + createInputResolver({ utxo: { available$: of(utxo) } }), + createBackendInputResolver(createMockChainHistoryProvider([tx])) + ); + + expect( + await resolver.resolveInput( + { + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { hints } + ) + ).toEqual({ + address: 'addr_test1vr8nl4u0u6fmtfnawx2rxfz95dy7m46t6dhzdftp2uha87syeufdg', + value: { coins: 50_000_000n } + }); + + expect( + await resolver.resolveInput( + { + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e7FFFFFFFF') + }, + { hints } + ) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 50_000_000n } + }); + + expect( + await resolver.resolveInput( + { + index: 1, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e7FFFFFFFF') + }, + { hints } + ) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 150_000_000n } + }); + expect( + await resolver.resolveInput( + { + index: 0, + txId: Cardano.TransactionId('0000bbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0FFFFFFFFFF') + }, + { hints } + ) + ).toEqual({ + address: + 'addr_test1qzs0umu0s2ammmpw0hea0w2crtcymdjvvlqngpgqy76gpfnuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qp3y3vz', + value: { coins: 200_000_000n } + }); + }); + + it('resolveInput resolves to null if the input can not be found', async () => { + const resolver = combineInputResolvers( + createInputResolver({ utxo: { available$: of([]) } }), + createBackendInputResolver(createMockChainHistoryProvider()) + ); + + expect( + await resolver.resolveInput({ + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d4') + }) + ).toBeNull(); + }); }); describe('requiresForeignSignatures', () => {