@ar.io/sdk 4.0.0-solana.23 → 4.0.0-solana.25
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/lib/esm/solana/deserialize.js +17 -0
- package/lib/esm/solana/index.js +3 -1
- package/lib/esm/solana/io-readable.js +33 -0
- package/lib/esm/solana/io-writeable.js +342 -5
- package/lib/esm/solana/predict-prescribed-observers.js +95 -0
- package/lib/esm/solana/send.js +243 -3
- package/lib/esm/version.js +1 -1
- package/lib/types/solana/deserialize.d.ts +1 -0
- package/lib/types/solana/index.d.ts +2 -1
- package/lib/types/solana/io-readable.d.ts +15 -0
- package/lib/types/solana/io-writeable.d.ts +148 -2
- package/lib/types/solana/predict-prescribed-observers.d.ts +28 -0
- package/lib/types/solana/send.d.ts +80 -2
- package/lib/types/types/io.d.ts +16 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +3 -2
|
@@ -108,6 +108,12 @@ class BorshReader {
|
|
|
108
108
|
return undefined;
|
|
109
109
|
return this.readU32();
|
|
110
110
|
}
|
|
111
|
+
readOptionU16() {
|
|
112
|
+
const tag = this.readU8();
|
|
113
|
+
if (tag === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
return this.readU16();
|
|
116
|
+
}
|
|
111
117
|
skip(bytes) {
|
|
112
118
|
this.offset += bytes;
|
|
113
119
|
}
|
|
@@ -332,6 +338,15 @@ export function deserializeGatewayWithAccumulator(data) {
|
|
|
332
338
|
const delegateRewardShareRatio = r.readU16() / 100;
|
|
333
339
|
const minDelegatedStake = r.readU64AsNumber();
|
|
334
340
|
const allowlistEnabled = r.readBool();
|
|
341
|
+
// GATEWAY_VERSION 1.1.0 added two fields to GatewaySettings2 — MUST read them
|
|
342
|
+
// here to keep the byte stream aligned for every field after `settings`.
|
|
343
|
+
// - pending_delegate_reward_share_ratio: Option<u16> (Fix #7) — basis points
|
|
344
|
+
// of a deferred reward-share change applied at the next epoch's tally.
|
|
345
|
+
// - delegation_disabled_at: Option<i64> (Fix #6) — unix seconds the operator
|
|
346
|
+
// disabled delegation; starts the re-enable cooldown.
|
|
347
|
+
const pendingRatioRaw = r.readOptionU16();
|
|
348
|
+
const pendingDelegateRewardShareRatio = pendingRatioRaw === undefined ? undefined : pendingRatioRaw / 100;
|
|
349
|
+
const delegationDisabledAt = r.readOptionI64();
|
|
335
350
|
// RegistryIndex (index: u32, _reserved: u8 — was is_registered:bool)
|
|
336
351
|
r.readU32(); // registryIndex
|
|
337
352
|
r.readU8(); // _reserved (layout-preserving placeholder for the legacy is_registered byte)
|
|
@@ -378,6 +393,8 @@ export function deserializeGatewayWithAccumulator(data) {
|
|
|
378
393
|
fqdn,
|
|
379
394
|
port,
|
|
380
395
|
protocol: 'https', // protocolIdx: 0=Http, 1=Https — only HTTPS in practice
|
|
396
|
+
pendingDelegateRewardShareRatio, // Fix #7: undefined when no change is queued
|
|
397
|
+
delegationDisabledAt, // Fix #6: undefined when delegation is enabled
|
|
381
398
|
};
|
|
382
399
|
return {
|
|
383
400
|
operator: operator,
|
package/lib/esm/solana/index.js
CHANGED
|
@@ -54,7 +54,7 @@ export { ARWEAVE_TX_REGEX, AR_IO_PROTOCOL, arweaveUri, FQDN_REGEX, MARIO_PER_ARI
|
|
|
54
54
|
// Solana implementation classes (still exported for advanced/direct usage —
|
|
55
55
|
// the `ARIO` / `ANT` factories above wrap these).
|
|
56
56
|
export { SolanaARIOReadable } from './io-readable.js';
|
|
57
|
-
export { SolanaARIOWriteable } from './io-writeable.js';
|
|
57
|
+
export { isInvalidGatewayAccountError, SolanaARIOWriteable, } from './io-writeable.js';
|
|
58
58
|
// ANT classes
|
|
59
59
|
export { SolanaANTReadable } from './ant-readable.js';
|
|
60
60
|
export { SolanaANTWriteable } from './ant-writeable.js';
|
|
@@ -88,6 +88,8 @@ export { hashName, getArioConfigPDA, getBalancePDA, getVaultPDA, getVaultCounter
|
|
|
88
88
|
// `ANT_RECORD_DISCRIMINATOR`). Pull them from there instead of asking
|
|
89
89
|
// this module — single source of truth, derived from the IDL.
|
|
90
90
|
export { BorshReader, BorshWriter, deserializeGateway, deserializeArnsRecord, deserializeVault, deserializeDelegation, deserializeBalance, deserializeEpochSettings, deserializeArioConfig, deserializeDemandFactor, deserializeReservedName, deserializeReturnedName, deserializeWithdrawal, deserializeRedelegationRecord, deserializePrimaryNameRequest, deserializePrimaryName, deserializeAllowlist, deserializeGarSettings, deserializeEpochSettingsFull, deserializeEpoch, deserializeObservation, deserializeAntConfig, deserializeAntControllers, deserializeAntRecord, deserializeAclConfig, deserializeAclPage, } from './deserialize.js';
|
|
91
|
+
// Off-chain prediction of prescribe_epoch's observer selection (cranker helper)
|
|
92
|
+
export { predictPrescribedObservers, } from './predict-prescribed-observers.js';
|
|
91
93
|
// Constants
|
|
92
94
|
export * from './constants.js';
|
|
93
95
|
// Cluster-specific deployment constants (devnet program IDs, RPC URL,
|
|
@@ -1743,6 +1743,39 @@ export class SolanaARIOReadable {
|
|
|
1743
1743
|
}
|
|
1744
1744
|
return out;
|
|
1745
1745
|
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Enumerate Joined Gateway PDAs whose delegation has been DISABLED
|
|
1748
|
+
* (`allow_delegated_staking == false`) yet still hold delegated stake
|
|
1749
|
+
* (`total_delegated_stake > 0`) — i.e. delegates that an operator's disable
|
|
1750
|
+
* left stranded (WP §6.3 / Fix #6). Each such gateway's delegates must be
|
|
1751
|
+
* cranked out via
|
|
1752
|
+
* {@link SolanaARIOWriteable.claimDelegateFromDisabledGateway} (enumerate
|
|
1753
|
+
* them with {@link getGatewayDelegates}) before the operator can re-enable
|
|
1754
|
+
* delegation. This is the discovery primitive a cranker uses to sweep them.
|
|
1755
|
+
*/
|
|
1756
|
+
async getDisabledGatewaysWithDelegatedStake() {
|
|
1757
|
+
const accounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
|
|
1758
|
+
const decoder = getGatewayDecoder();
|
|
1759
|
+
const out = [];
|
|
1760
|
+
for (const { pubkey, data } of accounts) {
|
|
1761
|
+
try {
|
|
1762
|
+
const g = decoder.decode(data);
|
|
1763
|
+
if (g.status !== GatewayStatus.Joined)
|
|
1764
|
+
continue;
|
|
1765
|
+
if (!g.settings.allowDelegatedStaking && g.totalDelegatedStake > 0n) {
|
|
1766
|
+
out.push({
|
|
1767
|
+
pubkey,
|
|
1768
|
+
operator: g.operator,
|
|
1769
|
+
totalDelegatedStake: g.totalDelegatedStake,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
catch {
|
|
1774
|
+
// skip malformed
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return out;
|
|
1778
|
+
}
|
|
1746
1779
|
/**
|
|
1747
1780
|
* Enumerate Delegation PDAs with `amount == 0`. Eligible for
|
|
1748
1781
|
* `closeEmptyDelegation` (rent refund to the original delegator).
|
|
@@ -38,12 +38,13 @@ function toGeneratedFundingSourceSpec(s) {
|
|
|
38
38
|
import { getSyncAttributesInstruction } from '@ar.io/solana-contracts/ant';
|
|
39
39
|
import { getApprovePrimaryNameInstructionAsync, getCloseExpiredRequestInstruction, getCreateVaultInstructionAsync, getExtendVaultInstructionAsync, getIncreaseVaultInstructionAsync, getReleaseVaultInstructionAsync, getRemovePrimaryNameInstructionAsync, getRequestAndSetPrimaryNameFromFundingPlanInstructionAsync, getRequestAndSetPrimaryNameInstructionAsync, getRequestPrimaryNameFromFundingPlanInstructionAsync, getRequestPrimaryNameInstructionAsync, getRevokeVaultInstructionAsync, getVaultedTransferInstructionAsync, } from '@ar.io/solana-contracts/core';
|
|
40
40
|
import { getDelegationDecoder, getGatewayDecoder, } from '@ar.io/solana-contracts/gar';
|
|
41
|
-
import { Protocol, getAllowDelegateInstructionAsync, getCancelWithdrawalInstruction, getClaimDelegateFromLeavingGatewayInstructionAsync, getClaimWithdrawalInstructionAsync, getCloseDrainedWithdrawalInstruction, getCloseEmptyDelegationInstruction, getCloseEpochInstructionAsync, getCloseObservationInstructionAsync, getCreateEpochInstructionAsync, getDecreaseDelegateStakeInstructionAsync, getDecreaseOperatorStakeInstructionAsync, getDelegateStakeInstructionAsync, getDisallowDelegateInstructionAsync, getDistributeEpochInstructionAsync, getFinalizeGoneInstructionAsync, getIncreaseOperatorStakeInstructionAsync, getInstantWithdrawalInstructionAsync, getJoinNetworkInstructionAsync, getLeaveNetworkInstructionAsync, getPrescribeEpochInstructionAsync, getPruneGatewayInstructionAsync, getRedelegateStakeInstructionAsync, getSaveObservationsInstructionAsync, getSetAllowlistEnabledInstructionAsync, getTallyWeightsInstructionAsync, getUpdateGatewaySettingsInstructionAsync, } from '@ar.io/solana-contracts/gar';
|
|
41
|
+
import { Protocol, getAllowDelegateInstructionAsync, getCancelWithdrawalInstruction, getClaimDelegateFromDisabledGatewayInstructionAsync, getClaimDelegateFromLeavingGatewayInstructionAsync, getClaimWithdrawalInstructionAsync, getCloseDrainedWithdrawalInstruction, getCloseEmptyDelegationInstruction, getCloseEpochInstructionAsync, getCloseObservationInstructionAsync, getCreateEpochInstructionAsync, getDecreaseDelegateStakeInstructionAsync, getDecreaseOperatorStakeInstructionAsync, getDelegateStakeInstructionAsync, getDisallowDelegateInstructionAsync, getDistributeEpochInstructionAsync, getFinalizeGoneInstructionAsync, getIncreaseOperatorStakeInstructionAsync, getInstantWithdrawalInstructionAsync, getJoinNetworkInstructionAsync, getLeaveNetworkInstructionAsync, getPrescribeEpochInstructionAsync, getPruneGatewayInstructionAsync, getRedelegateStakeInstructionAsync, getSaveObservationsInstructionAsync, getSetAllowlistEnabledInstructionAsync, getTallyWeightsInstructionAsync, getUpdateGatewaySettingsInstructionAsync, } from '@ar.io/solana-contracts/gar';
|
|
42
42
|
import { getTransferCheckedInstruction } from '@solana-program/token';
|
|
43
43
|
import { ARIO_ANT_PROGRAM_ID, TOKEN_DECIMALS } from './constants.js';
|
|
44
44
|
import { SolanaARIOReadable } from './io-readable.js';
|
|
45
45
|
import { getAntRecordPDA, getArioConfigPDA, getArnsRecordPDA, getArnsRegistryPDA, getArnsSettingsPDA, getDelegationPDA, getDemandFactorPDA, getEpochPDA, getEpochSettingsPDA, getGarSettingsPDA, getGatewayPDA, getGatewayRegistryPDA, getObservationPDA, getObserverLookupPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getPrimaryNameReversePDA, getReservedNamePDA, getReturnedNamePDA, getVaultPDA, getWithdrawalCounterPDA, getWithdrawalPDA, hashName, } from './pda.js';
|
|
46
|
-
import {
|
|
46
|
+
import { predictPrescribedObservers, } from './predict-prescribed-observers.js';
|
|
47
|
+
import { reclaimLookupTablesForSigner, sendAndConfirm, sendWithEphemeralLookupTable, } from './send.js';
|
|
47
48
|
const addressDecoder = getAddressDecoder();
|
|
48
49
|
/** Resolve mARIOToken | number to a plain number */
|
|
49
50
|
function toAmount(qty) {
|
|
@@ -181,6 +182,29 @@ export function encodeReportTxId(reportTxId) {
|
|
|
181
182
|
decoded.copy(out);
|
|
182
183
|
return out;
|
|
183
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Detect the GAR `InvalidGatewayAccount` error by Anchor error name/message
|
|
187
|
+
* (walking the cause chain + `context.logs`), NOT by numeric code — codes are
|
|
188
|
+
* `6000 + enum-index` and shift across program versions, but the name and
|
|
189
|
+
* message are stable. `prescribe_epoch` raises this when a supplied observer
|
|
190
|
+
* Gateway PDA is missing/spoofed (e.g. a predicted observer left the registry
|
|
191
|
+
* between prediction and tx landing).
|
|
192
|
+
*/
|
|
193
|
+
export function isInvalidGatewayAccountError(error) {
|
|
194
|
+
const parts = [];
|
|
195
|
+
let cur = error;
|
|
196
|
+
for (let i = 0; cur != null && i < 8; i++) {
|
|
197
|
+
const e = cur;
|
|
198
|
+
if (e.message)
|
|
199
|
+
parts.push(e.message);
|
|
200
|
+
if (Array.isArray(e.context?.logs))
|
|
201
|
+
parts.push(e.context.logs.join('\n'));
|
|
202
|
+
cur = e.cause;
|
|
203
|
+
}
|
|
204
|
+
const text = parts.join('\n');
|
|
205
|
+
return (text.includes('InvalidGatewayAccount') ||
|
|
206
|
+
text.includes('Invalid gateway account'));
|
|
207
|
+
}
|
|
184
208
|
export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
185
209
|
signer;
|
|
186
210
|
rpcSubscriptions;
|
|
@@ -1747,6 +1771,47 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
1747
1771
|
return { id: sig };
|
|
1748
1772
|
}
|
|
1749
1773
|
// =========================================
|
|
1774
|
+
// Claim delegation from gateway with delegation DISABLED (ario-gar, Fix #6)
|
|
1775
|
+
// =========================================
|
|
1776
|
+
/**
|
|
1777
|
+
* Claim a delegate's stake out of a gateway that has DISABLED delegation
|
|
1778
|
+
* (`allow_delegated_staking == false`), moving it into the delegate's own
|
|
1779
|
+
* withdrawal vault (WP §6.3 / Fix #6). This is the disabled-gateway analog of
|
|
1780
|
+
* {@link claimDelegateFromLeavingGateway}: the on-chain instruction is
|
|
1781
|
+
* permissionless, so a cranker can sweep delegates out (the operator cannot
|
|
1782
|
+
* re-enable delegation until `total_delegated_stake == 0` and the cooldown
|
|
1783
|
+
* elapses). The withdrawal-counter and withdrawal PDAs are seeded by the
|
|
1784
|
+
* DELEGATOR, so a cranker must pass that delegate's `delegatorAddress`.
|
|
1785
|
+
*
|
|
1786
|
+
* @param params.gatewayAddress The gateway whose delegation was disabled.
|
|
1787
|
+
* @param params.delegatorAddress The delegate to claim for. Defaults to the
|
|
1788
|
+
* signer (self-claim). Pass another address to crank on a delegate's behalf;
|
|
1789
|
+
* the signer covers rent (`payer`) but stake still routes to the delegate's
|
|
1790
|
+
* own vault (the delegator key is bound by the delegation PDA seeds).
|
|
1791
|
+
*/
|
|
1792
|
+
async claimDelegateFromDisabledGateway(params, _options) {
|
|
1793
|
+
const gateway = address(params.gatewayAddress);
|
|
1794
|
+
const delegator = params.delegatorAddress
|
|
1795
|
+
? address(params.delegatorAddress)
|
|
1796
|
+
: this.signer.address;
|
|
1797
|
+
const [gatewayPda] = await getGatewayPDA(gateway, this.garProgram);
|
|
1798
|
+
const [delegationPda] = await getDelegationPDA(gateway, delegator, this.garProgram);
|
|
1799
|
+
// Withdrawal counter + vault are PDA-seeded by the delegator, not the payer.
|
|
1800
|
+
const nextId = await this.getNextWithdrawalId(delegator);
|
|
1801
|
+
const [withdrawalPda] = await getWithdrawalPDA(delegator, nextId, this.garProgram);
|
|
1802
|
+
const ix = await getClaimDelegateFromDisabledGatewayInstructionAsync({
|
|
1803
|
+
gateway: gatewayPda,
|
|
1804
|
+
delegation: delegationPda,
|
|
1805
|
+
withdrawal: withdrawalPda,
|
|
1806
|
+
// `delegator` is an unsigned seeds-derivation key; `payer` (the signer)
|
|
1807
|
+
// covers rent on the init_if_needed counter + the new withdrawal.
|
|
1808
|
+
delegator,
|
|
1809
|
+
payer: this.signer,
|
|
1810
|
+
}, { programAddress: this.garProgram });
|
|
1811
|
+
const sig = await this.sendTransaction([ix], 1_000_000);
|
|
1812
|
+
return { id: sig };
|
|
1813
|
+
}
|
|
1814
|
+
// =========================================
|
|
1750
1815
|
// Delegation allowlist (ario-gar)
|
|
1751
1816
|
// =========================================
|
|
1752
1817
|
/** Add an address to the gateway's delegation allowlist. */
|
|
@@ -1995,9 +2060,22 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
1995
2060
|
}
|
|
1996
2061
|
/**
|
|
1997
2062
|
* Prescribe observers and names for an epoch. Permissionless — call after
|
|
1998
|
-
* weights are tallied.
|
|
1999
|
-
*
|
|
2063
|
+
* weights are tallied.
|
|
2064
|
+
*
|
|
2065
|
+
* `gatewayAccounts` MUST be the Gateway PDAs of the SELECTED observers only
|
|
2066
|
+
* — at most `epoch_settings.prescribed_observer_count` (≤50), NOT the whole
|
|
2067
|
+
* registry. The selection is computed on-chain; mirror it off-chain with
|
|
2068
|
+
* {@link predictPrescribedObservers} / {@link getPredictedObserverPDAs} to
|
|
2069
|
+
* learn the set. Passing every registry gateway (e.g. via
|
|
2070
|
+
* {@link getAllRegistryGatewayPDAs}) hits Solana's `MAX_TX_ACCOUNT_LOCKS = 64`
|
|
2071
|
+
* on large registries and the tx fails at pre-flight.
|
|
2072
|
+
*
|
|
2073
|
+
* The selected PDAs are appended as `remaining_accounts`, followed by the
|
|
2074
|
+
* optional `nameRegistryAccount` (must be LAST) which enables the name
|
|
2000
2075
|
* prescription leg.
|
|
2076
|
+
*
|
|
2077
|
+
* If a selected gateway leaves between prediction and tx landing, the tx
|
|
2078
|
+
* fails with `InvalidGatewayAccount` — retry once with a fresh prediction.
|
|
2001
2079
|
*/
|
|
2002
2080
|
async prescribeEpoch(params, _options) {
|
|
2003
2081
|
const ix = await getPrescribeEpochInstructionAsync(await this.withGarDefaults({
|
|
@@ -2014,7 +2092,28 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2014
2092
|
role: AccountRole.READONLY,
|
|
2015
2093
|
});
|
|
2016
2094
|
}
|
|
2017
|
-
const
|
|
2095
|
+
const fullIx = withRemainingAccounts(ix, remaining);
|
|
2096
|
+
// A prescribe tx with the selected observer set (~50 PDAs) exceeds Solana's
|
|
2097
|
+
// 1232-byte limit once there are more than ~24 remaining accounts, so route
|
|
2098
|
+
// those through an ephemeral Address Lookup Table (create → extend →
|
|
2099
|
+
// compressed v0 tx). Small sets (sparse testnets) take the cheaper inline
|
|
2100
|
+
// path. `prescribe_epoch` searches `remaining_accounts` by PDA, so serving
|
|
2101
|
+
// them via the ALT (which preserves instruction account order) is
|
|
2102
|
+
// transparent — incl. NameRegistry staying last. Validated on staging
|
|
2103
|
+
// (667 gateways, 50 observers): 428k CU, name prescription intact.
|
|
2104
|
+
if (remaining.length > 24) {
|
|
2105
|
+
const id = await sendWithEphemeralLookupTable({
|
|
2106
|
+
rpc: this.rpc,
|
|
2107
|
+
rpcSubscriptions: this.rpcSubscriptions,
|
|
2108
|
+
signer: this.signer,
|
|
2109
|
+
instruction: fullIx,
|
|
2110
|
+
lookupAddresses: remaining.map((a) => a.address),
|
|
2111
|
+
commitment: this.commitment,
|
|
2112
|
+
computeUnitLimit: 1_000_000,
|
|
2113
|
+
});
|
|
2114
|
+
return { id };
|
|
2115
|
+
}
|
|
2116
|
+
const sig = await this.sendTransaction([fullIx], 1_000_000);
|
|
2018
2117
|
return { id: sig };
|
|
2019
2118
|
}
|
|
2020
2119
|
/**
|
|
@@ -2115,6 +2214,244 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2115
2214
|
}
|
|
2116
2215
|
return pdas;
|
|
2117
2216
|
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Predict the Gateway PDAs that `prescribe_epoch` will select as observers
|
|
2219
|
+
* for `epochIndex`, mirroring the on-chain weighted-roulette selection.
|
|
2220
|
+
*
|
|
2221
|
+
* Returns at most `epoch_settings.prescribed_observer_count` (≤50) PDAs
|
|
2222
|
+
* regardless of registry size — the set to pass as `gatewayAccounts` to
|
|
2223
|
+
* {@link prescribeEpoch}. This is the size-safe replacement for
|
|
2224
|
+
* {@link getAllRegistryGatewayPDAs} on the prescribe path (which oversupplies
|
|
2225
|
+
* and trips `MAX_TX_ACCOUNT_LOCKS = 64` on large registries).
|
|
2226
|
+
*
|
|
2227
|
+
* Reads three accounts (epoch, registry, epoch settings) at the configured
|
|
2228
|
+
* commitment so the prediction reflects live registry weights. If a selected
|
|
2229
|
+
* gateway races out before the tx lands, `prescribeEpoch` throws
|
|
2230
|
+
* `InvalidGatewayAccount` — re-call this and retry once.
|
|
2231
|
+
*/
|
|
2232
|
+
async getPredictedObserverPDAs(epochIndex) {
|
|
2233
|
+
// --- Epoch: hashchain (frozen entropy) + active_gateway_count (walk bound) ---
|
|
2234
|
+
const [epochPda] = await getEpochPDA(epochIndex, this.garProgram);
|
|
2235
|
+
const epochAccount = await fetchEncodedAccount(this.rpc, epochPda, {
|
|
2236
|
+
commitment: this.commitment,
|
|
2237
|
+
});
|
|
2238
|
+
if (!epochAccount.exists)
|
|
2239
|
+
throw new Error(`Epoch ${epochIndex} not found`);
|
|
2240
|
+
const epochData = Buffer.from(epochAccount.data);
|
|
2241
|
+
// After the 8-byte discriminator (see fetchEpochRawFields): 9×u64 = 72
|
|
2242
|
+
// bytes, then hashchain[32], then active_gateway_count(u32).
|
|
2243
|
+
const EPOCH_BASE = 8;
|
|
2244
|
+
const hashchain = epochData.subarray(EPOCH_BASE + 72, EPOCH_BASE + 72 + 32);
|
|
2245
|
+
const activeGatewayCount = epochData.readUInt32LE(EPOCH_BASE + 104);
|
|
2246
|
+
// --- Registry: slots[0..activeGatewayCount] (address + composite_weight) ---
|
|
2247
|
+
const [registryPda] = await getGatewayRegistryPDA(this.garProgram);
|
|
2248
|
+
const registryAccount = await fetchEncodedAccount(this.rpc, registryPda, {
|
|
2249
|
+
commitment: this.commitment,
|
|
2250
|
+
});
|
|
2251
|
+
if (!registryAccount.exists)
|
|
2252
|
+
throw new Error('GatewayRegistry not found');
|
|
2253
|
+
const registryData = Buffer.from(registryAccount.data);
|
|
2254
|
+
const registryCount = registryData.readUInt32LE(40); // 8 disc + 32 authority
|
|
2255
|
+
const SLOTS_OFFSET = 48; // 8 + 32 + 4 count + 4 pad
|
|
2256
|
+
const SLOT_STRIDE = 56; // address(32)+weight(8)+start_ts(8)+status(1)+pad(7)
|
|
2257
|
+
// Walk exactly the on-chain prefix. The roulette uses
|
|
2258
|
+
// registry.gateways[0..epoch.active_gateway_count]; include zero-weight
|
|
2259
|
+
// slots so the cumulative walk and weight sum match byte-for-byte.
|
|
2260
|
+
const walkCount = Math.min(activeGatewayCount, registryCount, 3000);
|
|
2261
|
+
const slots = [];
|
|
2262
|
+
for (let i = 0; i < walkCount; i++) {
|
|
2263
|
+
const slotOffset = SLOTS_OFFSET + i * SLOT_STRIDE;
|
|
2264
|
+
slots.push({
|
|
2265
|
+
address: addressDecoder.decode(registryData.subarray(slotOffset, slotOffset + 32)),
|
|
2266
|
+
compositeWeight: registryData.readBigUInt64LE(slotOffset + 32),
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
// --- Epoch settings: prescribed_observer_count ---
|
|
2270
|
+
const [epochSettingsPda] = await getEpochSettingsPDA(this.garProgram);
|
|
2271
|
+
const settingsAccount = await fetchEncodedAccount(this.rpc, epochSettingsPda, {
|
|
2272
|
+
commitment: this.commitment,
|
|
2273
|
+
});
|
|
2274
|
+
if (!settingsAccount.exists)
|
|
2275
|
+
throw new Error('EpochSettings not found');
|
|
2276
|
+
const settings = deserializeEpochSettingsFull(Buffer.from(settingsAccount.data));
|
|
2277
|
+
// --- Predict selected operators, then derive their Gateway PDAs ---
|
|
2278
|
+
const operators = predictPrescribedObservers(hashchain, slots, settings.prescribedObserverCount);
|
|
2279
|
+
const pdas = [];
|
|
2280
|
+
for (const operator of operators) {
|
|
2281
|
+
const [gatewayPda] = await getGatewayPDA(operator, this.garProgram);
|
|
2282
|
+
pdas.push(gatewayPda);
|
|
2283
|
+
}
|
|
2284
|
+
return pdas;
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Reclaim rent from the ephemeral Address Lookup Tables this signer created
|
|
2288
|
+
* for `prescribe_epoch` (see {@link sendWithEphemeralLookupTable}). Each
|
|
2289
|
+
* prescribe leaves a single-use table allocated (~0.0126 SOL); reclaiming
|
|
2290
|
+
* needs a deactivate → ~513-slot cooldown → close sequence, so it can't run
|
|
2291
|
+
* inline. Call this from a throttled/permissionless cleanup pass (cranker /
|
|
2292
|
+
* observer) to deactivate active tables and close cooled-down ones, refunding
|
|
2293
|
+
* the rent to the signer.
|
|
2294
|
+
*
|
|
2295
|
+
* Discovery reads the signer's transaction history (RPC-portable; the ALT
|
|
2296
|
+
* program can't be enumerated via `getProgramAccounts`). The GAR + ArNS
|
|
2297
|
+
* program IDs are passed as the entry-ownership fingerprint so only genuine
|
|
2298
|
+
* prescribe tables are touched. Best-effort: at most `maxTables` submissions
|
|
2299
|
+
* per call, scanning at most `scanLimit` recent signatures.
|
|
2300
|
+
*/
|
|
2301
|
+
async reclaimLookupTableRent(opts) {
|
|
2302
|
+
return reclaimLookupTablesForSigner({
|
|
2303
|
+
rpc: this.rpc,
|
|
2304
|
+
rpcSubscriptions: this.rpcSubscriptions,
|
|
2305
|
+
signer: this.signer,
|
|
2306
|
+
allowedEntryOwners: [this.garProgram, this.arnsProgram],
|
|
2307
|
+
commitment: this.commitment,
|
|
2308
|
+
maxTables: opts?.maxTables,
|
|
2309
|
+
scanLimit: opts?.scanLimit,
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
/** Read and deserialize the full EpochSettings account. */
|
|
2313
|
+
async getEpochSettingsFull() {
|
|
2314
|
+
const [esPda] = await getEpochSettingsPDA(this.garProgram);
|
|
2315
|
+
const account = await fetchEncodedAccount(this.rpc, esPda, {
|
|
2316
|
+
commitment: this.commitment,
|
|
2317
|
+
});
|
|
2318
|
+
if (!account.exists)
|
|
2319
|
+
throw new Error('EpochSettings not found');
|
|
2320
|
+
return deserializeEpochSettingsFull(Buffer.from(account.data));
|
|
2321
|
+
}
|
|
2322
|
+
/**
|
|
2323
|
+
* Submit `prescribe_epoch` using the off-chain-predicted observer set, with a
|
|
2324
|
+
* single re-predict-and-retry on `InvalidGatewayAccount` (covers a gateway
|
|
2325
|
+
* leaving the registry between the prediction read and the tx landing).
|
|
2326
|
+
*/
|
|
2327
|
+
async prescribeWithPrediction(epochIndex, nameRegistryAccount) {
|
|
2328
|
+
const submit = async () => this.prescribeEpoch({
|
|
2329
|
+
epochIndex,
|
|
2330
|
+
gatewayAccounts: await this.getPredictedObserverPDAs(epochIndex),
|
|
2331
|
+
nameRegistryAccount,
|
|
2332
|
+
});
|
|
2333
|
+
try {
|
|
2334
|
+
return await submit();
|
|
2335
|
+
}
|
|
2336
|
+
catch (err) {
|
|
2337
|
+
if (!isInvalidGatewayAccountError(err))
|
|
2338
|
+
throw err;
|
|
2339
|
+
return submit();
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Advance the epoch lifecycle by ONE on-chain action and return what it did.
|
|
2344
|
+
*
|
|
2345
|
+
* Stateless and idempotent: it reads `EpochSettings` + the current `Epoch`,
|
|
2346
|
+
* determines the single next required step
|
|
2347
|
+
* (`create` → `tally` → `prescribe` → `distribute` → `close`), submits it,
|
|
2348
|
+
* and returns a {@link CrankEpochStepResult}. Call it repeatedly on your own
|
|
2349
|
+
* schedule — it owns *which* on-chain action is correct and *which accounts*
|
|
2350
|
+
* it needs; you own scheduling, logging, error classification, and any
|
|
2351
|
+
* permissionless cleanup.
|
|
2352
|
+
*
|
|
2353
|
+
* Crucially, the `prescribe` leg uses {@link getPredictedObserverPDAs} (only
|
|
2354
|
+
* the ~`prescribed_observer_count` selected Gateway PDAs), so it never trips
|
|
2355
|
+
* `MAX_TX_ACCOUNT_LOCKS = 64` on large registries — and it re-predicts and
|
|
2356
|
+
* retries once on `InvalidGatewayAccount`.
|
|
2357
|
+
*
|
|
2358
|
+
* Errors propagate to the caller (classify/retry as you see fit); the only
|
|
2359
|
+
* internally-handled error is the prescribe `InvalidGatewayAccount` retry.
|
|
2360
|
+
*/
|
|
2361
|
+
async crankEpochStep(opts = {}) {
|
|
2362
|
+
// tally_weights / distribute_epoch append the batch's Gateway PDAs as
|
|
2363
|
+
// remaining_accounts. distribute also CPIs into ario-core (treasury
|
|
2364
|
+
// release) so it carries 10 named accounts; with ~18+ gateway PDAs on top
|
|
2365
|
+
// the tx exceeds Solana's 1232-byte limit. Cap the lifecycle batch at 18 so
|
|
2366
|
+
// an oversized caller `batchSize` can't produce an unsendable tx (verified:
|
|
2367
|
+
// 30 gateways → 1527B; 18 → ~1050B). prescribe is the exception — it needs
|
|
2368
|
+
// ALL selected observers in one tx, so it uses an ALT instead (see
|
|
2369
|
+
// prescribeEpoch).
|
|
2370
|
+
const MAX_LIFECYCLE_BATCH = 18;
|
|
2371
|
+
const batchSize = Math.min(opts.batchSize ?? MAX_LIFECYCLE_BATCH, MAX_LIFECYCLE_BATCH);
|
|
2372
|
+
const enableClose = opts.enableClose ?? true;
|
|
2373
|
+
const retention = opts.epochRetention ?? 7;
|
|
2374
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
2375
|
+
const settings = await this.getEpochSettingsFull();
|
|
2376
|
+
if (!settings.enabled)
|
|
2377
|
+
return { action: 'idle', reason: 'epochs_disabled' };
|
|
2378
|
+
const currentIndex = settings.currentEpochIndex;
|
|
2379
|
+
// currentIndex is the NEXT epoch to create; the live one is currentIndex-1.
|
|
2380
|
+
const targetEpochIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
|
2381
|
+
const nextEpochStart = settings.genesisTimestamp + currentIndex * settings.epochDuration;
|
|
2382
|
+
// Bootstrap: no epochs yet.
|
|
2383
|
+
if (currentIndex === 0) {
|
|
2384
|
+
if (now < nextEpochStart)
|
|
2385
|
+
return { action: 'idle', reason: 'waiting_for_genesis' };
|
|
2386
|
+
const { id } = await this.createEpoch();
|
|
2387
|
+
return { action: 'create', epochIndex: 0, txId: id };
|
|
2388
|
+
}
|
|
2389
|
+
const epoch = await this.getEpochRaw(targetEpochIndex);
|
|
2390
|
+
if (!epoch)
|
|
2391
|
+
return { action: 'idle', reason: 'waiting_for_epoch' };
|
|
2392
|
+
// Tally (batched). activeGatewayCount===0 still needs one tx to flip the flag.
|
|
2393
|
+
if (epoch.weightsTallied === 0) {
|
|
2394
|
+
const gatewayAccounts = epoch.activeGatewayCount > 0
|
|
2395
|
+
? await this.getRegistryGatewayPDAs(epoch.tallyIndex, batchSize)
|
|
2396
|
+
: [];
|
|
2397
|
+
const { id } = await this.tallyWeights({
|
|
2398
|
+
epochIndex: targetEpochIndex,
|
|
2399
|
+
gatewayAccounts,
|
|
2400
|
+
});
|
|
2401
|
+
return {
|
|
2402
|
+
action: 'tally',
|
|
2403
|
+
epochIndex: targetEpochIndex,
|
|
2404
|
+
txId: id,
|
|
2405
|
+
progress: { index: epoch.tallyIndex, total: epoch.activeGatewayCount },
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
// Prescribe (predicted observers only — the size-safe path).
|
|
2409
|
+
if (epoch.prescriptionsDone === 0) {
|
|
2410
|
+
const nameRegistryAccount = opts.nameRegistryAccount === null
|
|
2411
|
+
? undefined
|
|
2412
|
+
: (opts.nameRegistryAccount ??
|
|
2413
|
+
(await getArnsRegistryPDA(this.arnsProgram))[0]);
|
|
2414
|
+
const { id } = await this.prescribeWithPrediction(targetEpochIndex, nameRegistryAccount);
|
|
2415
|
+
return { action: 'prescribe', epochIndex: targetEpochIndex, txId: id };
|
|
2416
|
+
}
|
|
2417
|
+
// Observations happen while the epoch is live.
|
|
2418
|
+
if (now < epoch.endTimestamp)
|
|
2419
|
+
return { action: 'idle', reason: 'waiting_for_observations' };
|
|
2420
|
+
// Distribute (batched).
|
|
2421
|
+
if (epoch.rewardsDistributed === 0) {
|
|
2422
|
+
const gatewayAccounts = epoch.activeGatewayCount > 0
|
|
2423
|
+
? await this.getRegistryGatewayPDAs(epoch.distributionIndex, batchSize)
|
|
2424
|
+
: [];
|
|
2425
|
+
const { id } = await this.distributeEpoch({
|
|
2426
|
+
epochIndex: targetEpochIndex,
|
|
2427
|
+
gatewayAccounts,
|
|
2428
|
+
});
|
|
2429
|
+
return {
|
|
2430
|
+
action: 'distribute',
|
|
2431
|
+
epochIndex: targetEpochIndex,
|
|
2432
|
+
txId: id,
|
|
2433
|
+
progress: {
|
|
2434
|
+
index: epoch.distributionIndex,
|
|
2435
|
+
total: epoch.activeGatewayCount,
|
|
2436
|
+
},
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
// Close a fully-distributed epoch past retention (GAR-006).
|
|
2440
|
+
if (enableClose && targetEpochIndex >= retention) {
|
|
2441
|
+
const closeTarget = targetEpochIndex - retention;
|
|
2442
|
+
const old = await this.getEpochRaw(closeTarget);
|
|
2443
|
+
if (old && old.rewardsDistributed === 1) {
|
|
2444
|
+
const { id } = await this.closeEpoch({ epochIndex: closeTarget });
|
|
2445
|
+
return { action: 'close', epochIndex: closeTarget, txId: id };
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
// Current epoch fully processed — create the next once its start arrives.
|
|
2449
|
+
if (now >= nextEpochStart) {
|
|
2450
|
+
const { id } = await this.createEpoch();
|
|
2451
|
+
return { action: 'create', epochIndex: currentIndex, txId: id };
|
|
2452
|
+
}
|
|
2453
|
+
return { action: 'idle', reason: 'epoch_complete' };
|
|
2454
|
+
}
|
|
2118
2455
|
/**
|
|
2119
2456
|
* Read the raw epoch account data for cranker state inspection.
|
|
2120
2457
|
* Returns null if the epoch account doesn't exist yet.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-chain prediction of `prescribe_epoch`'s observer selection.
|
|
3
|
+
*
|
|
4
|
+
* `ario_gar::prescribe_epoch` selects observers INTERNALLY via a weighted
|
|
5
|
+
* roulette over the `GatewayRegistry`, then uses `remaining_accounts` only as a
|
|
6
|
+
* lookup table for the ~50 selected Gateway PDAs (+ NameRegistry). A cranker
|
|
7
|
+
* that instead supplies every registry gateway hits Solana's
|
|
8
|
+
* `MAX_TX_ACCOUNT_LOCKS = 64` on large registries and the tx is rejected at
|
|
9
|
+
* pre-flight. This helper mirrors the on-chain selection so the caller can
|
|
10
|
+
* supply exactly the selected set.
|
|
11
|
+
*
|
|
12
|
+
* The selection is deterministic from on-chain state that is stable once
|
|
13
|
+
* `epoch.weights_tallied == 1`: the frozen `epoch.hashchain` entropy beacon and
|
|
14
|
+
* the live `registry.gateways[*].composite_weight`.
|
|
15
|
+
*
|
|
16
|
+
* Cross-language parity with the Rust handler
|
|
17
|
+
* (`ar-io-solana-contracts/programs/ario-gar/src/instructions/epoch.rs`, the
|
|
18
|
+
* `prescribe_epoch` observer-selection block) is asserted in
|
|
19
|
+
* `predict-prescribed-observers.test.ts` against vectors generated by the Rust
|
|
20
|
+
* reference example `programs/ario-gar/examples/predict_prescribed_observers.rs`.
|
|
21
|
+
* If you change this algorithm, update that example and the test together.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from 'crypto';
|
|
24
|
+
function sha256(data) {
|
|
25
|
+
return createHash('sha256').update(data).digest();
|
|
26
|
+
}
|
|
27
|
+
/** `u128::from_le_bytes(bytes[0..16])` — little-endian, first 16 bytes only. */
|
|
28
|
+
function leU128(bytes) {
|
|
29
|
+
let v = 0n;
|
|
30
|
+
for (let i = 15; i >= 0; i--) {
|
|
31
|
+
v = (v << 8n) | BigInt(bytes[i]);
|
|
32
|
+
}
|
|
33
|
+
return v;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Predict the operator pubkeys `prescribe_epoch` will select as observers.
|
|
37
|
+
*
|
|
38
|
+
* Returns the selected `GatewaySlot.address` values (operator pubkeys) in
|
|
39
|
+
* selection order, at most `maxObservers`. These correspond 1:1 to the on-chain
|
|
40
|
+
* `epoch.prescribed_observer_gateways` array, and are what Gateway PDAs are
|
|
41
|
+
* derived from (`[GATEWAY_SEED, operator]`). NOTE this is NOT
|
|
42
|
+
* `epoch.prescribed_observers`, which `prescribe_epoch` later overwrites with
|
|
43
|
+
* each gateway's resolved `observer_address`.
|
|
44
|
+
*
|
|
45
|
+
* @param epochHashchain `epoch.hashchain` — exactly 32 bytes, frozen at
|
|
46
|
+
* `create_epoch`.
|
|
47
|
+
* @param slots `registry.gateways[0 .. epoch.active_gateway_count]` in registry
|
|
48
|
+
* (slot-index) order. Pass the whole prefix including any zero-weight slots —
|
|
49
|
+
* order and the live weight sum must match the on-chain walk exactly. Empty /
|
|
50
|
+
* zero-weight slots contribute nothing and can never be selected.
|
|
51
|
+
* @param maxObservers `epoch_settings.prescribed_observer_count`. Clamped to
|
|
52
|
+
* `slots.length` (the on-chain `min(prescribed_observer_count, active_count)`).
|
|
53
|
+
*/
|
|
54
|
+
export function predictPrescribedObservers(epochHashchain, slots, maxObservers) {
|
|
55
|
+
if (epochHashchain.length !== 32) {
|
|
56
|
+
throw new Error(`epochHashchain must be 32 bytes, got ${epochHashchain.length}`);
|
|
57
|
+
}
|
|
58
|
+
const activeCount = slots.length;
|
|
59
|
+
const cap = Math.min(maxObservers, activeCount);
|
|
60
|
+
// Live total weight (epoch.rs: `for i in 0..active_count { total += w }`).
|
|
61
|
+
// u128 sum of u64 weights over <=3000 slots cannot overflow, so the Rust
|
|
62
|
+
// `saturating_add` never actually saturates; plain BigInt addition matches.
|
|
63
|
+
let totalWeight = 0n;
|
|
64
|
+
for (const slot of slots) {
|
|
65
|
+
totalWeight += slot.compositeWeight;
|
|
66
|
+
}
|
|
67
|
+
const selected = [];
|
|
68
|
+
if (totalWeight === 0n || activeCount === 0 || cap === 0) {
|
|
69
|
+
return selected;
|
|
70
|
+
}
|
|
71
|
+
// Initial entropy: sha256(hashchain).
|
|
72
|
+
let hashBytes = sha256(epochHashchain);
|
|
73
|
+
// GAR-019: bounded retries, up to cap * 10 rounds.
|
|
74
|
+
const maxRounds = cap * 10;
|
|
75
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
76
|
+
if (selected.length >= cap)
|
|
77
|
+
break;
|
|
78
|
+
const randomValue = leU128(hashBytes.subarray(0, 16)) % totalWeight;
|
|
79
|
+
let cumulative = 0n;
|
|
80
|
+
for (const slot of slots) {
|
|
81
|
+
cumulative += slot.compositeWeight;
|
|
82
|
+
if (cumulative > randomValue && slot.compositeWeight > 0n) {
|
|
83
|
+
// Anti-duplicate: skip if already chosen, but the round still consumes
|
|
84
|
+
// the roulette hit — break the walk either way (epoch.rs).
|
|
85
|
+
if (!selected.includes(slot.address)) {
|
|
86
|
+
selected.push(slot.address);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Re-hash for the next round: sha256(hash_bytes).
|
|
92
|
+
hashBytes = sha256(hashBytes);
|
|
93
|
+
}
|
|
94
|
+
return selected;
|
|
95
|
+
}
|
package/lib/esm/solana/send.js
CHANGED
|
@@ -8,17 +8,18 @@
|
|
|
8
8
|
* favor of the official package. See `sendAndConfirm` below for why we always
|
|
9
9
|
* pin BOTH instructions (even with a 0 priority fee).
|
|
10
10
|
*/
|
|
11
|
+
import { ADDRESS_LOOKUP_TABLE_PROGRAM_ADDRESS, getCloseLookupTableInstruction, getCreateLookupTableInstructionAsync, getDeactivateLookupTableInstruction, getExtendLookupTableInstruction, } from '@solana-program/address-lookup-table';
|
|
11
12
|
import { getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction, } from '@solana-program/compute-budget';
|
|
12
|
-
import { appendTransactionMessageInstructions, compileTransaction, createTransactionMessage, getBase64EncodedWireTransaction, getSignatureFromTransaction, pipe, sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, } from '@solana/kit';
|
|
13
|
+
import { appendTransactionMessageInstructions, compileTransaction, compressTransactionMessageUsingAddressLookupTables, createTransactionMessage, getAddressDecoder, getBase64EncodedWireTransaction, getSignatureFromTransaction, pipe, sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, } from '@solana/kit';
|
|
13
14
|
/**
|
|
14
15
|
* Build, sign, send, and confirm a transaction in one call.
|
|
15
16
|
*
|
|
16
17
|
* The caller supplies the core instructions; a compute-unit-limit instruction
|
|
17
18
|
* is prepended automatically.
|
|
18
19
|
*/
|
|
19
|
-
export async function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructions, commitment = 'confirmed', computeUnitLimit = 400_000, }) {
|
|
20
|
+
export async function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructions, commitment = 'confirmed', computeUnitLimit = 400_000, addressLookupTables, }) {
|
|
20
21
|
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
|
|
21
|
-
const
|
|
22
|
+
const baseMessage = pipe(createTransactionMessage({ version: 0 }), (tx) => setTransactionMessageFeePayerSigner(signer, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), (tx) => appendTransactionMessageInstructions([
|
|
22
23
|
getSetComputeUnitLimitInstruction({ units: computeUnitLimit }),
|
|
23
24
|
// Always pin the priority fee (even at 0) so wallets like Phantom
|
|
24
25
|
// don't silently *append* their own compute-budget instructions
|
|
@@ -32,6 +33,10 @@ export async function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructio
|
|
|
32
33
|
getSetComputeUnitPriceInstruction({ microLamports: 0n }),
|
|
33
34
|
...instructions,
|
|
34
35
|
], tx));
|
|
36
|
+
// Compress against any supplied lookup tables (v0). No-op when none given.
|
|
37
|
+
const message = addressLookupTables
|
|
38
|
+
? compressTransactionMessageUsingAddressLookupTables(baseMessage, addressLookupTables)
|
|
39
|
+
: baseMessage;
|
|
35
40
|
const signedTx = await signTransactionMessageWithSigners(message);
|
|
36
41
|
const sendAndConfirmFactory = sendAndConfirmTransactionFactory({
|
|
37
42
|
rpc,
|
|
@@ -115,3 +120,238 @@ async function logSimulationDiagnostics(rpc, message, originalError) {
|
|
|
115
120
|
console.warn('[solana-send] failed to collect diagnostics', diagErr);
|
|
116
121
|
}
|
|
117
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Submit `instruction` in a v0 transaction whose `lookupAddresses` (read-only
|
|
125
|
+
* accounts) are served from a freshly-created, ephemeral Address Lookup Table,
|
|
126
|
+
* so an instruction touching far more accounts than fit inline (e.g.
|
|
127
|
+
* `prescribe_epoch` with ≤50 observer PDAs + NameRegistry, ~2 KB of keys) still
|
|
128
|
+
* fits Solana's 1232-byte transaction-size limit.
|
|
129
|
+
*
|
|
130
|
+
* Three confirmed steps: create the table, extend it with the addresses (in
|
|
131
|
+
* ≤20-address batches to stay within the extend tx size), then send
|
|
132
|
+
* `instruction` compressed against the table. The sequential confirmations
|
|
133
|
+
* satisfy the rule that appended addresses are only usable the slot AFTER they
|
|
134
|
+
* are added. `signer` is the table's authority + payer; the table's (tiny) rent
|
|
135
|
+
* is left allocated — a future cleanup pass can deactivate + close it.
|
|
136
|
+
*/
|
|
137
|
+
export async function sendWithEphemeralLookupTable({ rpc, rpcSubscriptions, signer, instruction, lookupAddresses, commitment = 'confirmed', computeUnitLimit = 1_000_000, }) {
|
|
138
|
+
const recentSlot = await rpc.getSlot({ commitment: 'finalized' }).send();
|
|
139
|
+
const createIx = await getCreateLookupTableInstructionAsync({
|
|
140
|
+
authority: signer.address,
|
|
141
|
+
payer: signer,
|
|
142
|
+
recentSlot,
|
|
143
|
+
});
|
|
144
|
+
const tableAddress = createIx.accounts[0].address;
|
|
145
|
+
// Create the (empty) table.
|
|
146
|
+
await sendAndConfirm({
|
|
147
|
+
rpc,
|
|
148
|
+
rpcSubscriptions,
|
|
149
|
+
signer,
|
|
150
|
+
instructions: [createIx],
|
|
151
|
+
commitment,
|
|
152
|
+
computeUnitLimit: 60_000,
|
|
153
|
+
});
|
|
154
|
+
// Fill it, ≤20 addresses per extend tx.
|
|
155
|
+
const BATCH = 20;
|
|
156
|
+
for (let i = 0; i < lookupAddresses.length; i += BATCH) {
|
|
157
|
+
const extendIx = getExtendLookupTableInstruction({
|
|
158
|
+
address: tableAddress,
|
|
159
|
+
authority: signer,
|
|
160
|
+
payer: signer,
|
|
161
|
+
addresses: lookupAddresses.slice(i, i + BATCH),
|
|
162
|
+
});
|
|
163
|
+
await sendAndConfirm({
|
|
164
|
+
rpc,
|
|
165
|
+
rpcSubscriptions,
|
|
166
|
+
signer,
|
|
167
|
+
instructions: [extendIx],
|
|
168
|
+
commitment,
|
|
169
|
+
computeUnitLimit: 60_000,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Wait until the table holds every address AND one slot has elapsed since —
|
|
173
|
+
// addresses appended to a lookup table are only usable the slot AFTER they're
|
|
174
|
+
// added, and the validator that processes the prescribe must already see
|
|
175
|
+
// them. Skipping this yields "address table lookup uses an invalid index".
|
|
176
|
+
await waitForLookupTableActive(rpc, tableAddress, lookupAddresses.length);
|
|
177
|
+
// Send the real instruction, compressed against the now-active table.
|
|
178
|
+
return sendAndConfirm({
|
|
179
|
+
rpc,
|
|
180
|
+
rpcSubscriptions,
|
|
181
|
+
signer,
|
|
182
|
+
instructions: [instruction],
|
|
183
|
+
commitment,
|
|
184
|
+
computeUnitLimit,
|
|
185
|
+
addressLookupTables: { [tableAddress]: lookupAddresses },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Poll until an Address Lookup Table holds at least `expectedCount` addresses
|
|
190
|
+
* AND at least one slot has elapsed since they all landed. Lookup-table entries
|
|
191
|
+
* are only usable the slot AFTER they are appended, and the leader processing
|
|
192
|
+
* the consuming tx must already see them — otherwise the runtime rejects the tx
|
|
193
|
+
* with "address table lookup uses an invalid index". ALT account layout is a
|
|
194
|
+
* 56-byte metadata header followed by 32-byte addresses.
|
|
195
|
+
*/
|
|
196
|
+
async function waitForLookupTableActive(rpc, table, expectedCount, maxWaitMs = 30_000) {
|
|
197
|
+
const META = 56;
|
|
198
|
+
const start = Date.now();
|
|
199
|
+
let slotAllPresent = null;
|
|
200
|
+
while (Date.now() - start < maxWaitMs) {
|
|
201
|
+
const acc = await rpc.getAccountInfo(table, { encoding: 'base64' }).send();
|
|
202
|
+
const slot = acc.context.slot;
|
|
203
|
+
if (acc.value) {
|
|
204
|
+
const len = Buffer.from(acc.value.data[0], 'base64').length;
|
|
205
|
+
const count = len >= META ? Math.floor((len - META) / 32) : 0;
|
|
206
|
+
if (count >= expectedCount) {
|
|
207
|
+
if (slotAllPresent === null) {
|
|
208
|
+
slotAllPresent = slot;
|
|
209
|
+
}
|
|
210
|
+
else if (slot > slotAllPresent) {
|
|
211
|
+
return; // all addresses present + a slot has elapsed → warm
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
216
|
+
}
|
|
217
|
+
throw new Error(`lookup table ${table} not active (≥${expectedCount} addresses + 1 slot) within ${maxWaitMs}ms`);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Reclaim rent from the ephemeral Address Lookup Tables `signer` created for
|
|
221
|
+
* prescribe (see {@link sendWithEphemeralLookupTable}). Each prescribe leaves a
|
|
222
|
+
* single-use table allocated (~0.0126 SOL of rent); reclaiming needs a
|
|
223
|
+
* deactivate → ~513-slot cooldown → close sequence, so it can't run inline — a
|
|
224
|
+
* throttled permissionless cleanup pass (cranker / observer) calls this.
|
|
225
|
+
*
|
|
226
|
+
* Discovery is RPC-portable. `getProgramAccounts` on the Address Lookup Table
|
|
227
|
+
* program is rejected by Agave RPCs (`Invalid param: WrongSize`, on public
|
|
228
|
+
* devnet/mainnet-beta and dedicated providers alike — the ALT program can't be
|
|
229
|
+
* enumerated), so instead we read the signer's own transaction history
|
|
230
|
+
* (`getSignaturesForAddress` + `getTransaction`) and collect the tables it
|
|
231
|
+
* referenced via `message.addressTableLookups` — a prescribe ALT is used in
|
|
232
|
+
* exactly one transaction.
|
|
233
|
+
*
|
|
234
|
+
* Safety fingerprint: a candidate is only touched when EVERY one of its entries
|
|
235
|
+
* is owned by a program in `allowedEntryOwners` (the GAR + ArNS programs — i.e.
|
|
236
|
+
* observer Gateway PDAs + the ArNS NameRegistry). That composition uniquely
|
|
237
|
+
* identifies a prescribe ephemeral, so the pass never deactivates/closes an
|
|
238
|
+
* unrelated table even if `signer` is also used to author Address Lookup Tables
|
|
239
|
+
* for other purposes.
|
|
240
|
+
*
|
|
241
|
+
* DEACTIVATES still-active matches (starts the cooldown) and CLOSES deactivated
|
|
242
|
+
* matches past the cooldown (refunding rent to `signer`). At most `maxTables`
|
|
243
|
+
* submissions per call; scans at most `scanLimit` recent signatures. Best-effort:
|
|
244
|
+
* per-table failures are skipped and retried on the next pass.
|
|
245
|
+
*/
|
|
246
|
+
export async function reclaimLookupTablesForSigner({ rpc, rpcSubscriptions, signer, allowedEntryOwners, commitment = 'confirmed', maxTables = 10, scanLimit = 500, }) {
|
|
247
|
+
const ALT_META = 56; // metadata header before the 32-byte address array
|
|
248
|
+
const ACTIVE = 0xffffffffffffffffn; // u64::MAX = not yet deactivated
|
|
249
|
+
const COOLDOWN_SLOTS = 513n; // deactivation_slot must age out of SlotHashes
|
|
250
|
+
const allowed = new Set(allowedEntryOwners);
|
|
251
|
+
const addressDecoder = getAddressDecoder();
|
|
252
|
+
// getTransaction only honours 'confirmed' | 'finalized'.
|
|
253
|
+
const historyCommitment = commitment === 'finalized' ? 'finalized' : 'confirmed';
|
|
254
|
+
// --- Discover candidate tables from the signer's transaction history -------
|
|
255
|
+
const sigs = await rpc
|
|
256
|
+
.getSignaturesForAddress(signer.address, { limit: scanLimit })
|
|
257
|
+
.send();
|
|
258
|
+
const candidates = new Set();
|
|
259
|
+
for (const { signature } of sigs) {
|
|
260
|
+
// A little headroom over maxTables so already-closed candidates don't
|
|
261
|
+
// starve the budget; the rest get picked up next pass.
|
|
262
|
+
if (candidates.size >= maxTables * 3)
|
|
263
|
+
break;
|
|
264
|
+
const tx = await rpc
|
|
265
|
+
.getTransaction(signature, {
|
|
266
|
+
encoding: 'json',
|
|
267
|
+
maxSupportedTransactionVersion: 0,
|
|
268
|
+
commitment: historyCommitment,
|
|
269
|
+
})
|
|
270
|
+
.send();
|
|
271
|
+
const lookups = tx?.transaction?.message?.addressTableLookups ?? [];
|
|
272
|
+
for (const l of lookups)
|
|
273
|
+
candidates.add(l.accountKey);
|
|
274
|
+
}
|
|
275
|
+
// --- Reclaim ----------------------------------------------------------------
|
|
276
|
+
const currentSlot = await rpc.getSlot().send();
|
|
277
|
+
let deactivated = 0;
|
|
278
|
+
let closed = 0;
|
|
279
|
+
for (const table of candidates) {
|
|
280
|
+
if (deactivated + closed >= maxTables)
|
|
281
|
+
break;
|
|
282
|
+
const address = table;
|
|
283
|
+
try {
|
|
284
|
+
const info = await rpc
|
|
285
|
+
.getAccountInfo(address, { encoding: 'base64' })
|
|
286
|
+
.send();
|
|
287
|
+
const value = info.value;
|
|
288
|
+
if (!value)
|
|
289
|
+
continue; // already closed
|
|
290
|
+
if (value.owner !==
|
|
291
|
+
ADDRESS_LOOKUP_TABLE_PROGRAM_ADDRESS) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const data = Buffer.from(value.data[0], 'base64');
|
|
295
|
+
if (data.length < ALT_META)
|
|
296
|
+
continue;
|
|
297
|
+
const deactivationSlot = data.readBigUInt64LE(4);
|
|
298
|
+
// Fingerprint: every entry must be owned by an allowed program. A prescribe
|
|
299
|
+
// ALT is exclusively observer Gateway PDAs (GAR) + the NameRegistry (ArNS).
|
|
300
|
+
const entries = [];
|
|
301
|
+
for (let off = ALT_META; off + 32 <= data.length; off += 32) {
|
|
302
|
+
entries.push(addressDecoder.decode(data.subarray(off, off + 32)));
|
|
303
|
+
}
|
|
304
|
+
if (entries.length === 0)
|
|
305
|
+
continue;
|
|
306
|
+
const owners = await rpc
|
|
307
|
+
.getMultipleAccounts(entries, {
|
|
308
|
+
encoding: 'base64',
|
|
309
|
+
dataSlice: { offset: 0, length: 0 },
|
|
310
|
+
})
|
|
311
|
+
.send();
|
|
312
|
+
const allOwned = owners.value.every((a) => a != null && allowed.has(a.owner));
|
|
313
|
+
if (!allOwned)
|
|
314
|
+
continue; // not a prescribe ephemeral — leave it alone
|
|
315
|
+
if (deactivationSlot === ACTIVE) {
|
|
316
|
+
await sendAndConfirm({
|
|
317
|
+
rpc,
|
|
318
|
+
rpcSubscriptions,
|
|
319
|
+
signer,
|
|
320
|
+
commitment,
|
|
321
|
+
computeUnitLimit: 30_000,
|
|
322
|
+
instructions: [
|
|
323
|
+
getDeactivateLookupTableInstruction({ address, authority: signer }),
|
|
324
|
+
],
|
|
325
|
+
});
|
|
326
|
+
deactivated += 1;
|
|
327
|
+
}
|
|
328
|
+
else if (currentSlot > deactivationSlot + COOLDOWN_SLOTS) {
|
|
329
|
+
await sendAndConfirm({
|
|
330
|
+
rpc,
|
|
331
|
+
rpcSubscriptions,
|
|
332
|
+
signer,
|
|
333
|
+
commitment,
|
|
334
|
+
computeUnitLimit: 30_000,
|
|
335
|
+
instructions: [
|
|
336
|
+
getCloseLookupTableInstruction({
|
|
337
|
+
address,
|
|
338
|
+
authority: signer,
|
|
339
|
+
recipient: signer.address,
|
|
340
|
+
}),
|
|
341
|
+
],
|
|
342
|
+
});
|
|
343
|
+
closed += 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// best-effort: a racing close / not-yet-cooled table just gets retried
|
|
348
|
+
// on the next cleanup pass.
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
deactivated,
|
|
353
|
+
closed,
|
|
354
|
+
candidates: candidates.size,
|
|
355
|
+
scannedSignatures: sigs.length,
|
|
356
|
+
};
|
|
357
|
+
}
|
package/lib/esm/version.js
CHANGED
|
@@ -43,7 +43,7 @@ export * from '../types/index.js';
|
|
|
43
43
|
export * from '../utils/index.js';
|
|
44
44
|
export { ARWEAVE_TX_REGEX, AR_IO_PROTOCOL, arweaveUri, FQDN_REGEX, MARIO_PER_ARIO, } from '../constants.js';
|
|
45
45
|
export { SolanaARIOReadable } from './io-readable.js';
|
|
46
|
-
export { SolanaARIOWriteable } from './io-writeable.js';
|
|
46
|
+
export { type CrankAction, type CrankEpochStepOptions, type CrankEpochStepResult, isInvalidGatewayAccountError, SolanaARIOWriteable, } from './io-writeable.js';
|
|
47
47
|
export { SolanaANTReadable } from './ant-readable.js';
|
|
48
48
|
export { SolanaANTWriteable } from './ant-writeable.js';
|
|
49
49
|
export { SolanaANTRegistryReadable } from './ant-registry-readable.js';
|
|
@@ -60,6 +60,7 @@ export type { SpawnSolanaANTParams, SpawnSolanaANTResult, SpawnSolanaANTState, }
|
|
|
60
60
|
export { hashName, getArioConfigPDA, getBalancePDA, getVaultPDA, getVaultCounterPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getGatewayRegistryPDA, getGarSettingsPDA, getGatewayPDA, getDelegationPDA, getWithdrawalPDA, getWithdrawalCounterPDA, getAllowlistPDA, getEpochPDA, getEpochSettingsPDA, getObservationPDA, getArnsRegistryPDA, getArnsSettingsPDA, getArnsRecordPDA, getArnsRecordPDAFromHash, getReservedNamePDA, getReturnedNamePDA, getDemandFactorPDA, getPrimaryNameReversePDA, getRedelegationRecordPDA, getAntConfigPDA, getAntControllersPDA, getAntRecordPDA, getAclConfigPDA, getAclPagePDA, getEscrowAntPDA, getEscrowTokenPDA, getEscrowVaultPDA, } from './pda.js';
|
|
61
61
|
export { BorshReader, BorshWriter, deserializeGateway, deserializeArnsRecord, deserializeVault, deserializeDelegation, deserializeBalance, deserializeEpochSettings, deserializeArioConfig, deserializeDemandFactor, deserializeReservedName, deserializeReturnedName, deserializeWithdrawal, deserializeRedelegationRecord, deserializePrimaryNameRequest, deserializePrimaryName, deserializeAllowlist, deserializeGarSettings, deserializeEpochSettingsFull, deserializeEpoch, deserializeObservation, deserializeAntConfig, deserializeAntControllers, deserializeAntRecord, deserializeAclConfig, deserializeAclPage, } from './deserialize.js';
|
|
62
62
|
export type { DeserializedAclEntry } from './deserialize.js';
|
|
63
|
+
export { predictPrescribedObservers, type RegistrySlotWeight, } from './predict-prescribed-observers.js';
|
|
63
64
|
export * from './constants.js';
|
|
64
65
|
export * from './clusters.js';
|
|
65
66
|
export { createCircuitBreakerRpc, defaultFallbackUrl, } from './rpc-circuit-breaker.js';
|
|
@@ -309,6 +309,21 @@ export declare class SolanaARIOReadable {
|
|
|
309
309
|
pubkey: Address;
|
|
310
310
|
operator: Address;
|
|
311
311
|
}>>;
|
|
312
|
+
/**
|
|
313
|
+
* Enumerate Joined Gateway PDAs whose delegation has been DISABLED
|
|
314
|
+
* (`allow_delegated_staking == false`) yet still hold delegated stake
|
|
315
|
+
* (`total_delegated_stake > 0`) — i.e. delegates that an operator's disable
|
|
316
|
+
* left stranded (WP §6.3 / Fix #6). Each such gateway's delegates must be
|
|
317
|
+
* cranked out via
|
|
318
|
+
* {@link SolanaARIOWriteable.claimDelegateFromDisabledGateway} (enumerate
|
|
319
|
+
* them with {@link getGatewayDelegates}) before the operator can re-enable
|
|
320
|
+
* delegation. This is the discovery primitive a cranker uses to sweep them.
|
|
321
|
+
*/
|
|
322
|
+
getDisabledGatewaysWithDelegatedStake(): Promise<Array<{
|
|
323
|
+
pubkey: Address;
|
|
324
|
+
operator: Address;
|
|
325
|
+
totalDelegatedStake: bigint;
|
|
326
|
+
}>>;
|
|
312
327
|
/**
|
|
313
328
|
* Enumerate Delegation PDAs with `amount == 0`. Eligible for
|
|
314
329
|
* `closeEmptyDelegation` (rent refund to the original delegator).
|
|
@@ -23,6 +23,7 @@ import type { ILogger } from '../common/logger.js';
|
|
|
23
23
|
import type { MessageResult, WriteOptions } from '../types/common.js';
|
|
24
24
|
import type { ArNSPurchaseParams, BuyRecordParams, CreateVaultParams, DelegateStakeParams, ExtendLeaseParams, ExtendVaultParams, IncreaseUndernameLimitParams, IncreaseVaultParams, JoinNetworkParams, RedelegateStakeParams, RevokeVaultParams, UpdateGatewaySettingsParams, VaultedTransferParams } from '../types/io.js';
|
|
25
25
|
import type { mARIOToken } from '../types/token.js';
|
|
26
|
+
import { deserializeEpochSettingsFull } from './deserialize.js';
|
|
26
27
|
import { SolanaARIOReadable } from './io-readable.js';
|
|
27
28
|
import type { SolanaRpcSubscriptions, SolanaSigner, SolanaWriteConfig } from './types.js';
|
|
28
29
|
/**
|
|
@@ -82,6 +83,50 @@ export declare function buildObservationBitmap(registryAddresses: string[], fail
|
|
|
82
83
|
* auditability that the field exists for.
|
|
83
84
|
*/
|
|
84
85
|
export declare function encodeReportTxId(reportTxId: string | undefined): Buffer;
|
|
86
|
+
/** The single on-chain action a {@link SolanaARIOWriteable.crankEpochStep} call performed. */
|
|
87
|
+
export type CrankAction = 'create' | 'tally' | 'prescribe' | 'distribute' | 'close' | 'idle';
|
|
88
|
+
/** Options for {@link SolanaARIOWriteable.crankEpochStep}. */
|
|
89
|
+
export interface CrankEpochStepOptions {
|
|
90
|
+
/** Gateways per tally/distribute batch. Default 30. */
|
|
91
|
+
batchSize?: number;
|
|
92
|
+
/**
|
|
93
|
+
* NameRegistry account for the name-prescription leg. Defaults to the
|
|
94
|
+
* registry derived from the configured ArNS program. Pass `null` to disable
|
|
95
|
+
* name prescription entirely.
|
|
96
|
+
*/
|
|
97
|
+
nameRegistryAccount?: Address | null;
|
|
98
|
+
/** Close fully-distributed epochs older than `epochRetention`. Default true. */
|
|
99
|
+
enableClose?: boolean;
|
|
100
|
+
/** Epochs of retention before an epoch may be closed (GAR-006). Default 7. */
|
|
101
|
+
epochRetention?: number;
|
|
102
|
+
/** Unix seconds; defaults to the wall clock. Injectable for testing. */
|
|
103
|
+
now?: number;
|
|
104
|
+
}
|
|
105
|
+
/** Result of a single {@link SolanaARIOWriteable.crankEpochStep} call. */
|
|
106
|
+
export interface CrankEpochStepResult {
|
|
107
|
+
/** The action performed (or `'idle'` when nothing was due). */
|
|
108
|
+
action: CrankAction;
|
|
109
|
+
/** The epoch the action targeted (absent for `'idle'`). */
|
|
110
|
+
epochIndex?: number;
|
|
111
|
+
/** Confirmed transaction signature, when an action was submitted. */
|
|
112
|
+
txId?: string;
|
|
113
|
+
/** Batch progress for `'tally'` / `'distribute'`. */
|
|
114
|
+
progress?: {
|
|
115
|
+
index: number;
|
|
116
|
+
total: number;
|
|
117
|
+
};
|
|
118
|
+
/** For `action: 'idle'`, why nothing was done. */
|
|
119
|
+
reason?: 'epochs_disabled' | 'waiting_for_genesis' | 'waiting_for_epoch' | 'waiting_for_observations' | 'epoch_complete';
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Detect the GAR `InvalidGatewayAccount` error by Anchor error name/message
|
|
123
|
+
* (walking the cause chain + `context.logs`), NOT by numeric code — codes are
|
|
124
|
+
* `6000 + enum-index` and shift across program versions, but the name and
|
|
125
|
+
* message are stable. `prescribe_epoch` raises this when a supplied observer
|
|
126
|
+
* Gateway PDA is missing/spoofed (e.g. a predicted observer left the registry
|
|
127
|
+
* between prediction and tx landing).
|
|
128
|
+
*/
|
|
129
|
+
export declare function isInvalidGatewayAccountError(error: unknown): boolean;
|
|
85
130
|
export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
86
131
|
protected readonly signer: SolanaSigner;
|
|
87
132
|
protected readonly rpcSubscriptions: SolanaRpcSubscriptions;
|
|
@@ -377,6 +422,26 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
377
422
|
claimDelegateFromLeavingGateway(params: {
|
|
378
423
|
gatewayAddress: string;
|
|
379
424
|
}, _options?: WriteOptions): Promise<MessageResult>;
|
|
425
|
+
/**
|
|
426
|
+
* Claim a delegate's stake out of a gateway that has DISABLED delegation
|
|
427
|
+
* (`allow_delegated_staking == false`), moving it into the delegate's own
|
|
428
|
+
* withdrawal vault (WP §6.3 / Fix #6). This is the disabled-gateway analog of
|
|
429
|
+
* {@link claimDelegateFromLeavingGateway}: the on-chain instruction is
|
|
430
|
+
* permissionless, so a cranker can sweep delegates out (the operator cannot
|
|
431
|
+
* re-enable delegation until `total_delegated_stake == 0` and the cooldown
|
|
432
|
+
* elapses). The withdrawal-counter and withdrawal PDAs are seeded by the
|
|
433
|
+
* DELEGATOR, so a cranker must pass that delegate's `delegatorAddress`.
|
|
434
|
+
*
|
|
435
|
+
* @param params.gatewayAddress The gateway whose delegation was disabled.
|
|
436
|
+
* @param params.delegatorAddress The delegate to claim for. Defaults to the
|
|
437
|
+
* signer (self-claim). Pass another address to crank on a delegate's behalf;
|
|
438
|
+
* the signer covers rent (`payer`) but stake still routes to the delegate's
|
|
439
|
+
* own vault (the delegator key is bound by the delegation PDA seeds).
|
|
440
|
+
*/
|
|
441
|
+
claimDelegateFromDisabledGateway(params: {
|
|
442
|
+
gatewayAddress: string;
|
|
443
|
+
delegatorAddress?: string;
|
|
444
|
+
}, _options?: WriteOptions): Promise<MessageResult>;
|
|
380
445
|
/** Add an address to the gateway's delegation allowlist. */
|
|
381
446
|
allowDelegate(params: {
|
|
382
447
|
delegate: string;
|
|
@@ -428,9 +493,22 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
428
493
|
}, _options?: WriteOptions): Promise<MessageResult>;
|
|
429
494
|
/**
|
|
430
495
|
* Prescribe observers and names for an epoch. Permissionless — call after
|
|
431
|
-
* weights are tallied.
|
|
432
|
-
*
|
|
496
|
+
* weights are tallied.
|
|
497
|
+
*
|
|
498
|
+
* `gatewayAccounts` MUST be the Gateway PDAs of the SELECTED observers only
|
|
499
|
+
* — at most `epoch_settings.prescribed_observer_count` (≤50), NOT the whole
|
|
500
|
+
* registry. The selection is computed on-chain; mirror it off-chain with
|
|
501
|
+
* {@link predictPrescribedObservers} / {@link getPredictedObserverPDAs} to
|
|
502
|
+
* learn the set. Passing every registry gateway (e.g. via
|
|
503
|
+
* {@link getAllRegistryGatewayPDAs}) hits Solana's `MAX_TX_ACCOUNT_LOCKS = 64`
|
|
504
|
+
* on large registries and the tx fails at pre-flight.
|
|
505
|
+
*
|
|
506
|
+
* The selected PDAs are appended as `remaining_accounts`, followed by the
|
|
507
|
+
* optional `nameRegistryAccount` (must be LAST) which enables the name
|
|
433
508
|
* prescription leg.
|
|
509
|
+
*
|
|
510
|
+
* If a selected gateway leaves between prediction and tx landing, the tx
|
|
511
|
+
* fails with `InvalidGatewayAccount` — retry once with a fresh prediction.
|
|
434
512
|
*/
|
|
435
513
|
prescribeEpoch(params: {
|
|
436
514
|
epochIndex: number;
|
|
@@ -461,6 +539,74 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
461
539
|
getRegistryGatewayPDAs(startIndex: number, batchSize: number): Promise<Address[]>;
|
|
462
540
|
/** Get ALL active gateway PDAs from the registry. */
|
|
463
541
|
getAllRegistryGatewayPDAs(): Promise<Address[]>;
|
|
542
|
+
/**
|
|
543
|
+
* Predict the Gateway PDAs that `prescribe_epoch` will select as observers
|
|
544
|
+
* for `epochIndex`, mirroring the on-chain weighted-roulette selection.
|
|
545
|
+
*
|
|
546
|
+
* Returns at most `epoch_settings.prescribed_observer_count` (≤50) PDAs
|
|
547
|
+
* regardless of registry size — the set to pass as `gatewayAccounts` to
|
|
548
|
+
* {@link prescribeEpoch}. This is the size-safe replacement for
|
|
549
|
+
* {@link getAllRegistryGatewayPDAs} on the prescribe path (which oversupplies
|
|
550
|
+
* and trips `MAX_TX_ACCOUNT_LOCKS = 64` on large registries).
|
|
551
|
+
*
|
|
552
|
+
* Reads three accounts (epoch, registry, epoch settings) at the configured
|
|
553
|
+
* commitment so the prediction reflects live registry weights. If a selected
|
|
554
|
+
* gateway races out before the tx lands, `prescribeEpoch` throws
|
|
555
|
+
* `InvalidGatewayAccount` — re-call this and retry once.
|
|
556
|
+
*/
|
|
557
|
+
getPredictedObserverPDAs(epochIndex: number): Promise<Address[]>;
|
|
558
|
+
/**
|
|
559
|
+
* Reclaim rent from the ephemeral Address Lookup Tables this signer created
|
|
560
|
+
* for `prescribe_epoch` (see {@link sendWithEphemeralLookupTable}). Each
|
|
561
|
+
* prescribe leaves a single-use table allocated (~0.0126 SOL); reclaiming
|
|
562
|
+
* needs a deactivate → ~513-slot cooldown → close sequence, so it can't run
|
|
563
|
+
* inline. Call this from a throttled/permissionless cleanup pass (cranker /
|
|
564
|
+
* observer) to deactivate active tables and close cooled-down ones, refunding
|
|
565
|
+
* the rent to the signer.
|
|
566
|
+
*
|
|
567
|
+
* Discovery reads the signer's transaction history (RPC-portable; the ALT
|
|
568
|
+
* program can't be enumerated via `getProgramAccounts`). The GAR + ArNS
|
|
569
|
+
* program IDs are passed as the entry-ownership fingerprint so only genuine
|
|
570
|
+
* prescribe tables are touched. Best-effort: at most `maxTables` submissions
|
|
571
|
+
* per call, scanning at most `scanLimit` recent signatures.
|
|
572
|
+
*/
|
|
573
|
+
reclaimLookupTableRent(opts?: {
|
|
574
|
+
maxTables?: number;
|
|
575
|
+
scanLimit?: number;
|
|
576
|
+
}): Promise<{
|
|
577
|
+
deactivated: number;
|
|
578
|
+
closed: number;
|
|
579
|
+
candidates: number;
|
|
580
|
+
scannedSignatures: number;
|
|
581
|
+
}>;
|
|
582
|
+
/** Read and deserialize the full EpochSettings account. */
|
|
583
|
+
getEpochSettingsFull(): Promise<ReturnType<typeof deserializeEpochSettingsFull>>;
|
|
584
|
+
/**
|
|
585
|
+
* Submit `prescribe_epoch` using the off-chain-predicted observer set, with a
|
|
586
|
+
* single re-predict-and-retry on `InvalidGatewayAccount` (covers a gateway
|
|
587
|
+
* leaving the registry between the prediction read and the tx landing).
|
|
588
|
+
*/
|
|
589
|
+
protected prescribeWithPrediction(epochIndex: number, nameRegistryAccount?: Address): Promise<MessageResult>;
|
|
590
|
+
/**
|
|
591
|
+
* Advance the epoch lifecycle by ONE on-chain action and return what it did.
|
|
592
|
+
*
|
|
593
|
+
* Stateless and idempotent: it reads `EpochSettings` + the current `Epoch`,
|
|
594
|
+
* determines the single next required step
|
|
595
|
+
* (`create` → `tally` → `prescribe` → `distribute` → `close`), submits it,
|
|
596
|
+
* and returns a {@link CrankEpochStepResult}. Call it repeatedly on your own
|
|
597
|
+
* schedule — it owns *which* on-chain action is correct and *which accounts*
|
|
598
|
+
* it needs; you own scheduling, logging, error classification, and any
|
|
599
|
+
* permissionless cleanup.
|
|
600
|
+
*
|
|
601
|
+
* Crucially, the `prescribe` leg uses {@link getPredictedObserverPDAs} (only
|
|
602
|
+
* the ~`prescribed_observer_count` selected Gateway PDAs), so it never trips
|
|
603
|
+
* `MAX_TX_ACCOUNT_LOCKS = 64` on large registries — and it re-predicts and
|
|
604
|
+
* retries once on `InvalidGatewayAccount`.
|
|
605
|
+
*
|
|
606
|
+
* Errors propagate to the caller (classify/retry as you see fit); the only
|
|
607
|
+
* internally-handled error is the prescribe `InvalidGatewayAccount` retry.
|
|
608
|
+
*/
|
|
609
|
+
crankEpochStep(opts?: CrankEpochStepOptions): Promise<CrankEpochStepResult>;
|
|
464
610
|
/**
|
|
465
611
|
* Read the raw epoch account data for cranker state inspection.
|
|
466
612
|
* Returns null if the epoch account doesn't exist yet.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Address } from '@solana/kit';
|
|
2
|
+
/** One registry slot's selection-relevant fields (`GatewaySlot`). */
|
|
3
|
+
export interface RegistrySlotWeight {
|
|
4
|
+
/** `GatewaySlot.address` — the operator pubkey. Gateway PDAs derive from this. */
|
|
5
|
+
address: Address;
|
|
6
|
+
/** `GatewaySlot.composite_weight` (u64, mARIO-scaled). */
|
|
7
|
+
compositeWeight: bigint;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Predict the operator pubkeys `prescribe_epoch` will select as observers.
|
|
11
|
+
*
|
|
12
|
+
* Returns the selected `GatewaySlot.address` values (operator pubkeys) in
|
|
13
|
+
* selection order, at most `maxObservers`. These correspond 1:1 to the on-chain
|
|
14
|
+
* `epoch.prescribed_observer_gateways` array, and are what Gateway PDAs are
|
|
15
|
+
* derived from (`[GATEWAY_SEED, operator]`). NOTE this is NOT
|
|
16
|
+
* `epoch.prescribed_observers`, which `prescribe_epoch` later overwrites with
|
|
17
|
+
* each gateway's resolved `observer_address`.
|
|
18
|
+
*
|
|
19
|
+
* @param epochHashchain `epoch.hashchain` — exactly 32 bytes, frozen at
|
|
20
|
+
* `create_epoch`.
|
|
21
|
+
* @param slots `registry.gateways[0 .. epoch.active_gateway_count]` in registry
|
|
22
|
+
* (slot-index) order. Pass the whole prefix including any zero-weight slots —
|
|
23
|
+
* order and the live weight sum must match the on-chain walk exactly. Empty /
|
|
24
|
+
* zero-weight slots contribute nothing and can never be selected.
|
|
25
|
+
* @param maxObservers `epoch_settings.prescribed_observer_count`. Clamped to
|
|
26
|
+
* `slots.length` (the on-chain `min(prescribed_observer_count, active_count)`).
|
|
27
|
+
*/
|
|
28
|
+
export declare function predictPrescribedObservers(epochHashchain: Uint8Array, slots: RegistrySlotWeight[], maxObservers: number): Address[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Commitment, type Instruction, type TransactionSigner } from '@solana/kit';
|
|
1
|
+
import { type Address, type Commitment, type Instruction, type TransactionSigner } from '@solana/kit';
|
|
2
2
|
import type { SolanaRpc, SolanaRpcSubscriptions } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Build, sign, send, and confirm a transaction in one call.
|
|
@@ -6,11 +6,89 @@ import type { SolanaRpc, SolanaRpcSubscriptions } from './types.js';
|
|
|
6
6
|
* The caller supplies the core instructions; a compute-unit-limit instruction
|
|
7
7
|
* is prepended automatically.
|
|
8
8
|
*/
|
|
9
|
-
export declare function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructions, commitment, computeUnitLimit, }: {
|
|
9
|
+
export declare function sendAndConfirm({ rpc, rpcSubscriptions, signer, instructions, commitment, computeUnitLimit, addressLookupTables, }: {
|
|
10
10
|
rpc: SolanaRpc;
|
|
11
11
|
rpcSubscriptions: SolanaRpcSubscriptions;
|
|
12
12
|
signer: TransactionSigner;
|
|
13
13
|
instructions: Instruction[];
|
|
14
14
|
commitment?: Commitment;
|
|
15
15
|
computeUnitLimit?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Address Lookup Tables to compress the (v0) message against, as
|
|
18
|
+
* `{ [tableAddress]: addresses }`. Accounts present in a table are referenced
|
|
19
|
+
* by 1-byte index instead of their 32-byte key, shrinking the transaction —
|
|
20
|
+
* required when an instruction touches more accounts than fit inline (e.g.
|
|
21
|
+
* `prescribe_epoch` with ~50 observer PDAs). The tables MUST already be
|
|
22
|
+
* on-chain and active. See {@link sendWithEphemeralLookupTable}.
|
|
23
|
+
*/
|
|
24
|
+
addressLookupTables?: Record<string, Address[]>;
|
|
16
25
|
}): Promise<string>;
|
|
26
|
+
/**
|
|
27
|
+
* Submit `instruction` in a v0 transaction whose `lookupAddresses` (read-only
|
|
28
|
+
* accounts) are served from a freshly-created, ephemeral Address Lookup Table,
|
|
29
|
+
* so an instruction touching far more accounts than fit inline (e.g.
|
|
30
|
+
* `prescribe_epoch` with ≤50 observer PDAs + NameRegistry, ~2 KB of keys) still
|
|
31
|
+
* fits Solana's 1232-byte transaction-size limit.
|
|
32
|
+
*
|
|
33
|
+
* Three confirmed steps: create the table, extend it with the addresses (in
|
|
34
|
+
* ≤20-address batches to stay within the extend tx size), then send
|
|
35
|
+
* `instruction` compressed against the table. The sequential confirmations
|
|
36
|
+
* satisfy the rule that appended addresses are only usable the slot AFTER they
|
|
37
|
+
* are added. `signer` is the table's authority + payer; the table's (tiny) rent
|
|
38
|
+
* is left allocated — a future cleanup pass can deactivate + close it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sendWithEphemeralLookupTable({ rpc, rpcSubscriptions, signer, instruction, lookupAddresses, commitment, computeUnitLimit, }: {
|
|
41
|
+
rpc: SolanaRpc;
|
|
42
|
+
rpcSubscriptions: SolanaRpcSubscriptions;
|
|
43
|
+
signer: TransactionSigner;
|
|
44
|
+
instruction: Instruction;
|
|
45
|
+
lookupAddresses: Address[];
|
|
46
|
+
commitment?: Commitment;
|
|
47
|
+
computeUnitLimit?: number;
|
|
48
|
+
}): Promise<string>;
|
|
49
|
+
/**
|
|
50
|
+
* Reclaim rent from the ephemeral Address Lookup Tables `signer` created for
|
|
51
|
+
* prescribe (see {@link sendWithEphemeralLookupTable}). Each prescribe leaves a
|
|
52
|
+
* single-use table allocated (~0.0126 SOL of rent); reclaiming needs a
|
|
53
|
+
* deactivate → ~513-slot cooldown → close sequence, so it can't run inline — a
|
|
54
|
+
* throttled permissionless cleanup pass (cranker / observer) calls this.
|
|
55
|
+
*
|
|
56
|
+
* Discovery is RPC-portable. `getProgramAccounts` on the Address Lookup Table
|
|
57
|
+
* program is rejected by Agave RPCs (`Invalid param: WrongSize`, on public
|
|
58
|
+
* devnet/mainnet-beta and dedicated providers alike — the ALT program can't be
|
|
59
|
+
* enumerated), so instead we read the signer's own transaction history
|
|
60
|
+
* (`getSignaturesForAddress` + `getTransaction`) and collect the tables it
|
|
61
|
+
* referenced via `message.addressTableLookups` — a prescribe ALT is used in
|
|
62
|
+
* exactly one transaction.
|
|
63
|
+
*
|
|
64
|
+
* Safety fingerprint: a candidate is only touched when EVERY one of its entries
|
|
65
|
+
* is owned by a program in `allowedEntryOwners` (the GAR + ArNS programs — i.e.
|
|
66
|
+
* observer Gateway PDAs + the ArNS NameRegistry). That composition uniquely
|
|
67
|
+
* identifies a prescribe ephemeral, so the pass never deactivates/closes an
|
|
68
|
+
* unrelated table even if `signer` is also used to author Address Lookup Tables
|
|
69
|
+
* for other purposes.
|
|
70
|
+
*
|
|
71
|
+
* DEACTIVATES still-active matches (starts the cooldown) and CLOSES deactivated
|
|
72
|
+
* matches past the cooldown (refunding rent to `signer`). At most `maxTables`
|
|
73
|
+
* submissions per call; scans at most `scanLimit` recent signatures. Best-effort:
|
|
74
|
+
* per-table failures are skipped and retried on the next pass.
|
|
75
|
+
*/
|
|
76
|
+
export declare function reclaimLookupTablesForSigner({ rpc, rpcSubscriptions, signer, allowedEntryOwners, commitment, maxTables, scanLimit, }: {
|
|
77
|
+
rpc: SolanaRpc;
|
|
78
|
+
rpcSubscriptions: SolanaRpcSubscriptions;
|
|
79
|
+
signer: TransactionSigner;
|
|
80
|
+
/**
|
|
81
|
+
* Program IDs that EVERY entry of a reclaimable prescribe ALT must be owned by
|
|
82
|
+
* — pass the GAR + ArNS program IDs. The fingerprint that keeps reclamation
|
|
83
|
+
* from touching unrelated lookup tables.
|
|
84
|
+
*/
|
|
85
|
+
allowedEntryOwners: Address[];
|
|
86
|
+
commitment?: Commitment;
|
|
87
|
+
maxTables?: number;
|
|
88
|
+
scanLimit?: number;
|
|
89
|
+
}): Promise<{
|
|
90
|
+
deactivated: number;
|
|
91
|
+
closed: number;
|
|
92
|
+
candidates: number;
|
|
93
|
+
scannedSignatures: number;
|
|
94
|
+
}>;
|
package/lib/types/types/io.d.ts
CHANGED
|
@@ -232,6 +232,22 @@ export type GatewaySettings = {
|
|
|
232
232
|
fqdn: string;
|
|
233
233
|
port: number;
|
|
234
234
|
protocol: 'https';
|
|
235
|
+
/**
|
|
236
|
+
* Solana only (GATEWAY_VERSION 1.1.0+). A `delegateRewardShareRatio` change
|
|
237
|
+
* requested mid-epoch is staged here and applied at the next epoch's
|
|
238
|
+
* `tally_weights` (WP §6.3 / Fix #7), so the active value stays epoch-stable.
|
|
239
|
+
* When set, render the active `delegateRewardShareRatio` as the current rate
|
|
240
|
+
* and this as "pending until next epoch". Percent (0-95), same scale as
|
|
241
|
+
* `delegateRewardShareRatio`. Undefined when no change is queued.
|
|
242
|
+
*/
|
|
243
|
+
pendingDelegateRewardShareRatio?: number;
|
|
244
|
+
/**
|
|
245
|
+
* Solana only (GATEWAY_VERSION 1.1.0+). Unix seconds when the operator
|
|
246
|
+
* disabled delegation (WP §6.3 / Fix #6). Re-enabling is blocked until every
|
|
247
|
+
* delegate has been withdrawn AND the withdrawal-period cooldown has elapsed
|
|
248
|
+
* since this time. Undefined when delegation is enabled.
|
|
249
|
+
*/
|
|
250
|
+
delegationDisabledAt?: number;
|
|
235
251
|
};
|
|
236
252
|
export type BalanceWithAddress = {
|
|
237
253
|
address: WalletAddress;
|
package/lib/types/version.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ar.io/sdk",
|
|
3
|
-
"version": "4.0.0-solana.
|
|
3
|
+
"version": "4.0.0-solana.25",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/ar-io/ar-io-sdk.git"
|
|
@@ -123,7 +123,8 @@
|
|
|
123
123
|
"typescript": "^5.1.6"
|
|
124
124
|
},
|
|
125
125
|
"dependencies": {
|
|
126
|
-
"@ar.io/solana-contracts": "0.
|
|
126
|
+
"@ar.io/solana-contracts": "0.5.0-staging.15",
|
|
127
|
+
"@solana-program/address-lookup-table": "^0.11.0",
|
|
127
128
|
"@solana-program/compute-budget": "^0.15.0",
|
|
128
129
|
"@solana-program/token": "^0.13.0",
|
|
129
130
|
"@solana/kit": "^6.8.0",
|