@ar.io/sdk 4.0.0-solana.39 → 4.0.0-solana.40

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.
@@ -1212,6 +1212,36 @@ export class SolanaARIOReadable {
1212
1212
  }
1213
1213
  return { failureSummaries, reports };
1214
1214
  }
1215
+ /**
1216
+ * Observer pubkeys that have an OPEN Observation PDA for `epochIndex`. Lean —
1217
+ * reads only the Observation accounts (no gateway-registry decode like
1218
+ * {@link getObservations}). Used by the crank to close observations before
1219
+ * `close_epoch`, whose `observations_closed == observations_submitted`
1220
+ * precondition would otherwise wedge epoch progression.
1221
+ */
1222
+ async getEpochObservers(epochIndex) {
1223
+ const epochIndexBuf = Buffer.alloc(8);
1224
+ epochIndexBuf.writeBigUInt64LE(BigInt(epochIndex));
1225
+ const accounts = await this.getAccountsByDiscriminator(this.garProgram, OBSERVATION_DISCRIMINATOR, [
1226
+ {
1227
+ memcmp: {
1228
+ offset: 8n,
1229
+ bytes: bs58.encode(epochIndexBuf),
1230
+ encoding: 'base58',
1231
+ },
1232
+ },
1233
+ ]);
1234
+ const observers = [];
1235
+ for (const { data } of accounts) {
1236
+ try {
1237
+ observers.push(address(deserializeObservation(data).observer));
1238
+ }
1239
+ catch {
1240
+ // skip malformed
1241
+ }
1242
+ }
1243
+ return observers;
1244
+ }
1215
1245
  async getDistributions(epoch) {
1216
1246
  const epochIndex = await this.resolveEpochIndex(epoch);
1217
1247
  const epochData = await this.fetchEpoch(epochIndex);
@@ -233,6 +233,9 @@ export function encodeReportTxId(reportTxId) {
233
233
  * the per-tx account/1232-byte budget even when none share a gateway.
234
234
  */
235
235
  const MAX_COMPOUND_BATCH = 12;
236
+ /** Observation PDAs closed per tx before close_epoch (each ix carries Epoch +
237
+ * Observation + payer + system accounts — keep well under the tx account cap). */
238
+ const MAX_CLOSE_OBSERVATION_BATCH = 8;
236
239
  /** Demand-factor period length (seconds) — mirrors `PERIOD_LENGTH_SECONDS` in ario-arns. */
237
240
  const DEMAND_FACTOR_PERIOD_SECONDS = 86_400;
238
241
  /**
@@ -2758,12 +2761,47 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2758
2761
  };
2759
2762
  }
2760
2763
  // Close a fully-distributed epoch past retention (GAR-006).
2764
+ //
2765
+ // CRITICAL: this is cleanup of OLD epochs and must NEVER block creation of
2766
+ // NEW ones. `close_epoch` reverts with EpochObservationsNotClosed until the
2767
+ // epoch's Observation PDAs are closed (observations_closed ==
2768
+ // observations_submitted), so we close those FIRST. The whole branch is
2769
+ // wrapped so any failure falls through to create-next instead of throwing
2770
+ // out of crankEpochStep — otherwise a single un-closeable epoch wedges epoch
2771
+ // progression network-wide (every operator's crank hits the same wall).
2761
2772
  if (enableClose && targetEpochIndex >= retention) {
2762
2773
  const closeTarget = targetEpochIndex - retention;
2763
- const old = await this.getEpochRaw(closeTarget);
2764
- if (old && old.rewardsDistributed === 1) {
2765
- const { id } = await this.closeEpoch({ epochIndex: closeTarget });
2766
- return { action: 'close', epochIndex: closeTarget, txId: id };
2774
+ try {
2775
+ const old = await this.getEpochRaw(closeTarget);
2776
+ if (old && old.rewardsDistributed === 1) {
2777
+ if (old.observationsSubmitted > old.observationsClosed) {
2778
+ // Open observations remain — close a batch before close_epoch.
2779
+ const observers = await this.getEpochObservers(closeTarget);
2780
+ if (observers.length > 0) {
2781
+ const batch = observers.slice(0, MAX_CLOSE_OBSERVATION_BATCH);
2782
+ const { id } = await this.closeObservations({
2783
+ epochIndex: closeTarget,
2784
+ observers: batch,
2785
+ });
2786
+ return {
2787
+ action: 'close_observation',
2788
+ epochIndex: closeTarget,
2789
+ txId: id,
2790
+ progress: { index: batch.length, total: observers.length },
2791
+ };
2792
+ }
2793
+ // Counter says open but no Observation PDA exists (orphaned counter):
2794
+ // can't close it, so don't attempt close_epoch (it would revert) and
2795
+ // don't wedge — fall through to create-next.
2796
+ }
2797
+ else {
2798
+ const { id } = await this.closeEpoch({ epochIndex: closeTarget });
2799
+ return { action: 'close', epochIndex: closeTarget, txId: id };
2800
+ }
2801
+ }
2802
+ }
2803
+ catch {
2804
+ // Best-effort cleanup — never block epoch creation. Retried next tick.
2767
2805
  }
2768
2806
  }
2769
2807
  // Lazy-state maintenance — lower urgency than the lifecycle, reached only
@@ -2908,10 +2946,13 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2908
2946
  * [8 per_obs][8 reward_rate][8 weight_lo][8 weight_hi][32 hashchain]
2909
2947
  * [4 active_gw_count][4 dist_idx][4 tally_idx]
2910
2948
  * [1 observer_count][1 name_count][1 obs_submitted][1 rewards_dist]
2911
- * [1 weights_tallied][1 prescriptions_done][1 bump][1 _pad1]
2949
+ * [1 weights_tallied][1 prescriptions_done][1 bump][1 obs_closed]
2912
2950
  * [6000 failure_counts][1600 prescribed_observers]
2913
2951
  * [1600 prescribed_observer_gateways][64 prescribed_names]
2914
2952
  * [7 has_observed][5 _pad2]
2953
+ *
2954
+ * NOTE: byte +123 is `observations_closed` (NOT padding) — `close_epoch`
2955
+ * reverts with EpochObservationsNotClosed until it equals obs_submitted.
2915
2956
  */
2916
2957
  fetchEpochRawFields(data) {
2917
2958
  const base = 8;
@@ -2919,15 +2960,19 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
2919
2960
  const activeGatewayCount = data.readUInt32LE(base + 104);
2920
2961
  const distributionIndex = data.readUInt32LE(base + 108);
2921
2962
  const tallyIndex = data.readUInt32LE(base + 112);
2963
+ const observationsSubmitted = data.readUInt8(base + 118);
2922
2964
  const rewardsDistributed = data.readUInt8(base + 119);
2923
2965
  const weightsTallied = data.readUInt8(base + 120);
2924
2966
  const prescriptionsDone = data.readUInt8(base + 121);
2967
+ const observationsClosed = data.readUInt8(base + 123);
2925
2968
  return {
2926
2969
  tallyIndex,
2927
2970
  distributionIndex,
2928
2971
  weightsTallied,
2929
2972
  prescriptionsDone,
2930
2973
  rewardsDistributed,
2974
+ observationsSubmitted,
2975
+ observationsClosed,
2931
2976
  activeGatewayCount,
2932
2977
  endTimestamp,
2933
2978
  };
@@ -3091,6 +3136,29 @@ export class SolanaARIOWriteable extends SolanaARIOReadable {
3091
3136
  const sig = await this.sendTransaction([ix]);
3092
3137
  return { id: sig };
3093
3138
  }
3139
+ /**
3140
+ * Close multiple Observation PDAs for one epoch in a single tx (each
3141
+ * `close_observation` increments the parent Epoch's `observations_closed`).
3142
+ * Permissionless; rent is refunded to the payer. Used by the crank to satisfy
3143
+ * `close_epoch`'s `observations_closed == observations_submitted` precondition
3144
+ * before closing a retention-aged epoch. Keep the batch small — each ix carries
3145
+ * the Epoch + Observation + payer + system accounts.
3146
+ */
3147
+ async closeObservations(params, _options) {
3148
+ if (params.observers.length === 0) {
3149
+ throw new Error('closeObservations: observers must be non-empty');
3150
+ }
3151
+ const ixs = await Promise.all(params.observers.map(async (obs) => {
3152
+ const [observationPda] = await getObservationPDA(params.epochIndex, address(obs), this.garProgram);
3153
+ return getCloseObservationInstructionAsync({
3154
+ observation: observationPda,
3155
+ payer: this.signer,
3156
+ epochIndex: BigInt(params.epochIndex),
3157
+ }, { programAddress: this.garProgram });
3158
+ }));
3159
+ const sig = await this.sendTransaction(ixs);
3160
+ return { id: sig };
3161
+ }
3094
3162
  /**
3095
3163
  * Close an empty Delegation PDA (`amount == 0`) and refund rent to the
3096
3164
  * original delegator (NOT the caller — see GAR-016, prevents griefing).
@@ -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.39';
17
+ export const version = '4.0.0-solana.40';
@@ -253,6 +253,14 @@ export declare class SolanaARIOReadable {
253
253
  getPrescribedObservers(epoch?: EpochInput): Promise<WeightedObserver[]>;
254
254
  getPrescribedNames(epoch?: EpochInput): Promise<string[]>;
255
255
  getObservations(epoch?: EpochInput): Promise<EpochObservationData>;
256
+ /**
257
+ * Observer pubkeys that have an OPEN Observation PDA for `epochIndex`. Lean —
258
+ * reads only the Observation accounts (no gateway-registry decode like
259
+ * {@link getObservations}). Used by the crank to close observations before
260
+ * `close_epoch`, whose `observations_closed == observations_submitted`
261
+ * precondition would otherwise wedge epoch progression.
262
+ */
263
+ getEpochObservers(epochIndex: number): Promise<Address[]>;
256
264
  getDistributions(epoch?: EpochInput): Promise<EpochDistributionData>;
257
265
  getEligibleEpochRewards(epoch?: EpochInput, params?: PaginationParams<EligibleDistribution>): Promise<PaginationResult<EligibleDistribution>>;
258
266
  /**
@@ -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' | 'compound' | 'update_demand_factor' | 'prune_returned_names' | 'close' | 'idle';
125
+ export type CrankAction = 'create' | 'tally' | 'prescribe' | 'distribute' | 'compound' | 'update_demand_factor' | 'prune_returned_names' | 'close_observation' | 'close' | 'idle';
126
126
  /** Options for {@link SolanaARIOWriteable.crankEpochStep}. */
127
127
  export interface CrankEpochStepOptions {
128
128
  /** Gateways per tally/distribute batch. Default 30. */
@@ -776,6 +776,8 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
776
776
  weightsTallied: number;
777
777
  prescriptionsDone: number;
778
778
  rewardsDistributed: number;
779
+ observationsSubmitted: number;
780
+ observationsClosed: number;
779
781
  activeGatewayCount: number;
780
782
  endTimestamp: number;
781
783
  } | null>;
@@ -788,10 +790,13 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
788
790
  * [8 per_obs][8 reward_rate][8 weight_lo][8 weight_hi][32 hashchain]
789
791
  * [4 active_gw_count][4 dist_idx][4 tally_idx]
790
792
  * [1 observer_count][1 name_count][1 obs_submitted][1 rewards_dist]
791
- * [1 weights_tallied][1 prescriptions_done][1 bump][1 _pad1]
793
+ * [1 weights_tallied][1 prescriptions_done][1 bump][1 obs_closed]
792
794
  * [6000 failure_counts][1600 prescribed_observers]
793
795
  * [1600 prescribed_observer_gateways][64 prescribed_names]
794
796
  * [7 has_observed][5 _pad2]
797
+ *
798
+ * NOTE: byte +123 is `observations_closed` (NOT padding) — `close_epoch`
799
+ * reverts with EpochObservationsNotClosed until it equals obs_submitted.
795
800
  */
796
801
  private fetchEpochRawFields;
797
802
  /**
@@ -856,6 +861,18 @@ export declare class SolanaARIOWriteable extends SolanaARIOReadable {
856
861
  epochIndex: number;
857
862
  observer: string;
858
863
  }, _options?: WriteOptions): Promise<MessageResult>;
864
+ /**
865
+ * Close multiple Observation PDAs for one epoch in a single tx (each
866
+ * `close_observation` increments the parent Epoch's `observations_closed`).
867
+ * Permissionless; rent is refunded to the payer. Used by the crank to satisfy
868
+ * `close_epoch`'s `observations_closed == observations_submitted` precondition
869
+ * before closing a retention-aged epoch. Keep the batch small — each ix carries
870
+ * the Epoch + Observation + payer + system accounts.
871
+ */
872
+ closeObservations(params: {
873
+ epochIndex: number;
874
+ observers: string[];
875
+ }, _options?: WriteOptions): Promise<MessageResult>;
859
876
  /**
860
877
  * Close an empty Delegation PDA (`amount == 0`) and refund rent to the
861
878
  * original delegator (NOT the caller — see GAR-016, prevents griefing).
@@ -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.38";
16
+ export declare const version = "4.0.0-solana.39";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ar.io/sdk",
3
- "version": "4.0.0-solana.39",
3
+ "version": "4.0.0-solana.40",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ar-io/ar-io-sdk.git"