Skip to content

Commit d9dcd1c

Browse files
authored
feat(experimental): add accountNotifications websocket method (#1650)
1 parent e0b865d commit d9dcd1c

File tree

5 files changed

+315
-55
lines changed

5 files changed

+315
-55
lines changed

packages/rpc-core/src/response-patcher-allowed-numeric-values.ts

Lines changed: 39 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@ import { KEYPATH_WILDCARD } from './response-patcher-types';
55
import { createSolanaRpcApi } from './rpc-methods';
66
import { SolanaRpcSubscriptions, SolanaRpcSubscriptionsUnstable } from './rpc-subscriptions';
77

8+
// Numeric values nested in `jsonParsed` accounts
9+
const jsonParsedTokenAccountsConfigs = [
10+
// parsed Token/Token22 token account
11+
['data', 'parsed', 'info', 'tokenAmount', 'decimals'],
12+
['data', 'parsed', 'info', 'tokenAmount', 'uiAmount'],
13+
['data', 'parsed', 'info', 'rentExemptReserve', 'decimals'],
14+
['data', 'parsed', 'info', 'rentExemptReserve', 'uiAmount'],
15+
['data', 'parsed', 'info', 'delegatedAmount', 'decimals'],
16+
['data', 'parsed', 'info', 'delegatedAmount', 'uiAmount'],
17+
['data', 'parsed', 'info', 'extensions', KEYPATH_WILDCARD, 'state', 'olderTransferFee', 'transferFeeBasisPoints'],
18+
['data', 'parsed', 'info', 'extensions', KEYPATH_WILDCARD, 'state', 'newerTransferFee', 'transferFeeBasisPoints'],
19+
['data', 'parsed', 'info', 'extensions', KEYPATH_WILDCARD, 'state', 'preUpdateAverageRate'],
20+
['data', 'parsed', 'info', 'extensions', KEYPATH_WILDCARD, 'state', 'currentRate'],
21+
];
22+
const jsonParsedAccountsConfigs = [
23+
...jsonParsedTokenAccountsConfigs,
24+
// parsed AddressTableLookup account
25+
['data', 'parsed', 'info', 'lastExtendedSlotStartIndex'],
26+
// parsed Config account
27+
['data', 'parsed', 'info', 'slashPenalty'],
28+
['data', 'parsed', 'info', 'warmupCooldownRate'],
29+
// parsed Token/Token22 mint account
30+
['data', 'parsed', 'info', 'decimals'],
31+
// parsed Token/Token22 multisig account
32+
['data', 'parsed', 'info', 'numRequiredSigners'],
33+
['data', 'parsed', 'info', 'numValidSigners'],
34+
// parsed Stake account
35+
['data', 'parsed', 'info', 'stake', 'delegation', 'warmupCooldownRate'],
36+
// parsed Sysvar rent account
37+
['data', 'parsed', 'info', 'exemptionThreshold'],
38+
['data', 'parsed', 'info', 'burnPercent'],
39+
// parsed Vote account
40+
['data', 'parsed', 'info', 'commission'],
41+
['data', 'parsed', 'info', 'votes', KEYPATH_WILDCARD, 'confirmationCount'],
42+
];
43+
844
type AllowedNumericKeypaths<TApi> = Partial<Record<keyof TApi, readonly KeyPath[]>>;
945

1046
let memoizedNotificationKeypaths: AllowedNumericKeypaths<
@@ -20,7 +56,9 @@ export function getAllowedNumericKeypathsForNotification(): AllowedNumericKeypat
2056
IRpcSubscriptionsApi<SolanaRpcSubscriptions & SolanaRpcSubscriptionsUnstable>
2157
> {
2258
if (!memoizedNotificationKeypaths) {
23-
memoizedNotificationKeypaths = {};
59+
memoizedNotificationKeypaths = {
60+
accountNotifications: jsonParsedAccountsConfigs.map(c => ['value', ...c]),
61+
};
2462
}
2563
return memoizedNotificationKeypaths;
2664
}
@@ -31,59 +69,6 @@ export function getAllowedNumericKeypathsForNotification(): AllowedNumericKeypat
3169
*/
3270
export function getAllowedNumericKeypathsForResponse(): AllowedNumericKeypaths<ReturnType<typeof createSolanaRpcApi>> {
3371
if (!memoizedResponseKeypaths) {
34-
// Numeric values nested in `jsonParsed` accounts
35-
const jsonParsedTokenAccountsConfigs = [
36-
// parsed Token/Token22 token account
37-
['data', 'parsed', 'info', 'tokenAmount', 'decimals'],
38-
['data', 'parsed', 'info', 'tokenAmount', 'uiAmount'],
39-
['data', 'parsed', 'info', 'rentExemptReserve', 'decimals'],
40-
['data', 'parsed', 'info', 'rentExemptReserve', 'uiAmount'],
41-
['data', 'parsed', 'info', 'delegatedAmount', 'decimals'],
42-
['data', 'parsed', 'info', 'delegatedAmount', 'uiAmount'],
43-
[
44-
'data',
45-
'parsed',
46-
'info',
47-
'extensions',
48-
KEYPATH_WILDCARD,
49-
'state',
50-
'olderTransferFee',
51-
'transferFeeBasisPoints',
52-
],
53-
[
54-
'data',
55-
'parsed',
56-
'info',
57-
'extensions',
58-
KEYPATH_WILDCARD,
59-
'state',
60-
'newerTransferFee',
61-
'transferFeeBasisPoints',
62-
],
63-
['data', 'parsed', 'info', 'extensions', KEYPATH_WILDCARD, 'state', 'preUpdateAverageRate'],
64-
['data', 'parsed', 'info', 'extensions', KEYPATH_WILDCARD, 'state', 'currentRate'],
65-
];
66-
const jsonParsedAccountsConfigs = [
67-
...jsonParsedTokenAccountsConfigs,
68-
// parsed AddressTableLookup account
69-
['data', 'parsed', 'info', 'lastExtendedSlotStartIndex'],
70-
// parsed Config account
71-
['data', 'parsed', 'info', 'slashPenalty'],
72-
['data', 'parsed', 'info', 'warmupCooldownRate'],
73-
// parsed Token/Token22 mint account
74-
['data', 'parsed', 'info', 'decimals'],
75-
// parsed Token/Token22 multisig account
76-
['data', 'parsed', 'info', 'numRequiredSigners'],
77-
['data', 'parsed', 'info', 'numValidSigners'],
78-
// parsed Stake account
79-
['data', 'parsed', 'info', 'stake', 'delegation', 'warmupCooldownRate'],
80-
// parsed Sysvar rent account
81-
['data', 'parsed', 'info', 'exemptionThreshold'],
82-
['data', 'parsed', 'info', 'burnPercent'],
83-
// parsed Vote account
84-
['data', 'parsed', 'info', 'commission'],
85-
['data', 'parsed', 'info', 'votes', KEYPATH_WILDCARD, 'confirmationCount'],
86-
];
8772
memoizedResponseKeypaths = {
8873
getAccountInfo: jsonParsedAccountsConfigs.map(c => ['value', ...c]),
8974
getBlock: [
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Commitment } from '../../rpc-methods';
2+
3+
describe('accountNotifications', () => {
4+
([undefined, 'confirmed', 'finalized', 'processed'] as (Commitment | undefined)[]).forEach(commitment => {
5+
describe(`when called with \`${commitment}\` commitment`, () => {
6+
it.todo('produces account notifications');
7+
});
8+
});
9+
10+
describe('when called with base58 encoding', () => {
11+
it.todo('produces account notifications with annotated base58 encoding');
12+
});
13+
14+
describe('when called with base64 encoding', () => {
15+
it.todo('produces account notifications with annotated base64 encoding');
16+
});
17+
18+
describe('when called with base64+zstd encoding', () => {
19+
it.todo('produces account notifications with annotated base64+zstd encoding');
20+
});
21+
22+
describe('when called with jsonParsed encoding', () => {
23+
describe('for an account without parse-able JSON data', () => {
24+
it.todo('produces account notifications as base64');
25+
});
26+
27+
describe('for an account with parse-able JSON data', () => {
28+
it.todo('produces account notifications with parsed JSON data for AddressLookupTable account');
29+
30+
it.todo('produces account notifications with parsed JSON data for BpfLoaderUpgradeable account');
31+
32+
it.todo('produces account notifications with parsed JSON data for Config validator account');
33+
34+
it.todo('produces account notifications with parsed JSON data for Config stake account');
35+
36+
it.todo('produces account notifications with parsed JSON data for Nonce account');
37+
38+
it.todo('produces account notifications with parsed JSON data for SPL Token mint account');
39+
40+
it.todo('produces account notifications with parsed JSON data for SPL Token token account');
41+
42+
it.todo('produces account notifications with parsed JSON data for SPL token multisig account');
43+
44+
it.todo('produces account notifications with parsed JSON data for SPL Token 22 mint account');
45+
46+
it.todo('produces account notifications with parsed JSON data for Stake account');
47+
48+
it.todo('produces account notifications with parsed JSON data for Sysvar rent account');
49+
50+
it.todo('produces account notifications with parsed JSON data for Vote account');
51+
});
52+
});
53+
54+
describe('when called with no encoding', () => {
55+
it.todo('produces account notifications with base58 data without an annotation');
56+
});
57+
});
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/* eslint-disable @typescript-eslint/ban-ts-comment */
2+
3+
import { Base58EncodedAddress } from '@solana/addresses';
4+
import { PendingRpcSubscription, RpcSubscriptions } from '@solana/rpc-transport/dist/types/json-rpc-types';
5+
6+
import { LamportsUnsafeBeyond2Pow53Minus1 } from '../../lamports';
7+
import {
8+
Base58EncodedBytes,
9+
Base58EncodedDataResponse,
10+
Base64EncodedDataResponse,
11+
Base64EncodedZStdCompressedDataResponse,
12+
RpcResponse,
13+
U64UnsafeBeyond2Pow53Minus1,
14+
} from '../../rpc-methods/common';
15+
import { AccountNotificationsApi } from '../account-notifications';
16+
17+
async () => {
18+
const rpcSubscriptions = null as unknown as RpcSubscriptions<AccountNotificationsApi>;
19+
// See scripts/fixtures/GQE2yjns7SKKuMc89tveBDpzYHwXfeuB2PGAbGaPWc6G.json
20+
// data is 'test data'
21+
// Note: In type tests, it doesn't matter if the account is actually JSON-parseable
22+
const pubkey =
23+
'GQE2yjns7SKKuMc89tveBDpzYHwXfeuB2PGAbGaPWc6G' as Base58EncodedAddress<'GQE2yjns7SKKuMc89tveBDpzYHwXfeuB2PGAbGaPWc6G'>;
24+
25+
type TNotificationBase = Readonly<{
26+
executable: boolean;
27+
lamports: LamportsUnsafeBeyond2Pow53Minus1;
28+
owner: Base58EncodedAddress;
29+
rentEpoch: U64UnsafeBeyond2Pow53Minus1;
30+
}>;
31+
32+
// No optional configs
33+
rpcSubscriptions.accountNotifications(pubkey) satisfies PendingRpcSubscription<
34+
RpcResponse<
35+
TNotificationBase & {
36+
data: Base58EncodedBytes;
37+
}
38+
>
39+
>;
40+
rpcSubscriptions
41+
.accountNotifications(pubkey)
42+
.subscribe({ abortSignal: new AbortController().signal }) satisfies Promise<
43+
AsyncIterable<
44+
RpcResponse<
45+
TNotificationBase & {
46+
data: Base58EncodedBytes;
47+
}
48+
>
49+
>
50+
>;
51+
// With optional configs
52+
rpcSubscriptions.accountNotifications(pubkey, { commitment: 'confirmed' }) satisfies PendingRpcSubscription<
53+
RpcResponse<
54+
TNotificationBase & {
55+
data: Base58EncodedBytes;
56+
}
57+
>
58+
>;
59+
rpcSubscriptions
60+
.accountNotifications(pubkey, { commitment: 'confirmed' })
61+
.subscribe({ abortSignal: new AbortController().signal }) satisfies Promise<
62+
AsyncIterable<
63+
RpcResponse<
64+
TNotificationBase & {
65+
data: Base58EncodedBytes;
66+
}
67+
>
68+
>
69+
>;
70+
// Base58 encoded data
71+
rpcSubscriptions.accountNotifications(pubkey, { encoding: 'base58' }) satisfies PendingRpcSubscription<
72+
RpcResponse<
73+
TNotificationBase & {
74+
data: Base58EncodedDataResponse;
75+
}
76+
>
77+
>;
78+
rpcSubscriptions
79+
.accountNotifications(pubkey, { encoding: 'base58' })
80+
.subscribe({ abortSignal: new AbortController().signal }) satisfies Promise<
81+
AsyncIterable<
82+
RpcResponse<
83+
TNotificationBase & {
84+
data: Base58EncodedDataResponse;
85+
}
86+
>
87+
>
88+
>;
89+
// Base64 encoded data
90+
rpcSubscriptions.accountNotifications(pubkey, { encoding: 'base64' }) satisfies PendingRpcSubscription<
91+
RpcResponse<
92+
TNotificationBase & {
93+
data: Base64EncodedDataResponse;
94+
}
95+
>
96+
>;
97+
rpcSubscriptions
98+
.accountNotifications(pubkey, { encoding: 'base64' })
99+
.subscribe({ abortSignal: new AbortController().signal }) satisfies Promise<
100+
AsyncIterable<
101+
RpcResponse<
102+
TNotificationBase & {
103+
data: Base64EncodedDataResponse;
104+
}
105+
>
106+
>
107+
>;
108+
// Base64 + ZSTD encoded data
109+
rpcSubscriptions.accountNotifications(pubkey, { encoding: 'base64+zstd' }) satisfies PendingRpcSubscription<
110+
RpcResponse<
111+
TNotificationBase & {
112+
data: Base64EncodedZStdCompressedDataResponse;
113+
}
114+
>
115+
>;
116+
rpcSubscriptions
117+
.accountNotifications(pubkey, { encoding: 'base64+zstd' })
118+
.subscribe({ abortSignal: new AbortController().signal }) satisfies Promise<
119+
AsyncIterable<
120+
RpcResponse<
121+
TNotificationBase & {
122+
data: Base64EncodedZStdCompressedDataResponse;
123+
}
124+
>
125+
>
126+
>;
127+
// JSON parsed data
128+
rpcSubscriptions.accountNotifications(pubkey, { encoding: 'jsonParsed' }) satisfies PendingRpcSubscription<
129+
RpcResponse<
130+
TNotificationBase & {
131+
data:
132+
| Readonly<{
133+
program: string;
134+
parsed: unknown;
135+
space: U64UnsafeBeyond2Pow53Minus1;
136+
}>
137+
| Base64EncodedDataResponse;
138+
}
139+
>
140+
>;
141+
rpcSubscriptions
142+
.accountNotifications(pubkey, { encoding: 'jsonParsed' })
143+
.subscribe({ abortSignal: new AbortController().signal }) satisfies Promise<
144+
AsyncIterable<
145+
RpcResponse<
146+
TNotificationBase & {
147+
data:
148+
| Readonly<{
149+
program: string;
150+
parsed: unknown;
151+
space: U64UnsafeBeyond2Pow53Minus1;
152+
}>
153+
| Base64EncodedDataResponse;
154+
}
155+
>
156+
>
157+
>;
158+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Base58EncodedAddress } from '@solana/addresses';
2+
3+
import {
4+
AccountInfoBase,
5+
AccountInfoWithBase58Bytes,
6+
AccountInfoWithBase58EncodedData,
7+
AccountInfoWithBase64EncodedData,
8+
AccountInfoWithBase64EncodedZStdCompressedData,
9+
AccountInfoWithJsonData,
10+
Commitment,
11+
RpcResponse,
12+
} from '../rpc-methods/common';
13+
14+
type AccountNotificationsApiCommonConfig = Readonly<{
15+
commitment?: Commitment;
16+
}>;
17+
18+
export interface AccountNotificationsApi {
19+
/**
20+
* Subscribe to an account to receive notifications when the lamports or data for
21+
* a given account public key changes.
22+
*
23+
* The notification format is the same as seen in the `getAccountInfo` RPC HTTP method.
24+
* @see https://docs.solana.com/api/websocket#getAccountInfo
25+
*/
26+
accountNotifications(
27+
address: Base58EncodedAddress,
28+
config: AccountNotificationsApiCommonConfig &
29+
Readonly<{
30+
encoding: 'base64';
31+
}>
32+
): RpcResponse<AccountInfoBase & AccountInfoWithBase64EncodedData>;
33+
accountNotifications(
34+
address: Base58EncodedAddress,
35+
config: AccountNotificationsApiCommonConfig &
36+
Readonly<{
37+
encoding: 'base64+zstd';
38+
}>
39+
): RpcResponse<AccountInfoBase & AccountInfoWithBase64EncodedZStdCompressedData>;
40+
accountNotifications(
41+
address: Base58EncodedAddress,
42+
config: AccountNotificationsApiCommonConfig &
43+
Readonly<{
44+
encoding: 'jsonParsed';
45+
}>
46+
): RpcResponse<AccountInfoBase & AccountInfoWithJsonData>;
47+
accountNotifications(
48+
address: Base58EncodedAddress,
49+
config: AccountNotificationsApiCommonConfig &
50+
Readonly<{
51+
encoding: 'base58';
52+
}>
53+
): RpcResponse<AccountInfoBase & AccountInfoWithBase58EncodedData>;
54+
accountNotifications(
55+
address: Base58EncodedAddress,
56+
config?: AccountNotificationsApiCommonConfig
57+
): RpcResponse<AccountInfoBase & AccountInfoWithBase58Bytes>;
58+
}

0 commit comments

Comments
 (0)