Skip to content

Commit d1418f4

Browse files
feat!: add a new function to generate and track unused addresses in ObservableWallets
BREAKING CHANGE: CIP30 getUnusedAddresses now returns the next used address instead of an empty array - add a new getNextUnusedAddress method to the ObservableWallet interface.
1 parent ffc61a5 commit d1418f4

File tree

6 files changed

+334
-5
lines changed

6 files changed

+334
-5
lines changed

packages/wallet/src/Wallets/BaseWallet.ts

+58-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
distinctBlock,
4242
distinctEraSummaries
4343
} from '../services';
44+
import { AddressType, Bip32Account, GroupedAddress, WitnessedTx, Witnesser, util } from '@cardano-sdk/key-management';
4445
import {
4546
AssetProvider,
4647
Cardano,
@@ -64,6 +65,7 @@ import {
6465
SignDataProps,
6566
SyncStatus,
6667
UpdateWitnessProps,
68+
WalletAddress,
6769
WalletNetworkInfoProvider
6870
} from '../types';
6971
import { BehaviorObservable, TrackerSubject, coldObservableProvider } from '@cardano-sdk/util-rxjs';
@@ -86,7 +88,6 @@ import {
8688
tap,
8789
throwError
8890
} from 'rxjs';
89-
import { Bip32Account, GroupedAddress, WitnessedTx, Witnesser, util } from '@cardano-sdk/key-management';
9091
import { ChangeAddressResolver, InputSelector, roundRobinRandomImprove } from '@cardano-sdk/input-selection';
9192
import { Cip30DataSignature } from '@cardano-sdk/dapp-connector';
9293
import { Ed25519PublicKey, Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
@@ -142,6 +143,27 @@ export const isBip32PublicCredentialsManager = (
142143
credManager: PublicCredentialsManager
143144
): credManager is Bip32PublicCredentialsManager => !isScriptPublicCredentialsManager(credManager);
144145

146+
/**
147+
* Gets whether the given address has a transaction history.
148+
*
149+
* @param address The address to query.
150+
* @param chainHistoryProvider The chain history provider where to fetch the history from.
151+
*/
152+
const addressHasTx = async (
153+
address: Cardano.PaymentAddress,
154+
chainHistoryProvider: ChainHistoryProvider
155+
): Promise<boolean> => {
156+
const txs = await chainHistoryProvider.transactionsByAddresses({
157+
addresses: [address],
158+
pagination: {
159+
limit: 1,
160+
startAt: 0
161+
}
162+
});
163+
164+
return txs.totalResultCount > 0;
165+
};
166+
145167
export interface BaseWalletDependencies {
146168
readonly witnesser: Witnesser;
147169
readonly txSubmitProvider: TxSubmitProvider;
@@ -821,6 +843,41 @@ export class BaseWallet implements ObservableWallet {
821843
return firstValueFrom(this.addresses$);
822844
}
823845

846+
async getNextUnusedAddress(): Promise<WalletAddress[]> {
847+
const knownAddresses = await firstValueFrom(this.addresses$);
848+
849+
if (knownAddresses.length === 0) {
850+
throw new Error('No known address found for this wallet');
851+
}
852+
853+
if (isBip32PublicCredentialsManager(this.#publicCredentialsManager)) {
854+
knownAddresses.sort((a, b) => b.index - a.index);
855+
const latestAddress = knownAddresses[0];
856+
857+
let isEmpty = !(await addressHasTx(latestAddress.address, this.chainHistoryProvider));
858+
859+
if (isEmpty) return [latestAddress];
860+
861+
const newAddress = await this.#publicCredentialsManager.bip32Account.deriveAddress(
862+
{ index: latestAddress.index + 1, type: AddressType.External },
863+
0
864+
);
865+
866+
await firstValueFrom(this.#addressTracker.addAddresses([newAddress]));
867+
868+
// Sanity check, make sure the newly generated address is also empty.
869+
isEmpty = !(await addressHasTx(newAddress.address, this.chainHistoryProvider));
870+
871+
if (isEmpty) return [newAddress];
872+
873+
return await this.getNextUnusedAddress();
874+
}
875+
876+
// Script wallet.
877+
const isEmpty = !(await addressHasTx(knownAddresses[0].address, this.chainHistoryProvider));
878+
return isEmpty ? [knownAddresses[0]] : [];
879+
}
880+
824881
/** Update the witness of a transaction with witness provided by this wallet */
825882
async updateWitness({ tx, sender }: UpdateWitnessProps): Promise<Cardano.Tx> {
826883
return this.finalizeTx({

packages/wallet/src/cip30.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,10 @@ const baseCip30WalletApi = (
388388
},
389389
getUnusedAddresses: async (): Promise<Cbor[]> => {
390390
logger.debug('getting unused addresses');
391-
return Promise.resolve([]);
391+
const wallet = await firstValueFrom(wallet$);
392+
const addresses = await wallet.getNextUnusedAddress();
393+
394+
return addresses.map((groupAddresses) => cardanoAddressToCbor(groupAddresses.address));
392395
},
393396
getUsedAddresses: async (): Promise<Cbor[]> => {
394397
logger.debug('getting used addresses');

packages/wallet/src/types.ts

+8
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ export interface ObservableWallet {
130130
*/
131131
discoverAddresses(): Promise<WalletAddress[]>;
132132

133+
/**
134+
* Get the next unused address for the wallet.
135+
*
136+
* @returns Promise that resolves with the next unused addresses. Return an empty array if there
137+
* are no available unused addresses (I.E Single address wallets such as script wallets which already used up their only address).
138+
*/
139+
getNextUnusedAddress(): Promise<WalletAddress[]>;
140+
133141
shutdown(): void;
134142
}
135143

packages/wallet/test/PersonalWallet/methods.test.ts

+260-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable unicorn/consistent-destructuring, sonarjs/no-duplicate-string, @typescript-eslint/no-floating-promises, promise/no-nesting */
22
import * as Crypto from '@cardano-sdk/crypto';
3-
import { AddressDiscovery, BaseWallet, TxInFlight, createPersonalWallet } from '../../src';
3+
import { AddressDiscovery, BaseWallet, TxInFlight, createPersonalWallet, createSharedWallet } from '../../src';
44
import { AddressType, Bip32Account, GroupedAddress, Witnesser, util } from '@cardano-sdk/key-management';
55
import { AssetId, createStubStakePoolProvider, mockProviders as mocks } from '@cardano-sdk/util-dev';
66
import { BehaviorSubject, Subscription, firstValueFrom, skip } from 'rxjs';
@@ -76,6 +76,9 @@ describe('BaseWallet methods', () => {
7676
stakeKeyDerivationPath,
7777
type: AddressType.External
7878
};
79+
80+
const groupedAddress2: GroupedAddress = { ...groupedAddress, address: '1' as Cardano.PaymentAddress, index: 1 };
81+
const groupedAddress3: GroupedAddress = { ...groupedAddress, address: '2' as Cardano.PaymentAddress, index: 2 };
7982
let txSubmitProvider: mocks.TxSubmitProviderStub;
8083
let networkInfoProvider: mocks.NetworkInfoProviderStub;
8184
let assetProvider: mocks.MockAssetProvider;
@@ -531,4 +534,260 @@ describe('BaseWallet methods', () => {
531534
await expect(firstValueFrom(wallet.addresses$)).resolves.toEqual(newAddresses);
532535
});
533536
});
537+
538+
// eslint-disable-next-line sonarjs/cognitive-complexity
539+
describe('getNextUnusedAddress', () => {
540+
const script: Cardano.NativeScript = {
541+
__type: Cardano.ScriptType.Native,
542+
kind: Cardano.NativeScriptKind.RequireAllOf,
543+
scripts: [
544+
{
545+
__type: Cardano.ScriptType.Native,
546+
keyHash: Crypto.Ed25519KeyHashHex('b275b08c999097247f7c17e77007c7010cd19f20cc086ad99d398538'),
547+
kind: Cardano.NativeScriptKind.RequireSignature
548+
}
549+
]
550+
};
551+
552+
// Computed from script above
553+
const scriptAddress = {
554+
accountIndex: 0,
555+
address:
556+
'addr_test1xrrujjsfy60k96pm8ymadcxhx82p54z3aswlwxa8r9pggy78e99qjf5lvt5rkwfh6msdwvw5rf29rmqa7ud6wx2zssfs0l4tsq' as Cardano.PaymentAddress,
557+
index: 0,
558+
networkId: Cardano.NetworkId.Testnet,
559+
rewardAccount: 'stake_test17rrujjsfy60k96pm8ymadcxhx82p54z3aswlwxa8r9pggycfg4jxy' as Cardano.RewardAccount,
560+
type: AddressType.External
561+
};
562+
563+
beforeEach(() => {
564+
wallet.shutdown();
565+
566+
bip32Account.deriveAddress = jest.fn((args) => {
567+
if (args.index === 0) {
568+
return Promise.resolve(groupedAddress);
569+
}
570+
571+
if (args.index === 1) {
572+
return Promise.resolve(groupedAddress2);
573+
}
574+
575+
return Promise.resolve(groupedAddress3);
576+
});
577+
});
578+
579+
it('returns the latest known empty address if any', async () => {
580+
chainHistoryProvider = {
581+
blocksByHashes: jest.fn().mockResolvedValue([{ epoch: Cardano.EpochNo(3) }]),
582+
healthCheck: jest.fn().mockResolvedValue({ ok: true }),
583+
transactionsByAddresses: jest.fn().mockResolvedValue({
584+
pageResults: [],
585+
totalResultCount: 0
586+
}),
587+
transactionsByHashes: jest.fn().mockResolvedValue([])
588+
};
589+
590+
wallet = createPersonalWallet(
591+
{ name: 'Test Wallet' },
592+
{
593+
addressDiscovery,
594+
assetProvider,
595+
bip32Account,
596+
chainHistoryProvider,
597+
handleProvider,
598+
logger,
599+
networkInfoProvider,
600+
rewardsProvider,
601+
stakePoolProvider,
602+
txSubmitProvider,
603+
utxoProvider,
604+
witnesser
605+
}
606+
);
607+
608+
await waitForWalletStateSettle(wallet);
609+
610+
// Only one address being tracked.
611+
await expect(firstValueFrom(wallet.addresses$)).resolves.toEqual([groupedAddress]);
612+
await expect(wallet.getNextUnusedAddress()).resolves.toEqual([groupedAddress]);
613+
});
614+
615+
it('returns a fresh empty address if all known addresses are used', async () => {
616+
chainHistoryProvider = {
617+
blocksByHashes: jest.fn().mockResolvedValue([{ epoch: Cardano.EpochNo(3) }]),
618+
healthCheck: jest.fn().mockResolvedValue({ ok: true }),
619+
transactionsByAddresses: jest.fn((args) => {
620+
if (args.addresses[0] === '1') {
621+
return Promise.resolve({
622+
pageResults: [],
623+
totalResultCount: 0
624+
});
625+
}
626+
627+
return Promise.resolve({
628+
pageResults: mocks.queryTransactionsResult.pageResults,
629+
totalResultCount: mocks.queryTransactionsResult.totalResultCount
630+
});
631+
}),
632+
transactionsByHashes: jest.fn().mockResolvedValue([])
633+
};
634+
635+
wallet = createPersonalWallet(
636+
{ name: 'Test Wallet' },
637+
{
638+
addressDiscovery,
639+
assetProvider,
640+
bip32Account,
641+
chainHistoryProvider,
642+
handleProvider,
643+
logger,
644+
networkInfoProvider,
645+
rewardsProvider,
646+
stakePoolProvider,
647+
txSubmitProvider,
648+
utxoProvider,
649+
witnesser
650+
}
651+
);
652+
653+
await waitForWalletStateSettle(wallet);
654+
655+
// Only one address being tracked.
656+
await expect(firstValueFrom(wallet.addresses$)).resolves.toEqual([groupedAddress]);
657+
await expect(wallet.getNextUnusedAddress()).resolves.toEqual([groupedAddress2]);
658+
659+
// New empty address is now being tracked.
660+
await expect(firstValueFrom(wallet.addresses$)).resolves.toEqual([groupedAddress, groupedAddress2]);
661+
662+
// No new addresses are generated until the new empty address is used up.
663+
await expect(wallet.getNextUnusedAddress()).resolves.toEqual([groupedAddress2]);
664+
});
665+
666+
it('returns a fresh empty address if all known addresses are used, and the new created address was also used', async () => {
667+
chainHistoryProvider = {
668+
blocksByHashes: jest.fn().mockResolvedValue([{ epoch: Cardano.EpochNo(3) }]),
669+
healthCheck: jest.fn().mockResolvedValue({ ok: true }),
670+
transactionsByAddresses: jest.fn((args) => {
671+
if (args.addresses[0] === '2') {
672+
return Promise.resolve({
673+
pageResults: [],
674+
totalResultCount: 0
675+
});
676+
}
677+
678+
return Promise.resolve({
679+
pageResults: mocks.queryTransactionsResult.pageResults,
680+
totalResultCount: mocks.queryTransactionsResult.totalResultCount
681+
});
682+
}),
683+
transactionsByHashes: jest.fn().mockResolvedValue([])
684+
};
685+
686+
wallet = createPersonalWallet(
687+
{ name: 'Test Wallet' },
688+
{
689+
addressDiscovery,
690+
assetProvider,
691+
bip32Account,
692+
chainHistoryProvider,
693+
handleProvider,
694+
logger,
695+
networkInfoProvider,
696+
rewardsProvider,
697+
stakePoolProvider,
698+
txSubmitProvider,
699+
utxoProvider,
700+
witnesser
701+
}
702+
);
703+
704+
await waitForWalletStateSettle(wallet);
705+
706+
// Only one address being tracked.
707+
await expect(firstValueFrom(wallet.addresses$)).resolves.toEqual([groupedAddress]);
708+
await expect(wallet.getNextUnusedAddress()).resolves.toEqual([groupedAddress3]);
709+
710+
// Discovered used address, plus new empty address is now being tracked.
711+
await expect(firstValueFrom(wallet.addresses$)).resolves.toEqual([
712+
groupedAddress,
713+
groupedAddress2,
714+
groupedAddress3
715+
]);
716+
717+
// No new addresses are generated until the new empty address is used up.
718+
await expect(wallet.getNextUnusedAddress()).resolves.toEqual([groupedAddress3]);
719+
});
720+
721+
it('returns script address if unused', async () => {
722+
chainHistoryProvider = {
723+
blocksByHashes: jest.fn().mockResolvedValue([{ epoch: Cardano.EpochNo(3) }]),
724+
healthCheck: jest.fn().mockResolvedValue({ ok: true }),
725+
transactionsByAddresses: jest.fn().mockResolvedValue({
726+
pageResults: [],
727+
totalResultCount: 0
728+
}),
729+
transactionsByHashes: jest.fn().mockResolvedValue([])
730+
};
731+
732+
wallet = createSharedWallet(
733+
{ name: 'Test Wallet' },
734+
{
735+
assetProvider,
736+
chainHistoryProvider,
737+
handleProvider,
738+
logger,
739+
networkInfoProvider,
740+
paymentScript: script,
741+
rewardsProvider,
742+
stakePoolProvider,
743+
stakingScript: script,
744+
txSubmitProvider,
745+
utxoProvider,
746+
witnesser
747+
}
748+
);
749+
750+
await waitForWalletStateSettle(wallet);
751+
752+
await expect(wallet.getNextUnusedAddress()).resolves.toEqual([scriptAddress]);
753+
// Only one address being tracked.
754+
await expect(firstValueFrom(wallet.addresses$)).resolves.toEqual([scriptAddress]);
755+
});
756+
757+
it('returns an empty array if script address is already used', async () => {
758+
chainHistoryProvider = {
759+
blocksByHashes: jest.fn().mockResolvedValue([{ epoch: Cardano.EpochNo(3) }]),
760+
healthCheck: jest.fn().mockResolvedValue({ ok: true }),
761+
transactionsByAddresses: jest.fn().mockResolvedValue({
762+
pageResults: [],
763+
totalResultCount: 1
764+
}),
765+
transactionsByHashes: jest.fn().mockResolvedValue([])
766+
};
767+
768+
wallet = createSharedWallet(
769+
{ name: 'Test Wallet' },
770+
{
771+
assetProvider,
772+
chainHistoryProvider,
773+
handleProvider,
774+
logger,
775+
networkInfoProvider,
776+
paymentScript: script,
777+
rewardsProvider,
778+
stakePoolProvider,
779+
stakingScript: script,
780+
txSubmitProvider,
781+
utxoProvider,
782+
witnesser
783+
}
784+
);
785+
786+
await waitForWalletStateSettle(wallet);
787+
788+
await expect(wallet.getNextUnusedAddress()).resolves.toEqual([]);
789+
// Only one address being tracked.
790+
await expect(firstValueFrom(wallet.addresses$)).resolves.toEqual([scriptAddress]);
791+
});
792+
});
534793
});

0 commit comments

Comments
 (0)