@ar.io/sdk 3.24.0 → 4.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +682 -600
- package/lib/esm/cli/cli.js +188 -152
- package/lib/esm/cli/commands/antCommands.js +23 -58
- package/lib/esm/cli/commands/arnsPurchaseCommands.js +48 -30
- package/lib/esm/cli/commands/escrowCommands.js +221 -0
- package/lib/esm/cli/commands/gatewayWriteCommands.js +142 -23
- package/lib/esm/cli/commands/pruneCommands.js +150 -0
- package/lib/esm/cli/commands/readCommands.js +22 -3
- package/lib/esm/cli/commands/transfer.js +6 -6
- package/lib/esm/cli/options.js +124 -58
- package/lib/esm/cli/utils.js +280 -174
- package/lib/esm/common/ant-registry.js +17 -143
- package/lib/esm/common/ant.js +44 -1167
- package/lib/esm/common/faucet.js +11 -6
- package/lib/esm/common/index.js +0 -4
- package/lib/esm/common/io.js +25 -1412
- package/lib/esm/constants.js +13 -19
- package/lib/esm/solana/ant-readable.js +724 -0
- package/lib/esm/solana/ant-registry-readable.js +133 -0
- package/lib/esm/solana/ant-registry-writeable.js +472 -0
- package/lib/esm/solana/ant-writeable.js +384 -0
- package/lib/esm/solana/ata.js +70 -0
- package/lib/esm/solana/canonical-message.js +128 -0
- package/lib/esm/solana/clusters.js +111 -0
- package/lib/esm/solana/constants.js +146 -0
- package/lib/esm/solana/delegation-math.js +112 -0
- package/lib/esm/solana/deserialize.js +711 -0
- package/lib/esm/solana/escrow.js +839 -0
- package/lib/{cjs/utils/json.js → esm/solana/events.js} +15 -10
- package/lib/esm/solana/funding-plan.js +699 -0
- package/lib/esm/solana/index.js +126 -0
- package/lib/esm/solana/instruction.js +39 -0
- package/lib/esm/solana/io-readable.js +2182 -0
- package/lib/esm/solana/io-writeable.js +3196 -0
- package/lib/esm/solana/json-rpc.js +90 -0
- package/lib/esm/solana/metadata.js +81 -0
- package/lib/esm/solana/mpl-core.js +192 -0
- package/lib/esm/solana/pda.js +332 -0
- package/lib/esm/solana/predict-prescribed-observers.js +110 -0
- package/lib/esm/solana/retry.js +117 -0
- package/lib/esm/solana/rpc-circuit-breaker.js +258 -0
- package/lib/esm/solana/send.js +372 -0
- package/lib/esm/solana/spawn-ant.js +224 -0
- package/lib/esm/solana/types.js +1 -0
- package/lib/esm/types/ant.js +27 -15
- package/lib/esm/types/io.js +8 -11
- package/lib/esm/utils/ant.js +0 -63
- package/lib/esm/utils/index.js +0 -3
- package/lib/esm/version.js +1 -1
- package/lib/types/cli/commands/antCommands.d.ts +5 -13
- package/lib/types/cli/commands/arnsPurchaseCommands.d.ts +33 -7
- package/lib/types/cli/commands/escrowCommands.d.ts +68 -0
- package/lib/types/cli/commands/gatewayWriteCommands.d.ts +12 -11
- package/lib/types/cli/commands/pruneCommands.d.ts +31 -0
- package/lib/types/cli/commands/readCommands.d.ts +27 -22
- package/lib/types/cli/commands/transfer.d.ts +9 -9
- package/lib/types/cli/options.d.ts +76 -21
- package/lib/types/cli/types.d.ts +11 -13
- package/lib/types/cli/utils.d.ts +71 -31
- package/lib/types/common/ant-registry.d.ts +49 -47
- package/lib/types/common/ant.d.ts +54 -539
- package/lib/types/common/faucet.d.ts +20 -8
- package/lib/types/common/index.d.ts +0 -3
- package/lib/types/common/io.d.ts +51 -263
- package/lib/types/constants.d.ts +11 -18
- package/lib/types/solana/ant-readable.d.ts +180 -0
- package/lib/types/solana/ant-registry-readable.d.ts +105 -0
- package/lib/types/solana/ant-registry-writeable.d.ts +249 -0
- package/lib/types/solana/ant-writeable.d.ts +177 -0
- package/lib/types/solana/ata.d.ts +44 -0
- package/lib/types/solana/canonical-message.d.ts +121 -0
- package/lib/types/solana/clusters.d.ts +109 -0
- package/lib/types/solana/constants.d.ts +119 -0
- package/lib/types/solana/delegation-math.d.ts +45 -0
- package/lib/types/solana/deserialize.d.ts +262 -0
- package/lib/types/solana/escrow.d.ts +480 -0
- package/lib/types/solana/events.d.ts +38 -0
- package/lib/types/solana/funding-plan.d.ts +225 -0
- package/lib/types/solana/index.d.ts +87 -0
- package/lib/types/solana/instruction.d.ts +39 -0
- package/lib/types/solana/io-readable.d.ts +499 -0
- package/lib/types/solana/io-writeable.d.ts +893 -0
- package/lib/types/solana/json-rpc.d.ts +47 -0
- package/lib/types/solana/metadata.d.ts +84 -0
- package/lib/types/solana/mpl-core.d.ts +120 -0
- package/lib/types/solana/pda.d.ts +95 -0
- package/lib/types/solana/predict-prescribed-observers.d.ts +28 -0
- package/lib/types/solana/retry.d.ts +62 -0
- package/lib/types/solana/rpc-circuit-breaker.d.ts +78 -0
- package/lib/types/solana/send.d.ts +94 -0
- package/lib/types/solana/spawn-ant.d.ts +145 -0
- package/lib/types/solana/types.d.ts +82 -0
- package/lib/types/types/ant-registry.d.ts +43 -4
- package/lib/types/types/ant.d.ts +114 -96
- package/lib/types/types/common.d.ts +18 -74
- package/lib/types/types/faucet.d.ts +2 -2
- package/lib/types/types/io.d.ts +244 -158
- package/lib/types/types/token.d.ts +0 -12
- package/lib/types/utils/ant.d.ts +1 -12
- package/lib/types/utils/index.d.ts +0 -3
- package/lib/types/version.d.ts +1 -1
- package/package.json +36 -33
- package/lib/cjs/cli/cli.js +0 -822
- package/lib/cjs/cli/commands/antCommands.js +0 -113
- package/lib/cjs/cli/commands/arnsPurchaseCommands.js +0 -212
- package/lib/cjs/cli/commands/gatewayWriteCommands.js +0 -210
- package/lib/cjs/cli/commands/readCommands.js +0 -215
- package/lib/cjs/cli/commands/transfer.js +0 -159
- package/lib/cjs/cli/options.js +0 -470
- package/lib/cjs/cli/types.js +0 -2
- package/lib/cjs/cli/utils.js +0 -639
- package/lib/cjs/common/ant-registry.js +0 -155
- package/lib/cjs/common/ant-versions.js +0 -93
- package/lib/cjs/common/ant.js +0 -1182
- package/lib/cjs/common/arweave.js +0 -27
- package/lib/cjs/common/contracts/ao-process.js +0 -224
- package/lib/cjs/common/error.js +0 -64
- package/lib/cjs/common/faucet.js +0 -150
- package/lib/cjs/common/hyperbeam/hb.js +0 -173
- package/lib/cjs/common/index.js +0 -42
- package/lib/cjs/common/io.js +0 -1423
- package/lib/cjs/common/logger.js +0 -83
- package/lib/cjs/common/loggers/winston.js +0 -68
- package/lib/cjs/common/marketplace.js +0 -731
- package/lib/cjs/common/turbo.js +0 -223
- package/lib/cjs/constants.js +0 -41
- package/lib/cjs/node/index.js +0 -39
- package/lib/cjs/package.json +0 -1
- package/lib/cjs/types/ant-registry.js +0 -2
- package/lib/cjs/types/ant.js +0 -168
- package/lib/cjs/types/common.js +0 -2
- package/lib/cjs/types/faucet.js +0 -2
- package/lib/cjs/types/index.js +0 -37
- package/lib/cjs/types/io.js +0 -51
- package/lib/cjs/types/token.js +0 -116
- package/lib/cjs/utils/ant.js +0 -108
- package/lib/cjs/utils/ao.js +0 -432
- package/lib/cjs/utils/arweave.js +0 -285
- package/lib/cjs/utils/base64.js +0 -62
- package/lib/cjs/utils/hash.js +0 -56
- package/lib/cjs/utils/index.js +0 -38
- package/lib/cjs/utils/processes.js +0 -173
- package/lib/cjs/utils/random.js +0 -30
- package/lib/cjs/utils/schema.js +0 -15
- package/lib/cjs/utils/url.js +0 -37
- package/lib/cjs/version.js +0 -20
- package/lib/cjs/web/index.js +0 -41
- package/lib/esm/common/ant-versions.js +0 -87
- package/lib/esm/common/arweave.js +0 -21
- package/lib/esm/common/contracts/ao-process.js +0 -220
- package/lib/esm/common/hyperbeam/hb.js +0 -169
- package/lib/esm/common/marketplace.js +0 -724
- package/lib/esm/common/turbo.js +0 -215
- package/lib/esm/node/index.js +0 -20
- package/lib/esm/utils/ao.js +0 -420
- package/lib/esm/utils/arweave.js +0 -271
- package/lib/esm/utils/processes.js +0 -167
- package/lib/esm/web/index.js +0 -20
- package/lib/types/common/ant-versions.d.ts +0 -39
- package/lib/types/common/arweave.d.ts +0 -17
- package/lib/types/common/contracts/ao-process.d.ts +0 -47
- package/lib/types/common/hyperbeam/hb.d.ts +0 -88
- package/lib/types/common/marketplace.d.ts +0 -568
- package/lib/types/common/turbo.d.ts +0 -61
- package/lib/types/node/index.d.ts +0 -20
- package/lib/types/utils/ao.d.ts +0 -80
- package/lib/types/utils/arweave.d.ts +0 -79
- package/lib/types/utils/processes.d.ts +0 -39
- package/lib/types/web/index.d.ts +0 -20
|
@@ -0,0 +1,2182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (C) 2022-2024 Permanent Data Solutions, Inc.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Solana implementation of the ARIORead interface.
|
|
18
|
+
*
|
|
19
|
+
* Reads AR.IO protocol state directly from Solana PDAs using RPC,
|
|
20
|
+
* returning the same types that the AO implementation returns.
|
|
21
|
+
* This allows consumers to switch backends transparently.
|
|
22
|
+
*/
|
|
23
|
+
import { address, fetchEncodedAccount, fetchEncodedAccounts, getAddressDecoder, } from '@solana/kit';
|
|
24
|
+
import bs58 from 'bs58';
|
|
25
|
+
import { ARNS_RECORD_DISCRIMINATOR, RESERVED_NAME_DISCRIMINATOR, RETURNED_NAME_DISCRIMINATOR, getArnsConfigDecoder, getArnsRecordDecoder, getReservedNameDecoder, getReturnedNameDecoder, } from '@ar.io/solana-contracts/arns';
|
|
26
|
+
import { PRIMARY_NAME_DISCRIMINATOR, PRIMARY_NAME_REQUEST_DISCRIMINATOR, VAULT_DISCRIMINATOR, getPrimaryNameRequestDecoder, getVaultDecoder, } from '@ar.io/solana-contracts/core';
|
|
27
|
+
import { ALLOWLIST_ENTRY_DISCRIMINATOR, DELEGATION_DISCRIMINATOR, GATEWAY_DISCRIMINATOR, GatewayStatus, OBSERVATION_DISCRIMINATOR, WITHDRAWAL_DISCRIMINATOR, getDelegationDecoder, getGatewayDecoder, getWithdrawalDecoder, } from '@ar.io/solana-contracts/gar';
|
|
28
|
+
import { Logger } from '../common/logger.js';
|
|
29
|
+
import { SolanaANTRegistryReadable } from './ant-registry-readable.js';
|
|
30
|
+
import { getAssociatedTokenAddressKit } from './ata.js';
|
|
31
|
+
import { ARIO_ANT_PROGRAM_ID, ARIO_ARNS_PROGRAM_ID, ARIO_CORE_PROGRAM_ID, ARIO_GAR_PROGRAM_ID, ARNS_RECORD_ANT_OFFSET, RATE_SCALE, } from './constants.js';
|
|
32
|
+
import { computeLiveDelegationBalance, selectCompoundableDelegations, } from './delegation-math.js';
|
|
33
|
+
import { deserializeAllowlist, deserializeArioConfig, deserializeArnsRecord, deserializeDelegation, deserializeDemandFactor, deserializeEpoch, deserializeEpochSettings, deserializeEpochSettingsFull, deserializeGarSettings, deserializeGarSupplyCounters, deserializeGateway, deserializeGatewayWithAccumulator, deserializeObservation, deserializePrimaryName, deserializePrimaryNameRequest, deserializeRedelegationRecord, deserializeReservedName, deserializeReturnedName, deserializeVault, deserializeWithdrawal, } from './deserialize.js';
|
|
34
|
+
import { TOKEN_PROGRAM_ADDRESS } from './instruction.js';
|
|
35
|
+
import { getArioConfigPDA, getArnsRecordPDA, getArnsRecordPDAFromHash, getArnsSettingsPDA, getDemandFactorPDA, getEpochPDA, getEpochSettingsPDA, getGarSettingsPDA, getGatewayPDA, getGatewayRegistryPDA, getObserverLookupPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getReservedNamePDA, getReturnedNamePDA, getVaultPDA, } from './pda.js';
|
|
36
|
+
import { withRetry } from './retry.js';
|
|
37
|
+
const addressDecoder = getAddressDecoder();
|
|
38
|
+
/** All-zero address — equivalent of web3.js `PublicKey.default`. */
|
|
39
|
+
const DEFAULT_ADDRESS = address('11111111111111111111111111111111');
|
|
40
|
+
// =========================================
|
|
41
|
+
// Pagination helper
|
|
42
|
+
// =========================================
|
|
43
|
+
/**
|
|
44
|
+
* Normalize whatever `params.filters?.processId` shape came in (undefined,
|
|
45
|
+
* single string, or array — `PaginationParams` widens it past what ArNS
|
|
46
|
+
* actually supports) into a flat `string[]` ANT-mint list.
|
|
47
|
+
*/
|
|
48
|
+
function normalizeProcessIdFilter(raw) {
|
|
49
|
+
if (raw == null)
|
|
50
|
+
return [];
|
|
51
|
+
if (typeof raw === 'string')
|
|
52
|
+
return [raw];
|
|
53
|
+
if (Array.isArray(raw))
|
|
54
|
+
return raw.filter((v) => typeof v === 'string');
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* On-chain timestamps in the Solana programs are stored in **seconds**
|
|
59
|
+
* (matching the Lua-source convention as ported), but the public SDK
|
|
60
|
+
* surface — shared with the AO backend — exposes them in **milliseconds**
|
|
61
|
+
* because every JS consumer (UI, cranker, migration tools) feeds them into
|
|
62
|
+
* `new Date()`/`Date.now()` arithmetic. This boundary helper converts at
|
|
63
|
+
* the read-path projection layer so internal arithmetic in this file can
|
|
64
|
+
* keep working in seconds against the deserializer output, but everything
|
|
65
|
+
* we hand back to a caller is in JS-millisecond units.
|
|
66
|
+
*
|
|
67
|
+
* Use `toMsTimestamps(obj)` for projection-layer return values, and
|
|
68
|
+
* `secToMs(n)` for one-off scalars.
|
|
69
|
+
*/
|
|
70
|
+
const TIMESTAMP_FIELDS_MS = [
|
|
71
|
+
'startTimestamp',
|
|
72
|
+
'endTimestamp',
|
|
73
|
+
'distributionTimestamp',
|
|
74
|
+
];
|
|
75
|
+
function secToMs(n) {
|
|
76
|
+
return n * 1000;
|
|
77
|
+
}
|
|
78
|
+
function toMsTimestamps(obj) {
|
|
79
|
+
const out = { ...obj };
|
|
80
|
+
for (const f of TIMESTAMP_FIELDS_MS) {
|
|
81
|
+
const v = out[f];
|
|
82
|
+
if (typeof v === 'number')
|
|
83
|
+
out[f] = v * 1000;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Drop the SDK-internal extras (`name`, `owner`) and the `processId` re-key
|
|
89
|
+
* that `deserializeArnsRecord` adds, projecting back to the cross-backend
|
|
90
|
+
* `ArNSNameDataWithName` shape consumers expect.
|
|
91
|
+
*
|
|
92
|
+
* Timestamps are converted from on-chain seconds to JS milliseconds here
|
|
93
|
+
* (see `toMsTimestamps` above for rationale).
|
|
94
|
+
*/
|
|
95
|
+
function arnsRecordToWithName(record) {
|
|
96
|
+
return {
|
|
97
|
+
name: record.name,
|
|
98
|
+
processId: record.processId,
|
|
99
|
+
startTimestamp: secToMs(record.startTimestamp),
|
|
100
|
+
undernameLimit: record.undernameLimit,
|
|
101
|
+
purchasePrice: record.purchasePrice,
|
|
102
|
+
type: record.type,
|
|
103
|
+
...('endTimestamp' in record
|
|
104
|
+
? { endTimestamp: secToMs(record.endTimestamp) }
|
|
105
|
+
: {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* TTL for {@link SolanaARIOReadable.getCachedAccount}. DemandFactor / config
|
|
110
|
+
* accounts change on the order of epochs, so a few seconds of staleness is a
|
|
111
|
+
* safe trade for collapsing burst reads (e.g. per-row cost lookups).
|
|
112
|
+
*/
|
|
113
|
+
const CONFIG_CACHE_TTL_MS = 30_000;
|
|
114
|
+
function paginate(items, params) {
|
|
115
|
+
const limit = params?.limit ?? 100;
|
|
116
|
+
const startIdx = params?.cursor ? parseInt(params.cursor, 10) : 0;
|
|
117
|
+
const page = items.slice(startIdx, startIdx + limit);
|
|
118
|
+
const hasMore = startIdx + limit < items.length;
|
|
119
|
+
return {
|
|
120
|
+
items: page,
|
|
121
|
+
limit,
|
|
122
|
+
totalItems: items.length,
|
|
123
|
+
sortOrder: params?.sortOrder ?? 'asc',
|
|
124
|
+
hasMore,
|
|
125
|
+
nextCursor: hasMore ? String(startIdx + limit) : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Solana-backed read-only client for the AR.IO protocol.
|
|
130
|
+
*
|
|
131
|
+
* Usage:
|
|
132
|
+
* ```ts
|
|
133
|
+
* import { createSolanaRpc } from '@solana/kit';
|
|
134
|
+
* import { SolanaARIOReadable } from '@ar.io/sdk/solana';
|
|
135
|
+
*
|
|
136
|
+
* const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
|
|
137
|
+
* const ario = new SolanaARIOReadable({ rpc });
|
|
138
|
+
*
|
|
139
|
+
* const gateway = await ario.getGateway({ address: 'GatewayOperatorPubkey...' });
|
|
140
|
+
* const record = await ario.getArNSRecord({ name: 'ardrive' });
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export class SolanaARIOReadable {
|
|
144
|
+
rpc;
|
|
145
|
+
commitment;
|
|
146
|
+
logger;
|
|
147
|
+
// Allow overriding program IDs (e.g., for devnet/localnet)
|
|
148
|
+
coreProgram;
|
|
149
|
+
garProgram;
|
|
150
|
+
arnsProgram;
|
|
151
|
+
antProgram;
|
|
152
|
+
// Memoized ARIO mint address (read once from ArioConfig.mint and reused
|
|
153
|
+
// for every SPL-ATA derivation in getBalance/getBalances).
|
|
154
|
+
_arioMint;
|
|
155
|
+
// Short-TTL cache for slow-changing config-ish accounts (DemandFactor,
|
|
156
|
+
// ArnsConfig, etc.). Collapses the many identical reads a UI fires in a
|
|
157
|
+
// burst — e.g. the returned-names page running `getCostDetails` per row,
|
|
158
|
+
// each re-reading the same DemandFactor PDA — into a single network call.
|
|
159
|
+
_accountCache = new Map();
|
|
160
|
+
constructor(config) {
|
|
161
|
+
this.rpc = config.rpc;
|
|
162
|
+
this.commitment = config.commitment ?? 'confirmed';
|
|
163
|
+
this.logger = config.logger ?? Logger.default;
|
|
164
|
+
this.coreProgram = config.coreProgramId ?? ARIO_CORE_PROGRAM_ID;
|
|
165
|
+
this.garProgram = config.garProgramId ?? ARIO_GAR_PROGRAM_ID;
|
|
166
|
+
this.arnsProgram = config.arnsProgramId ?? ARIO_ARNS_PROGRAM_ID;
|
|
167
|
+
this.antProgram = config.antProgramId ?? ARIO_ANT_PROGRAM_ID;
|
|
168
|
+
}
|
|
169
|
+
/** Helper to fetch an encoded account (kit's replacement for Connection.getAccountInfo). */
|
|
170
|
+
async getAccount(pda) {
|
|
171
|
+
return withRetry(() => fetchEncodedAccount(this.rpc, pda, {
|
|
172
|
+
commitment: this.commitment,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Like {@link getAccount} but caches the result per-PDA for `ttlMs`. Use only
|
|
177
|
+
* for accounts that change slowly (DemandFactor, ArnsConfig) where a few
|
|
178
|
+
* seconds of staleness is acceptable in exchange for collapsing repeated
|
|
179
|
+
* reads. A successful fetch is cached; misses (`exists: false`) are not.
|
|
180
|
+
*/
|
|
181
|
+
async getCachedAccount(pda, ttlMs = CONFIG_CACHE_TTL_MS) {
|
|
182
|
+
const key = String(pda);
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
const hit = this._accountCache.get(key);
|
|
185
|
+
if (hit && hit.expiresAt > now)
|
|
186
|
+
return hit.account;
|
|
187
|
+
const account = await this.getAccount(pda);
|
|
188
|
+
if (account.exists) {
|
|
189
|
+
this._accountCache.set(key, { account, expiresAt: now + ttlMs });
|
|
190
|
+
}
|
|
191
|
+
return account;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Helper for `getProgramAccounts` with a discriminator memcmp filter.
|
|
195
|
+
*
|
|
196
|
+
* Pass the Codama-generated `<NAME>_DISCRIMINATOR: Uint8Array` constant
|
|
197
|
+
* directly — kit's RPC requires a base58 string for `memcmp.bytes`, so
|
|
198
|
+
* we bs58-encode here to keep call sites from doing it inline (and to
|
|
199
|
+
* keep the IDL-derived bytes as the single source of truth).
|
|
200
|
+
*/
|
|
201
|
+
async getAccountsByDiscriminator(programId, discriminator, extraFilters = []) {
|
|
202
|
+
const filters = [
|
|
203
|
+
{
|
|
204
|
+
memcmp: {
|
|
205
|
+
offset: 0n,
|
|
206
|
+
bytes: bs58.encode(discriminator),
|
|
207
|
+
encoding: 'base58',
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
...extraFilters,
|
|
211
|
+
];
|
|
212
|
+
// Note: kit's getProgramAccounts returns a plain array (no context wrapper)
|
|
213
|
+
// when called without `withContext: true`. With `encoding: 'base64'`, each
|
|
214
|
+
// account's `data` is a `[base64, 'base64']` tuple. We bypass kit's strict
|
|
215
|
+
// generic overload typing here with a cast — the runtime shape is stable.
|
|
216
|
+
const result = await withRetry(() => this.rpc
|
|
217
|
+
.getProgramAccounts(programId, {
|
|
218
|
+
commitment: this.commitment,
|
|
219
|
+
encoding: 'base64',
|
|
220
|
+
filters,
|
|
221
|
+
})
|
|
222
|
+
.send());
|
|
223
|
+
return result.map((entry) => ({
|
|
224
|
+
pubkey: entry.pubkey,
|
|
225
|
+
data: Buffer.from(entry.account.data[0], 'base64'),
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Batch-fetch the `cumulative_reward_per_token` accumulator for every gateway
|
|
230
|
+
* in `operatorAddresses`. Returns a Map keyed by base58 operator address.
|
|
231
|
+
* Used by the delegate readers below to compute the live delegation balance
|
|
232
|
+
* without an on-chain settlement call (see {@link computeLiveDelegationBalance}
|
|
233
|
+
* and `INVARIANTS.md` in the contracts repo). Missing gateways are silently
|
|
234
|
+
* skipped — callers fall back to the stale `Delegation.amount` for those
|
|
235
|
+
* (the accumulator delta is 0 and live == stored anyway when the gateway
|
|
236
|
+
* has no rewards to distribute).
|
|
237
|
+
*/
|
|
238
|
+
async getGatewayAccumulators(operatorAddresses) {
|
|
239
|
+
const unique = Array.from(new Set(operatorAddresses));
|
|
240
|
+
if (unique.length === 0)
|
|
241
|
+
return new Map();
|
|
242
|
+
const pdas = await Promise.all(unique.map(async (op) => (await getGatewayPDA(address(op), this.garProgram))[0]));
|
|
243
|
+
const accounts = await withRetry(() => fetchEncodedAccounts(this.rpc, pdas, {
|
|
244
|
+
commitment: this.commitment,
|
|
245
|
+
}));
|
|
246
|
+
const out = new Map();
|
|
247
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
248
|
+
const acct = accounts[i];
|
|
249
|
+
if (!acct.exists)
|
|
250
|
+
continue;
|
|
251
|
+
try {
|
|
252
|
+
// Internal variant: surfaces the u128 accumulator that the public
|
|
253
|
+
// `deserializeGateway` deliberately drops (BigInt is not
|
|
254
|
+
// JSON-serializable and would leak through getGateway).
|
|
255
|
+
const gw = deserializeGatewayWithAccumulator(Buffer.from(acct.data));
|
|
256
|
+
out.set(unique[i], gw.cumulativeRewardPerToken);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// Skip malformed; the caller will fall back to the raw delegation amount.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
/** Read the gateway registry and return addresses in registry index order */
|
|
265
|
+
async getRegistryGatewayAddresses() {
|
|
266
|
+
const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
|
|
267
|
+
const registryAccount = await this.getAccount(registryPda);
|
|
268
|
+
if (!registryAccount.exists)
|
|
269
|
+
return [];
|
|
270
|
+
const registryData = Buffer.from(registryAccount.data);
|
|
271
|
+
const count = registryData.readUInt32LE(40); // 8 disc + 32 authority
|
|
272
|
+
const slotsOffset = 48; // 8 + 32 + 4 + 4
|
|
273
|
+
// GatewaySlot: address(32) + composite_weight(8) + start_timestamp(8)
|
|
274
|
+
// + status(1) + _padding(7) = 56 bytes (see ario-gar
|
|
275
|
+
// state/mod.rs::GatewaySlot).
|
|
276
|
+
const SLOT_STRIDE = 56;
|
|
277
|
+
const addresses = [];
|
|
278
|
+
for (let i = 0; i < count && i < 3000; i++) {
|
|
279
|
+
const slotOffset = slotsOffset + i * SLOT_STRIDE;
|
|
280
|
+
const addr = addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32));
|
|
281
|
+
addresses.push(addr);
|
|
282
|
+
}
|
|
283
|
+
return addresses;
|
|
284
|
+
}
|
|
285
|
+
// =========================================
|
|
286
|
+
// Protocol info
|
|
287
|
+
// =========================================
|
|
288
|
+
async getInfo() {
|
|
289
|
+
const [[configPda], [epochSettingsPda]] = await Promise.all([
|
|
290
|
+
getArioConfigPDA(this.coreProgram),
|
|
291
|
+
getEpochSettingsPDA(this.garProgram),
|
|
292
|
+
]);
|
|
293
|
+
const [configAccount, epochAccount] = await Promise.all([
|
|
294
|
+
this.getAccount(configPda),
|
|
295
|
+
this.getAccount(epochSettingsPda),
|
|
296
|
+
]);
|
|
297
|
+
const config = configAccount.exists
|
|
298
|
+
? deserializeArioConfig(Buffer.from(configAccount.data))
|
|
299
|
+
: null;
|
|
300
|
+
const epoch = epochAccount.exists
|
|
301
|
+
? deserializeEpochSettings(Buffer.from(epochAccount.data))
|
|
302
|
+
: null;
|
|
303
|
+
return {
|
|
304
|
+
Ticker: 'ARIO',
|
|
305
|
+
Name: 'AR.IO',
|
|
306
|
+
Logo: '',
|
|
307
|
+
Denomination: 6,
|
|
308
|
+
Handlers: [],
|
|
309
|
+
LastCreatedEpochIndex: 0,
|
|
310
|
+
LastDistributedEpochIndex: 0,
|
|
311
|
+
...(config
|
|
312
|
+
? {
|
|
313
|
+
totalSupply: config.totalSupply,
|
|
314
|
+
protocolBalance: config.protocolBalance,
|
|
315
|
+
}
|
|
316
|
+
: {}),
|
|
317
|
+
...(epoch ? { epochSettings: epoch } : {}),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
async getTokenSupply() {
|
|
321
|
+
const [configPda] = await getArioConfigPDA(this.coreProgram);
|
|
322
|
+
const [garSettingsPda] = await getGarSettingsPDA(this.garProgram);
|
|
323
|
+
const [configAccount, garSettingsAccount] = await Promise.all([
|
|
324
|
+
this.getAccount(configPda),
|
|
325
|
+
this.getAccount(garSettingsPda),
|
|
326
|
+
]);
|
|
327
|
+
if (!configAccount.exists) {
|
|
328
|
+
throw new Error('ArioConfig account not found');
|
|
329
|
+
}
|
|
330
|
+
const config = deserializeArioConfig(Buffer.from(configAccount.data));
|
|
331
|
+
// Supply counters from GatewaySettings (staked/delegated/withdrawn).
|
|
332
|
+
// Falls back to 0 if GatewaySettings doesn't exist yet or is at the
|
|
333
|
+
// old size (pre-supply-counters layout).
|
|
334
|
+
let staked = 0;
|
|
335
|
+
let delegated = 0;
|
|
336
|
+
let withdrawn = 0;
|
|
337
|
+
// `protocolBalance` is the REWARD RESERVE: the live balance of the protocol
|
|
338
|
+
// token account the epoch cranker emits from (ario-gar `distribute_epoch`
|
|
339
|
+
// reads `protocol_token_account.amount`). This matches AO's `protocolBalance`
|
|
340
|
+
// semantics (the qNvAoz0 reserve) and makes the six buckets sum to `total`:
|
|
341
|
+
// circulating + locked + staked + delegated + withdrawn + protocolBalance == total
|
|
342
|
+
//
|
|
343
|
+
// It is deliberately NOT `ArioConfig.protocol_balance`, which is a *folded*
|
|
344
|
+
// accounting field (= reserve + staked + delegated + withdrawn) and so
|
|
345
|
+
// double-counts the staking buckets — surfacing it as "protocol balance"
|
|
346
|
+
// over-reports the reward reserve by tens of millions of ARIO. We fall back
|
|
347
|
+
// to the folded value only when GatewaySettings or the token account can't
|
|
348
|
+
// be read (e.g. pre-init).
|
|
349
|
+
let protocolBalance = config.protocolBalance;
|
|
350
|
+
if (garSettingsAccount.exists) {
|
|
351
|
+
const garData = Buffer.from(garSettingsAccount.data);
|
|
352
|
+
try {
|
|
353
|
+
const counters = deserializeGarSupplyCounters(garData);
|
|
354
|
+
staked = counters.totalStaked;
|
|
355
|
+
delegated = counters.totalDelegated;
|
|
356
|
+
withdrawn = counters.totalWithdrawn;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Old-layout account without supply counters — fall back to 0
|
|
360
|
+
}
|
|
361
|
+
// The protocol token account is pinned in GatewaySettings at offset 189
|
|
362
|
+
// (see ario-gar state/mod.rs::GatewaySettings: 8 disc + 32 authority +
|
|
363
|
+
// 32 mint + 6×8 economic params + 4 max_delegates + 1 migration_active +
|
|
364
|
+
// 32 migration_authority + 32 stake_token_account = 189; the pubkey runs
|
|
365
|
+
// [189, 221)). Read its live SPL balance as the reward reserve.
|
|
366
|
+
if (garData.length >= 221) {
|
|
367
|
+
try {
|
|
368
|
+
const protocolTokenAccount = addressDecoder.decode(garData.subarray(189, 221));
|
|
369
|
+
const reserve = await this.getTokenAccountAmount(protocolTokenAccount);
|
|
370
|
+
if (reserve !== null)
|
|
371
|
+
protocolBalance = reserve;
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// Couldn't read the reserve — keep the folded fallback.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
total: config.totalSupply,
|
|
380
|
+
circulating: config.circulatingSupply,
|
|
381
|
+
locked: config.lockedSupply,
|
|
382
|
+
staked,
|
|
383
|
+
delegated,
|
|
384
|
+
withdrawn,
|
|
385
|
+
protocolBalance,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Read the `amount` (in mARIO) of an SPL token account directly by address.
|
|
390
|
+
* Returns `null` if the account doesn't exist or is too small to be a token
|
|
391
|
+
* account, so callers can distinguish "absent" from a real zero balance.
|
|
392
|
+
*
|
|
393
|
+
* Uses a portable little-endian u64 decode — some browser bundlers strip the
|
|
394
|
+
* BigInt readers from the `buffer` shim's prototype (see `getBalance`).
|
|
395
|
+
*/
|
|
396
|
+
async getTokenAccountAmount(tokenAccount) {
|
|
397
|
+
const account = await this.getAccount(tokenAccount);
|
|
398
|
+
if (!account.exists)
|
|
399
|
+
return null;
|
|
400
|
+
const data = account.data;
|
|
401
|
+
if (data.length < 72)
|
|
402
|
+
return null;
|
|
403
|
+
let amount = 0n;
|
|
404
|
+
for (let i = 7; i >= 0; i--) {
|
|
405
|
+
amount = (amount << 8n) | BigInt(data[64 + i]);
|
|
406
|
+
}
|
|
407
|
+
// ARIO supply caps at 1B * 1e6 mARIO ≈ 2^50, well under MAX_SAFE_INTEGER.
|
|
408
|
+
return Number(amount);
|
|
409
|
+
}
|
|
410
|
+
// =========================================
|
|
411
|
+
// Balance read methods
|
|
412
|
+
// =========================================
|
|
413
|
+
/**
|
|
414
|
+
* Resolve the ARIO SPL mint address from the on-chain `ArioConfig`.
|
|
415
|
+
*
|
|
416
|
+
* `ArioConfig` layout: [8 disc][32 authority][32 mint][...]. We decode
|
|
417
|
+
* the mint at offset 40 and cache it for the lifetime of this instance —
|
|
418
|
+
* the mint never changes once the protocol is deployed.
|
|
419
|
+
*/
|
|
420
|
+
async getArioMint() {
|
|
421
|
+
if (this._arioMint)
|
|
422
|
+
return this._arioMint;
|
|
423
|
+
const [configPda] = await getArioConfigPDA(this.coreProgram);
|
|
424
|
+
const account = await this.getAccount(configPda);
|
|
425
|
+
if (!account.exists) {
|
|
426
|
+
throw new Error(`ArioConfig not found at ${configPda} on coreProgram ${this.coreProgram} — is the program deployed and initialized?`);
|
|
427
|
+
}
|
|
428
|
+
const data = Buffer.from(account.data);
|
|
429
|
+
const mint = addressDecoder.decode(data.subarray(40, 72));
|
|
430
|
+
this._arioMint = mint;
|
|
431
|
+
return mint;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Liquid ARIO balance for an address.
|
|
435
|
+
*
|
|
436
|
+
* On Solana the ARIO token is a real SPL mint, so the canonical liquid
|
|
437
|
+
* balance lives on the user's Associated Token Account — *not* the
|
|
438
|
+
* `ario-core::Balance` PDA. The Balance PDA is only populated by the
|
|
439
|
+
* one-shot AO-to-Solana migration importer for legacy snapshot accounts;
|
|
440
|
+
* spending it requires a separate claim flow that mints/transfers SPL
|
|
441
|
+
* tokens to the user's ATA. Steady-state instructions like `buy_name`,
|
|
442
|
+
* gateway/delegate stake, and ARIO transfers all move SPL tokens, so the
|
|
443
|
+
* ATA is what every UI and on-chain caller cares about.
|
|
444
|
+
*
|
|
445
|
+
* Returns 0 if the user has no ATA initialized yet.
|
|
446
|
+
*/
|
|
447
|
+
async getBalance({ address: owner, }) {
|
|
448
|
+
const mint = await this.getArioMint();
|
|
449
|
+
const ata = await getAssociatedTokenAddressKit(mint, address(owner));
|
|
450
|
+
const account = await this.getAccount(ata);
|
|
451
|
+
if (!account.exists)
|
|
452
|
+
return 0;
|
|
453
|
+
// SPL Token Account layout: [0..32]=mint, [32..64]=owner, [64..72]=amount(u64 LE), …
|
|
454
|
+
const data = account.data;
|
|
455
|
+
if (data.length < 72)
|
|
456
|
+
return 0;
|
|
457
|
+
// NOTE: avoid `Buffer.readBigUInt64LE` — some browser bundlers (notably
|
|
458
|
+
// arns-react's Vite output) strip the BigInt readers from the
|
|
459
|
+
// `buffer@6.0.3` shim's prototype. Manual little-endian u64 decode is
|
|
460
|
+
// portable across every JS runtime.
|
|
461
|
+
let amount = 0n;
|
|
462
|
+
for (let i = 7; i >= 0; i--) {
|
|
463
|
+
amount = (amount << 8n) | BigInt(data[64 + i]);
|
|
464
|
+
}
|
|
465
|
+
// ARIO supply caps at 1B * 1e6 mARIO ≈ 2^50, well under Number.MAX_SAFE_INTEGER.
|
|
466
|
+
return Number(amount);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Enumerate liquid ARIO balances by querying the SPL Token program for
|
|
470
|
+
* every initialized token account on the ARIO mint.
|
|
471
|
+
*
|
|
472
|
+
* Filters: token-account size = 165, mint at offset 0. We then decode
|
|
473
|
+
* `owner` (offset 32) and `amount` (offset 64) from each.
|
|
474
|
+
*/
|
|
475
|
+
async getBalances(params) {
|
|
476
|
+
const mint = await this.getArioMint();
|
|
477
|
+
const filters = [
|
|
478
|
+
{ dataSize: 165n },
|
|
479
|
+
{
|
|
480
|
+
memcmp: {
|
|
481
|
+
offset: 0n,
|
|
482
|
+
bytes: mint,
|
|
483
|
+
encoding: 'base58',
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
];
|
|
487
|
+
const result = await withRetry(() => this.rpc
|
|
488
|
+
.getProgramAccounts(TOKEN_PROGRAM_ADDRESS, {
|
|
489
|
+
commitment: this.commitment,
|
|
490
|
+
encoding: 'base64',
|
|
491
|
+
filters,
|
|
492
|
+
})
|
|
493
|
+
.send());
|
|
494
|
+
const items = [];
|
|
495
|
+
for (const entry of result) {
|
|
496
|
+
try {
|
|
497
|
+
const data = Buffer.from(entry.account.data[0], 'base64');
|
|
498
|
+
if (data.length < 72)
|
|
499
|
+
continue;
|
|
500
|
+
const ownerAddress = addressDecoder.decode(data.subarray(32, 64));
|
|
501
|
+
const amount = Number(data.readBigUInt64LE(64));
|
|
502
|
+
if (amount > 0) {
|
|
503
|
+
items.push({ address: ownerAddress, balance: amount });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// Skip malformed accounts
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return paginate(items, params);
|
|
511
|
+
}
|
|
512
|
+
// =========================================
|
|
513
|
+
// Vault read methods
|
|
514
|
+
// =========================================
|
|
515
|
+
async getVault({ address: owner, vaultId, }) {
|
|
516
|
+
const [pda] = await getVaultPDA(address(owner), BigInt(vaultId), this.coreProgram);
|
|
517
|
+
const account = await this.getAccount(pda);
|
|
518
|
+
if (!account.exists) {
|
|
519
|
+
throw new Error(`Vault not found for ${owner}:${vaultId}`);
|
|
520
|
+
}
|
|
521
|
+
const vault = deserializeVault(Buffer.from(account.data));
|
|
522
|
+
return {
|
|
523
|
+
balance: vault.balance,
|
|
524
|
+
startTimestamp: secToMs(vault.startTimestamp),
|
|
525
|
+
endTimestamp: secToMs(vault.endTimestamp),
|
|
526
|
+
controller: vault.controller,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
async getVaults(params) {
|
|
530
|
+
const accounts = await this.getAccountsByDiscriminator(this.coreProgram, VAULT_DISCRIMINATOR);
|
|
531
|
+
const items = [];
|
|
532
|
+
for (const { pubkey, data } of accounts) {
|
|
533
|
+
try {
|
|
534
|
+
const vault = deserializeVault(data);
|
|
535
|
+
items.push({
|
|
536
|
+
address: vault.owner,
|
|
537
|
+
vaultId: pubkey,
|
|
538
|
+
balance: vault.balance,
|
|
539
|
+
startTimestamp: secToMs(vault.startTimestamp),
|
|
540
|
+
endTimestamp: secToMs(vault.endTimestamp),
|
|
541
|
+
controller: vault.controller,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// Skip malformed accounts
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return paginate(items, params);
|
|
549
|
+
}
|
|
550
|
+
// =========================================
|
|
551
|
+
// Gateway read methods
|
|
552
|
+
// =========================================
|
|
553
|
+
async getGateway({ address: addr }) {
|
|
554
|
+
const [pda] = await getGatewayPDA(address(addr), this.garProgram);
|
|
555
|
+
const account = await this.getAccount(pda);
|
|
556
|
+
if (!account.exists) {
|
|
557
|
+
throw new Error(`Gateway not found for operator ${addr}`);
|
|
558
|
+
}
|
|
559
|
+
const gw = deserializeGateway(Buffer.from(account.data));
|
|
560
|
+
const { operator: _, ...gateway } = gw;
|
|
561
|
+
return toMsTimestamps(gateway);
|
|
562
|
+
}
|
|
563
|
+
async getGateways(params) {
|
|
564
|
+
const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
|
|
565
|
+
const registryAccount = await this.getAccount(registryPda);
|
|
566
|
+
if (!registryAccount.exists) {
|
|
567
|
+
return paginate([], params);
|
|
568
|
+
}
|
|
569
|
+
const registryData = Buffer.from(registryAccount.data);
|
|
570
|
+
const count = registryData.readUInt32LE(40);
|
|
571
|
+
const slotsOffset = 48;
|
|
572
|
+
// GatewaySlot = address(32) + composite_weight(8) + start_timestamp(8)
|
|
573
|
+
// + status(1) + _padding(7) = 56 bytes (see ario-gar
|
|
574
|
+
// state/mod.rs::GatewaySlot). A previous off-by-16-bytes-per-slot
|
|
575
|
+
// stride silently read garbage for slots 1+, returning at most
|
|
576
|
+
// one gateway no matter how many had joined.
|
|
577
|
+
const SLOT_STRIDE = 56;
|
|
578
|
+
const gatewayAddresses = [];
|
|
579
|
+
for (let i = 0; i < count && i < 3000; i++) {
|
|
580
|
+
const slotOffset = slotsOffset + i * SLOT_STRIDE;
|
|
581
|
+
const addr = addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32));
|
|
582
|
+
if (addr !== DEFAULT_ADDRESS) {
|
|
583
|
+
gatewayAddresses.push(addr);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Batch fetch gateway PDAs (kit has no hard limit but keep 100-at-a-time
|
|
587
|
+
// for sensible RPC request sizes).
|
|
588
|
+
const allItems = [];
|
|
589
|
+
for (let i = 0; i < gatewayAddresses.length; i += 100) {
|
|
590
|
+
const batch = gatewayAddresses.slice(i, i + 100);
|
|
591
|
+
const pdas = await Promise.all(batch.map(async (addr) => (await getGatewayPDA(addr, this.garProgram))[0]));
|
|
592
|
+
const accounts = await fetchEncodedAccounts(this.rpc, pdas, {
|
|
593
|
+
commitment: this.commitment,
|
|
594
|
+
});
|
|
595
|
+
for (const acct of accounts) {
|
|
596
|
+
if (!acct.exists)
|
|
597
|
+
continue;
|
|
598
|
+
try {
|
|
599
|
+
const gw = deserializeGateway(Buffer.from(acct.data));
|
|
600
|
+
allItems.push(toMsTimestamps({ ...gw, gatewayAddress: gw.operator }));
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// Skip malformed
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return paginate(allItems, params);
|
|
608
|
+
}
|
|
609
|
+
async getGatewayDelegates(params) {
|
|
610
|
+
const gateway = address(params.address);
|
|
611
|
+
// Filter delegations by gateway pubkey at offset 8 (after discriminator)
|
|
612
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR, [
|
|
613
|
+
{
|
|
614
|
+
memcmp: { offset: 8n, bytes: gateway, encoding: 'base58' },
|
|
615
|
+
},
|
|
616
|
+
]);
|
|
617
|
+
// Fetch this gateway's current reward accumulator so we can return live
|
|
618
|
+
// balances (raw `Delegation.amount` is stale between settlements — see
|
|
619
|
+
// INVARIANTS.md and `computeLiveDelegationBalance`).
|
|
620
|
+
const accumulators = await this.getGatewayAccumulators([gateway]);
|
|
621
|
+
const cumulative = accumulators.get(gateway) ?? 0n;
|
|
622
|
+
const items = [];
|
|
623
|
+
for (const { data } of accounts) {
|
|
624
|
+
try {
|
|
625
|
+
const del = deserializeDelegation(data);
|
|
626
|
+
items.push({
|
|
627
|
+
address: del.delegator,
|
|
628
|
+
delegatedStake: computeLiveDelegationBalance({
|
|
629
|
+
delegatedStake: del.delegatedStake,
|
|
630
|
+
rewardDebt: del.rewardDebt,
|
|
631
|
+
cumulativeRewardPerToken: cumulative,
|
|
632
|
+
}),
|
|
633
|
+
startTimestamp: secToMs(del.startTimestamp),
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
// Skip malformed
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return paginate(items, params);
|
|
641
|
+
}
|
|
642
|
+
async getGatewayDelegateAllowList(params) {
|
|
643
|
+
const gateway = address(params.address);
|
|
644
|
+
// Filter allowlist entries by gateway pubkey at offset 8
|
|
645
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, ALLOWLIST_ENTRY_DISCRIMINATOR, [
|
|
646
|
+
{
|
|
647
|
+
memcmp: { offset: 8n, bytes: gateway, encoding: 'base58' },
|
|
648
|
+
},
|
|
649
|
+
]);
|
|
650
|
+
const items = [];
|
|
651
|
+
for (const { data } of accounts) {
|
|
652
|
+
try {
|
|
653
|
+
const entry = deserializeAllowlist(data);
|
|
654
|
+
items.push(entry.delegate);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// Skip malformed
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return paginate(items, params);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Returns every delegation a wallet currently has, covering both halves
|
|
664
|
+
* of the `Delegation` union:
|
|
665
|
+
*
|
|
666
|
+
* - `type: 'stake'` — active `Delegation` PDAs (filtered by delegator at
|
|
667
|
+
* memcmp offset 40 = 8 disc + 32 gateway).
|
|
668
|
+
* - `type: 'vault'` — pending delegate-stake withdrawals: `Withdrawal`
|
|
669
|
+
* PDAs filtered by owner at memcmp offset 8 (= 8 disc), then narrowed
|
|
670
|
+
* client-side to `isDelegate: true`. Operator-stake withdrawals are
|
|
671
|
+
* excluded — those are surfaced via `getWithdrawals` /
|
|
672
|
+
* `getGatewayVaults`.
|
|
673
|
+
*
|
|
674
|
+
* Both queries run in parallel; consumers see a single merged result
|
|
675
|
+
* matching the cross-backend interface contract.
|
|
676
|
+
*/
|
|
677
|
+
async getDelegations(params) {
|
|
678
|
+
const owner = address(params.address);
|
|
679
|
+
const [delegationAccounts, withdrawalAccounts] = await Promise.all([
|
|
680
|
+
// Active delegations — `Delegation` PDA layout:
|
|
681
|
+
// disc(8) + gateway(32) + delegator(32) + ... — delegator at offset 40.
|
|
682
|
+
this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR, [
|
|
683
|
+
{
|
|
684
|
+
memcmp: { offset: 40n, bytes: owner, encoding: 'base58' },
|
|
685
|
+
},
|
|
686
|
+
]),
|
|
687
|
+
// Pending vault delegations — `Withdrawal` PDA layout:
|
|
688
|
+
// disc(8) + owner(32) + withdrawal_id(8) + gateway(32) + ... — owner at offset 8.
|
|
689
|
+
this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR, [
|
|
690
|
+
{
|
|
691
|
+
memcmp: { offset: 8n, bytes: owner, encoding: 'base58' },
|
|
692
|
+
},
|
|
693
|
+
]),
|
|
694
|
+
]);
|
|
695
|
+
const decodedDelegations = [];
|
|
696
|
+
for (const { pubkey, data } of delegationAccounts) {
|
|
697
|
+
try {
|
|
698
|
+
decodedDelegations.push({
|
|
699
|
+
pubkey: pubkey,
|
|
700
|
+
del: deserializeDelegation(data),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// Skip malformed
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Batch-fetch each referenced gateway's reward accumulator so we can
|
|
708
|
+
// return live balances. See INVARIANTS.md and `computeLiveDelegationBalance`.
|
|
709
|
+
const accumulators = await this.getGatewayAccumulators(decodedDelegations.map(({ del }) => del.gateway));
|
|
710
|
+
const stakeItems = decodedDelegations.map(({ pubkey, del }) => ({
|
|
711
|
+
type: 'stake',
|
|
712
|
+
gatewayAddress: del.gateway,
|
|
713
|
+
delegationId: pubkey,
|
|
714
|
+
startTimestamp: secToMs(del.startTimestamp),
|
|
715
|
+
balance: computeLiveDelegationBalance({
|
|
716
|
+
delegatedStake: del.delegatedStake,
|
|
717
|
+
rewardDebt: del.rewardDebt,
|
|
718
|
+
cumulativeRewardPerToken: accumulators.get(del.gateway) ?? 0n,
|
|
719
|
+
}),
|
|
720
|
+
}));
|
|
721
|
+
const vaultItems = [];
|
|
722
|
+
for (const { pubkey, data } of withdrawalAccounts) {
|
|
723
|
+
try {
|
|
724
|
+
const w = deserializeWithdrawal(data);
|
|
725
|
+
// Delegate-stake decreases only. Operator-stake withdrawals (the
|
|
726
|
+
// operator's own decreaseOperatorStake calls) belong on
|
|
727
|
+
// `getWithdrawals` / `getGatewayVaults`, not `getDelegations`.
|
|
728
|
+
if (!w.isDelegate)
|
|
729
|
+
continue;
|
|
730
|
+
vaultItems.push({
|
|
731
|
+
type: 'vault',
|
|
732
|
+
gatewayAddress: w.gateway,
|
|
733
|
+
delegationId: pubkey,
|
|
734
|
+
vaultId: w.vaultId,
|
|
735
|
+
balance: w.balance,
|
|
736
|
+
startTimestamp: secToMs(w.startTimestamp),
|
|
737
|
+
endTimestamp: secToMs(w.endTimestamp),
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
// Skip malformed
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return paginate([...stakeItems, ...vaultItems], params);
|
|
745
|
+
}
|
|
746
|
+
async getAllowedDelegates(params) {
|
|
747
|
+
return this.getGatewayDelegateAllowList(params);
|
|
748
|
+
}
|
|
749
|
+
async getGatewayVaults(params) {
|
|
750
|
+
const gateway = address(params.address);
|
|
751
|
+
// Withdrawal: disc(8) + owner(32) + withdrawal_id(8) + gateway(32)
|
|
752
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR, [
|
|
753
|
+
{
|
|
754
|
+
memcmp: { offset: 48n, bytes: gateway, encoding: 'base58' },
|
|
755
|
+
},
|
|
756
|
+
]);
|
|
757
|
+
const items = [];
|
|
758
|
+
for (const { pubkey, data } of accounts) {
|
|
759
|
+
try {
|
|
760
|
+
const w = deserializeWithdrawal(data);
|
|
761
|
+
if (!w.isDelegate) {
|
|
762
|
+
items.push({
|
|
763
|
+
cursorId: pubkey,
|
|
764
|
+
vaultId: w.vaultId,
|
|
765
|
+
balance: w.balance,
|
|
766
|
+
startTimestamp: secToMs(w.startTimestamp),
|
|
767
|
+
endTimestamp: secToMs(w.endTimestamp),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
// Skip malformed
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return paginate(items, params);
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Return every pending stake withdrawal owned by `address` — operator-stake
|
|
779
|
+
* decreases (`isDelegate: false`) and delegate-stake decreases
|
|
780
|
+
* (`isDelegate: true`) in one paginated result. A withdrawal is claimable
|
|
781
|
+
* when `Date.now() >= endTimestamp`; release the funds via
|
|
782
|
+
* `claimWithdrawal({ withdrawalId: item.vaultId })`.
|
|
783
|
+
*
|
|
784
|
+
* Solana-only: AO releases withdrawals automatically at maturity and has no
|
|
785
|
+
* equivalent per-owner read; the AO backend throws.
|
|
786
|
+
*/
|
|
787
|
+
async getWithdrawals(params) {
|
|
788
|
+
const owner = address(params.address);
|
|
789
|
+
// Withdrawal layout: disc(8) + owner(32) + withdrawal_id(8) + gateway(32).
|
|
790
|
+
// Filter by owner at offset 8 — returns both operator-stake (isDelegate=false)
|
|
791
|
+
// and delegate-stake (isDelegate=true) withdrawals for this wallet.
|
|
792
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR, [
|
|
793
|
+
{
|
|
794
|
+
memcmp: { offset: 8n, bytes: owner, encoding: 'base58' },
|
|
795
|
+
},
|
|
796
|
+
]);
|
|
797
|
+
const items = [];
|
|
798
|
+
for (const { pubkey, data } of accounts) {
|
|
799
|
+
try {
|
|
800
|
+
const w = deserializeWithdrawal(data);
|
|
801
|
+
items.push({
|
|
802
|
+
cursorId: pubkey,
|
|
803
|
+
vaultId: w.vaultId,
|
|
804
|
+
balance: w.balance,
|
|
805
|
+
startTimestamp: secToMs(w.startTimestamp),
|
|
806
|
+
endTimestamp: secToMs(w.endTimestamp),
|
|
807
|
+
gatewayAddress: w.gateway,
|
|
808
|
+
isDelegate: w.isDelegate,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
// Skip malformed
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return paginate(items, params);
|
|
816
|
+
}
|
|
817
|
+
// =========================================
|
|
818
|
+
// ArNS read methods
|
|
819
|
+
// =========================================
|
|
820
|
+
async getArNSRecord({ name }) {
|
|
821
|
+
const [pda] = await getArnsRecordPDA(name, this.arnsProgram);
|
|
822
|
+
const account = await this.getAccount(pda);
|
|
823
|
+
if (!account.exists) {
|
|
824
|
+
throw new Error(`ArNS record not found: ${name}`);
|
|
825
|
+
}
|
|
826
|
+
const record = deserializeArnsRecord(Buffer.from(account.data));
|
|
827
|
+
const { name: _, owner: __, ...nameData } = record;
|
|
828
|
+
return nameData;
|
|
829
|
+
}
|
|
830
|
+
async getArNSRecords(params) {
|
|
831
|
+
// `processId` is the only filter the AO backend supports today and the
|
|
832
|
+
// only one that maps to a fixed-offset memcmp on `ArnsRecord` (the
|
|
833
|
+
// `ant` field, see `ARNS_RECORD_ANT_OFFSET`). When supplied we
|
|
834
|
+
// dispatch through the bulk-by-mint path so the RPC does the
|
|
835
|
+
// filtering instead of streaming every record back to the client.
|
|
836
|
+
const filterMints = normalizeProcessIdFilter(params?.filters?.processId);
|
|
837
|
+
if (filterMints.length > 0) {
|
|
838
|
+
const items = await this.fetchArnsRecordsByAntMints(filterMints);
|
|
839
|
+
return paginate(items, params);
|
|
840
|
+
}
|
|
841
|
+
const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, ARNS_RECORD_DISCRIMINATOR);
|
|
842
|
+
const items = [];
|
|
843
|
+
for (const { data } of accounts) {
|
|
844
|
+
try {
|
|
845
|
+
items.push(arnsRecordToWithName(deserializeArnsRecord(data)));
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
// Skip accounts that don't match
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return paginate(items, params);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Fetch every `ArnsRecord` whose `ant` field equals one of `mints`.
|
|
855
|
+
*
|
|
856
|
+
* Issues one `getProgramAccounts` per mint with a memcmp filter at
|
|
857
|
+
* `ARNS_RECORD_ANT_OFFSET`, in parallel. Cheaper than scanning the
|
|
858
|
+
* whole registry as soon as the caller has fewer mints than the
|
|
859
|
+
* registry has records (today the break-even is ≈ a few hundred
|
|
860
|
+
* mints against ≈ 4k records, and rises as the registry grows).
|
|
861
|
+
*
|
|
862
|
+
* The shape mirrors `getArNSRecord` / `getArNSRecords` — same
|
|
863
|
+
* `ArNSNameDataWithName` items, no pagination wrapper. Callers
|
|
864
|
+
* that want pagination should drive it via `getArNSRecords({
|
|
865
|
+
* filters: { processId: mints } })` instead.
|
|
866
|
+
*/
|
|
867
|
+
async getArNSRecordsByAntMints({ mints, }) {
|
|
868
|
+
return this.fetchArnsRecordsByAntMints(mints);
|
|
869
|
+
}
|
|
870
|
+
async fetchArnsRecordsByAntMints(mints) {
|
|
871
|
+
const unique = Array.from(new Set(mints));
|
|
872
|
+
if (unique.length === 0)
|
|
873
|
+
return [];
|
|
874
|
+
// Parallel fan-out: one filtered gPA per mint. Each request is
|
|
875
|
+
// selective (matches at most one record on a healthy registry),
|
|
876
|
+
// so the marginal cost is mostly the round trip; major RPCs index
|
|
877
|
+
// memcmp filters at stable offsets, keeping this O(N) in network
|
|
878
|
+
// round trips rather than O(N) in registry size.
|
|
879
|
+
const perMint = await Promise.all(unique.map((mint) => this.getAccountsByDiscriminator(this.arnsProgram, ARNS_RECORD_DISCRIMINATOR, [
|
|
880
|
+
{
|
|
881
|
+
memcmp: {
|
|
882
|
+
offset: BigInt(ARNS_RECORD_ANT_OFFSET),
|
|
883
|
+
bytes: mint,
|
|
884
|
+
encoding: 'base58',
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
])));
|
|
888
|
+
const items = [];
|
|
889
|
+
const seen = new Set();
|
|
890
|
+
for (const accounts of perMint) {
|
|
891
|
+
for (const { pubkey, data } of accounts) {
|
|
892
|
+
const key = String(pubkey);
|
|
893
|
+
if (seen.has(key))
|
|
894
|
+
continue;
|
|
895
|
+
seen.add(key);
|
|
896
|
+
try {
|
|
897
|
+
items.push(arnsRecordToWithName(deserializeArnsRecord(data)));
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
// Skip malformed
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return items;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Resolve every ArNS record currently controlled by `address`.
|
|
908
|
+
*
|
|
909
|
+
* Mirrors the AO backend: walk the on-chain ANT ACL for the wallet
|
|
910
|
+
* (`Owned ∪ Controlled`), then issue point-queries against the
|
|
911
|
+
* ArNS registry for those mints. This is semantically *not* a query
|
|
912
|
+
* over `ArnsRecord.owner` — that field is a write-once "purchase
|
|
913
|
+
* receipt" and never reflects current control on Solana (see
|
|
914
|
+
* ISSUES.md). Authoritative control flows through the ANT NFT
|
|
915
|
+
* owner / `AntControllers`, which is exactly what the ACL
|
|
916
|
+
* indexes.
|
|
917
|
+
*/
|
|
918
|
+
async getArNSRecordsForAddress(params) {
|
|
919
|
+
const registry = new SolanaANTRegistryReadable({
|
|
920
|
+
rpc: this.rpc,
|
|
921
|
+
commitment: this.commitment,
|
|
922
|
+
logger: this.logger,
|
|
923
|
+
antProgramId: this.antProgram,
|
|
924
|
+
});
|
|
925
|
+
const { Owned = [], Controlled = [] } = await registry.accessControlList({
|
|
926
|
+
address: params.address,
|
|
927
|
+
});
|
|
928
|
+
const mints = Array.from(new Set([...Owned, ...Controlled]));
|
|
929
|
+
if (mints.length === 0) {
|
|
930
|
+
return {
|
|
931
|
+
items: [],
|
|
932
|
+
hasMore: false,
|
|
933
|
+
nextCursor: undefined,
|
|
934
|
+
limit: params.limit ?? 100,
|
|
935
|
+
totalItems: 0,
|
|
936
|
+
sortOrder: params.sortOrder ?? 'asc',
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
const items = await this.fetchArnsRecordsByAntMints(mints);
|
|
940
|
+
return paginate(items, params);
|
|
941
|
+
}
|
|
942
|
+
async getArNSReservedNames(params) {
|
|
943
|
+
const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, RESERVED_NAME_DISCRIMINATOR);
|
|
944
|
+
const items = [];
|
|
945
|
+
for (const { data } of accounts) {
|
|
946
|
+
try {
|
|
947
|
+
const reserved = deserializeReservedName(data);
|
|
948
|
+
items.push({
|
|
949
|
+
name: reserved.name,
|
|
950
|
+
target: reserved.target,
|
|
951
|
+
endTimestamp: typeof reserved.endTimestamp === 'number'
|
|
952
|
+
? secToMs(reserved.endTimestamp)
|
|
953
|
+
: reserved.endTimestamp,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
// Skip malformed
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return paginate(items, params);
|
|
961
|
+
}
|
|
962
|
+
async getArNSReservedName({ name, }) {
|
|
963
|
+
const [pda] = await getReservedNamePDA(name, this.arnsProgram);
|
|
964
|
+
const account = await this.getAccount(pda);
|
|
965
|
+
if (!account.exists) {
|
|
966
|
+
throw new Error(`Reserved name not found: ${name}`);
|
|
967
|
+
}
|
|
968
|
+
const reserved = deserializeReservedName(Buffer.from(account.data));
|
|
969
|
+
return {
|
|
970
|
+
target: reserved.target,
|
|
971
|
+
endTimestamp: typeof reserved.endTimestamp === 'number'
|
|
972
|
+
? secToMs(reserved.endTimestamp)
|
|
973
|
+
: reserved.endTimestamp,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
async getArNSReturnedNames(params) {
|
|
977
|
+
const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, RETURNED_NAME_DISCRIMINATOR);
|
|
978
|
+
const items = [];
|
|
979
|
+
for (const { data } of accounts) {
|
|
980
|
+
try {
|
|
981
|
+
items.push(toMsTimestamps(deserializeReturnedName(data)));
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
// Skip malformed
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return paginate(items, params);
|
|
988
|
+
}
|
|
989
|
+
async getArNSReturnedName({ name, }) {
|
|
990
|
+
const [pda] = await getReturnedNamePDA(name, this.arnsProgram);
|
|
991
|
+
// A ReturnedName account is immutable once created (its premium is derived
|
|
992
|
+
// client-side from the static start/end timestamps), so cache the read.
|
|
993
|
+
// The returned-names price table reads each name's PDA twice (lease +
|
|
994
|
+
// permabuy) and `getTokenCost` reads it again — all share one fetch.
|
|
995
|
+
const account = await this.getCachedAccount(pda);
|
|
996
|
+
if (!account.exists) {
|
|
997
|
+
throw new Error(`Returned name not found: ${name}`);
|
|
998
|
+
}
|
|
999
|
+
return toMsTimestamps(deserializeReturnedName(Buffer.from(account.data)));
|
|
1000
|
+
}
|
|
1001
|
+
// =========================================
|
|
1002
|
+
// Epoch read methods
|
|
1003
|
+
// =========================================
|
|
1004
|
+
async getEpochSettings() {
|
|
1005
|
+
const [pda] = await getEpochSettingsPDA(this.garProgram);
|
|
1006
|
+
const account = await this.getAccount(pda);
|
|
1007
|
+
if (!account.exists) {
|
|
1008
|
+
throw new Error('Epoch settings account not found');
|
|
1009
|
+
}
|
|
1010
|
+
return deserializeEpochSettings(Buffer.from(account.data));
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Resolve an EpochInput to an epoch index number.
|
|
1014
|
+
* - undefined: returns current epoch index from EpochSettings
|
|
1015
|
+
* - { epochIndex }: returns directly
|
|
1016
|
+
* - { timestamp }: computes from genesis timestamp and epoch duration
|
|
1017
|
+
*/
|
|
1018
|
+
async resolveEpochIndex(epoch) {
|
|
1019
|
+
if (epoch && 'epochIndex' in epoch) {
|
|
1020
|
+
return epoch.epochIndex;
|
|
1021
|
+
}
|
|
1022
|
+
const [pda] = await getEpochSettingsPDA(this.garProgram);
|
|
1023
|
+
const account = await this.getAccount(pda);
|
|
1024
|
+
if (!account.exists)
|
|
1025
|
+
throw new Error('EpochSettings account not found');
|
|
1026
|
+
const settings = deserializeEpochSettingsFull(Buffer.from(account.data));
|
|
1027
|
+
if (!epoch) {
|
|
1028
|
+
// On-chain `current_epoch_index` is "NEXT epoch to be created"
|
|
1029
|
+
// (incremented inside `create_epoch` AFTER the PDA is initialized
|
|
1030
|
+
// — see programs/ario-gar/src/instructions/epoch.rs:161). The
|
|
1031
|
+
// currently-active epoch is therefore one back. Floor at 0 for
|
|
1032
|
+
// the pre-bootstrap edge case where no epochs have been created
|
|
1033
|
+
// yet. Without this adjustment, every call to getEpoch(undefined)
|
|
1034
|
+
// sits in the cranker's close_epoch ↔ create_epoch gap and throws
|
|
1035
|
+
// "Epoch N not found" — which broke ContractEpochSource on a
|
|
1036
|
+
// live cluster (May 2026 devnet).
|
|
1037
|
+
return Math.max(0, settings.currentEpochIndex - 1);
|
|
1038
|
+
}
|
|
1039
|
+
// { timestamp } — compute epoch index. The public API takes `timestamp`
|
|
1040
|
+
// in JS milliseconds (matching the AO contract convention), but
|
|
1041
|
+
// genesisTimestamp/epochDuration come straight off chain in seconds, so
|
|
1042
|
+
// normalize to seconds before doing the division.
|
|
1043
|
+
const tsSeconds = Math.floor(epoch.timestamp / 1000);
|
|
1044
|
+
const elapsed = tsSeconds - settings.genesisTimestamp;
|
|
1045
|
+
return Math.floor(elapsed / settings.epochDuration);
|
|
1046
|
+
}
|
|
1047
|
+
/** Fetch and deserialize an Epoch account by index */
|
|
1048
|
+
async fetchEpoch(epochIndex) {
|
|
1049
|
+
const [pda] = await getEpochPDA(epochIndex, this.garProgram);
|
|
1050
|
+
const account = await this.getAccount(pda);
|
|
1051
|
+
if (!account.exists) {
|
|
1052
|
+
throw new Error(`Epoch ${epochIndex} not found`);
|
|
1053
|
+
}
|
|
1054
|
+
return deserializeEpoch(Buffer.from(account.data));
|
|
1055
|
+
}
|
|
1056
|
+
async getEpoch(epoch) {
|
|
1057
|
+
const epochIndex = await this.resolveEpochIndex(epoch);
|
|
1058
|
+
const epochData = await this.fetchEpoch(epochIndex);
|
|
1059
|
+
// Build prescribed observers list (only up to observerCount)
|
|
1060
|
+
const prescribedObservers = [];
|
|
1061
|
+
for (let i = 0; i < epochData.observerCount; i++) {
|
|
1062
|
+
const observerAddress = epochData.prescribedObservers[i];
|
|
1063
|
+
const gatewayAddress = epochData.prescribedObserverGateways[i];
|
|
1064
|
+
if (observerAddress === DEFAULT_ADDRESS)
|
|
1065
|
+
continue;
|
|
1066
|
+
// Try to fetch gateway data for weights
|
|
1067
|
+
let weights = {
|
|
1068
|
+
stakeWeight: 0,
|
|
1069
|
+
tenureWeight: 0,
|
|
1070
|
+
gatewayRewardRatioWeight: 0,
|
|
1071
|
+
observerRewardRatioWeight: 0,
|
|
1072
|
+
gatewayPerformanceRatio: 0,
|
|
1073
|
+
observerPerformanceRatio: 0,
|
|
1074
|
+
compositeWeight: 0,
|
|
1075
|
+
normalizedCompositeWeight: 0,
|
|
1076
|
+
};
|
|
1077
|
+
let stake = 0;
|
|
1078
|
+
let startTimestamp = 0;
|
|
1079
|
+
try {
|
|
1080
|
+
const gw = await this.getGateway({ address: gatewayAddress });
|
|
1081
|
+
weights = gw.weights;
|
|
1082
|
+
stake = gw.operatorStake;
|
|
1083
|
+
// gw.startTimestamp is already converted to ms by getGateway.
|
|
1084
|
+
startTimestamp = gw.startTimestamp;
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
// Gateway may no longer exist
|
|
1088
|
+
}
|
|
1089
|
+
prescribedObservers.push({
|
|
1090
|
+
gatewayAddress: gatewayAddress,
|
|
1091
|
+
observerAddress: observerAddress,
|
|
1092
|
+
stake,
|
|
1093
|
+
startTimestamp,
|
|
1094
|
+
...weights,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
// Build prescribed names list by resolving hashes → ArnsRecord PDAs
|
|
1098
|
+
const prescribedNames = [];
|
|
1099
|
+
const zeroHash = Buffer.alloc(32);
|
|
1100
|
+
for (let i = 0; i < epochData.nameCount; i++) {
|
|
1101
|
+
const nameHash = epochData.prescribedNameHashes[i];
|
|
1102
|
+
if (!nameHash || nameHash.equals(zeroHash))
|
|
1103
|
+
continue;
|
|
1104
|
+
try {
|
|
1105
|
+
const [recordPda] = await getArnsRecordPDAFromHash(nameHash, this.arnsProgram);
|
|
1106
|
+
const recordAccount = await this.getAccount(recordPda);
|
|
1107
|
+
if (recordAccount.exists) {
|
|
1108
|
+
const record = deserializeArnsRecord(Buffer.from(recordAccount.data));
|
|
1109
|
+
prescribedNames.push(record.name);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
// Record may have been removed
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// Build observations from Observation PDAs
|
|
1117
|
+
const observations = await this.getObservations({ epochIndex });
|
|
1118
|
+
// Build distribution totals
|
|
1119
|
+
const distributions = {
|
|
1120
|
+
totalEligibleGateways: epochData.activeGatewayCount,
|
|
1121
|
+
totalEligibleRewards: epochData.totalEligibleRewards,
|
|
1122
|
+
totalEligibleObserverReward: epochData.perObserverReward * epochData.observerCount,
|
|
1123
|
+
totalEligibleGatewayReward: epochData.perGatewayReward * epochData.activeGatewayCount,
|
|
1124
|
+
};
|
|
1125
|
+
return {
|
|
1126
|
+
epochIndex,
|
|
1127
|
+
startHeight: 0, // Solana doesn't use AO block heights
|
|
1128
|
+
startTimestamp: secToMs(epochData.startTimestamp),
|
|
1129
|
+
endTimestamp: secToMs(epochData.endTimestamp),
|
|
1130
|
+
distributionTimestamp: secToMs(epochData.endTimestamp),
|
|
1131
|
+
observations,
|
|
1132
|
+
prescribedObservers,
|
|
1133
|
+
prescribedNames,
|
|
1134
|
+
distributions,
|
|
1135
|
+
arnsStats: {
|
|
1136
|
+
totalReturnedNames: 0,
|
|
1137
|
+
totalActiveNames: 0,
|
|
1138
|
+
totalGracePeriodNames: 0,
|
|
1139
|
+
totalReservedNames: 0,
|
|
1140
|
+
},
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
async getCurrentEpoch() {
|
|
1144
|
+
return this.getEpoch(undefined);
|
|
1145
|
+
}
|
|
1146
|
+
async getPrescribedObservers(epoch) {
|
|
1147
|
+
const epochData = await this.getEpoch(epoch);
|
|
1148
|
+
return epochData.prescribedObservers;
|
|
1149
|
+
}
|
|
1150
|
+
async getPrescribedNames(epoch) {
|
|
1151
|
+
const epochIndex = await this.resolveEpochIndex(epoch);
|
|
1152
|
+
const epochData = await this.fetchEpoch(epochIndex);
|
|
1153
|
+
const names = [];
|
|
1154
|
+
const zeroHash = Buffer.alloc(32);
|
|
1155
|
+
for (let i = 0; i < epochData.nameCount; i++) {
|
|
1156
|
+
const nameHash = epochData.prescribedNameHashes[i];
|
|
1157
|
+
if (!nameHash || nameHash.equals(zeroHash))
|
|
1158
|
+
continue;
|
|
1159
|
+
try {
|
|
1160
|
+
const [recordPda] = await getArnsRecordPDAFromHash(nameHash, this.arnsProgram);
|
|
1161
|
+
const recordAccount = await this.getAccount(recordPda);
|
|
1162
|
+
if (recordAccount.exists) {
|
|
1163
|
+
const record = deserializeArnsRecord(Buffer.from(recordAccount.data));
|
|
1164
|
+
names.push(record.name);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
catch {
|
|
1168
|
+
// Record may have been removed
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return names;
|
|
1172
|
+
}
|
|
1173
|
+
async getObservations(epoch) {
|
|
1174
|
+
const epochIndex = await this.resolveEpochIndex(epoch);
|
|
1175
|
+
// Fetch all Observation accounts for this epoch
|
|
1176
|
+
const epochIndexBuf = Buffer.alloc(8);
|
|
1177
|
+
epochIndexBuf.writeBigUInt64LE(BigInt(epochIndex));
|
|
1178
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, OBSERVATION_DISCRIMINATOR, [
|
|
1179
|
+
{
|
|
1180
|
+
memcmp: {
|
|
1181
|
+
offset: 8n,
|
|
1182
|
+
bytes: bs58.encode(epochIndexBuf),
|
|
1183
|
+
encoding: 'base58',
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
]);
|
|
1187
|
+
const failureSummaries = {};
|
|
1188
|
+
const reports = {};
|
|
1189
|
+
// Read gateway registry to get index-to-address mapping (matches bitfield order)
|
|
1190
|
+
const gatewayAddresses = await this.getRegistryGatewayAddresses();
|
|
1191
|
+
for (const { data } of accounts) {
|
|
1192
|
+
try {
|
|
1193
|
+
const obs = deserializeObservation(data);
|
|
1194
|
+
reports[obs.observer] = obs.reportTxId;
|
|
1195
|
+
// Parse gateway_results bitmap — 1 = passed, 0 = failed (on-chain convention)
|
|
1196
|
+
for (let i = 0; i < obs.gatewayCount && i < gatewayAddresses.length; i++) {
|
|
1197
|
+
const byteIdx = Math.floor(i / 8);
|
|
1198
|
+
const bitIdx = i % 8;
|
|
1199
|
+
const passed = (obs.gatewayResults[byteIdx] >> bitIdx) & 1;
|
|
1200
|
+
if (!passed) {
|
|
1201
|
+
const gwAddr = gatewayAddresses[i];
|
|
1202
|
+
if (!failureSummaries[gwAddr]) {
|
|
1203
|
+
failureSummaries[gwAddr] = [];
|
|
1204
|
+
}
|
|
1205
|
+
failureSummaries[gwAddr].push(obs.observer);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
// Skip malformed
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return { failureSummaries, reports };
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Observer pubkeys that have an OPEN Observation PDA for `epochIndex`. Lean —
|
|
1217
|
+
* reads only the Observation accounts (no gateway-registry decode like
|
|
1218
|
+
* {@link getObservations}). Used by the crank to close observations before
|
|
1219
|
+
* `close_epoch`, whose `observations_closed == observations_submitted`
|
|
1220
|
+
* precondition would otherwise wedge epoch progression.
|
|
1221
|
+
*/
|
|
1222
|
+
async getEpochObservers(epochIndex) {
|
|
1223
|
+
const epochIndexBuf = Buffer.alloc(8);
|
|
1224
|
+
epochIndexBuf.writeBigUInt64LE(BigInt(epochIndex));
|
|
1225
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, OBSERVATION_DISCRIMINATOR, [
|
|
1226
|
+
{
|
|
1227
|
+
memcmp: {
|
|
1228
|
+
offset: 8n,
|
|
1229
|
+
bytes: bs58.encode(epochIndexBuf),
|
|
1230
|
+
encoding: 'base58',
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
]);
|
|
1234
|
+
const observers = [];
|
|
1235
|
+
for (const { data } of accounts) {
|
|
1236
|
+
try {
|
|
1237
|
+
observers.push(address(deserializeObservation(data).observer));
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
// skip malformed
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return observers;
|
|
1244
|
+
}
|
|
1245
|
+
async getDistributions(epoch) {
|
|
1246
|
+
const epochIndex = await this.resolveEpochIndex(epoch);
|
|
1247
|
+
const epochData = await this.fetchEpoch(epochIndex);
|
|
1248
|
+
return {
|
|
1249
|
+
totalEligibleGateways: epochData.activeGatewayCount,
|
|
1250
|
+
totalEligibleRewards: epochData.totalEligibleRewards,
|
|
1251
|
+
totalEligibleObserverReward: epochData.perObserverReward * epochData.observerCount,
|
|
1252
|
+
totalEligibleGatewayReward: epochData.perGatewayReward * epochData.activeGatewayCount,
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
async getEligibleEpochRewards(epoch, params) {
|
|
1256
|
+
const epochIndex = await this.resolveEpochIndex(epoch);
|
|
1257
|
+
const epochData = await this.fetchEpoch(epochIndex);
|
|
1258
|
+
const items = [];
|
|
1259
|
+
// Each gateway operator gets a gateway reward
|
|
1260
|
+
const gatewayAccounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
|
|
1261
|
+
for (const { data } of gatewayAccounts) {
|
|
1262
|
+
try {
|
|
1263
|
+
const gw = deserializeGateway(data);
|
|
1264
|
+
if (gw.status !== 'joined')
|
|
1265
|
+
continue;
|
|
1266
|
+
items.push({
|
|
1267
|
+
type: 'operatorReward',
|
|
1268
|
+
recipient: gw.operator,
|
|
1269
|
+
eligibleReward: epochData.perGatewayReward,
|
|
1270
|
+
gatewayAddress: gw.operator,
|
|
1271
|
+
cursorId: gw.operator,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
catch {
|
|
1275
|
+
// skip
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
// Each prescribed observer gets an observer reward
|
|
1279
|
+
for (let i = 0; i < epochData.observerCount; i++) {
|
|
1280
|
+
const observerAddr = epochData.prescribedObservers[i];
|
|
1281
|
+
const gatewayAddr = epochData.prescribedObserverGateways[i];
|
|
1282
|
+
if (observerAddr === DEFAULT_ADDRESS)
|
|
1283
|
+
continue;
|
|
1284
|
+
items.push({
|
|
1285
|
+
type: 'operatorReward',
|
|
1286
|
+
recipient: gatewayAddr,
|
|
1287
|
+
eligibleReward: epochData.perObserverReward,
|
|
1288
|
+
gatewayAddress: gatewayAddr,
|
|
1289
|
+
cursorId: `${gatewayAddr}-observer`,
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
return paginate(items, params);
|
|
1293
|
+
}
|
|
1294
|
+
// =========================================
|
|
1295
|
+
// Pricing / cost read methods
|
|
1296
|
+
// =========================================
|
|
1297
|
+
/**
|
|
1298
|
+
* Compute the token cost for an ArNS operation.
|
|
1299
|
+
*
|
|
1300
|
+
* Mirrors the Rust pricing functions in ario-arns/src/pricing.rs.
|
|
1301
|
+
* Uses BigInt for u128-equivalent overflow-safe arithmetic.
|
|
1302
|
+
*/
|
|
1303
|
+
async getTokenCost(params) {
|
|
1304
|
+
const [dfPda] = await getDemandFactorPDA(this.arnsProgram);
|
|
1305
|
+
const dfAccount = await this.getCachedAccount(dfPda);
|
|
1306
|
+
if (!dfAccount.exists)
|
|
1307
|
+
throw new Error('DemandFactor account not found');
|
|
1308
|
+
const df = deserializeDemandFactor(Buffer.from(dfAccount.data));
|
|
1309
|
+
const name = params.name.toLowerCase();
|
|
1310
|
+
const nameLen = Math.min(Math.max(name.length, 1), 51);
|
|
1311
|
+
const baseFee = df.fees[nameLen - 1] ?? df.fees[df.fees.length - 1];
|
|
1312
|
+
// currentDemandFactor is already divided by RATE_SCALE in deserializer,
|
|
1313
|
+
// but we need the raw scaled value for integer math
|
|
1314
|
+
const demandFactorRaw = BigInt(Math.round(df.currentDemandFactor * RATE_SCALE));
|
|
1315
|
+
const scale = BigInt(RATE_SCALE);
|
|
1316
|
+
const bf = BigInt(baseFee);
|
|
1317
|
+
let cost;
|
|
1318
|
+
switch (params.intent) {
|
|
1319
|
+
case 'Buy-Name':
|
|
1320
|
+
case 'Buy-Record': {
|
|
1321
|
+
const purchaseType = params.type ?? 'lease';
|
|
1322
|
+
if (purchaseType === 'permabuy') {
|
|
1323
|
+
cost = (bf * demandFactorRaw * 5n) / scale;
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
const years = BigInt(params.years ?? 1);
|
|
1327
|
+
const annualPct = 200000n;
|
|
1328
|
+
const yearFactor = scale + annualPct * years;
|
|
1329
|
+
cost = (bf * demandFactorRaw * yearFactor) / scale / scale;
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
const returned = await this.getArNSReturnedName({ name });
|
|
1333
|
+
if (returned) {
|
|
1334
|
+
// returned.startTimestamp is in ms (public API convention),
|
|
1335
|
+
// so the rest of this comparison is in ms too.
|
|
1336
|
+
const now = Date.now();
|
|
1337
|
+
const elapsed = now - returned.startTimestamp;
|
|
1338
|
+
const duration = 14 * 86_400_000;
|
|
1339
|
+
if (elapsed < duration) {
|
|
1340
|
+
const remaining = BigInt(duration - elapsed);
|
|
1341
|
+
const dur = BigInt(duration);
|
|
1342
|
+
const pctRemaining = (remaining * scale) / dur;
|
|
1343
|
+
const multiplier = 50n * pctRemaining;
|
|
1344
|
+
cost = (cost * multiplier) / scale;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
// Not a returned name — no premium
|
|
1350
|
+
}
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
case 'Extend-Lease': {
|
|
1354
|
+
const years = BigInt(params.years ?? 1);
|
|
1355
|
+
const annualPct = 200000n;
|
|
1356
|
+
cost = (bf * demandFactorRaw * annualPct * years) / scale / scale;
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
// A Primary-Name-Request is priced identically to a single
|
|
1360
|
+
// Increase-Undername-Limit operation against the same name: it uses the
|
|
1361
|
+
// name-length-indexed base fee (`bf`) and a quantity of 1. Fall through
|
|
1362
|
+
// to share that logic rather than duplicating it.
|
|
1363
|
+
case 'Primary-Name-Request':
|
|
1364
|
+
case 'Increase-Undername-Limit': {
|
|
1365
|
+
const qty = BigInt(params.quantity ?? 1);
|
|
1366
|
+
let isPermabuy = false;
|
|
1367
|
+
try {
|
|
1368
|
+
const record = await this.getArNSRecord({ name });
|
|
1369
|
+
isPermabuy = record.type === 'permabuy';
|
|
1370
|
+
}
|
|
1371
|
+
catch {
|
|
1372
|
+
// default to lease pricing
|
|
1373
|
+
}
|
|
1374
|
+
const pct = isPermabuy ? 5000n : 1000n;
|
|
1375
|
+
cost = (bf * demandFactorRaw * pct * qty) / scale / scale;
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
case 'Upgrade-Name': {
|
|
1379
|
+
const permabuyCost = (bf * demandFactorRaw * 5n) / scale;
|
|
1380
|
+
cost = permabuyCost;
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
default:
|
|
1384
|
+
throw new Error(`Unknown intent: ${params.intent}`);
|
|
1385
|
+
}
|
|
1386
|
+
return Number(cost);
|
|
1387
|
+
}
|
|
1388
|
+
async getCostDetails(params) {
|
|
1389
|
+
const tokenCost = await this.getTokenCost(params);
|
|
1390
|
+
const discounts = [];
|
|
1391
|
+
if (params.fromAddress) {
|
|
1392
|
+
try {
|
|
1393
|
+
// Operator-discount check. Read the gateway PDA through the short-TTL
|
|
1394
|
+
// cache (NOT public `getGateway`, which stays fresh for gateway pages):
|
|
1395
|
+
// a price table calls `getCostDetails` many times for the SAME
|
|
1396
|
+
// `fromAddress`, so this collapses N redundant gateway reads to one.
|
|
1397
|
+
const [gwPda] = await getGatewayPDA(address(params.fromAddress), this.garProgram);
|
|
1398
|
+
const gwAccount = await this.getCachedAccount(gwPda);
|
|
1399
|
+
if (gwAccount.exists) {
|
|
1400
|
+
const gw = deserializeGateway(Buffer.from(gwAccount.data));
|
|
1401
|
+
if (gw.status === 'joined') {
|
|
1402
|
+
// Match on-chain eligibility from ario-arns pricing.rs
|
|
1403
|
+
// `try_apply_gateway_discount`:
|
|
1404
|
+
// 1. Tenure: gateway running >= 180 days (15_552_000 seconds)
|
|
1405
|
+
const GATEWAY_DISCOUNT_MIN_TENURE_S = 15_552_000;
|
|
1406
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1407
|
+
const timeRunning = nowSeconds - gw.startTimestamp;
|
|
1408
|
+
// 2. Performance: >= 90% epoch pass rate
|
|
1409
|
+
const passRate = ((1 + gw.stats.passedEpochCount) /
|
|
1410
|
+
(1 + gw.stats.totalEpochCount)) *
|
|
1411
|
+
1_000_000;
|
|
1412
|
+
if (timeRunning >= GATEWAY_DISCOUNT_MIN_TENURE_S &&
|
|
1413
|
+
passRate >= 900_000) {
|
|
1414
|
+
const discountAmount = Math.floor((tokenCost * 200_000) / RATE_SCALE);
|
|
1415
|
+
discounts.push({
|
|
1416
|
+
name: 'Gateway Operator',
|
|
1417
|
+
discountTotal: discountAmount,
|
|
1418
|
+
multiplier: 0.8,
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
catch {
|
|
1425
|
+
// Not a gateway operator — no discount
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
const totalDiscount = discounts.reduce((sum, d) => sum + d.discountTotal, 0);
|
|
1429
|
+
const finalCost = tokenCost - totalDiscount;
|
|
1430
|
+
// Project Solana state into the public-facing `FundingPlan` shape when
|
|
1431
|
+
// the caller asks about a specific funding source for a specific wallet.
|
|
1432
|
+
// (`fromAddress` is required — we can't enumerate funding sources for
|
|
1433
|
+
// an unknown wallet; without `fundFrom` we don't know what to budget
|
|
1434
|
+
// against.) The internal `funding-plan.ts` `FundingPlan` is a
|
|
1435
|
+
// separate, instruction-building plan — keep them distinct.
|
|
1436
|
+
let fundingPlan;
|
|
1437
|
+
if (params.fromAddress && params.fundFrom !== undefined) {
|
|
1438
|
+
fundingPlan = await this.buildPublicFundingPlan({
|
|
1439
|
+
fromAddress: params.fromAddress,
|
|
1440
|
+
fundFrom: params.fundFrom,
|
|
1441
|
+
cost: finalCost,
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
return {
|
|
1445
|
+
tokenCost: finalCost,
|
|
1446
|
+
discounts,
|
|
1447
|
+
...(fundingPlan ? { fundingPlan } : {}),
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Project Solana on-chain state into the cross-backend `FundingPlan`
|
|
1452
|
+
* shape consumed by UI flows like "how short are you on this purchase,
|
|
1453
|
+
* and from which sources?". Always returns the wallet's `balance` and
|
|
1454
|
+
* full per-gateway `stakes` breakdown (active delegations + pending
|
|
1455
|
+
* delegate-stake withdrawals); `shortfall` is computed against the
|
|
1456
|
+
* specific `fundFrom` semantics.
|
|
1457
|
+
*
|
|
1458
|
+
* Note: the internal `src/solana/funding-plan.ts` `FundingPlan` is a
|
|
1459
|
+
* different type — that's the multi-source instruction-building plan
|
|
1460
|
+
* used by `buyRecord({ fundFrom: 'any' })`. The two share a concept but
|
|
1461
|
+
* not a shape; the public type here is what consumer UIs see.
|
|
1462
|
+
*/
|
|
1463
|
+
async buildPublicFundingPlan({ fromAddress, fundFrom, cost, }) {
|
|
1464
|
+
// Pull balance + full delegation list (stake + vault) in parallel.
|
|
1465
|
+
// Limit is intentionally large — the public FundingPlan reports the
|
|
1466
|
+
// *entire* per-gateway breakdown, not a pagination window.
|
|
1467
|
+
const [balance, delegations] = await Promise.all([
|
|
1468
|
+
this.getBalance({ address: fromAddress }),
|
|
1469
|
+
this.getDelegations({ address: fromAddress, limit: 10_000 }),
|
|
1470
|
+
]);
|
|
1471
|
+
const stakes = {};
|
|
1472
|
+
for (const d of delegations.items) {
|
|
1473
|
+
const gateway = d.gatewayAddress;
|
|
1474
|
+
if (!stakes[gateway]) {
|
|
1475
|
+
stakes[gateway] = { vaults: [], delegatedStake: 0 };
|
|
1476
|
+
}
|
|
1477
|
+
if (d.type === 'stake') {
|
|
1478
|
+
stakes[gateway].delegatedStake = d.balance;
|
|
1479
|
+
}
|
|
1480
|
+
else {
|
|
1481
|
+
// `Record<string, number>[]` per-vault entries — AO-era shape we
|
|
1482
|
+
// keep for cross-backend compatibility.
|
|
1483
|
+
stakes[gateway].vaults.push({ [d.vaultId]: d.balance });
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
const sumDelegated = Object.values(stakes).reduce((sum, g) => sum + g.delegatedStake, 0);
|
|
1487
|
+
const sumVaulted = Object.values(stakes).reduce((sum, g) => sum +
|
|
1488
|
+
g.vaults.reduce((vsum, v) => vsum + Object.values(v).reduce((a, b) => a + b, 0), 0), 0);
|
|
1489
|
+
// Compute shortfall against the *eligible* pool for the chosen
|
|
1490
|
+
// `fundFrom`. `turbo` and `plan` are special: turbo is paid in
|
|
1491
|
+
// off-chain credits (no mARIO shortfall meaningful here), and plan
|
|
1492
|
+
// is caller-supplied (caller did their own arithmetic).
|
|
1493
|
+
let eligible;
|
|
1494
|
+
switch (fundFrom) {
|
|
1495
|
+
case 'balance':
|
|
1496
|
+
eligible = balance;
|
|
1497
|
+
break;
|
|
1498
|
+
case 'stakes':
|
|
1499
|
+
eligible = sumDelegated;
|
|
1500
|
+
break;
|
|
1501
|
+
case 'withdrawal':
|
|
1502
|
+
eligible = sumVaulted;
|
|
1503
|
+
break;
|
|
1504
|
+
case 'any':
|
|
1505
|
+
eligible = balance + sumDelegated + sumVaulted;
|
|
1506
|
+
break;
|
|
1507
|
+
case 'turbo':
|
|
1508
|
+
case 'plan':
|
|
1509
|
+
eligible = Number.MAX_SAFE_INTEGER;
|
|
1510
|
+
break;
|
|
1511
|
+
default: {
|
|
1512
|
+
// Exhaustiveness check — surface a missed FundFrom variant at
|
|
1513
|
+
// type-check time, not at runtime.
|
|
1514
|
+
const _exhaustive = fundFrom;
|
|
1515
|
+
eligible = 0;
|
|
1516
|
+
void _exhaustive;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return {
|
|
1520
|
+
address: fromAddress,
|
|
1521
|
+
balance,
|
|
1522
|
+
stakes,
|
|
1523
|
+
shortfall: Math.max(0, cost - eligible),
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
async getRegistrationFees() {
|
|
1527
|
+
const [dfPda] = await getDemandFactorPDA(this.arnsProgram);
|
|
1528
|
+
const account = await this.getCachedAccount(dfPda);
|
|
1529
|
+
if (!account.exists) {
|
|
1530
|
+
throw new Error('DemandFactor account not found');
|
|
1531
|
+
}
|
|
1532
|
+
const df = deserializeDemandFactor(Buffer.from(account.data));
|
|
1533
|
+
const result = {};
|
|
1534
|
+
for (let len = 1; len <= 51; len++) {
|
|
1535
|
+
const baseFee = df.fees[len - 1] ?? 0;
|
|
1536
|
+
result[len] = {
|
|
1537
|
+
lease: {
|
|
1538
|
+
1: Math.floor(baseFee * (1 + 0.2 * 1) * df.currentDemandFactor),
|
|
1539
|
+
2: Math.floor(baseFee * (1 + 0.2 * 2) * df.currentDemandFactor),
|
|
1540
|
+
3: Math.floor(baseFee * (1 + 0.2 * 3) * df.currentDemandFactor),
|
|
1541
|
+
4: Math.floor(baseFee * (1 + 0.2 * 4) * df.currentDemandFactor),
|
|
1542
|
+
5: Math.floor(baseFee * (1 + 0.2 * 5) * df.currentDemandFactor),
|
|
1543
|
+
},
|
|
1544
|
+
permabuy: Math.floor(baseFee * 5 * df.currentDemandFactor),
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
return result;
|
|
1548
|
+
}
|
|
1549
|
+
async getDemandFactor() {
|
|
1550
|
+
const [pda] = await getDemandFactorPDA(this.arnsProgram);
|
|
1551
|
+
const account = await this.getCachedAccount(pda);
|
|
1552
|
+
if (!account.exists) {
|
|
1553
|
+
throw new Error('DemandFactor account not found');
|
|
1554
|
+
}
|
|
1555
|
+
return deserializeDemandFactor(Buffer.from(account.data))
|
|
1556
|
+
.currentDemandFactor;
|
|
1557
|
+
}
|
|
1558
|
+
async getDemandFactorSettings() {
|
|
1559
|
+
const [pda] = await getDemandFactorPDA(this.arnsProgram);
|
|
1560
|
+
const account = await this.getCachedAccount(pda);
|
|
1561
|
+
if (!account.exists) {
|
|
1562
|
+
throw new Error('DemandFactor account not found');
|
|
1563
|
+
}
|
|
1564
|
+
const df = deserializeDemandFactor(Buffer.from(account.data));
|
|
1565
|
+
return {
|
|
1566
|
+
periodZeroStartTimestamp: df.periodZeroStartTimestamp,
|
|
1567
|
+
movingAvgPeriodCount: 7,
|
|
1568
|
+
periodLengthMs: 86_400 * 1000,
|
|
1569
|
+
demandFactorBaseValue: 1,
|
|
1570
|
+
demandFactorMin: 0.5,
|
|
1571
|
+
demandFactorUpAdjustmentRate: 50,
|
|
1572
|
+
demandFactorDownAdjustmentRate: 25,
|
|
1573
|
+
maxPeriodsAtMinDemandFactor: df.consecutivePeriodsWithMinDemandFactor,
|
|
1574
|
+
criteria: 'revenue',
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
// =========================================
|
|
1578
|
+
// Primary name read methods
|
|
1579
|
+
// =========================================
|
|
1580
|
+
async getPrimaryName(params) {
|
|
1581
|
+
// On-chain `PrimaryName` stores only {owner, name, set_at}. The ANT mint
|
|
1582
|
+
// that PrimaryName.processId expects lives on the matching ArnsRecord
|
|
1583
|
+
// (looked up by the base name). Both lookup paths below deserialize the
|
|
1584
|
+
// on-chain account and then enrich with the ArnsRecord lookup.
|
|
1585
|
+
const baseNameOf = (n) => {
|
|
1586
|
+
const parts = n.toLowerCase().split('_');
|
|
1587
|
+
return parts.length === 2 ? parts[1] : parts[0];
|
|
1588
|
+
};
|
|
1589
|
+
const enrich = async (pn) => {
|
|
1590
|
+
const rec = await this.getArNSRecord({ name: baseNameOf(pn.name) });
|
|
1591
|
+
return { ...pn, processId: rec.processId };
|
|
1592
|
+
};
|
|
1593
|
+
if ('address' in params) {
|
|
1594
|
+
const [pda] = await getPrimaryNamePDA(address(params.address), this.coreProgram);
|
|
1595
|
+
const account = await this.getAccount(pda);
|
|
1596
|
+
if (!account.exists) {
|
|
1597
|
+
throw new Error(`Primary name not found for address ${params.address}`);
|
|
1598
|
+
}
|
|
1599
|
+
return enrich(deserializePrimaryName(Buffer.from(account.data)));
|
|
1600
|
+
}
|
|
1601
|
+
// Lookup by name — scan all primary name accounts
|
|
1602
|
+
const accounts = await this.getAccountsByDiscriminator(this.coreProgram, PRIMARY_NAME_DISCRIMINATOR);
|
|
1603
|
+
for (const { data } of accounts) {
|
|
1604
|
+
try {
|
|
1605
|
+
const pn = deserializePrimaryName(data);
|
|
1606
|
+
if (pn.name === params.name) {
|
|
1607
|
+
return enrich(pn);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
// Skip malformed
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
throw new Error(`Primary name not found: ${params.name}`);
|
|
1615
|
+
}
|
|
1616
|
+
async getPrimaryNameRequest(params) {
|
|
1617
|
+
const [pda] = await getPrimaryNameRequestPDA(address(params.initiator), this.coreProgram);
|
|
1618
|
+
const account = await this.getAccount(pda);
|
|
1619
|
+
if (!account.exists) {
|
|
1620
|
+
throw new Error(`Primary name request not found for ${params.initiator}`);
|
|
1621
|
+
}
|
|
1622
|
+
return deserializePrimaryNameRequest(Buffer.from(account.data));
|
|
1623
|
+
}
|
|
1624
|
+
async getPrimaryNameRequests(params) {
|
|
1625
|
+
const accounts = await this.getAccountsByDiscriminator(this.coreProgram, PRIMARY_NAME_REQUEST_DISCRIMINATOR);
|
|
1626
|
+
const items = [];
|
|
1627
|
+
for (const { data } of accounts) {
|
|
1628
|
+
try {
|
|
1629
|
+
items.push(deserializePrimaryNameRequest(data));
|
|
1630
|
+
}
|
|
1631
|
+
catch {
|
|
1632
|
+
// Skip malformed
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return paginate(items, params);
|
|
1636
|
+
}
|
|
1637
|
+
async getPrimaryNames(params) {
|
|
1638
|
+
const accounts = await this.getAccountsByDiscriminator(this.coreProgram, PRIMARY_NAME_DISCRIMINATOR);
|
|
1639
|
+
// Enrich each on-chain PrimaryName with its ArnsRecord.processId (the
|
|
1640
|
+
// on-chain account doesn't store it; see deserializePrimaryName).
|
|
1641
|
+
// Records that no longer have a matching ArnsRecord are silently
|
|
1642
|
+
// skipped — same forgiveness the per-name lookup already applies.
|
|
1643
|
+
const baseNameOf = (n) => {
|
|
1644
|
+
const parts = n.toLowerCase().split('_');
|
|
1645
|
+
return parts.length === 2 ? parts[1] : parts[0];
|
|
1646
|
+
};
|
|
1647
|
+
const items = [];
|
|
1648
|
+
for (const { data } of accounts) {
|
|
1649
|
+
try {
|
|
1650
|
+
const pn = deserializePrimaryName(data);
|
|
1651
|
+
const rec = await this.getArNSRecord({ name: baseNameOf(pn.name) });
|
|
1652
|
+
items.push({ ...pn, processId: rec.processId });
|
|
1653
|
+
}
|
|
1654
|
+
catch {
|
|
1655
|
+
// Skip malformed or orphaned (ArnsRecord missing).
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return paginate(items, params);
|
|
1659
|
+
}
|
|
1660
|
+
// =========================================
|
|
1661
|
+
// Redelegation fee
|
|
1662
|
+
// =========================================
|
|
1663
|
+
async getRedelegationFee(params) {
|
|
1664
|
+
const { getRedelegationRecordPDA } = await import('./pda.js');
|
|
1665
|
+
const [pda] = await getRedelegationRecordPDA(address(params.address), this.garProgram);
|
|
1666
|
+
const account = await this.getAccount(pda);
|
|
1667
|
+
if (!account.exists) {
|
|
1668
|
+
return { redelegationFeeRate: 0, feeResetTimestamp: 0 };
|
|
1669
|
+
}
|
|
1670
|
+
const record = deserializeRedelegationRecord(Buffer.from(account.data));
|
|
1671
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1672
|
+
if (now >= record.feeResetAt) {
|
|
1673
|
+
return { redelegationFeeRate: 0, feeResetTimestamp: record.feeResetAt };
|
|
1674
|
+
}
|
|
1675
|
+
const feeRate = Math.min(record.redelegationCount * 10, 60);
|
|
1676
|
+
return {
|
|
1677
|
+
redelegationFeeRate: feeRate,
|
|
1678
|
+
feeResetTimestamp: record.feeResetAt,
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
// =========================================
|
|
1682
|
+
// Gateway registry settings
|
|
1683
|
+
// =========================================
|
|
1684
|
+
async getGatewayRegistrySettings() {
|
|
1685
|
+
const [pda] = await getGarSettingsPDA(this.garProgram);
|
|
1686
|
+
const account = await this.getAccount(pda);
|
|
1687
|
+
if (!account.exists) {
|
|
1688
|
+
throw new Error('GarSettings account not found');
|
|
1689
|
+
}
|
|
1690
|
+
return deserializeGarSettings(Buffer.from(account.data));
|
|
1691
|
+
}
|
|
1692
|
+
// =========================================
|
|
1693
|
+
// Aggregate queries
|
|
1694
|
+
// =========================================
|
|
1695
|
+
async getAllDelegates(params) {
|
|
1696
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR);
|
|
1697
|
+
const decoded = [];
|
|
1698
|
+
for (const { pubkey, data } of accounts) {
|
|
1699
|
+
try {
|
|
1700
|
+
decoded.push({
|
|
1701
|
+
pubkey: pubkey,
|
|
1702
|
+
del: deserializeDelegation(data),
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
catch {
|
|
1706
|
+
// Skip malformed
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
// Batch-fetch each referenced gateway's reward accumulator so we can
|
|
1710
|
+
// return live balances. See INVARIANTS.md and `computeLiveDelegationBalance`.
|
|
1711
|
+
const accumulators = await this.getGatewayAccumulators(decoded.map(({ del }) => del.gateway));
|
|
1712
|
+
const items = decoded.map(({ pubkey, del }) => ({
|
|
1713
|
+
address: del.delegator,
|
|
1714
|
+
gatewayAddress: del.gateway,
|
|
1715
|
+
delegatedStake: computeLiveDelegationBalance({
|
|
1716
|
+
delegatedStake: del.delegatedStake,
|
|
1717
|
+
rewardDebt: del.rewardDebt,
|
|
1718
|
+
cumulativeRewardPerToken: accumulators.get(del.gateway) ?? 0n,
|
|
1719
|
+
}),
|
|
1720
|
+
startTimestamp: secToMs(del.startTimestamp),
|
|
1721
|
+
vaultedStake: 0,
|
|
1722
|
+
cursorId: pubkey,
|
|
1723
|
+
}));
|
|
1724
|
+
return paginate(items, params);
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Enumerate every delegation that has pending (unsettled) rewards — the work
|
|
1728
|
+
* list for the permissionless `compound_delegation_rewards` crank. Pending is
|
|
1729
|
+
* computed from the gateway's reward-per-share accumulator (mirrors
|
|
1730
|
+
* {@link computeLiveDelegationBalance}); the crank only changes balances, so
|
|
1731
|
+
* rewards already accrue correctly without it.
|
|
1732
|
+
*
|
|
1733
|
+
* Skips delegations whose gateway is `Leaving` (those settle through
|
|
1734
|
+
* `claim_delegate_from_leaving_gateway`, not compounding) and any below
|
|
1735
|
+
* `minPendingRewards` — compounding sub-threshold dust just advances
|
|
1736
|
+
* `reward_debt` for no balance gain. Feed the result, chunked, to
|
|
1737
|
+
* `SolanaARIOWriteable.compoundDelegationRewardsBatch`.
|
|
1738
|
+
*/
|
|
1739
|
+
async getDelegationsToCompound(params) {
|
|
1740
|
+
const minPending = params?.minPendingRewards ?? 0;
|
|
1741
|
+
// One scan for gateways → accumulator + status.
|
|
1742
|
+
const gatewayAccounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
|
|
1743
|
+
const gateways = new Map();
|
|
1744
|
+
for (const { data } of gatewayAccounts) {
|
|
1745
|
+
try {
|
|
1746
|
+
const gw = deserializeGatewayWithAccumulator(data);
|
|
1747
|
+
gateways.set(gw.operator, {
|
|
1748
|
+
cumulativeRewardPerToken: gw.cumulativeRewardPerToken,
|
|
1749
|
+
status: gw.status,
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
catch {
|
|
1753
|
+
// Skip malformed.
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
// One scan for delegations; decode then delegate the selection logic to the
|
|
1757
|
+
// pure `selectCompoundableDelegations` (unit-tested in delegation-math).
|
|
1758
|
+
const delegationAccounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR);
|
|
1759
|
+
const delegations = [];
|
|
1760
|
+
for (const { data } of delegationAccounts) {
|
|
1761
|
+
try {
|
|
1762
|
+
const del = deserializeDelegation(data);
|
|
1763
|
+
delegations.push({
|
|
1764
|
+
gateway: del.gateway,
|
|
1765
|
+
delegator: del.delegator,
|
|
1766
|
+
delegatedStake: del.delegatedStake,
|
|
1767
|
+
rewardDebt: del.rewardDebt,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
catch {
|
|
1771
|
+
// Skip malformed.
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return selectCompoundableDelegations(delegations, gateways, minPending);
|
|
1775
|
+
}
|
|
1776
|
+
async getAllGatewayVaults(params) {
|
|
1777
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR);
|
|
1778
|
+
const items = [];
|
|
1779
|
+
for (const { pubkey, data } of accounts) {
|
|
1780
|
+
try {
|
|
1781
|
+
const w = deserializeWithdrawal(data);
|
|
1782
|
+
items.push({
|
|
1783
|
+
cursorId: pubkey,
|
|
1784
|
+
vaultId: w.vaultId,
|
|
1785
|
+
balance: w.balance,
|
|
1786
|
+
startTimestamp: secToMs(w.startTimestamp),
|
|
1787
|
+
endTimestamp: secToMs(w.endTimestamp),
|
|
1788
|
+
gatewayAddress: w.gateway,
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
catch {
|
|
1792
|
+
// Skip malformed
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return paginate(items, params);
|
|
1796
|
+
}
|
|
1797
|
+
// =========================================
|
|
1798
|
+
// Prune / cleanup discovery (Solana-only)
|
|
1799
|
+
// =========================================
|
|
1800
|
+
//
|
|
1801
|
+
// These helpers enumerate accounts eligible for the permissionless prune
|
|
1802
|
+
// ix surface (see SolanaARIOWriteable). All read on-chain via
|
|
1803
|
+
// `getProgramAccounts` + the Codama decoders, then post-filter
|
|
1804
|
+
// client-side because most eligibility predicates can't be expressed as
|
|
1805
|
+
// memcmp filters (variable-length names shift offsets; Option<i64>
|
|
1806
|
+
// adds a tag byte). Volume is bounded — the cranker is expected to
|
|
1807
|
+
// call these once per epoch cycle, not per-tx.
|
|
1808
|
+
//
|
|
1809
|
+
// See `docs/CRANKER_PRUNING_PLAN.md` for the design.
|
|
1810
|
+
/**
|
|
1811
|
+
* Enumerate ArnsRecord PDAs whose lease has fully expired
|
|
1812
|
+
* (`end_timestamp + grace_period + return_auction_duration <= now`).
|
|
1813
|
+
* Permabuys (no `end_timestamp`) are excluded. Pass a unix-seconds `now`.
|
|
1814
|
+
*/
|
|
1815
|
+
async getExpiredArnsRecords(now) {
|
|
1816
|
+
const [arnsConfigPda] = await getArnsSettingsPDA(this.arnsProgram);
|
|
1817
|
+
const cfgAccount = await this.getCachedAccount(arnsConfigPda);
|
|
1818
|
+
if (!cfgAccount.exists)
|
|
1819
|
+
return [];
|
|
1820
|
+
const cfg = getArnsConfigDecoder().decode(cfgAccount.data);
|
|
1821
|
+
const grace = Number(cfg.gracePeriodSeconds);
|
|
1822
|
+
const auction = Number(cfg.returnAuctionDurationSeconds);
|
|
1823
|
+
const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, ARNS_RECORD_DISCRIMINATOR);
|
|
1824
|
+
const decoder = getArnsRecordDecoder();
|
|
1825
|
+
const out = [];
|
|
1826
|
+
for (const { pubkey, data } of accounts) {
|
|
1827
|
+
try {
|
|
1828
|
+
const r = decoder.decode(data);
|
|
1829
|
+
if (r.endTimestamp.__option !== 'Some')
|
|
1830
|
+
continue;
|
|
1831
|
+
const end = Number(r.endTimestamp.value);
|
|
1832
|
+
if (end + grace + auction <= now) {
|
|
1833
|
+
out.push({
|
|
1834
|
+
pubkey,
|
|
1835
|
+
name: r.name,
|
|
1836
|
+
endTimestamp: r.endTimestamp.value,
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
catch {
|
|
1841
|
+
// skip malformed
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
return out;
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Enumerate ReturnedName PDAs whose Dutch auction window has fully
|
|
1848
|
+
* elapsed (`returned_at + return_auction_duration <= now`).
|
|
1849
|
+
*/
|
|
1850
|
+
async getExpiredReturnedNames(now) {
|
|
1851
|
+
const [arnsConfigPda] = await getArnsSettingsPDA(this.arnsProgram);
|
|
1852
|
+
const cfgAccount = await this.getCachedAccount(arnsConfigPda);
|
|
1853
|
+
if (!cfgAccount.exists)
|
|
1854
|
+
return [];
|
|
1855
|
+
const cfg = getArnsConfigDecoder().decode(cfgAccount.data);
|
|
1856
|
+
const auction = Number(cfg.returnAuctionDurationSeconds);
|
|
1857
|
+
const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, RETURNED_NAME_DISCRIMINATOR);
|
|
1858
|
+
const decoder = getReturnedNameDecoder();
|
|
1859
|
+
const out = [];
|
|
1860
|
+
for (const { pubkey, data } of accounts) {
|
|
1861
|
+
try {
|
|
1862
|
+
const r = decoder.decode(data);
|
|
1863
|
+
if (Number(r.returnedAt) + auction <= now) {
|
|
1864
|
+
out.push({ pubkey, name: r.name, returnedAt: r.returnedAt });
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
catch {
|
|
1868
|
+
// skip malformed
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
return out;
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Enumerate ReservedName PDAs whose `expires_at` has passed.
|
|
1875
|
+
* Permanent reservations (`expires_at: None`) are excluded.
|
|
1876
|
+
*/
|
|
1877
|
+
async getExpiredReservations(now) {
|
|
1878
|
+
const accounts = await this.getAccountsByDiscriminator(this.arnsProgram, RESERVED_NAME_DISCRIMINATOR);
|
|
1879
|
+
const decoder = getReservedNameDecoder();
|
|
1880
|
+
const out = [];
|
|
1881
|
+
for (const { pubkey, data } of accounts) {
|
|
1882
|
+
try {
|
|
1883
|
+
const r = decoder.decode(data);
|
|
1884
|
+
if (r.expiresAt.__option !== 'Some')
|
|
1885
|
+
continue;
|
|
1886
|
+
if (Number(r.expiresAt.value) <= now) {
|
|
1887
|
+
out.push({ pubkey, name: r.name });
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
catch {
|
|
1891
|
+
// skip malformed
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
return out;
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Enumerate Gateway PDAs in `Joined` status with
|
|
1898
|
+
* `stats.failed_consecutive >= maxFailures`. These are eligible for
|
|
1899
|
+
* `pruneGateway` (slash + remove from registry).
|
|
1900
|
+
*/
|
|
1901
|
+
async getDeficientGateways(maxFailures) {
|
|
1902
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
|
|
1903
|
+
const decoder = getGatewayDecoder();
|
|
1904
|
+
const out = [];
|
|
1905
|
+
for (const { pubkey, data } of accounts) {
|
|
1906
|
+
try {
|
|
1907
|
+
const g = decoder.decode(data);
|
|
1908
|
+
if (g.status !== GatewayStatus.Joined)
|
|
1909
|
+
continue;
|
|
1910
|
+
if (g.stats.failedConsecutive >= maxFailures) {
|
|
1911
|
+
out.push({
|
|
1912
|
+
pubkey,
|
|
1913
|
+
operator: g.operator,
|
|
1914
|
+
failedConsecutive: g.stats.failedConsecutive,
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
catch {
|
|
1919
|
+
// skip malformed
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
return out;
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Enumerate Gateway PDAs that are candidates for `finalizeGone` GC — i.e.
|
|
1926
|
+
* those whose `status == Leaving`.
|
|
1927
|
+
*
|
|
1928
|
+
* NOTE: despite the historical name, this returns `Leaving` (not `Gone`)
|
|
1929
|
+
* gateways. `Gone` is NOT a persistent discovery state: the on-chain
|
|
1930
|
+
* `finalize_gone` instruction accepts a `Leaving` gateway, flips it to
|
|
1931
|
+
* `Gone`, and closes the Gateway PDA in the *same* instruction
|
|
1932
|
+
* (programs/ario-gar/src/instructions/gateway.rs::finalize_gone), so a
|
|
1933
|
+
* gateway is never observably parked at `Gone`. Filtering on `Gone` matched
|
|
1934
|
+
* nothing and left Leaving gateways un-GC'd. Time/delegation eligibility is
|
|
1935
|
+
* still enforced on-chain (`finalize_gone` reverts early if the leave window
|
|
1936
|
+
* hasn't elapsed or delegations remain), so over-returning not-yet-eligible
|
|
1937
|
+
* Leaving gateways is safe — the tx just no-ops/reverts.
|
|
1938
|
+
*
|
|
1939
|
+
* @deprecated Prefer {@link getLeavingGateways} — same result, accurate name.
|
|
1940
|
+
*/
|
|
1941
|
+
async getGoneGateways() {
|
|
1942
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
|
|
1943
|
+
const decoder = getGatewayDecoder();
|
|
1944
|
+
const out = [];
|
|
1945
|
+
for (const { pubkey, data } of accounts) {
|
|
1946
|
+
try {
|
|
1947
|
+
const g = decoder.decode(data);
|
|
1948
|
+
if (g.status === GatewayStatus.Leaving) {
|
|
1949
|
+
out.push({ pubkey, operator: g.operator });
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
catch {
|
|
1953
|
+
// skip malformed
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
return out;
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Enumerate Gateway PDAs whose `status == Leaving` — the persistent
|
|
1960
|
+
* pre-finalization state that `finalizeGone` GC's. Alias for
|
|
1961
|
+
* {@link getGoneGateways} with a name that matches the on-chain state.
|
|
1962
|
+
*/
|
|
1963
|
+
async getLeavingGateways() {
|
|
1964
|
+
return this.getGoneGateways();
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Enumerate Joined Gateway PDAs whose delegation has been DISABLED
|
|
1968
|
+
* (`allow_delegated_staking == false`) yet still hold delegated stake
|
|
1969
|
+
* (`total_delegated_stake > 0`) — i.e. delegates that an operator's disable
|
|
1970
|
+
* left stranded (WP §6.3 / Fix #6). Each such gateway's delegates must be
|
|
1971
|
+
* cranked out via
|
|
1972
|
+
* {@link SolanaARIOWriteable.claimDelegateFromDisabledGateway} (enumerate
|
|
1973
|
+
* them with {@link getGatewayDelegates}) before the operator can re-enable
|
|
1974
|
+
* delegation. This is the discovery primitive a cranker uses to sweep them.
|
|
1975
|
+
*/
|
|
1976
|
+
async getDisabledGatewaysWithDelegatedStake() {
|
|
1977
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
|
|
1978
|
+
const decoder = getGatewayDecoder();
|
|
1979
|
+
const out = [];
|
|
1980
|
+
for (const { pubkey, data } of accounts) {
|
|
1981
|
+
try {
|
|
1982
|
+
const g = decoder.decode(data);
|
|
1983
|
+
if (g.status !== GatewayStatus.Joined)
|
|
1984
|
+
continue;
|
|
1985
|
+
if (!g.settings.allowDelegatedStaking && g.totalDelegatedStake > 0n) {
|
|
1986
|
+
out.push({
|
|
1987
|
+
pubkey,
|
|
1988
|
+
operator: g.operator,
|
|
1989
|
+
totalDelegatedStake: g.totalDelegatedStake,
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
catch {
|
|
1994
|
+
// skip malformed
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return out;
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Enumerate Delegation PDAs with `amount == 0`. Eligible for
|
|
2001
|
+
* `closeEmptyDelegation` (rent refund to the original delegator).
|
|
2002
|
+
*/
|
|
2003
|
+
async getEmptyDelegations() {
|
|
2004
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR);
|
|
2005
|
+
const decoder = getDelegationDecoder();
|
|
2006
|
+
const out = [];
|
|
2007
|
+
for (const { pubkey, data } of accounts) {
|
|
2008
|
+
try {
|
|
2009
|
+
const d = decoder.decode(data);
|
|
2010
|
+
if (d.amount === 0n) {
|
|
2011
|
+
out.push({ pubkey, gateway: d.gateway, delegator: d.delegator });
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
catch {
|
|
2015
|
+
// skip malformed
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
return out;
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Enumerate Withdrawal PDAs with `amount == 0` (drained via
|
|
2022
|
+
* fund-from-withdrawal payments). Eligible for `closeDrainedWithdrawal`
|
|
2023
|
+
* (rent refund to owner).
|
|
2024
|
+
*/
|
|
2025
|
+
async getDrainedWithdrawals() {
|
|
2026
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR);
|
|
2027
|
+
const decoder = getWithdrawalDecoder();
|
|
2028
|
+
const out = [];
|
|
2029
|
+
for (const { pubkey, data } of accounts) {
|
|
2030
|
+
try {
|
|
2031
|
+
const w = decoder.decode(data);
|
|
2032
|
+
if (w.amount === 0n) {
|
|
2033
|
+
out.push({ pubkey, owner: w.owner, withdrawalId: w.withdrawalId });
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
catch {
|
|
2037
|
+
// skip malformed
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
return out;
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Enumerate Vault PDAs whose `end_timestamp` has passed (eligible for
|
|
2044
|
+
* `releaseVault`). Note: `releaseVault` is owner-signed, so the cranker
|
|
2045
|
+
* can only release its own vaults — the helper still surfaces every
|
|
2046
|
+
* expired vault so other consumers (UIs, indexers) can use it too.
|
|
2047
|
+
*/
|
|
2048
|
+
async getExpiredVaults(now) {
|
|
2049
|
+
const accounts = await this.getAccountsByDiscriminator(this.coreProgram, VAULT_DISCRIMINATOR);
|
|
2050
|
+
const decoder = getVaultDecoder();
|
|
2051
|
+
const out = [];
|
|
2052
|
+
for (const { pubkey, data } of accounts) {
|
|
2053
|
+
try {
|
|
2054
|
+
const v = decoder.decode(data);
|
|
2055
|
+
if (Number(v.endTimestamp) <= now) {
|
|
2056
|
+
out.push({
|
|
2057
|
+
pubkey,
|
|
2058
|
+
owner: v.owner,
|
|
2059
|
+
vaultId: v.vaultId,
|
|
2060
|
+
endTimestamp: v.endTimestamp,
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
catch {
|
|
2065
|
+
// skip malformed
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
return out;
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Enumerate PrimaryNameRequest PDAs whose `expires_at` has passed.
|
|
2072
|
+
* Eligible for `closeExpiredRequest` (rent refund to original initiator).
|
|
2073
|
+
*/
|
|
2074
|
+
async getExpiredPrimaryNameRequests(now) {
|
|
2075
|
+
const accounts = await this.getAccountsByDiscriminator(this.coreProgram, PRIMARY_NAME_REQUEST_DISCRIMINATOR);
|
|
2076
|
+
const decoder = getPrimaryNameRequestDecoder();
|
|
2077
|
+
const out = [];
|
|
2078
|
+
for (const { pubkey, data } of accounts) {
|
|
2079
|
+
try {
|
|
2080
|
+
const r = decoder.decode(data);
|
|
2081
|
+
if (Number(r.expiresAt) <= now) {
|
|
2082
|
+
out.push({ pubkey, initiator: r.initiator });
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
catch {
|
|
2086
|
+
// skip malformed
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
return out;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Read the live `ArnsConfig` (used by the cranker to gate
|
|
2093
|
+
* `pruneExpiredNames` / `pruneReturnedNames` on the
|
|
2094
|
+
* `next_*_prune_timestamp` hints).
|
|
2095
|
+
*/
|
|
2096
|
+
async getArnsConfigRaw() {
|
|
2097
|
+
const [pda] = await getArnsSettingsPDA(this.arnsProgram);
|
|
2098
|
+
const account = await this.getCachedAccount(pda);
|
|
2099
|
+
if (!account.exists)
|
|
2100
|
+
return null;
|
|
2101
|
+
const cfg = getArnsConfigDecoder().decode(account.data);
|
|
2102
|
+
return {
|
|
2103
|
+
nextRecordsPruneTimestamp: cfg.nextRecordsPruneTimestamp,
|
|
2104
|
+
nextReturnedNamesPruneTimestamp: cfg.nextReturnedNamesPruneTimestamp,
|
|
2105
|
+
gracePeriodSeconds: cfg.gracePeriodSeconds,
|
|
2106
|
+
returnAuctionDurationSeconds: cfg.returnAuctionDurationSeconds,
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
// =========================================
|
|
2110
|
+
// Name resolution (ArNSNameResolver)
|
|
2111
|
+
// =========================================
|
|
2112
|
+
async resolveArNSName({ name }) {
|
|
2113
|
+
const parts = name.split('_');
|
|
2114
|
+
const baseName = parts.length > 1 ? parts[parts.length - 1] : parts[0];
|
|
2115
|
+
const record = await this.getArNSRecord({ name: baseName });
|
|
2116
|
+
// TODO: resolve undername via ANT program when undername !== '@'
|
|
2117
|
+
return {
|
|
2118
|
+
name: baseName,
|
|
2119
|
+
txId: '',
|
|
2120
|
+
type: record.type,
|
|
2121
|
+
processId: record.processId,
|
|
2122
|
+
ttlSeconds: 3600,
|
|
2123
|
+
undernameLimit: record.undernameLimit,
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
// =========================================================================
|
|
2127
|
+
// Observer helpers (Solana-only; used by gateway-side report submission)
|
|
2128
|
+
// =========================================================================
|
|
2129
|
+
/**
|
|
2130
|
+
* Resolve the gateway operator pubkey backing a given observer pubkey.
|
|
2131
|
+
* The `ObserverLookup` PDA is written at `join_network` (and rotated by
|
|
2132
|
+
* `update_observer_address`); when present its `gateway` field is the
|
|
2133
|
+
* operator pubkey. Returns `undefined` when the observer isn't
|
|
2134
|
+
* registered on any gateway.
|
|
2135
|
+
*/
|
|
2136
|
+
async getObserverLookup(observer) {
|
|
2137
|
+
const [pda] = await getObserverLookupPDA(observer, this.garProgram);
|
|
2138
|
+
const account = await this.getAccount(pda);
|
|
2139
|
+
if (!account.exists)
|
|
2140
|
+
return undefined;
|
|
2141
|
+
const data = Buffer.from(account.data);
|
|
2142
|
+
// Layout: 8 disc + 32 gateway + 1 bump.
|
|
2143
|
+
const gateway = addressDecoder.decode(data.subarray(8, 40));
|
|
2144
|
+
const bump = data.readUInt8(40);
|
|
2145
|
+
return { gateway, bump };
|
|
2146
|
+
}
|
|
2147
|
+
/**
|
|
2148
|
+
* Pre-flight gate for `save_observations` submission. Reads the Epoch
|
|
2149
|
+
* account once and reports whether the given observer pubkey is:
|
|
2150
|
+
* - `prescribed`: in `epoch.prescribed_observers[..observer_count]`
|
|
2151
|
+
* - `observerIdx`: position in the array (matches the `has_observed`
|
|
2152
|
+
* bit index when prescribed)
|
|
2153
|
+
* - `alreadyObserved`: whether the bit at `observerIdx` is set
|
|
2154
|
+
* - `windowOpen`: whether `now < epoch.end_timestamp`
|
|
2155
|
+
*
|
|
2156
|
+
* Use this from a sink/wrapper to skip cheap-to-skip cases before
|
|
2157
|
+
* paying for a transaction simulation that would just bounce.
|
|
2158
|
+
*/
|
|
2159
|
+
async getEpochObservationStatus(epochIndex, observer) {
|
|
2160
|
+
const epoch = await this.fetchEpoch(epochIndex);
|
|
2161
|
+
let observerIdx = -1;
|
|
2162
|
+
for (let i = 0; i < epoch.observerCount; i++) {
|
|
2163
|
+
if (epoch.prescribedObservers[i] === observer) {
|
|
2164
|
+
observerIdx = i;
|
|
2165
|
+
break;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
const prescribed = observerIdx !== -1;
|
|
2169
|
+
const alreadyObserved = prescribed &&
|
|
2170
|
+
((epoch.hasObserved[Math.floor(observerIdx / 8)] >> (observerIdx % 8)) &
|
|
2171
|
+
1) ===
|
|
2172
|
+
1;
|
|
2173
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
2174
|
+
return {
|
|
2175
|
+
prescribed,
|
|
2176
|
+
observerIdx,
|
|
2177
|
+
alreadyObserved,
|
|
2178
|
+
windowOpen: nowSec < epoch.endTimestamp,
|
|
2179
|
+
endTimestampSec: epoch.endTimestamp,
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
}
|