@ar.io/sdk 4.0.0-solana.32 → 4.0.0-solana.34
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/delegation-math.js +34 -0
- package/lib/esm/solana/io-readable.js +51 -1
- package/lib/esm/solana/io-writeable.js +277 -22
- package/lib/esm/solana/rpc-circuit-breaker.js +169 -1
- package/lib/esm/version.js +1 -1
- package/lib/types/solana/delegation-math.d.ts +25 -0
- package/lib/types/solana/io-readable.d.ts +20 -0
- package/lib/types/solana/io-writeable.d.ts +97 -1
- package/lib/types/solana/rpc-circuit-breaker.d.ts +29 -0
- package/lib/types/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -76,3 +76,37 @@ export function computeLiveDelegationBalance({ delegatedStake, rewardDebt, cumul
|
|
|
76
76
|
const capped = live > U64_MAX ? U64_MAX : live;
|
|
77
77
|
return Number(capped);
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Select the delegations worth compounding, from decoded delegations + a map
|
|
81
|
+
* of their gateways' reward accumulators. Pure (no I/O) so it's unit-testable
|
|
82
|
+
* independently of RPC; `SolanaARIOReadable.getDelegationsToCompound` is just
|
|
83
|
+
* fetch+decode wrapped around this.
|
|
84
|
+
*
|
|
85
|
+
* A delegation is included when its pending reward (live balance − settled
|
|
86
|
+
* principal) exceeds `minPendingRewards`, EXCEPT when its gateway is `leaving`
|
|
87
|
+
* (those settle via `claim_delegate_from_leaving_gateway`, not compounding) or
|
|
88
|
+
* its gateway is missing/unreadable. Compounding sub-threshold dust only
|
|
89
|
+
* advances `reward_debt` for no balance gain, so it's filtered out.
|
|
90
|
+
*/
|
|
91
|
+
export function selectCompoundableDelegations(delegations, gatewaysByOperator, minPendingRewards = 0) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const del of delegations) {
|
|
94
|
+
const gw = gatewaysByOperator.get(del.gateway);
|
|
95
|
+
if (!gw || gw.status === 'leaving')
|
|
96
|
+
continue;
|
|
97
|
+
const live = computeLiveDelegationBalance({
|
|
98
|
+
delegatedStake: del.delegatedStake,
|
|
99
|
+
rewardDebt: del.rewardDebt,
|
|
100
|
+
cumulativeRewardPerToken: gw.cumulativeRewardPerToken,
|
|
101
|
+
});
|
|
102
|
+
const pendingRewards = live - del.delegatedStake;
|
|
103
|
+
if (pendingRewards <= minPendingRewards)
|
|
104
|
+
continue;
|
|
105
|
+
out.push({
|
|
106
|
+
gatewayAddress: del.gateway,
|
|
107
|
+
delegatorAddress: del.delegator,
|
|
108
|
+
pendingRewards,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
@@ -29,7 +29,7 @@ import { Logger } from '../common/logger.js';
|
|
|
29
29
|
import { SolanaANTRegistryReadable } from './ant-registry-readable.js';
|
|
30
30
|
import { getAssociatedTokenAddressKit } from './ata.js';
|
|
31
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 } from './delegation-math.js';
|
|
32
|
+
import { computeLiveDelegationBalance, selectCompoundableDelegations, } from './delegation-math.js';
|
|
33
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
34
|
import { TOKEN_PROGRAM_ADDRESS } from './instruction.js';
|
|
35
35
|
import { getArioConfigPDA, getArnsRecordPDA, getArnsRecordPDAFromHash, getArnsSettingsPDA, getDemandFactorPDA, getEpochPDA, getEpochSettingsPDA, getGarSettingsPDA, getGatewayPDA, getGatewayRegistryPDA, getObserverLookupPDA, getPrimaryNamePDA, getPrimaryNameRequestPDA, getReservedNamePDA, getReturnedNamePDA, getVaultPDA, } from './pda.js';
|
|
@@ -1639,6 +1639,56 @@ export class SolanaARIOReadable {
|
|
|
1639
1639
|
}));
|
|
1640
1640
|
return paginate(items, params);
|
|
1641
1641
|
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Enumerate every delegation that has pending (unsettled) rewards — the work
|
|
1644
|
+
* list for the permissionless `compound_delegation_rewards` crank. Pending is
|
|
1645
|
+
* computed from the gateway's reward-per-share accumulator (mirrors
|
|
1646
|
+
* {@link computeLiveDelegationBalance}); the crank only changes balances, so
|
|
1647
|
+
* rewards already accrue correctly without it.
|
|
1648
|
+
*
|
|
1649
|
+
* Skips delegations whose gateway is `Leaving` (those settle through
|
|
1650
|
+
* `claim_delegate_from_leaving_gateway`, not compounding) and any below
|
|
1651
|
+
* `minPendingRewards` — compounding sub-threshold dust just advances
|
|
1652
|
+
* `reward_debt` for no balance gain. Feed the result, chunked, to
|
|
1653
|
+
* `SolanaARIOWriteable.compoundDelegationRewardsBatch`.
|
|
1654
|
+
*/
|
|
1655
|
+
async getDelegationsToCompound(params) {
|
|
1656
|
+
const minPending = params?.minPendingRewards ?? 0;
|
|
1657
|
+
// One scan for gateways → accumulator + status.
|
|
1658
|
+
const gatewayAccounts = await this.getAccountsByDiscriminator(this.garProgram, GATEWAY_DISCRIMINATOR);
|
|
1659
|
+
const gateways = new Map();
|
|
1660
|
+
for (const { data } of gatewayAccounts) {
|
|
1661
|
+
try {
|
|
1662
|
+
const gw = deserializeGatewayWithAccumulator(data);
|
|
1663
|
+
gateways.set(gw.operator, {
|
|
1664
|
+
cumulativeRewardPerToken: gw.cumulativeRewardPerToken,
|
|
1665
|
+
status: gw.status,
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
catch {
|
|
1669
|
+
// Skip malformed.
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
// One scan for delegations; decode then delegate the selection logic to the
|
|
1673
|
+
// pure `selectCompoundableDelegations` (unit-tested in delegation-math).
|
|
1674
|
+
const delegationAccounts = await this.getAccountsByDiscriminator(this.garProgram, DELEGATION_DISCRIMINATOR);
|
|
1675
|
+
const delegations = [];
|
|
1676
|
+
for (const { data } of delegationAccounts) {
|
|
1677
|
+
try {
|
|
1678
|
+
const del = deserializeDelegation(data);
|
|
1679
|
+
delegations.push({
|
|
1680
|
+
gateway: del.gateway,
|
|
1681
|
+
delegator: del.delegator,
|
|
1682
|
+
delegatedStake: del.delegatedStake,
|
|
1683
|
+
rewardDebt: del.rewardDebt,
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
catch {
|
|
1687
|
+
// Skip malformed.
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return selectCompoundableDelegations(delegations, gateways, minPending);
|
|
1691
|
+
}
|
|
1642
1692
|
async getAllGatewayVaults(params) {
|
|
1643
1693
|
const accounts = await this.getAccountsByDiscriminator(this.garProgram, WITHDRAWAL_DISCRIMINATOR);
|
|
1644
1694
|
const items = [];
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
* surface for them.
|
|
35
35
|
*/
|
|
36
36
|
import { AccountRole, address, appendTransactionMessageInstructions, compileTransaction, createTransactionMessage, fetchEncodedAccount, getAddressDecoder, getBase64EncodedWireTransaction, pipe, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, } from '@solana/kit';
|
|
37
|
-
import { CostIntent, PurchaseType, getBuyNameFromDelegationInstructionAsync, getBuyNameFromFundingPlanInstructionAsync, getBuyNameFromOperatorStakeInstructionAsync, getBuyNameFromWithdrawalInstructionAsync, getBuyNameInstructionAsync, getBuyReturnedNameFromDelegationInstructionAsync, getBuyReturnedNameFromFundingPlanInstructionAsync, getBuyReturnedNameFromOperatorStakeInstructionAsync, getBuyReturnedNameFromWithdrawalInstructionAsync, getBuyReturnedNameInstructionAsync, getExtendLeaseFromDelegationInstructionAsync, getExtendLeaseFromFundingPlanInstructionAsync, getExtendLeaseFromOperatorStakeInstructionAsync, getExtendLeaseFromWithdrawalInstructionAsync, getExtendLeaseInstructionAsync, getGetTokenCostInstructionAsync, getIncreaseUndernameLimitFromDelegationInstructionAsync, getIncreaseUndernameLimitFromFundingPlanInstructionAsync, getIncreaseUndernameLimitFromOperatorStakeInstructionAsync, getIncreaseUndernameLimitFromWithdrawalInstructionAsync, getIncreaseUndernameLimitInstructionAsync, getMigrateArnsRecordInstruction, getPruneExpiredNamesInstructionAsync, getPruneExpiredReservationInstruction, getPruneNameToReturnedInstructionAsync, getPruneReturnedNamesInstructionAsync, getReassignNameInstructionAsync, getReleaseNameInstructionAsync, getUpgradeNameFromDelegationInstructionAsync, getUpgradeNameFromFundingPlanInstructionAsync, getUpgradeNameFromOperatorStakeInstructionAsync, getUpgradeNameFromWithdrawalInstructionAsync, getUpgradeNameInstructionAsync, } from '@ar.io/solana-contracts/arns';
|
|
37
|
+
import { CostIntent, PurchaseType, getBuyNameFromDelegationInstructionAsync, getBuyNameFromFundingPlanInstructionAsync, getBuyNameFromOperatorStakeInstructionAsync, getBuyNameFromWithdrawalInstructionAsync, getBuyNameInstructionAsync, getBuyReturnedNameFromDelegationInstructionAsync, getBuyReturnedNameFromFundingPlanInstructionAsync, getBuyReturnedNameFromOperatorStakeInstructionAsync, getBuyReturnedNameFromWithdrawalInstructionAsync, getBuyReturnedNameInstructionAsync, getExtendLeaseFromDelegationInstructionAsync, getExtendLeaseFromFundingPlanInstructionAsync, getExtendLeaseFromOperatorStakeInstructionAsync, getExtendLeaseFromWithdrawalInstructionAsync, getExtendLeaseInstructionAsync, getGetTokenCostInstructionAsync, getIncreaseUndernameLimitFromDelegationInstructionAsync, getIncreaseUndernameLimitFromFundingPlanInstructionAsync, getIncreaseUndernameLimitFromOperatorStakeInstructionAsync, getIncreaseUndernameLimitFromWithdrawalInstructionAsync, getIncreaseUndernameLimitInstructionAsync, getMigrateArnsRecordInstruction, getPruneExpiredNamesInstructionAsync, getPruneExpiredReservationInstruction, getPruneNameToReturnedInstructionAsync, getPruneReturnedNamesInstructionAsync, getReassignNameInstructionAsync, getReleaseNameInstructionAsync, getUpdateDemandFactorInstruction, getUpgradeNameFromDelegationInstructionAsync, getUpgradeNameFromFundingPlanInstructionAsync, getUpgradeNameFromOperatorStakeInstructionAsync, getUpgradeNameFromWithdrawalInstructionAsync, getUpgradeNameInstructionAsync, } from '@ar.io/solana-contracts/arns';
|
|
38
38
|
import { FundingSourceKind as GeneratedFundingSourceKindEnum } from '@ar.io/solana-contracts/gar';
|
|
39
39
|
import { buildCreateAtaIdempotentIx, getAssociatedTokenAddressKit, } from './ata.js';
|
|
40
|
-
import { deserializeArnsRecord, deserializeEpochSettingsFull, deserializePrimaryName, } from './deserialize.js';
|
|
40
|
+
import { deserializeArnsRecord, deserializeDemandFactor, deserializeEpochSettingsFull, deserializePrimaryName, } from './deserialize.js';
|
|
41
41
|
import { buildFundingPlan as buildFundingPlanCore, buildFundingPlanRemainingAccounts, computeResidueIndexes, predictResidueVaults, } from './funding-plan.js';
|
|
42
42
|
/** Maps the SDK's user-facing FundingSourceKind string union to the
|
|
43
43
|
* Codama-generated enum used by the on-chain ix payload. */
|
|
@@ -53,7 +53,7 @@ function toGeneratedFundingSourceSpec(s) {
|
|
|
53
53
|
import { getSyncAttributesInstruction } from '@ar.io/solana-contracts/ant';
|
|
54
54
|
import { getApprovePrimaryNameInstructionAsync, getCloseExpiredRequestInstruction, getCreateVaultInstructionAsync, getExtendVaultInstructionAsync, getIncreaseVaultInstructionAsync, getReleaseVaultInstructionAsync, getRemovePrimaryNameInstructionAsync, getRequestAndSetPrimaryNameFromFundingPlanInstructionAsync, getRequestAndSetPrimaryNameInstructionAsync, getRequestPrimaryNameFromFundingPlanInstructionAsync, getRequestPrimaryNameInstructionAsync, getRevokeVaultInstructionAsync, getVaultedTransferInstructionAsync, } from '@ar.io/solana-contracts/core';
|
|
55
55
|
import { getDelegationDecoder, getGatewayDecoder, } from '@ar.io/solana-contracts/gar';
|
|
56
|
-
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, getUpdateObserverAddressInstructionAsync, } from '@ar.io/solana-contracts/gar';
|
|
56
|
+
import { Protocol, getAllowDelegateInstructionAsync, getCancelWithdrawalInstruction, getClaimDelegateFromDisabledGatewayInstructionAsync, getClaimDelegateFromLeavingGatewayInstructionAsync, getClaimWithdrawalInstructionAsync, getCloseDrainedWithdrawalInstruction, getCloseEmptyDelegationInstruction, getCloseEpochInstructionAsync, getCloseObservationInstructionAsync, getCompoundDelegationRewardsInstruction, getCreateEpochInstructionAsync, getDecreaseDelegateStakeInstructionAsync, getDecreaseOperatorStakeInstructionAsync, getDelegateStakeInstructionAsync, getDisallowDelegateInstructionAsync, getDistributeEpochInstructionAsync, getFinalizeGoneInstructionAsync, getIncreaseOperatorStakeInstructionAsync, getInstantWithdrawalInstructionAsync, getJoinNetworkInstructionAsync, getLeaveNetworkInstructionAsync, getPrescribeEpochInstructionAsync, getPruneGatewayInstructionAsync, getRedelegateStakeInstructionAsync, getSaveObservationsInstructionAsync, getSetAllowlistEnabledInstructionAsync, getTallyWeightsInstructionAsync, getUpdateGatewaySettingsInstructionAsync, getUpdateObserverAddressInstructionAsync, } from '@ar.io/solana-contracts/gar';
|
|
57
57
|
import { getTransferCheckedInstruction } from '@solana-program/token';
|
|
58
58
|
import { ARIO_ANT_PROGRAM_ID, TOKEN_DECIMALS } from './constants.js';
|
|
59
59
|
import { SolanaARIOReadable } from './io-readable.js';
|
|
@@ -227,6 +227,14 @@ export function encodeReportTxId(reportTxId) {
|
|
|
227
227
|
decoded.copy(out);
|
|
228
228
|
return out;
|
|
229
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Max `compound_delegation_rewards` instructions packed into one batched tx.
|
|
232
|
+
* Each carries a gateway + delegation + delegator account; 12 stays well within
|
|
233
|
+
* the per-tx account/1232-byte budget even when none share a gateway.
|
|
234
|
+
*/
|
|
235
|
+
const MAX_COMPOUND_BATCH = 12;
|
|
236
|
+
/** Demand-factor period length (seconds) — mirrors `PERIOD_LENGTH_SECONDS` in ario-arns. */
|
|
237
|
+
const DEMAND_FACTOR_PERIOD_SECONDS = 86_400;
|
|
230
238
|
/**
|
|
231
239
|
* Detect the GAR `InvalidGatewayAccount` error by Anchor error name/message
|
|
232
240
|
* (walking the cause chain + `context.logs`), NOT by numeric code — codes are
|
|
@@ -873,7 +881,16 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
873
881
|
params: buyNameParams,
|
|
874
882
|
}, { programAddress: this.arnsProgram });
|
|
875
883
|
}
|
|
876
|
-
else if (params.fundFrom === 'plan' ||
|
|
884
|
+
else if (params.fundFrom === 'plan' ||
|
|
885
|
+
params.fundFrom === 'any' ||
|
|
886
|
+
params.fundFrom === 'stakes' ||
|
|
887
|
+
params.fundFrom === 'withdrawal') {
|
|
888
|
+
// 'stakes'/'withdrawal' WITHOUT an explicit gatewayAddress/withdrawalId
|
|
889
|
+
// land here (the single-source branches above handle the explicit case).
|
|
890
|
+
// Route through the funding planner, which constrains sources to the
|
|
891
|
+
// chosen mode — it never silently spends liquid balance and auto-splits
|
|
892
|
+
// across the caller's delegations/vaults. (Previously these fell through
|
|
893
|
+
// to the balance path below, silently draining liquid ARIO.)
|
|
877
894
|
ix = await this._buildBuyNameFromFundingPlanIx({
|
|
878
895
|
params,
|
|
879
896
|
antPubkey,
|
|
@@ -884,8 +901,10 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
884
901
|
arnsConfig,
|
|
885
902
|
});
|
|
886
903
|
}
|
|
887
|
-
else
|
|
888
|
-
|
|
904
|
+
else if (!params.fundFrom ||
|
|
905
|
+
params.fundFrom === 'balance' ||
|
|
906
|
+
params.fundFrom === 'turbo') {
|
|
907
|
+
// Direct balance-funded buy.
|
|
889
908
|
ix = await getBuyNameInstructionAsync(await this.withArnsDefaults({
|
|
890
909
|
arnsRecord,
|
|
891
910
|
buyerTokenAccount: buyerATA,
|
|
@@ -896,6 +915,9 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
896
915
|
params: buyNameParams,
|
|
897
916
|
}), { programAddress: this.arnsProgram });
|
|
898
917
|
}
|
|
918
|
+
else {
|
|
919
|
+
throw new Error(`unsupported fundFrom mode '${params.fundFrom}' for buyRecord`);
|
|
920
|
+
}
|
|
899
921
|
// Sprint 4 / ADR-016: bundle `ant.sync_attributes` IFF the buyer
|
|
900
922
|
// owns the ANT (preserves BD-096 — non-holder buys defer the trait
|
|
901
923
|
// sync to a later `syncAttributes()` call by the actual owner).
|
|
@@ -1420,7 +1442,14 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
1420
1442
|
return getExtendLeaseFromWithdrawalInstructionAsync({ ...wBase, years: args.years }, { programAddress: this.arnsProgram });
|
|
1421
1443
|
return getIncreaseUndernameLimitFromWithdrawalInstructionAsync({ ...wBase, quantity: args.quantity }, { programAddress: this.arnsProgram });
|
|
1422
1444
|
}
|
|
1423
|
-
if (args.params.fundFrom === 'plan' ||
|
|
1445
|
+
if (args.params.fundFrom === 'plan' ||
|
|
1446
|
+
args.params.fundFrom === 'any' ||
|
|
1447
|
+
args.params.fundFrom === 'stakes' ||
|
|
1448
|
+
args.params.fundFrom === 'withdrawal') {
|
|
1449
|
+
// 'stakes'/'withdrawal' without an explicit gatewayAddress/withdrawalId
|
|
1450
|
+
// route here (the single-source branches above handle the explicit
|
|
1451
|
+
// case). The planner constrains sources to the chosen mode and never
|
|
1452
|
+
// spends liquid balance.
|
|
1424
1453
|
// Cost estimation for manage variants: each operation has its own
|
|
1425
1454
|
// pricing path. Keep it pragmatic — let the planner build the plan
|
|
1426
1455
|
// around the user's desired total (caller can pass explicit sources
|
|
@@ -1979,10 +2008,54 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
1979
2008
|
years: params.years ?? 1,
|
|
1980
2009
|
ant: antPubkey,
|
|
1981
2010
|
};
|
|
2011
|
+
// Returned-name price is a per-slot-decaying Dutch auction, so the
|
|
2012
|
+
// multi-source funding plan (which pre-commits exact source amounts) can't
|
|
2013
|
+
// match the execution-time cost → FundingPlanAmountMismatch (#6066). Prefer
|
|
2014
|
+
// a single-source stake path: it carries no amount, so the program computes
|
|
2015
|
+
// and draws the live cost itself. When the caller asked to fund from
|
|
2016
|
+
// stakes/withdrawal/any without naming a specific gateway/vault,
|
|
2017
|
+
// auto-resolve a single source with enough stake to cover the
|
|
2018
|
+
// (premium-inclusive) cost.
|
|
2019
|
+
let resolvedGateway = params.gatewayAddress;
|
|
2020
|
+
let resolvedFundAsOperator = params.fundAsOperator ?? false;
|
|
2021
|
+
let resolvedWithdrawalId = params.withdrawalId;
|
|
2022
|
+
const wantsStake = params.fundFrom === 'stakes' ||
|
|
2023
|
+
params.fundFrom === 'withdrawal' ||
|
|
2024
|
+
params.fundFrom === 'any';
|
|
2025
|
+
if (wantsStake &&
|
|
2026
|
+
resolvedGateway === undefined &&
|
|
2027
|
+
resolvedWithdrawalId === undefined &&
|
|
2028
|
+
!params.sources?.length) {
|
|
2029
|
+
const picked = await this._autoPickReturnedNameStakeSource(params);
|
|
2030
|
+
if (picked?.kind === 'delegation') {
|
|
2031
|
+
resolvedGateway = picked.gateway;
|
|
2032
|
+
resolvedFundAsOperator = false;
|
|
2033
|
+
}
|
|
2034
|
+
else if (picked?.kind === 'operatorStake') {
|
|
2035
|
+
resolvedGateway = picked.gateway;
|
|
2036
|
+
resolvedFundAsOperator = true;
|
|
2037
|
+
}
|
|
2038
|
+
else if (picked?.kind === 'withdrawal') {
|
|
2039
|
+
resolvedWithdrawalId = picked.withdrawalId;
|
|
2040
|
+
}
|
|
2041
|
+
else if (params.fundFrom !== 'any') {
|
|
2042
|
+
// 'stakes'/'withdrawal' explicitly requested but nothing covers it.
|
|
2043
|
+
throw new Error(`buyReturnedName: no ${params.fundFrom === 'withdrawal'
|
|
2044
|
+
? 'matured withdrawal vault'
|
|
2045
|
+
: 'delegation/operator stake'} large enough to fund '${params.name}' was found for ` +
|
|
2046
|
+
`${this.signer.address}. Fund from balance, or add stake first.`);
|
|
2047
|
+
}
|
|
2048
|
+
// 'any' with nothing found → falls through to the balance path.
|
|
2049
|
+
}
|
|
1982
2050
|
let ix;
|
|
1983
|
-
|
|
2051
|
+
const useBalance = !params.fundFrom ||
|
|
1984
2052
|
params.fundFrom === 'balance' ||
|
|
1985
|
-
params.fundFrom === 'turbo'
|
|
2053
|
+
params.fundFrom === 'turbo' ||
|
|
2054
|
+
(params.fundFrom === 'any' &&
|
|
2055
|
+
resolvedGateway === undefined &&
|
|
2056
|
+
resolvedWithdrawalId === undefined &&
|
|
2057
|
+
!params.sources?.length);
|
|
2058
|
+
if (useBalance) {
|
|
1986
2059
|
ix = await getBuyReturnedNameInstructionAsync(await this.withArnsDefaults({
|
|
1987
2060
|
arnsRecord,
|
|
1988
2061
|
returnedName: returnedNamePda,
|
|
@@ -2011,10 +2084,10 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2011
2084
|
garProgram: this.garProgram,
|
|
2012
2085
|
params: buyParams,
|
|
2013
2086
|
};
|
|
2014
|
-
if (
|
|
2015
|
-
const gatewayAddr = address(
|
|
2087
|
+
if (resolvedGateway !== undefined) {
|
|
2088
|
+
const gatewayAddr = address(resolvedGateway);
|
|
2016
2089
|
const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
|
|
2017
|
-
if (
|
|
2090
|
+
if (resolvedFundAsOperator) {
|
|
2018
2091
|
ix = await getBuyReturnedNameFromOperatorStakeInstructionAsync({ ...sharedReturnedBase, gateway: gatewayPda }, { programAddress: this.arnsProgram });
|
|
2019
2092
|
}
|
|
2020
2093
|
else {
|
|
@@ -2026,17 +2099,16 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2026
2099
|
}, { programAddress: this.arnsProgram });
|
|
2027
2100
|
}
|
|
2028
2101
|
}
|
|
2029
|
-
else if (
|
|
2030
|
-
|
|
2031
|
-
const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, params.withdrawalId, this.garProgram);
|
|
2102
|
+
else if (resolvedWithdrawalId !== undefined) {
|
|
2103
|
+
const [withdrawalPda] = await getWithdrawalPDA(this.signer.address, resolvedWithdrawalId, this.garProgram);
|
|
2032
2104
|
ix = await getBuyReturnedNameFromWithdrawalInstructionAsync({ ...sharedReturnedBase, withdrawal: withdrawalPda }, { programAddress: this.arnsProgram });
|
|
2033
2105
|
}
|
|
2034
|
-
else if (params.fundFrom === 'plan'
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2039
|
-
//
|
|
2106
|
+
else if (params.fundFrom === 'plan' && params.sources?.length) {
|
|
2107
|
+
// Explicit caller-supplied plan only: the caller owns the source
|
|
2108
|
+
// amounts and accepts the decay risk (the price moves per slot, so a
|
|
2109
|
+
// stale plan trips FundingPlanAmountMismatch). We do NOT auto-discover
|
|
2110
|
+
// a multi-source plan for returned names — see the single-source note
|
|
2111
|
+
// above.
|
|
2040
2112
|
const cost = await this._simulateTokenCost({
|
|
2041
2113
|
intent: CostIntent.BuyName,
|
|
2042
2114
|
name: params.name,
|
|
@@ -2060,14 +2132,63 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2060
2132
|
throw new Error(`unsupported fundFrom mode '${params.fundFrom}' for buyReturnedName`);
|
|
2061
2133
|
}
|
|
2062
2134
|
}
|
|
2135
|
+
// The on-chain `buy_returned_name*` handlers take `initiator_token_account`
|
|
2136
|
+
// and `buyer_token_account` as `Account<TokenAccount>` (NOT `init`), so
|
|
2137
|
+
// Anchor requires both ATAs to already exist or fails with
|
|
2138
|
+
// AccountNotInitialized (#3012). The original initiator may never have held
|
|
2139
|
+
// ARIO, and the premium always settles from the buyer's liquid ATA — bundle
|
|
2140
|
+
// idempotent ATA creates so the buy succeeds without a separate setup step
|
|
2141
|
+
// (mirrors the vault-ATA handling above). Idempotent: ~1500 CU each, no-op
|
|
2142
|
+
// when the account already exists.
|
|
2143
|
+
const createInitiatorAtaIx = buildCreateAtaIdempotentIx(this.signer.address, initiatorATA, initiator, arnsConfig.mint);
|
|
2144
|
+
const createBuyerAtaIx = buildCreateAtaIdempotentIx(this.signer.address, buyerATA, this.signer.address, arnsConfig.mint);
|
|
2063
2145
|
// Sprint 4 / ADR-016: bundle ant.sync_attributes after the buy so the
|
|
2064
2146
|
// Attributes plugin reflects the new record holder. assetOverride =
|
|
2065
2147
|
// antPubkey because the ArnsRecord PDA is created by buy_returned_name
|
|
2066
2148
|
// and doesn't exist on-chain at SDK build time.
|
|
2067
2149
|
const syncIx = await this._buildSyncAttributesIxIfOwner(params.name, antPubkey);
|
|
2068
|
-
const sig = await this.sendTransaction(
|
|
2150
|
+
const sig = await this.sendTransaction([
|
|
2151
|
+
createBuyerAtaIx,
|
|
2152
|
+
createInitiatorAtaIx,
|
|
2153
|
+
ix,
|
|
2154
|
+
...(syncIx ? [syncIx] : []),
|
|
2155
|
+
]);
|
|
2069
2156
|
return { id: sig };
|
|
2070
2157
|
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Pick a single stake-derived funding source that can cover a returned-name
|
|
2160
|
+
* purchase, for the single-source `buy_returned_name_from_*` paths.
|
|
2161
|
+
*
|
|
2162
|
+
* Returned-name prices decay per slot, so the multi-source funding plan
|
|
2163
|
+
* (which pre-commits exact amounts) can't match the execution-time cost. The
|
|
2164
|
+
* single-source paths carry no amount — the program draws the live cost — so
|
|
2165
|
+
* we only need to pick ONE source with enough stake. We size the pick against
|
|
2166
|
+
* the premium-inclusive estimate (an upper bound, since the price only falls
|
|
2167
|
+
* from now) and choose the largest matching source. Returns `null` when no
|
|
2168
|
+
* single source covers the estimate.
|
|
2169
|
+
*/
|
|
2170
|
+
async _autoPickReturnedNameStakeSource(params) {
|
|
2171
|
+
const estimate = BigInt(Math.ceil(await this.getTokenCost({
|
|
2172
|
+
intent: 'Buy-Name',
|
|
2173
|
+
name: params.name,
|
|
2174
|
+
type: params.type,
|
|
2175
|
+
years: params.years ?? 1,
|
|
2176
|
+
})));
|
|
2177
|
+
const arnsConfig = await this.getArnsConfig();
|
|
2178
|
+
const { discoverFundingSources } = await import('./funding-plan.js');
|
|
2179
|
+
const sources = await discoverFundingSources(this.rpc, this.signer.address, { arioMint: arnsConfig.mint, garProgram: this.garProgram });
|
|
2180
|
+
// 'withdrawal' mode → matured withdrawal vaults only; otherwise prefer
|
|
2181
|
+
// operator stake when the caller asked, else a delegation.
|
|
2182
|
+
const wantKind = params.fundFrom === 'withdrawal'
|
|
2183
|
+
? 'withdrawal'
|
|
2184
|
+
: params.fundAsOperator === true
|
|
2185
|
+
? 'operatorStake'
|
|
2186
|
+
: 'delegation';
|
|
2187
|
+
const candidates = sources
|
|
2188
|
+
.filter((s) => s.kind === wantKind && s.available >= estimate)
|
|
2189
|
+
.sort((a, b) => b.available > a.available ? 1 : b.available < a.available ? -1 : 0);
|
|
2190
|
+
return candidates[0] ?? null;
|
|
2191
|
+
}
|
|
2071
2192
|
// =========================================
|
|
2072
2193
|
// Name management (ario-arns)
|
|
2073
2194
|
// =========================================
|
|
@@ -2124,6 +2245,66 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2124
2245
|
return { id: sig };
|
|
2125
2246
|
}
|
|
2126
2247
|
// =========================================
|
|
2248
|
+
// Lazy-state crank steps — materialize accumulator/period state.
|
|
2249
|
+
// Both are permissionless + idempotent (safe to crank on a schedule).
|
|
2250
|
+
// =========================================
|
|
2251
|
+
/**
|
|
2252
|
+
* Roll the demand factor forward to the current period. Permissionless and
|
|
2253
|
+
* idempotent — a no-op within the same period. Pricing already rolls the
|
|
2254
|
+
* factor inline on every buy/extend, so this only refreshes the STORED
|
|
2255
|
+
* factor that `getDemandFactor` and between-buy price previews read; a
|
|
2256
|
+
* periodic crank (~once per 24h `PERIOD_LENGTH_SECONDS`) keeps it current.
|
|
2257
|
+
*/
|
|
2258
|
+
async updateDemandFactor(_options) {
|
|
2259
|
+
const [demandFactorPda] = await getDemandFactorPDA(this.arnsProgram);
|
|
2260
|
+
const ix = getUpdateDemandFactorInstruction({ demandFactor: demandFactorPda, payer: this.signer }, { programAddress: this.arnsProgram });
|
|
2261
|
+
const sig = await this.sendTransaction([ix]);
|
|
2262
|
+
return { id: sig };
|
|
2263
|
+
}
|
|
2264
|
+
/**
|
|
2265
|
+
* Materialize a single delegate's pending rewards into their delegated
|
|
2266
|
+
* stake by settling the gateway's reward-per-share accumulator.
|
|
2267
|
+
* Permissionless — there is no signer beyond the fee payer; `delegator` is
|
|
2268
|
+
* only a PDA-derivation seed. Rewards always accrue correctly in the
|
|
2269
|
+
* accumulator regardless of this call; compounding makes the on-chain
|
|
2270
|
+
* `delegatedStake` reflect them (and earn compound interest in the next
|
|
2271
|
+
* epoch's weighting). Idempotent — a no-op once already settled.
|
|
2272
|
+
*/
|
|
2273
|
+
async compoundDelegationRewards(params, _options) {
|
|
2274
|
+
const ix = await this.buildCompoundDelegationRewardsInstruction(params);
|
|
2275
|
+
const sig = await this.sendTransaction([ix]);
|
|
2276
|
+
return { id: sig };
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Compound many delegates' rewards in a SINGLE transaction — one
|
|
2280
|
+
* `compound_delegation_rewards` instruction per entry. Idempotent and
|
|
2281
|
+
* permissionless, so partial batches are safe to retry. Keep each batch
|
|
2282
|
+
* within the per-tx account/CU budget; grouping entries that share a gateway
|
|
2283
|
+
* lowers the unique-account count (the gateway account is reused across
|
|
2284
|
+
* instructions). Typical cranker usage: enumerate with
|
|
2285
|
+
* `SolanaARIOReadable.getDelegationsToCompound`, chunk, then call this.
|
|
2286
|
+
*/
|
|
2287
|
+
async compoundDelegationRewardsBatch(delegations, _options) {
|
|
2288
|
+
if (delegations.length === 0) {
|
|
2289
|
+
throw new Error('compoundDelegationRewardsBatch: delegations list is empty');
|
|
2290
|
+
}
|
|
2291
|
+
const ixs = await Promise.all(delegations.map((d) => this.buildCompoundDelegationRewardsInstruction(d)));
|
|
2292
|
+
const sig = await this.sendTransaction(ixs, 1_400_000);
|
|
2293
|
+
return { id: sig };
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Build a single `compound_delegation_rewards` instruction (shared by the
|
|
2297
|
+
* single + batch methods). PDAs are derived under the configured gar program
|
|
2298
|
+
* so the program-id override always targets the right cluster.
|
|
2299
|
+
*/
|
|
2300
|
+
async buildCompoundDelegationRewardsInstruction(params) {
|
|
2301
|
+
const gateway = address(params.gateway);
|
|
2302
|
+
const delegator = address(params.delegator);
|
|
2303
|
+
const [gatewayPda] = await getGatewayPDA(gateway, this.garProgram);
|
|
2304
|
+
const [delegationPda] = await getDelegationPDA(gateway, delegator, this.garProgram);
|
|
2305
|
+
return getCompoundDelegationRewardsInstruction({ gateway: gatewayPda, delegation: delegationPda, delegator }, { programAddress: this.garProgram });
|
|
2306
|
+
}
|
|
2307
|
+
// =========================================
|
|
2127
2308
|
// Epoch cranking (ario-gar) — permissionless
|
|
2128
2309
|
// =========================================
|
|
2129
2310
|
/**
|
|
@@ -2477,6 +2658,9 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2477
2658
|
const batchSize = Math.min(opts.batchSize ?? MAX_LIFECYCLE_BATCH, MAX_LIFECYCLE_BATCH);
|
|
2478
2659
|
const enableClose = opts.enableClose ?? true;
|
|
2479
2660
|
const retention = opts.epochRetention ?? 7;
|
|
2661
|
+
const enableCompound = opts.enableCompound ?? true;
|
|
2662
|
+
const compoundMinPending = opts.compoundMinPendingRewards ?? 0;
|
|
2663
|
+
const enableDemandFactorRoll = opts.enableDemandFactorRoll ?? true;
|
|
2480
2664
|
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
2481
2665
|
const settings = await this.getEpochSettingsFull();
|
|
2482
2666
|
if (!settings.enabled)
|
|
@@ -2561,6 +2745,22 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2561
2745
|
return { action: 'close', epochIndex: closeTarget, txId: id };
|
|
2562
2746
|
}
|
|
2563
2747
|
}
|
|
2748
|
+
// Lazy-state maintenance — lower urgency than the lifecycle, reached only
|
|
2749
|
+
// once the live epoch is fully distributed (rewardsDistributed === 1 here).
|
|
2750
|
+
// Compound FIRST so delegated stake reflects the just-distributed rewards
|
|
2751
|
+
// before the next epoch's tally weights it; then roll the demand factor if
|
|
2752
|
+
// its period elapsed. Both are permissionless + idempotent, and run BEFORE
|
|
2753
|
+
// creating the next epoch so the compounded stake is in place for its tally.
|
|
2754
|
+
if (enableCompound) {
|
|
2755
|
+
const compounded = await this.maybeCompoundStep(compoundMinPending);
|
|
2756
|
+
if (compounded)
|
|
2757
|
+
return compounded;
|
|
2758
|
+
}
|
|
2759
|
+
if (enableDemandFactorRoll) {
|
|
2760
|
+
const rolled = await this.maybeRollDemandFactorStep(now);
|
|
2761
|
+
if (rolled)
|
|
2762
|
+
return rolled;
|
|
2763
|
+
}
|
|
2564
2764
|
// Current epoch fully processed — create the next once its start arrives.
|
|
2565
2765
|
if (now >= nextEpochStart) {
|
|
2566
2766
|
const { id } = await this.createEpoch();
|
|
@@ -2568,6 +2768,61 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
2568
2768
|
}
|
|
2569
2769
|
return { action: 'idle', reason: 'epoch_complete' };
|
|
2570
2770
|
}
|
|
2771
|
+
/**
|
|
2772
|
+
* One compound batch over delegations with pending rewards (≤
|
|
2773
|
+
* {@link MAX_COMPOUND_BATCH} per tx), or `null` when none are due. Settling
|
|
2774
|
+
* is idempotent, so this converges over a few crank steps then no-ops until
|
|
2775
|
+
* the next epoch's distribution advances the accumulator again.
|
|
2776
|
+
*/
|
|
2777
|
+
async maybeCompoundStep(minPendingRewards) {
|
|
2778
|
+
const pending = await this.getDelegationsToCompound({ minPendingRewards });
|
|
2779
|
+
if (pending.length === 0)
|
|
2780
|
+
return null;
|
|
2781
|
+
const batch = pending.slice(0, MAX_COMPOUND_BATCH).map((p) => ({
|
|
2782
|
+
gateway: p.gatewayAddress,
|
|
2783
|
+
delegator: p.delegatorAddress,
|
|
2784
|
+
}));
|
|
2785
|
+
const { id } = await this.compoundDelegationRewardsBatch(batch);
|
|
2786
|
+
return {
|
|
2787
|
+
action: 'compound',
|
|
2788
|
+
txId: id,
|
|
2789
|
+
progress: { index: batch.length, total: pending.length },
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* Roll the demand factor forward if its (wall-clock) period elapsed since the
|
|
2794
|
+
* last stored roll, else `null`. Mirrors the on-chain period math; the roll
|
|
2795
|
+
* itself is idempotent.
|
|
2796
|
+
*/
|
|
2797
|
+
async maybeRollDemandFactorStep(now) {
|
|
2798
|
+
const state = await this.getDemandFactorPeriodState();
|
|
2799
|
+
if (!state)
|
|
2800
|
+
return null;
|
|
2801
|
+
const elapsed = now - state.periodZeroStartTimestamp;
|
|
2802
|
+
const periodForNow = elapsed < 0 ? 1 : Math.floor(elapsed / DEMAND_FACTOR_PERIOD_SECONDS) + 1;
|
|
2803
|
+
if (periodForNow <= state.currentPeriod)
|
|
2804
|
+
return null; // same period — no-op
|
|
2805
|
+
const { id } = await this.updateDemandFactor();
|
|
2806
|
+
return { action: 'update_demand_factor', txId: id };
|
|
2807
|
+
}
|
|
2808
|
+
/**
|
|
2809
|
+
* The DemandFactor account's stored period + period-zero start (seconds) —
|
|
2810
|
+
* the gate for {@link maybeRollDemandFactorStep}. `null` if the account
|
|
2811
|
+
* doesn't exist (pre-genesis).
|
|
2812
|
+
*/
|
|
2813
|
+
async getDemandFactorPeriodState() {
|
|
2814
|
+
const [pda] = await getDemandFactorPDA(this.arnsProgram);
|
|
2815
|
+
const account = await fetchEncodedAccount(this.rpc, pda, {
|
|
2816
|
+
commitment: this.commitment,
|
|
2817
|
+
});
|
|
2818
|
+
if (!account.exists)
|
|
2819
|
+
return null;
|
|
2820
|
+
const df = deserializeDemandFactor(Buffer.from(account.data));
|
|
2821
|
+
return {
|
|
2822
|
+
currentPeriod: df.currentPeriod,
|
|
2823
|
+
periodZeroStartTimestamp: df.periodZeroStartTimestamp,
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2571
2826
|
/**
|
|
2572
2827
|
* Read the raw epoch account data for cranker state inspection.
|
|
2573
2828
|
* Returns null if the epoch account doesn't exist yet.
|
|
@@ -47,6 +47,121 @@ const logger = new Logger({ level: 'error' });
|
|
|
47
47
|
const DEFAULT_MAINNET_RPC = 'https://api.mainnet-beta.solana.com';
|
|
48
48
|
const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
|
+
// Adaptive rate gate (token bucket + cooldown)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/** Default ceiling when `maxRequestsPerSecond` is not provided. */
|
|
53
|
+
const DEFAULT_MAX_RPS = 10;
|
|
54
|
+
/** Multiply the current rate by this on a 429 with no usable header. */
|
|
55
|
+
const AIMD_DECREASE = 0.5;
|
|
56
|
+
/** Never throttle below this many requests/second. */
|
|
57
|
+
const MIN_RATE = 1;
|
|
58
|
+
/** Consecutive successes before nudging the rate up by 1 (additive recovery). */
|
|
59
|
+
const RECOVERY_SUCCESSES = 20;
|
|
60
|
+
/** Fraction of a provider-advertised limit to actually use (safety margin). */
|
|
61
|
+
const RATE_SAFETY_FACTOR = 0.9;
|
|
62
|
+
/** Cooldown applied on a 429 that carries no `Retry-After`. */
|
|
63
|
+
const DEFAULT_COOLDOWN_MS = 1_000;
|
|
64
|
+
/**
|
|
65
|
+
* Token-bucket throttle whose rate can be retuned at runtime and which can be
|
|
66
|
+
* paused on demand. Tokens refill continuously at the current rate, capped at
|
|
67
|
+
* one second's worth (the burst allowance); waiters are released FIFO.
|
|
68
|
+
*/
|
|
69
|
+
function createRateGate(initialRate) {
|
|
70
|
+
let rate = Math.max(MIN_RATE, initialRate);
|
|
71
|
+
let capacity = Math.max(1, rate);
|
|
72
|
+
let tokens = capacity;
|
|
73
|
+
let lastRefill = Date.now();
|
|
74
|
+
let pausedUntil = 0;
|
|
75
|
+
const queue = [];
|
|
76
|
+
let timer = null;
|
|
77
|
+
const schedule = (ms) => {
|
|
78
|
+
if (timer !== null)
|
|
79
|
+
return;
|
|
80
|
+
timer = setTimeout(() => {
|
|
81
|
+
timer = null;
|
|
82
|
+
pump();
|
|
83
|
+
}, Math.max(ms, 1));
|
|
84
|
+
};
|
|
85
|
+
const refill = () => {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const elapsed = (now - lastRefill) / 1000;
|
|
88
|
+
if (elapsed > 0) {
|
|
89
|
+
tokens = Math.min(capacity, tokens + elapsed * rate);
|
|
90
|
+
lastRefill = now;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const pump = () => {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (pausedUntil > now) {
|
|
96
|
+
schedule(pausedUntil - now);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
refill();
|
|
100
|
+
while (tokens >= 1) {
|
|
101
|
+
const release = queue.shift();
|
|
102
|
+
if (!release)
|
|
103
|
+
break;
|
|
104
|
+
tokens -= 1;
|
|
105
|
+
release();
|
|
106
|
+
}
|
|
107
|
+
if (queue.length > 0) {
|
|
108
|
+
// Wake when the next whole token will have accrued.
|
|
109
|
+
schedule(Math.ceil(((1 - tokens) / rate) * 1000));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
acquire: () => new Promise((resolve) => {
|
|
114
|
+
queue.push(resolve);
|
|
115
|
+
pump();
|
|
116
|
+
}),
|
|
117
|
+
setRate: (ratePerSecond) => {
|
|
118
|
+
rate = Math.max(MIN_RATE, ratePerSecond);
|
|
119
|
+
capacity = Math.max(1, rate);
|
|
120
|
+
tokens = Math.min(tokens, capacity);
|
|
121
|
+
lastRefill = Date.now();
|
|
122
|
+
pump();
|
|
123
|
+
},
|
|
124
|
+
pauseFor: (ms) => {
|
|
125
|
+
const until = Date.now() + Math.max(0, ms);
|
|
126
|
+
if (until > pausedUntil)
|
|
127
|
+
pausedUntil = until;
|
|
128
|
+
schedule(ms);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// 429 / rate-limit header parsing
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/**
|
|
136
|
+
* If `err` is a transport HTTP 429, return its response `Headers`; else null.
|
|
137
|
+
* Duck-typed against the `@solana/errors` HTTP-error context
|
|
138
|
+
* (`{ statusCode, headers }`) so we avoid a hard dependency on the error code.
|
|
139
|
+
*/
|
|
140
|
+
function http429Headers(err) {
|
|
141
|
+
const ctx = err
|
|
142
|
+
?.context;
|
|
143
|
+
if (ctx?.statusCode === 429 && ctx.headers instanceof Headers) {
|
|
144
|
+
return ctx.headers;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Parse `Retry-After` (delta-seconds or HTTP-date) into ms, or null. */
|
|
149
|
+
function parseRetryAfterMs(headers) {
|
|
150
|
+
const v = headers.get('retry-after');
|
|
151
|
+
if (v === null || v === '')
|
|
152
|
+
return null;
|
|
153
|
+
const secs = Number(v);
|
|
154
|
+
if (Number.isFinite(secs))
|
|
155
|
+
return Math.max(0, secs * 1000);
|
|
156
|
+
const when = Date.parse(v);
|
|
157
|
+
return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
|
|
158
|
+
}
|
|
159
|
+
/** Provider-advertised requests/second limit (`x-ratelimit-rps-limit`), or null. */
|
|
160
|
+
function parseRpsLimit(headers) {
|
|
161
|
+
const v = Number(headers.get('x-ratelimit-rps-limit'));
|
|
162
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
50
165
|
// Implementation
|
|
51
166
|
// ---------------------------------------------------------------------------
|
|
52
167
|
/**
|
|
@@ -58,11 +173,51 @@ const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com';
|
|
|
58
173
|
export function createCircuitBreakerRpc({ primaryUrl, fallbackUrl, circuitBreakerOptions: opts = {}, }) {
|
|
59
174
|
const primaryTransport = createDefaultRpcTransport({ url: primaryUrl });
|
|
60
175
|
const fallbackTransport = createDefaultRpcTransport({ url: fallbackUrl });
|
|
176
|
+
// Throttling is always on. `maxRequestsPerSecond` is the *ceiling*: every
|
|
177
|
+
// request flows through an adaptive token bucket that backs off on HTTP 429
|
|
178
|
+
// (honoring `Retry-After` and `x-ratelimit-rps-limit` when present, AIMD
|
|
179
|
+
// otherwise) and recovers back toward the ceiling on sustained success.
|
|
180
|
+
const ceilingRate = opts.maxRequestsPerSecond !== undefined && opts.maxRequestsPerSecond > 0
|
|
181
|
+
? opts.maxRequestsPerSecond
|
|
182
|
+
: DEFAULT_MAX_RPS;
|
|
183
|
+
const gate = createRateGate(ceilingRate);
|
|
184
|
+
let currentRate = ceilingRate;
|
|
185
|
+
let successStreak = 0;
|
|
186
|
+
const onError = (err) => {
|
|
187
|
+
const headers = http429Headers(err);
|
|
188
|
+
if (!headers)
|
|
189
|
+
return; // only adapt to rate-limit (429) failures
|
|
190
|
+
successStreak = 0;
|
|
191
|
+
const advertised = parseRpsLimit(headers);
|
|
192
|
+
const next = advertised !== null
|
|
193
|
+
? Math.min(ceilingRate, Math.max(MIN_RATE, advertised * RATE_SAFETY_FACTOR))
|
|
194
|
+
: Math.max(MIN_RATE, currentRate * AIMD_DECREASE);
|
|
195
|
+
if (next !== currentRate) {
|
|
196
|
+
currentRate = next;
|
|
197
|
+
gate.setRate(currentRate);
|
|
198
|
+
}
|
|
199
|
+
const retryAfter = parseRetryAfterMs(headers);
|
|
200
|
+
gate.pauseFor(retryAfter ?? DEFAULT_COOLDOWN_MS);
|
|
201
|
+
logger.warn(`[rpc-circuit-breaker] 429 — throttling to ${currentRate.toFixed(1)} req/s` +
|
|
202
|
+
`, cooling down ${retryAfter ?? DEFAULT_COOLDOWN_MS}ms`);
|
|
203
|
+
};
|
|
204
|
+
const onSuccess = () => {
|
|
205
|
+
if (currentRate >= ceilingRate)
|
|
206
|
+
return;
|
|
207
|
+
if (++successStreak >= RECOVERY_SUCCESSES) {
|
|
208
|
+
successStreak = 0;
|
|
209
|
+
currentRate = Math.min(ceilingRate, currentRate + 1);
|
|
210
|
+
gate.setRate(currentRate);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
61
213
|
const breaker = new CircuitBreaker((request) => primaryTransport(request), {
|
|
62
214
|
timeout: opts.timeout ?? 10_000,
|
|
63
215
|
errorThresholdPercentage: opts.errorThresholdPercentage ?? 25,
|
|
64
216
|
resetTimeout: opts.resetTimeout ?? 60_000,
|
|
65
217
|
volumeThreshold: opts.volumeThreshold ?? 3,
|
|
218
|
+
...(opts.maxConcurrent !== undefined && opts.maxConcurrent > 0
|
|
219
|
+
? { capacity: opts.maxConcurrent }
|
|
220
|
+
: {}),
|
|
66
221
|
});
|
|
67
222
|
breaker.fallback((request) => fallbackTransport(request));
|
|
68
223
|
breaker.on('open', () => {
|
|
@@ -74,7 +229,20 @@ export function createCircuitBreakerRpc({ primaryUrl, fallbackUrl, circuitBreake
|
|
|
74
229
|
breaker.on('close', () => {
|
|
75
230
|
logger.info('[rpc-circuit-breaker] circuit CLOSED — primary RPC recovered');
|
|
76
231
|
});
|
|
77
|
-
|
|
232
|
+
// Adapt the rate to the *primary's* health via opossum's events: `failure`
|
|
233
|
+
// fires whenever the primary call rejects (a 429 included) even when the
|
|
234
|
+
// fallback then masks it by resolving `fire()`, and `success` fires on a
|
|
235
|
+
// healthy primary call. A plain try/catch around `fire()` would miss the
|
|
236
|
+
// fallback-masked 429s entirely.
|
|
237
|
+
breaker.on('failure', (err) => onError(err));
|
|
238
|
+
breaker.on('success', () => onSuccess());
|
|
239
|
+
const transport = (async (request) => {
|
|
240
|
+
// Throttle entry to the breaker so we stay under the provider's rate
|
|
241
|
+
// limit; the queue wait sits outside `fire`, so opossum's per-request
|
|
242
|
+
// timeout only measures the actual transport call.
|
|
243
|
+
await gate.acquire();
|
|
244
|
+
return breaker.fire(request);
|
|
245
|
+
});
|
|
78
246
|
return createSolanaRpcFromTransport(transport);
|
|
79
247
|
}
|
|
80
248
|
/**
|
package/lib/esm/version.js
CHANGED
|
@@ -18,3 +18,28 @@ export declare function computeLiveDelegationBalance({ delegatedStake, rewardDeb
|
|
|
18
18
|
rewardDebt: bigint;
|
|
19
19
|
cumulativeRewardPerToken: bigint;
|
|
20
20
|
}): number;
|
|
21
|
+
/**
|
|
22
|
+
* Select the delegations worth compounding, from decoded delegations + a map
|
|
23
|
+
* of their gateways' reward accumulators. Pure (no I/O) so it's unit-testable
|
|
24
|
+
* independently of RPC; `SolanaARIOReadable.getDelegationsToCompound` is just
|
|
25
|
+
* fetch+decode wrapped around this.
|
|
26
|
+
*
|
|
27
|
+
* A delegation is included when its pending reward (live balance − settled
|
|
28
|
+
* principal) exceeds `minPendingRewards`, EXCEPT when its gateway is `leaving`
|
|
29
|
+
* (those settle via `claim_delegate_from_leaving_gateway`, not compounding) or
|
|
30
|
+
* its gateway is missing/unreadable. Compounding sub-threshold dust only
|
|
31
|
+
* advances `reward_debt` for no balance gain, so it's filtered out.
|
|
32
|
+
*/
|
|
33
|
+
export declare function selectCompoundableDelegations(delegations: Array<{
|
|
34
|
+
gateway: string;
|
|
35
|
+
delegator: string;
|
|
36
|
+
delegatedStake: number;
|
|
37
|
+
rewardDebt: bigint;
|
|
38
|
+
}>, gatewaysByOperator: Map<string, {
|
|
39
|
+
cumulativeRewardPerToken: bigint;
|
|
40
|
+
status: string;
|
|
41
|
+
}>, minPendingRewards?: number): Array<{
|
|
42
|
+
gatewayAddress: string;
|
|
43
|
+
delegatorAddress: string;
|
|
44
|
+
pendingRewards: number;
|
|
45
|
+
}>;
|
|
@@ -287,6 +287,26 @@ export declare class SolanaARIOReadable {
|
|
|
287
287
|
}): Promise<RedelegationFeeInfo>;
|
|
288
288
|
getGatewayRegistrySettings(): Promise<GatewayRegistrySettings>;
|
|
289
289
|
getAllDelegates(params?: PaginationParams<AllDelegates>): Promise<PaginationResult<AllDelegates>>;
|
|
290
|
+
/**
|
|
291
|
+
* Enumerate every delegation that has pending (unsettled) rewards — the work
|
|
292
|
+
* list for the permissionless `compound_delegation_rewards` crank. Pending is
|
|
293
|
+
* computed from the gateway's reward-per-share accumulator (mirrors
|
|
294
|
+
* {@link computeLiveDelegationBalance}); the crank only changes balances, so
|
|
295
|
+
* rewards already accrue correctly without it.
|
|
296
|
+
*
|
|
297
|
+
* Skips delegations whose gateway is `Leaving` (those settle through
|
|
298
|
+
* `claim_delegate_from_leaving_gateway`, not compounding) and any below
|
|
299
|
+
* `minPendingRewards` — compounding sub-threshold dust just advances
|
|
300
|
+
* `reward_debt` for no balance gain. Feed the result, chunked, to
|
|
301
|
+
* `SolanaARIOWriteable.compoundDelegationRewardsBatch`.
|
|
302
|
+
*/
|
|
303
|
+
getDelegationsToCompound(params?: {
|
|
304
|
+
minPendingRewards?: number;
|
|
305
|
+
}): Promise<Array<{
|
|
306
|
+
gatewayAddress: string;
|
|
307
|
+
delegatorAddress: string;
|
|
308
|
+
pendingRewards: number;
|
|
309
|
+
}>>;
|
|
290
310
|
getAllGatewayVaults(params?: PaginationParams<AllGatewayVaults>): Promise<PaginationResult<AllGatewayVaults>>;
|
|
291
311
|
/**
|
|
292
312
|
* Enumerate ArnsRecord PDAs whose lease has fully expired
|
|
@@ -122,7 +122,7 @@ export declare function buildObservationBitmap(registryAddresses: string[], fail
|
|
|
122
122
|
*/
|
|
123
123
|
export declare function encodeReportTxId(reportTxId: string | undefined): Buffer;
|
|
124
124
|
/** The single on-chain action a {@link SolanaARIOWriteable.crankEpochStep} call performed. */
|
|
125
|
-
export type CrankAction = 'create' | 'tally' | 'prescribe' | 'distribute' | 'close' | 'idle';
|
|
125
|
+
export type CrankAction = 'create' | 'tally' | 'prescribe' | 'distribute' | 'compound' | 'update_demand_factor' | 'close' | 'idle';
|
|
126
126
|
/** Options for {@link SolanaARIOWriteable.crankEpochStep}. */
|
|
127
127
|
export interface CrankEpochStepOptions {
|
|
128
128
|
/** Gateways per tally/distribute batch. Default 30. */
|
|
@@ -137,6 +137,27 @@ export interface CrankEpochStepOptions {
|
|
|
137
137
|
enableClose?: boolean;
|
|
138
138
|
/** Epochs of retention before an epoch may be closed (GAR-006). Default 7. */
|
|
139
139
|
epochRetention?: number;
|
|
140
|
+
/**
|
|
141
|
+
* Compound pending delegate rewards (settle the reward-per-share accumulator
|
|
142
|
+
* into delegated stake) once the live epoch is fully distributed, so the next
|
|
143
|
+
* epoch's tally weights the compounded stake. Default true. The delegate
|
|
144
|
+
* rewards are correct in the accumulator regardless — this only materializes
|
|
145
|
+
* them on-chain. Each step compounds one batch; runs only in the otherwise-
|
|
146
|
+
* idle tail (never during tally/distribute).
|
|
147
|
+
*/
|
|
148
|
+
enableCompound?: boolean;
|
|
149
|
+
/**
|
|
150
|
+
* Skip compounding delegations whose pending reward is at/below this (mARIO).
|
|
151
|
+
* Avoids dust compounds that only advance `reward_debt`. Default 0.
|
|
152
|
+
*/
|
|
153
|
+
compoundMinPendingRewards?: number;
|
|
154
|
+
/**
|
|
155
|
+
* Roll the demand factor forward when its (wall-clock) period has elapsed.
|
|
156
|
+
* Idempotent — only sends a tx when the stored period is behind. Pricing is
|
|
157
|
+
* always lazily correct without this; it keeps the STORED factor (and reads
|
|
158
|
+
* between buys) current. Default true. Runs only in the idle tail.
|
|
159
|
+
*/
|
|
160
|
+
enableDemandFactorRoll?: boolean;
|
|
140
161
|
/** Unix seconds; defaults to the wall clock. Injectable for testing. */
|
|
141
162
|
now?: number;
|
|
142
163
|
}
|
|
@@ -501,6 +522,19 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
501
522
|
years?: number;
|
|
502
523
|
processId: string;
|
|
503
524
|
} & Partial<ArNSPurchaseParams>, _options?: WriteOptions): Promise<MessageResult>;
|
|
525
|
+
/**
|
|
526
|
+
* Pick a single stake-derived funding source that can cover a returned-name
|
|
527
|
+
* purchase, for the single-source `buy_returned_name_from_*` paths.
|
|
528
|
+
*
|
|
529
|
+
* Returned-name prices decay per slot, so the multi-source funding plan
|
|
530
|
+
* (which pre-commits exact amounts) can't match the execution-time cost. The
|
|
531
|
+
* single-source paths carry no amount — the program draws the live cost — so
|
|
532
|
+
* we only need to pick ONE source with enough stake. We size the pick against
|
|
533
|
+
* the premium-inclusive estimate (an upper bound, since the price only falls
|
|
534
|
+
* from now) and choose the largest matching source. Returns `null` when no
|
|
535
|
+
* single source covers the estimate.
|
|
536
|
+
*/
|
|
537
|
+
private _autoPickReturnedNameStakeSource;
|
|
504
538
|
/** Reassign an ArNS name to a different ANT. */
|
|
505
539
|
reassignName(params: {
|
|
506
540
|
name: string;
|
|
@@ -510,6 +544,46 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
510
544
|
releaseName(params: {
|
|
511
545
|
name: string;
|
|
512
546
|
}, _options?: WriteOptions): Promise<MessageResult>;
|
|
547
|
+
/**
|
|
548
|
+
* Roll the demand factor forward to the current period. Permissionless and
|
|
549
|
+
* idempotent — a no-op within the same period. Pricing already rolls the
|
|
550
|
+
* factor inline on every buy/extend, so this only refreshes the STORED
|
|
551
|
+
* factor that `getDemandFactor` and between-buy price previews read; a
|
|
552
|
+
* periodic crank (~once per 24h `PERIOD_LENGTH_SECONDS`) keeps it current.
|
|
553
|
+
*/
|
|
554
|
+
updateDemandFactor(_options?: WriteOptions): Promise<MessageResult>;
|
|
555
|
+
/**
|
|
556
|
+
* Materialize a single delegate's pending rewards into their delegated
|
|
557
|
+
* stake by settling the gateway's reward-per-share accumulator.
|
|
558
|
+
* Permissionless — there is no signer beyond the fee payer; `delegator` is
|
|
559
|
+
* only a PDA-derivation seed. Rewards always accrue correctly in the
|
|
560
|
+
* accumulator regardless of this call; compounding makes the on-chain
|
|
561
|
+
* `delegatedStake` reflect them (and earn compound interest in the next
|
|
562
|
+
* epoch's weighting). Idempotent — a no-op once already settled.
|
|
563
|
+
*/
|
|
564
|
+
compoundDelegationRewards(params: {
|
|
565
|
+
gateway: string;
|
|
566
|
+
delegator: string;
|
|
567
|
+
}, _options?: WriteOptions): Promise<MessageResult>;
|
|
568
|
+
/**
|
|
569
|
+
* Compound many delegates' rewards in a SINGLE transaction — one
|
|
570
|
+
* `compound_delegation_rewards` instruction per entry. Idempotent and
|
|
571
|
+
* permissionless, so partial batches are safe to retry. Keep each batch
|
|
572
|
+
* within the per-tx account/CU budget; grouping entries that share a gateway
|
|
573
|
+
* lowers the unique-account count (the gateway account is reused across
|
|
574
|
+
* instructions). Typical cranker usage: enumerate with
|
|
575
|
+
* `SolanaARIOReadable.getDelegationsToCompound`, chunk, then call this.
|
|
576
|
+
*/
|
|
577
|
+
compoundDelegationRewardsBatch(delegations: Array<{
|
|
578
|
+
gateway: string;
|
|
579
|
+
delegator: string;
|
|
580
|
+
}>, _options?: WriteOptions): Promise<MessageResult>;
|
|
581
|
+
/**
|
|
582
|
+
* Build a single `compound_delegation_rewards` instruction (shared by the
|
|
583
|
+
* single + batch methods). PDAs are derived under the configured gar program
|
|
584
|
+
* so the program-id override always targets the right cluster.
|
|
585
|
+
*/
|
|
586
|
+
private buildCompoundDelegationRewardsInstruction;
|
|
513
587
|
/**
|
|
514
588
|
* Create a new epoch. Permissionless — anyone can call when the next
|
|
515
589
|
* epoch's start time has arrived.
|
|
@@ -640,6 +714,28 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
|
|
|
640
714
|
* internally-handled error is the prescribe `InvalidGatewayAccount` retry.
|
|
641
715
|
*/
|
|
642
716
|
crankEpochStep(opts?: CrankEpochStepOptions): Promise<CrankEpochStepResult>;
|
|
717
|
+
/**
|
|
718
|
+
* One compound batch over delegations with pending rewards (≤
|
|
719
|
+
* {@link MAX_COMPOUND_BATCH} per tx), or `null` when none are due. Settling
|
|
720
|
+
* is idempotent, so this converges over a few crank steps then no-ops until
|
|
721
|
+
* the next epoch's distribution advances the accumulator again.
|
|
722
|
+
*/
|
|
723
|
+
private maybeCompoundStep;
|
|
724
|
+
/**
|
|
725
|
+
* Roll the demand factor forward if its (wall-clock) period elapsed since the
|
|
726
|
+
* last stored roll, else `null`. Mirrors the on-chain period math; the roll
|
|
727
|
+
* itself is idempotent.
|
|
728
|
+
*/
|
|
729
|
+
private maybeRollDemandFactorStep;
|
|
730
|
+
/**
|
|
731
|
+
* The DemandFactor account's stored period + period-zero start (seconds) —
|
|
732
|
+
* the gate for {@link maybeRollDemandFactorStep}. `null` if the account
|
|
733
|
+
* doesn't exist (pre-genesis).
|
|
734
|
+
*/
|
|
735
|
+
getDemandFactorPeriodState(): Promise<{
|
|
736
|
+
currentPeriod: number;
|
|
737
|
+
periodZeroStartTimestamp: number;
|
|
738
|
+
} | null>;
|
|
643
739
|
/**
|
|
644
740
|
* Read the raw epoch account data for cranker state inspection.
|
|
645
741
|
* Returns null if the epoch account doesn't exist yet.
|
|
@@ -24,6 +24,35 @@ export interface CircuitBreakerRpcOptions {
|
|
|
24
24
|
* @default 3
|
|
25
25
|
*/
|
|
26
26
|
volumeThreshold?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Ceiling for requests allowed through per second. Implemented as an
|
|
29
|
+
* adaptive token bucket *in front of* the breaker: excess requests are
|
|
30
|
+
* queued (FIFO) until a token frees, smoothing bursts so you stay under a
|
|
31
|
+
* provider's rate limit (avoids HTTP 429 / Solana error #8100002).
|
|
32
|
+
*
|
|
33
|
+
* The bucket auto-tunes on 429s: it honors `Retry-After`, drops to
|
|
34
|
+
* `x-ratelimit-rps-limit` when the provider advertises one (public Solana
|
|
35
|
+
* RPC does; QuickNode generally does not), otherwise halves the rate
|
|
36
|
+
* (AIMD). It recovers back up toward this ceiling on sustained success.
|
|
37
|
+
*
|
|
38
|
+
* The queue wait happens *before* `breaker.fire`, so it does NOT count
|
|
39
|
+
* against {@link CircuitBreakerRpcOptions.timeout}.
|
|
40
|
+
*
|
|
41
|
+
* Throttling is always on; omitting this (or passing `<= 0`) uses the
|
|
42
|
+
* {@link DEFAULT_MAX_RPS} default. To effectively remove the limit, pass a
|
|
43
|
+
* very large number.
|
|
44
|
+
* @default 10
|
|
45
|
+
*/
|
|
46
|
+
maxRequestsPerSecond?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Maximum number of concurrent in-flight requests (opossum `capacity`
|
|
49
|
+
* semaphore). Unlike {@link CircuitBreakerRpcOptions.maxRequestsPerSecond},
|
|
50
|
+
* excess requests are **rejected immediately** rather than queued — this is
|
|
51
|
+
* concurrency control, not a rate limit. For avoiding 429s you usually want
|
|
52
|
+
* `maxRequestsPerSecond` instead.
|
|
53
|
+
* @default undefined (unlimited)
|
|
54
|
+
*/
|
|
55
|
+
maxConcurrent?: number;
|
|
27
56
|
}
|
|
28
57
|
export interface CircuitBreakerRpcConfig {
|
|
29
58
|
/** URL for the primary (preferred) RPC endpoint. */
|
package/lib/types/version.d.ts
CHANGED