Skip to content

Commit 0799e1d

Browse files
AngelCastilloBmchappell
authored andcommitted
feat: add blockfrost input resolver and address discovery (#1633)
1 parent 59cc500 commit 0799e1d

File tree

11 files changed

+649
-11
lines changed

11 files changed

+649
-11
lines changed

apps/browser-extension-wallet/src/lib/scripts/background/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const getProviders = async (chainName: Wallet.ChainName): Promise<Wallet.
4343
const baseCardanoServicesUrl = getBaseUrlForChain(chainName);
4444
const magic = getMagicForChain(chainName);
4545
const { customSubmitTxUrl, featureFlags } = await getBackgroundStorage();
46+
4647
const isExperimentEnabled = (experimentName: ExperimentName) => !!(featureFlags?.[magic]?.[experimentName] ?? false);
4748

4849
return Wallet.createProviders({
@@ -65,7 +66,8 @@ export const getProviders = async (chainName: Wallet.ChainName): Promise<Wallet.
6566
useBlockfrostNetworkInfoProvider: isExperimentEnabled(ExperimentName.BLOCKFROST_NETWORK_INFO_PROVIDER),
6667
useBlockfrostRewardsProvider: isExperimentEnabled(ExperimentName.BLOCKFROST_REWARDS_PROVIDER),
6768
useBlockfrostTxSubmitProvider: isExperimentEnabled(ExperimentName.BLOCKFROST_TX_SUBMIT_PROVIDER),
68-
useBlockfrostUtxoProvider: isExperimentEnabled(ExperimentName.BLOCKFROST_UTXO_PROVIDER)
69+
useBlockfrostUtxoProvider: isExperimentEnabled(ExperimentName.BLOCKFROST_UTXO_PROVIDER),
70+
useBlockfrostAddressDiscovery: isExperimentEnabled(ExperimentName.BLOCKFROST_ADDRESS_DISCOVERY)
6971
}
7072
});
7173
};

apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,7 @@
22
import { runtime, storage as webStorage } from 'webextension-polyfill';
33
import { of, combineLatest, map, EMPTY, BehaviorSubject, Observable, from, firstValueFrom, defaultIfEmpty } from 'rxjs';
44
import { getProviders } from './config';
5-
import {
6-
DEFAULT_LOOK_AHEAD_SEARCH,
7-
DEFAULT_POLLING_CONFIG,
8-
HDSequentialDiscovery,
9-
createPersonalWallet,
10-
storage,
11-
createSharedWallet
12-
} from '@cardano-sdk/wallet';
5+
import { DEFAULT_POLLING_CONFIG, createPersonalWallet, storage, createSharedWallet } from '@cardano-sdk/wallet';
136
import { handleHttpProvider } from '@cardano-sdk/cardano-services-client';
147
import {
158
AnyWallet,
@@ -177,6 +170,7 @@ const walletFactory: WalletFactory<Wallet.WalletMetadata, Wallet.AccountMetadata
177170
? // eslint-disable-next-line no-magic-numbers
178171
Number(process.env.WALLET_POLLING_INTERVAL_IN_SEC) * 1000
179172
: DEFAULT_POLLING_CONFIG.pollInterval;
173+
180174
return createPersonalWallet(
181175
{
182176
name: walletAccount.metadata.name,
@@ -196,7 +190,6 @@ const walletFactory: WalletFactory<Wallet.WalletMetadata, Wallet.AccountMetadata
196190
logger
197191
})
198192
: noopHandleResolver,
199-
addressDiscovery: new HDSequentialDiscovery(providers.chainHistoryProvider, DEFAULT_LOOK_AHEAD_SEARCH),
200193
witnesser,
201194
bip32Account
202195
}

apps/browser-extension-wallet/src/providers/ExperimentsProvider/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const getDefaultFeatureFlags = (): FallbackConfiguration => ({
1212
[ExperimentName.BLOCKFROST_REWARDS_PROVIDER]: false,
1313
[ExperimentName.BLOCKFROST_TX_SUBMIT_PROVIDER]: false,
1414
[ExperimentName.BLOCKFROST_UTXO_PROVIDER]: false,
15+
[ExperimentName.BLOCKFROST_ADDRESS_DISCOVERY]: false,
1516
[ExperimentName.EXTENSION_STORAGE]: false,
1617
[ExperimentName.USE_DREP_PROVIDER_OVERRIDE]: false
1718
});
@@ -61,6 +62,10 @@ export const experiments: ExperimentsConfig = {
6162
value: false,
6263
default: false
6364
},
65+
[ExperimentName.BLOCKFROST_ADDRESS_DISCOVERY]: {
66+
value: false,
67+
default: false
68+
},
6469
[ExperimentName.EXTENSION_STORAGE]: {
6570
value: false,
6671
default: false

apps/browser-extension-wallet/src/providers/ExperimentsProvider/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export enum ExperimentName {
1717
BLOCKFROST_REWARDS_PROVIDER = 'blockfrost-rewards-provider',
1818
BLOCKFROST_TX_SUBMIT_PROVIDER = 'blockfrost-tx-submit-provider',
1919
BLOCKFROST_UTXO_PROVIDER = 'blockfrost-utxo-provider',
20+
BLOCKFROST_ADDRESS_DISCOVERY = 'blockfrost-address-discovery',
2021
EXTENSION_STORAGE = 'extension-storage',
2122
USE_DREP_PROVIDER_OVERRIDE = 'use-drep-provider-override'
2223
}

packages/cardano/src/wallet/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export * from '@wallet/lib/get-auxiliary-data';
8585
export * as util from '@wallet/util';
8686
export * from '@wallet/lib/providers';
8787
export * from '@wallet/lib/config';
88+
export * from '@wallet/lib/blockfrost-input-resolver';
89+
export * from '@wallet/lib/blockfrost-address-discovery';
8890

8991
export * as mockUtils from '@wallet/test/mocks';
9092
export * from '@wallet/types';
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/* eslint-disable no-magic-numbers */
2+
import { BlockfrostAddressDiscovery } from '../blockfrost-address-discovery';
3+
import { Cardano } from '@cardano-sdk/core';
4+
import { BlockfrostClient, BlockfrostError } from '@cardano-sdk/cardano-services-client';
5+
import { Logger } from 'ts-log';
6+
import { AddressType, Bip32Account, KeyRole } from '@cardano-sdk/key-management';
7+
8+
jest.mock('@cardano-sdk/cardano-services-client');
9+
10+
describe('BlockfrostAddressDiscovery', () => {
11+
let clientMock: jest.Mocked<BlockfrostClient>;
12+
let loggerMock: jest.Mocked<Logger>;
13+
let accountMock: jest.Mocked<Bip32Account>;
14+
let addressDiscovery: BlockfrostAddressDiscovery;
15+
16+
beforeEach(() => {
17+
clientMock = {
18+
request: jest.fn()
19+
} as unknown as jest.Mocked<BlockfrostClient>;
20+
21+
loggerMock = {
22+
debug: jest.fn(),
23+
info: jest.fn(),
24+
warn: jest.fn(),
25+
error: jest.fn()
26+
} as unknown as jest.Mocked<Logger>;
27+
28+
accountMock = {
29+
deriveAddress: jest.fn()
30+
} as unknown as jest.Mocked<Bip32Account>;
31+
32+
addressDiscovery = new BlockfrostAddressDiscovery(clientMock, loggerMock);
33+
});
34+
35+
afterEach(() => {
36+
jest.clearAllMocks();
37+
});
38+
39+
it('should discover addresses correctly', async () => {
40+
const rewardAccount = 'stake1u9p...' as Cardano.RewardAccount;
41+
const paymentAddress1 = 'addr1...' as Cardano.PaymentAddress;
42+
const paymentAddress2 = 'addr2...' as Cardano.PaymentAddress;
43+
44+
accountMock.deriveAddress
45+
.mockResolvedValueOnce({
46+
address: paymentAddress1,
47+
rewardAccount,
48+
type: AddressType.External,
49+
index: 0,
50+
networkId: Cardano.NetworkId.Mainnet,
51+
accountIndex: 0,
52+
stakeKeyDerivationPath: {
53+
index: 0,
54+
role: KeyRole.Stake
55+
}
56+
})
57+
.mockResolvedValue({
58+
address: paymentAddress2,
59+
rewardAccount,
60+
type: AddressType.External,
61+
index: 1,
62+
networkId: Cardano.NetworkId.Mainnet,
63+
accountIndex: 0,
64+
stakeKeyDerivationPath: {
65+
index: 0,
66+
role: KeyRole.Stake
67+
}
68+
});
69+
70+
clientMock.request.mockResolvedValueOnce([
71+
{
72+
address: paymentAddress1,
73+
rewardAccount,
74+
type: AddressType.External,
75+
index: 0,
76+
networkId: Cardano.NetworkId.Mainnet,
77+
accountIndex: 0,
78+
stakeKeyDerivationPath: {
79+
index: 0,
80+
role: KeyRole.Stake
81+
}
82+
},
83+
{
84+
address: paymentAddress2,
85+
rewardAccount,
86+
type: AddressType.External,
87+
index: 1,
88+
networkId: Cardano.NetworkId.Mainnet,
89+
accountIndex: 0,
90+
stakeKeyDerivationPath: {
91+
index: 0,
92+
role: KeyRole.Stake
93+
}
94+
}
95+
]);
96+
97+
const result = await addressDiscovery.discover(accountMock);
98+
99+
expect(result).toHaveLength(2);
100+
expect(clientMock.request).toHaveBeenCalledWith(`accounts/${rewardAccount}/addresses?count=100&page=1`);
101+
});
102+
103+
it('should not throw if Blockfrost returns 404 not found', async () => {
104+
const rewardAccount = 'stake1u9p...' as Cardano.RewardAccount;
105+
const paymentAddress1 = 'addr1...' as Cardano.PaymentAddress;
106+
107+
accountMock.deriveAddress.mockResolvedValue({
108+
address: paymentAddress1,
109+
rewardAccount,
110+
type: AddressType.External,
111+
index: 0,
112+
networkId: Cardano.NetworkId.Mainnet,
113+
accountIndex: 0,
114+
stakeKeyDerivationPath: {
115+
index: 0,
116+
role: KeyRole.Stake
117+
}
118+
});
119+
120+
const error = new BlockfrostError(404, 'Not Found');
121+
error.status = 404;
122+
123+
clientMock.request.mockRejectedValueOnce(error);
124+
const result = await addressDiscovery.discover(accountMock);
125+
expect(result).toHaveLength(1); // There is always at least one address
126+
});
127+
128+
it('should handle unknown/franken addresses gracefully', async () => {
129+
const rewardAccount = 'stake1u9p...' as Cardano.RewardAccount;
130+
const paymentAddress1 = 'addr1...' as Cardano.PaymentAddress;
131+
const frankenAddress = 'addrUnknown...' as Cardano.PaymentAddress;
132+
133+
accountMock.deriveAddress.mockResolvedValue({
134+
address: paymentAddress1,
135+
rewardAccount,
136+
type: AddressType.External,
137+
index: 0,
138+
networkId: Cardano.NetworkId.Mainnet,
139+
accountIndex: 0,
140+
stakeKeyDerivationPath: {
141+
index: 0,
142+
role: KeyRole.Stake
143+
}
144+
});
145+
146+
clientMock.request.mockResolvedValueOnce([{ address: paymentAddress1 }, { address: frankenAddress }]);
147+
148+
const result = await addressDiscovery.discover(accountMock);
149+
150+
expect(result).toHaveLength(1);
151+
expect(loggerMock.warn).toHaveBeenCalledWith('The following addresses under stakeIndex 0 were not matched:', [
152+
frankenAddress
153+
]);
154+
});
155+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/* eslint-disable no-magic-numbers, camelcase */
2+
import { BlockfrostInputResolver } from '../blockfrost-input-resolver';
3+
import { Cardano } from '@cardano-sdk/core';
4+
import { BlockfrostClient, BlockfrostError, BlockfrostToCore } from '@cardano-sdk/cardano-services-client';
5+
import { Logger } from 'ts-log';
6+
7+
jest.mock('@cardano-sdk/cardano-services-client');
8+
9+
describe('BlockfrostInputResolver', () => {
10+
let clientMock: jest.Mocked<BlockfrostClient>;
11+
let loggerMock: jest.Mocked<Logger>;
12+
let resolver: BlockfrostInputResolver;
13+
14+
beforeEach(() => {
15+
clientMock = {
16+
request: jest.fn()
17+
} as unknown as jest.Mocked<BlockfrostClient>;
18+
19+
loggerMock = {
20+
debug: jest.fn(),
21+
error: jest.fn(),
22+
warn: jest.fn()
23+
} as unknown as jest.Mocked<Logger>;
24+
25+
resolver = new BlockfrostInputResolver(clientMock, loggerMock);
26+
});
27+
28+
afterEach(() => {
29+
jest.clearAllMocks();
30+
});
31+
32+
it('should resolve input using hints if available', async () => {
33+
const txIn: Cardano.TxIn = { txId: 'txId1' as Cardano.TransactionId, index: 0 };
34+
const hint = {
35+
id: 'txId1' as Cardano.TransactionId,
36+
body: {
37+
outputs: [{ address: 'addr1' as Cardano.PaymentAddress, value: { coins: BigInt(1000) } }]
38+
}
39+
} as Cardano.Tx;
40+
41+
const result = await resolver.resolveInput(txIn, { hints: [hint] });
42+
43+
expect(result).toEqual(hint.body.outputs[0]);
44+
});
45+
46+
it('should fetch transaction output on first resolve and use cache on second resolve', async () => {
47+
const txIn: Cardano.TxIn = { txId: 'txId2' as Cardano.TransactionId, index: 1 };
48+
const responseMock = {
49+
outputs: [{ output_index: 1, address: 'addr2', amount: [{ unit: 'lovelace', quantity: '2000' }] }]
50+
};
51+
52+
clientMock.request.mockResolvedValue(responseMock);
53+
54+
jest.spyOn(BlockfrostToCore, 'txOut').mockReturnValue({
55+
address: 'addr2' as Cardano.PaymentAddress,
56+
value: { coins: BigInt(2000) }
57+
} as Cardano.TxOut);
58+
59+
const result1 = await resolver.resolveInput(txIn);
60+
61+
expect(result1).toEqual({ address: 'addr2', value: { coins: BigInt(2000) } });
62+
expect(clientMock.request).toHaveBeenCalledWith('txs/txId2/utxos');
63+
expect(clientMock.request).toHaveBeenCalledTimes(1);
64+
65+
const result2 = await resolver.resolveInput(txIn);
66+
67+
expect(result2).toEqual(result1);
68+
expect(clientMock.request).toHaveBeenCalledTimes(1);
69+
});
70+
71+
it('should return null if transaction output is not found', async () => {
72+
const txIn: Cardano.TxIn = { txId: 'txId3' as Cardano.TransactionId, index: 2 };
73+
const responseMock = { outputs: [] };
74+
clientMock.request.mockResolvedValue(responseMock);
75+
76+
const result = await resolver.resolveInput(txIn);
77+
78+
expect(result).toBeNull();
79+
});
80+
81+
it('should return null for 404 errors from Blockfrost', async () => {
82+
const txIn: Cardano.TxIn = { txId: 'txId4' as Cardano.TransactionId, index: 0 };
83+
84+
const error = new BlockfrostError(404, 'Not Found');
85+
error.status = 404;
86+
87+
clientMock.request.mockRejectedValue(error);
88+
89+
const result = await resolver.resolveInput(txIn);
90+
91+
expect(result).toBeNull();
92+
});
93+
94+
it('should throw errors for non-404 Blockfrost errors', async () => {
95+
const txIn: Cardano.TxIn = { txId: 'txId5' as Cardano.TransactionId, index: 0 };
96+
const error = new BlockfrostError(500, 'Invalid Request');
97+
error.status = 500;
98+
99+
clientMock.request.mockRejectedValue(error);
100+
101+
try {
102+
await resolver.resolveInput(txIn);
103+
} catch (error_) {
104+
expect(error_).toBeInstanceOf(BlockfrostError);
105+
expect(error_.status).toEqual(500);
106+
}
107+
});
108+
});

0 commit comments

Comments
 (0)