@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.
@@ -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' || params.fundFrom === 'any') {
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
- // 'balance' or undefined falls through to the original direct-buy path.
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' || args.params.fundFrom === 'any') {
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
- if (!params.fundFrom ||
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 (params.fundFrom === 'stakes' && params.gatewayAddress) {
2015
- const gatewayAddr = address(params.gatewayAddress);
2087
+ if (resolvedGateway !== undefined) {
2088
+ const gatewayAddr = address(resolvedGateway);
2016
2089
  const [gatewayPda] = await getGatewayPDA(gatewayAddr, this.garProgram);
2017
- if (params.fundAsOperator) {
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 (params.fundFrom === 'withdrawal' &&
2030
- params.withdrawalId !== undefined) {
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' || params.fundFrom === 'any') {
2035
- // Returned-name pricing is dynamic (Dutch auction premium); we trust
2036
- // explicit caller-supplied sources here and skip auto-discovery if
2037
- // sources is provided. For 'any' without sources, we fall back to a
2038
- // best-effort estimate using the plain registration fee caller can
2039
- // always retry with explicit sources on FundingPlanAmountMismatch.
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(syncIx ? [ix, syncIx] : [ix]);
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
- const transport = ((request) => breaker.fire(request));
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
  /**
@@ -14,4 +14,4 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  // AUTOMATICALLY GENERATED FILE - DO NOT TOUCH
17
- export const version = '4.0.0-solana.32';
17
+ export const version = '4.0.0-solana.34';
@@ -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. */
@@ -13,4 +13,4 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- export declare const version = "4.0.0-solana.31";
16
+ export declare const version = "4.0.0-solana.33";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ar.io/sdk",
3
- "version": "4.0.0-solana.32",
3
+ "version": "4.0.0-solana.34",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ar-io/ar-io-sdk.git"