Skip to content

Commit 90764f0

Browse files
author
Lucas Araujo
committed
feat(wallet): add signed transactions observable
1 parent f830661 commit 90764f0

File tree

9 files changed

+495
-24
lines changed

9 files changed

+495
-24
lines changed

packages/wallet/src/PersonalWallet/PersonalWallet.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,10 @@ export class PersonalWallet implements ObservableWallet {
199199
#newTransactions = {
200200
failedToSubmit$: new Subject<FailedTx>(),
201201
pending$: new Subject<OutgoingTx>(),
202+
signed$: new Subject<SignedTx>(),
202203
submitting$: new Subject<OutgoingTx>()
203204
};
205+
#removeSignedTx$: Subject<Cardano.TransactionId>;
204206
#reemitSubscriptions: Subscription;
205207
#failedFromReemitter$: Subject<FailedTx>;
206208
#trackedTxSubmitProvider: TrackedTxSubmitProvider;
@@ -403,6 +405,7 @@ export class PersonalWallet implements ObservableWallet {
403405
map((addresses) => addresses.map((groupedAddress) => groupedAddress.address))
404406
);
405407
this.#failedFromReemitter$ = new Subject<FailedTx>();
408+
this.#removeSignedTx$ = new Subject<Cardano.TransactionId>();
406409
this.transactions = createTransactionsTracker({
407410
addresses$,
408411
chainHistoryProvider: this.chainHistoryProvider,
@@ -411,6 +414,7 @@ export class PersonalWallet implements ObservableWallet {
411414
logger: contextLogger(this.#logger, 'transactions'),
412415
newTransactions: this.#newTransactions,
413416
onFatalError,
417+
removeSignedTx$: this.#removeSignedTx$,
414418
retryBackoffConfig,
415419
tip$: this.tip$,
416420
transactionsHistoryStore: stores.transactions
@@ -444,6 +448,7 @@ export class PersonalWallet implements ObservableWallet {
444448
logger: contextLogger(this.#logger, 'utxo'),
445449
onFatalError,
446450
retryBackoffConfig,
451+
signedTransactions$: this.transactions.outgoing.signed$,
447452
stores,
448453
tipBlockHeight$,
449454
transactionsInFlight$: this.transactions.outgoing.inFlight$,
@@ -535,7 +540,7 @@ export class PersonalWallet implements ObservableWallet {
535540

536541
async finalizeTx({ tx, sender, ...rest }: FinalizeTxProps, stubSign = false): Promise<Cardano.Tx> {
537542
const knownAddresses = await firstValueFrom(this.addresses$);
538-
const { tx: signedTx } = await finalizeTx(
543+
const result = await finalizeTx(
539544
tx,
540545
{
541546
...rest,
@@ -548,7 +553,8 @@ export class PersonalWallet implements ObservableWallet {
548553
{ bip32Account: this.bip32Account, witnesser: this.witnesser },
549554
stubSign
550555
);
551-
return signedTx;
556+
this.#newTransactions.signed$.next(result);
557+
return result.tx;
552558
}
553559

554560
private initializeHandles(handlePolicyIds$: Observable<Cardano.PolicyId[]>): Observable<HandleInfo[]> {
@@ -748,4 +754,8 @@ export class PersonalWallet implements ObservableWallet {
748754
await firstValueFrom(this.#addressTracker.addAddresses(newAddresses));
749755
return firstValueFrom(this.addresses$);
750756
}
757+
758+
removeSignedTx(id: Cardano.TransactionId): void {
759+
this.#removeSignedTx$.next(id);
760+
}
751761
}

packages/wallet/src/services/TransactionsTracker.ts

+55-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Subject,
88
combineLatest,
99
concat,
10+
concatMap,
1011
defaultIfEmpty,
1112
exhaustMap,
1213
filter,
@@ -33,8 +34,9 @@ import { Logger } from 'ts-log';
3334
import { Range, Shutdown, contextLogger } from '@cardano-sdk/util';
3435
import { RetryBackoffConfig } from 'backoff-rxjs';
3536
import { TrackerSubject, coldObservableProvider } from '@cardano-sdk/util-rxjs';
36-
import { distinctBlock, transactionsEquals } from './util';
37+
import { distinctBlock, transactionsEquals, txInEquals } from './util';
3738

39+
import { SignedTx } from '@cardano-sdk/tx-construction';
3840
import chunk from 'lodash/chunk';
3941
import intersectionBy from 'lodash/intersectionBy';
4042
import sortBy from 'lodash/sortBy';
@@ -51,9 +53,11 @@ export interface TransactionsTrackerProps {
5153
submitting$: Observable<OutgoingTx>;
5254
pending$: Observable<OutgoingTx>;
5355
failedToSubmit$: Observable<FailedTx>;
56+
signed$: Observable<SignedTx>;
5457
};
5558
failedFromReemitter$?: Observable<FailedTx>;
5659
logger: Logger;
60+
removeSignedTx$: Subject<Cardano.TransactionId>;
5761
onFatalError?: (value: unknown) => void;
5862
}
5963

@@ -217,12 +221,13 @@ export const createTransactionsTracker = (
217221
tip$,
218222
chainHistoryProvider,
219223
addresses$,
220-
newTransactions: { submitting$: newSubmitting$, pending$, failedToSubmit$ },
224+
newTransactions: { submitting$: newSubmitting$, pending$, signed$: newSigned$, failedToSubmit$ },
221225
retryBackoffConfig,
222226
transactionsHistoryStore: transactionsStore,
223227
inFlightTransactionsStore: newTransactionsStore,
224228
logger,
225229
failedFromReemitter$,
230+
removeSignedTx$,
226231
onFatalError
227232
}: TransactionsTrackerProps,
228233
{ transactionsSource$: txSource$, rollback$ }: TransactionsTrackerInternals = createAddressTransactionsProvider({
@@ -382,6 +387,53 @@ export const createTransactionsTracker = (
382387
)
383388
);
384389

390+
const signed$ = new TrackerSubject<SignedTx[]>(
391+
merge(
392+
newSigned$.pipe(map((signedTx) => ({ op: 'add' as const, signedTx }))),
393+
removeSignedTx$.pipe(map((id) => ({ id, op: 'remove' as const }))),
394+
from(inFlight$).pipe(
395+
concatMap((txs) => from(txs)),
396+
map((tx) => ({ id: tx.id, op: 'remove' as const }))
397+
),
398+
historicalTransactions$.pipe(
399+
map((txs) => ({
400+
inputs: txs.flatMap((tx) => tx.body.inputs.map((inputs) => inputs)),
401+
op: 'check_inputs' as const
402+
}))
403+
),
404+
tip$.pipe(map(({ slot }) => ({ op: 'check_interval' as const, slot })))
405+
).pipe(
406+
scan((signed, action) => {
407+
if (action.op === 'add') {
408+
return [...signed, action.signedTx];
409+
}
410+
if (action.op === 'remove') {
411+
return signed.filter(({ tx }) => tx.id !== action.id);
412+
}
413+
if (action.op === 'check_interval') {
414+
return signed.filter(
415+
({
416+
tx: {
417+
body: { validityInterval: { invalidHereafter } = {} }
418+
}
419+
}) => invalidHereafter && invalidHereafter > action.slot
420+
);
421+
}
422+
if (action.op === 'check_inputs') {
423+
return signed.filter(({ tx }) => {
424+
const anyUtxoIsUsed = tx.body.inputs.some((signedTxInput) =>
425+
action.inputs.some((historicalInput) => txInEquals(signedTxInput, historicalInput))
426+
);
427+
428+
return !anyUtxoIsUsed;
429+
});
430+
}
431+
return signed;
432+
}, [] as SignedTx[]),
433+
startWith([])
434+
)
435+
);
436+
385437
const onChain$ = new Subject<OutgoingOnChainTx>();
386438
const onChainSubscription = submittingOrPreviouslySubmitted$
387439
.pipe(mergeMap((group$) => group$.pipe(switchMap((tx) => txOnChain$(tx).pipe(takeUntil(txFailed$(tx)))))))
@@ -394,6 +446,7 @@ export const createTransactionsTracker = (
394446
inFlight$,
395447
onChain$,
396448
pending$,
449+
signed$,
397450
submitting$
398451
},
399452
rollback$,

packages/wallet/src/services/UtxoTracker.ts

+43-6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Logger } from 'ts-log';
33
import { NEVER, Observable, combineLatest, concat, distinctUntilChanged, map, of, switchMap } from 'rxjs';
44
import { PersistentCollectionTrackerSubject, txInEquals, utxoEquals } from './util';
55
import { RetryBackoffConfig } from 'backoff-rxjs';
6+
import { SignedTx } from '@cardano-sdk/tx-construction';
67
import { TxInFlight, UtxoTracker } from './types';
78
import { WalletStores } from '../persistence';
89
import { coldObservableProvider } from '@cardano-sdk/util-rxjs';
@@ -17,6 +18,7 @@ export interface UtxoTrackerProps {
1718
addresses$: Observable<Cardano.PaymentAddress[]>;
1819
stores: Pick<WalletStores, 'utxo' | 'unspendableUtxo'>;
1920
transactionsInFlight$: Observable<TxInFlight[]>;
21+
signedTransactions$: Observable<SignedTx[]>;
2022
tipBlockHeight$: Observable<Cardano.BlockNo>;
2123
retryBackoffConfig: RetryBackoffConfig;
2224
logger: Logger;
@@ -65,6 +67,7 @@ export const createUtxoTracker = (
6567
transactionsInFlight$,
6668
retryBackoffConfig,
6769
tipBlockHeight$,
70+
signedTransactions$,
6871
logger,
6972
onFatalError
7073
}: UtxoTrackerProps,
@@ -79,15 +82,24 @@ export const createUtxoTracker = (
7982
)
8083
}: UtxoTrackerInternals = {}
8184
): UtxoTracker => {
82-
const total$ = combineLatest([utxoSource$, transactionsInFlight$, addresses$]).pipe(
83-
map(([onChainUtxo, transactionsInFlight, ownAddresses]) => [
85+
const total$ = combineLatest([utxoSource$, transactionsInFlight$, signedTransactions$, addresses$]).pipe(
86+
map(([onChainUtxo, transactionsInFlight, signedTransactions, ownAddresses]) => [
8487
...onChainUtxo.filter(([utxoTxIn]) => {
8588
const utxoIsUsedInFlight = transactionsInFlight.some(({ body: { inputs } }) =>
86-
inputs.some((input) => input.txId === utxoTxIn.txId && input.index === utxoTxIn.index)
89+
inputs.some((input) => txInEquals(input, utxoTxIn))
8790
);
88-
utxoIsUsedInFlight &&
89-
logger.debug('OnChain UTXO is already used in in-flight transaction. Excluding from total$.', utxoTxIn);
90-
return !utxoIsUsedInFlight;
91+
const utxoIsUsedInSigned = signedTransactions.some(({ tx }) =>
92+
tx.body.inputs.some((input) => txInEquals(input, utxoTxIn))
93+
);
94+
(utxoIsUsedInSigned || utxoIsUsedInFlight) &&
95+
logger.debug(
96+
`OnChain UTXO is already used in ${
97+
utxoIsUsedInFlight ? 'in-flight' : 'signed'
98+
} transaction. Excluding from total$.`,
99+
utxoTxIn
100+
);
101+
102+
return !utxoIsUsedInFlight && !utxoIsUsedInSigned;
91103
}),
92104
...transactionsInFlight.flatMap(({ body: { outputs }, id }, txInFlightIndex) =>
93105
outputs
@@ -109,6 +121,31 @@ export const createUtxoTracker = (
109121
logger.debug('New UTXO available from in-flight transactions. Including in total$.', txIn);
110122
return [txIn, txOut];
111123
})
124+
),
125+
...signedTransactions.flatMap(({ tx: signedTx }, signedTxIndex) =>
126+
signedTx.body.outputs
127+
.filter(({ address }, outputIndex) => {
128+
if (!ownAddresses.includes(address)) {
129+
return false;
130+
}
131+
132+
const alreadyConsumed = signedTransactions.some(
133+
({ tx: { body } }, i) =>
134+
signedTxIndex !== i &&
135+
body.inputs.some((input) => txInEquals(input, { index: outputIndex, txId: signedTx.id }))
136+
);
137+
138+
return !alreadyConsumed;
139+
})
140+
.map((txOut): Cardano.Utxo => {
141+
const txIn: Cardano.HydratedTxIn = {
142+
address: txOut.address, // not necessarily correct in multi-address wallet
143+
index: signedTx.body.outputs.indexOf(txOut),
144+
txId: signedTx.id
145+
};
146+
logger.debug('New UTXO available from signed transactions. Including in total$.', txIn);
147+
return [txIn, txOut];
148+
})
112149
)
113150
]),
114151
map((utxo) => {

packages/wallet/src/services/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export interface TransactionsTracker {
8383
readonly inFlight$: Observable<TxInFlight[]>;
8484
readonly submitting$: Observable<OutgoingTx>;
8585
readonly pending$: Observable<OutgoingTx>;
86+
readonly signed$: Observable<SignedTx[]>;
8687
readonly failed$: Observable<FailedTx>;
8788
readonly onChain$: Observable<OutgoingOnChainTx>;
8889
};

packages/wallet/test/integration/cip30mapping.test.ts

+43-6
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ import {
2828
} from '@cardano-sdk/core';
2929
import { HexBlob, ManagedFreeableScope } from '@cardano-sdk/util';
3030
import { InMemoryUnspendableUtxoStore, createInMemoryWalletStores } from '../../src/persistence';
31-
import { InitializeTxProps, InitializeTxResult } from '@cardano-sdk/tx-construction';
32-
import { PersonalWallet, cip30 } from '../../src';
31+
import { InitializeTxProps, InitializeTxResult, finalizeTx } from '@cardano-sdk/tx-construction';
32+
import { Observable, firstValueFrom, of } from 'rxjs';
33+
import { PersonalWallet, WalletUtil, cip30 } from '../../src';
3334
import { Providers, createWallet } from './util';
3435
import { buildDRepIDFromDRepKey, waitForWalletStateSettle } from '../util';
35-
import { firstValueFrom, of } from 'rxjs';
3636
import { dummyLogger as logger } from 'ts-log';
3737
import { stakeKeyDerivationPath, testAsyncKeyAgent } from '../../../key-management/test/mocks';
3838
import uniq from 'lodash/uniq';
@@ -83,6 +83,35 @@ const createWalletAndApiWithStores = async (
8383
return { api, confirmationCallback, wallet };
8484
};
8585

86+
const signTx = async ({
87+
tx,
88+
addresses$,
89+
walletUtil
90+
}: {
91+
tx: Cardano.TxBodyWithHash;
92+
addresses$: Observable<GroupedAddress[]>;
93+
walletUtil: WalletUtil;
94+
}): Promise<Cardano.Tx> => {
95+
const keyAgent = await testAsyncKeyAgent();
96+
const knownAddresses = await firstValueFrom(addresses$);
97+
98+
const signed = await finalizeTx(
99+
tx,
100+
{
101+
signingContext: {
102+
knownAddresses,
103+
txInKeyPathMap: await util.createTxInKeyPathMap(tx.body, knownAddresses, walletUtil)
104+
}
105+
},
106+
{
107+
bip32Account: await Bip32Account.fromAsyncKeyAgent(keyAgent),
108+
witnesser: util.createBip32Ed25519Witnesser(keyAgent)
109+
}
110+
);
111+
112+
return signed.tx;
113+
};
114+
86115
describe('cip30', () => {
87116
const context: SenderContext = { sender: { url: 'https://lace.io' } };
88117
let wallet: PersonalWallet;
@@ -489,7 +518,11 @@ describe('cip30', () => {
489518

490519
beforeEach(async () => {
491520
const txInternals: InitializeTxResult = await wallet.initializeTx(simpleTxProps);
492-
finalizedTx = await wallet.finalizeTx({ tx: txInternals });
521+
finalizedTx = await signTx({
522+
addresses$: wallet.addresses$,
523+
tx: txInternals,
524+
walletUtil: wallet.util
525+
});
493526
hexTx = Serialization.Transaction.fromCore(finalizedTx).toCbor();
494527
});
495528

@@ -663,7 +696,7 @@ describe('cip30', () => {
663696
let hexTx: string;
664697
beforeAll(async () => {
665698
const txInternals = await wallet.initializeTx(simpleTxProps);
666-
const finalizedTx = await wallet.finalizeTx({ tx: txInternals });
699+
const finalizedTx = await signTx({ addresses$: wallet.addresses$, tx: txInternals, walletUtil: wallet.util });
667700
hexTx = Serialization.Transaction.fromCore(finalizedTx).toCbor();
668701
});
669702

@@ -789,7 +822,11 @@ describe('cip30', () => {
789822
])
790823
};
791824

792-
tx = await mockWallet.finalizeTx({ tx: await mockWallet.initializeTx(props) });
825+
tx = await signTx({
826+
addresses$: mockWallet.addresses$,
827+
tx: await mockWallet.initializeTx(props),
828+
walletUtil: mockWallet.util
829+
});
793830
});
794831

795832
afterEach(() => {

0 commit comments

Comments
 (0)