@soltracer/nft-staking 0.2.1 → 0.2.3

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/INTEGRATION.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # @soltracer/nft-staking
2
2
 
3
- SDK client for the solTracer NFT Staking program. Supports Legacy, pNFT, Core, and cNFT staking with escrow or wallet-lock modes, primary and secondary rewards, trait bonuses, lock tiers, and permanent burn mechanics.
3
+ SDK client for the solTracer NFT Staking program. Supports Legacy, pNFT, Core, and cNFT staking with escrow or
4
+ wallet-lock modes, primary and secondary rewards, trait bonuses, lock tiers, and permanent burn mechanics.
4
5
 
5
- The SDK auto-resolves pool state (rewardMint, stakingMode, collectionMint) and all PDA/ATA derivations internally — callers only need to provide the minimum required parameters.
6
+ The SDK auto-resolves pool state (rewardMint, stakingMode, collectionMint) and all PDA/ATA derivations internally —
7
+ callers only need to provide the minimum required parameters.
8
+
9
+ Staking wallets must have a user-management profile before staking, claiming, unstaking, or burning. The SDK derives the
10
+ profile PDA automatically, but the profile account must already exist. Banned profiles cannot create new stakes; they
11
+ may still claim or exit positions, with accrual capped at the profile ban timestamp.
6
12
 
7
13
  ## Installation
8
14
 
@@ -13,46 +19,47 @@ npm install @soltracer/nft-staking @soltracer/core @coral-xyz/anchor @solana/web
13
19
  ## Quick Start
14
20
 
15
21
  ```ts
16
- import { NftStakingClient } from "@soltracer/nft-staking";
17
- import { AnchorProvider } from "@coral-xyz/anchor";
22
+ import { NftStakingClient } from "@soltracer/nft-staking"
23
+ import { AnchorProvider } from "@coral-xyz/anchor"
18
24
 
19
- const provider = AnchorProvider.env();
20
- const client = NftStakingClient.create(provider, { projectId: 1 });
25
+ const provider = AnchorProvider.env()
26
+ const client = NftStakingClient.create(provider, { projectId: 1 })
21
27
 
22
28
  // Fetch a staking pool
23
- const pool = await client.fetchStakePool(undefined, poolId);
29
+ const pool = await client.fetchStakePool(undefined, poolId)
24
30
 
25
31
  // Stake an NFT (stakingMode auto-resolved from pool)
26
- const ix = await client.stakeNft(poolId, nftMint);
32
+ const ix = await client.stakeNft(poolId, nftMint)
27
33
 
28
34
  // Unstake (rewardMint + stakingMode auto-resolved)
29
- const ix = await client.unstakeNft(poolId, nftMint);
35
+ const ix = await client.unstakeNft(poolId, nftMint)
30
36
 
31
37
  // Claim rewards (rewardMint auto-resolved)
32
- const ix = await client.claimRewards(poolId);
38
+ const ix = await client.claimRewards(poolId)
33
39
  ```
34
40
 
35
41
  ## Client Setup
36
42
 
37
43
  ```ts
38
44
  // With project ID bound (recommended — eliminates projectId from every call)
39
- const client = NftStakingClient.create(provider, { projectId: 1 });
45
+ const client = NftStakingClient.create(provider, { projectId: 1 })
40
46
 
41
47
  // Without project ID (must pass it per-call, or set later)
42
- const client = NftStakingClient.create(provider);
43
- client.setProjectId(1); // set later, chainable
48
+ const client = NftStakingClient.create(provider)
49
+ client.setProjectId(1) // set later, chainable
44
50
 
45
51
  // From custom IDL
46
- const client = NftStakingClient.fromIdl(customIdl, provider, { projectId: 1 });
52
+ const client = NftStakingClient.fromIdl(customIdl, provider, { projectId: 1 })
47
53
  ```
48
54
 
49
55
  ## Fee Parameters
50
56
 
51
- Optional referral account for fee distribution. Fee PDA accounts (feeConfig, treasury, priceFeed) are resolved automatically.
57
+ Optional referral account for fee distribution. Fee PDA accounts (feeConfig, treasury, priceFeed) are resolved
58
+ automatically.
52
59
 
53
60
  ```ts
54
61
  interface FeeParams {
55
- referralAccount?: string; // base58 pubkey
62
+ referralAccount?: string // base58 pubkey
56
63
  }
57
64
  ```
58
65
 
@@ -61,34 +68,53 @@ interface FeeParams {
61
68
  ### Pool & Config
62
69
 
63
70
  ```ts
64
- const config = await client.fetchStakeConfig(); // uses bound projectId
65
- const config = await client.fetchStakeConfig(2); // explicit projectId
66
- const pool = await client.fetchStakePool(undefined, poolId);
67
- const pools = await client.fetchAllPools();
71
+ const config = await client.fetchStakeConfig() // uses bound projectId
72
+ const config = await client.fetchStakeConfig(2) // explicit projectId
73
+ const pool = await client.fetchStakePool(undefined, poolId)
74
+ const pools = await client.fetchAllPools()
68
75
  ```
69
76
 
70
77
  ### Stake Entries
71
78
 
72
79
  ```ts
73
- const entry = await client.fetchStakeEntry(poolId, nftMint);
74
- const entries = await client.fetchStakeEntriesByOwner(poolId, owner);
75
- const batch = await client.fetchStakeEntriesForMints(poolId, [mint1, mint2]);
76
- const all = await client.fetchAllStakeEntriesByPool(poolId);
77
- const cross = await client.fetchStakeEntriesAcrossPools([poolId1, poolId2], [mint1, mint2]);
80
+ const entry = await client.fetchStakeEntry(poolId, nftMint)
81
+ const entries = await client.fetchStakeEntriesByOwner(poolId, owner)
82
+ const batch = await client.fetchStakeEntriesForMints(poolId, [mint1, mint2])
83
+ const all = await client.fetchAllStakeEntriesByPool(poolId)
84
+ const cross = await client.fetchStakeEntriesAcrossPools([poolId1, poolId2], [mint1, mint2])
78
85
  ```
79
86
 
80
87
  ### Staker Accounts
81
88
 
82
89
  ```ts
83
- const staker = await client.fetchStakerAccount(poolId, wallet);
84
- const stakers = await client.fetchAllStakersByPool(poolId);
90
+ const staker = await client.fetchStakerAccount(poolId, wallet)
91
+ const stakers = await client.fetchAllStakersByPool(poolId)
92
+
93
+ const summary = await client.fetchStakerRewardSummary(poolId, wallet, {
94
+ rewardDecimals: 6, // optional: avoids a mint-account decimals fetch
95
+ secondaryRewardDecimals: { [secondaryMint.toBase58()]: 6 },
96
+ includeVaultBalances: true,
97
+ })
85
98
  ```
86
99
 
100
+ `fetchStakerRewardSummary()` returns display-ready raw and UI amounts for primary and secondary rewards:
101
+
102
+ - `primary.totalAccrued`, `primary.claimable`, `primary.unclaimable`.
103
+ - `secondary[i].totalAccrued`, `secondary[i].claimable`, `secondary[i].unclaimable`.
104
+ - `claimableBase` / `unclaimableBase` before quantity bonuses.
105
+ - `vaultBalance` when `includeVaultBalances` is true.
106
+ - `entries.claimableActive` and `entries.unclaimableActive` for explaining how many NFTs are currently claimable versus
107
+ claim-at-end locked.
108
+
109
+ Raw amounts are base units. UI amounts are divided by the returned `decimals`. The SDK caches mint decimals per client
110
+ instance, and callers can pass `rewardDecimals` / `secondaryRewardDecimals` from project metadata or a token registry to
111
+ avoid recurring mint-account RPC reads.
112
+
87
113
  ### Secondary Rewards
88
114
 
89
115
  ```ts
90
- const poolSecondary = await client.fetchPoolSecondaryRewards(poolId);
91
- const stakerSecondary = await client.fetchStakerSecondaryRewards(poolId, wallet);
116
+ const poolSecondary = await client.fetchPoolSecondaryRewards(poolId)
117
+ const stakerSecondary = await client.fetchStakerSecondaryRewards(poolId, wallet)
92
118
  ```
93
119
 
94
120
  ## Admin Instructions
@@ -96,7 +122,7 @@ const stakerSecondary = await client.fetchStakerSecondaryRewards(poolId, wallet)
96
122
  ### Initialize & Create Pools
97
123
 
98
124
  ```ts
99
- const ix = await client.initializeStakeConfig();
125
+ const ix = await client.initializeStakeConfig()
100
126
 
101
127
  const ix = await client.createStakePool(
102
128
  0, // stakingMode: 0 = Escrow, 1 = WalletLock
@@ -108,12 +134,15 @@ const ix = await client.createStakePool(
108
134
  traitBonusMode: 0,
109
135
  quantityThresholds: [],
110
136
  },
111
- [{ lockDuration: 0, rewardRate: 100, earlyUnstakePenaltyBps: 0 }],
137
+ [{ lockDuration: 30 * 86400, rewardRate: 150, earlyUnstakePenaltyBps: 500, claimOnlyAtEnd: false }],
112
138
  collectionMint,
113
- { rewardEndAt: 0, maxStaked: 0 } // optional
114
- );
139
+ { rewardEndAt: 0, maxStaked: 0, allowUnlockedStaking: true }, // optional
140
+ )
115
141
  ```
116
142
 
143
+ Lock tier reward rates override the base rate for NFTs staked into that tier. Set `allowUnlockedStaking: false` for
144
+ pools where every stake must choose a lock tier.
145
+
117
146
  ### Update Pool
118
147
 
119
148
  ```ts
@@ -134,57 +163,68 @@ const ix = await client.updateStakePool(poolId, {
134
163
  RewardMint is auto-resolved from pool state:
135
164
 
136
165
  ```ts
137
- const fund = await client.fundRewardVault(poolId, amount);
138
- const withdraw = await client.withdrawRewardVault(poolId, amount);
166
+ const fund = await client.fundRewardVault(poolId, amount)
167
+ const withdraw = await client.withdrawRewardVault(poolId, amount)
139
168
 
140
169
  // Or explicit rewardMint if needed:
141
- const fund = await client.fundRewardVault(poolId, amount, { rewardMint: mintPk });
170
+ const fund = await client.fundRewardVault(poolId, amount, { rewardMint: mintPk })
142
171
  ```
143
172
 
144
173
  ### Close Pool
145
174
 
146
175
  ```ts
147
- const ix = await client.closeStakePool(poolId);
176
+ const ix = await client.closeStakePool(poolId)
148
177
  ```
149
178
 
150
179
  ### Secondary Rewards Management
151
180
 
152
181
  ```ts
153
- const add = await client.addPoolSecondaryReward(poolId, rewardMint, baseRate, lockTierRates);
154
- const remove = await client.removePoolSecondaryReward(poolId, rewardIndex);
155
- const fund = await client.fundSecondaryVault(poolId, rewardIndex, amount, rewardMint);
156
- const withdraw = await client.withdrawSecondaryVault(poolId, rewardIndex, amount, rewardMint);
182
+ const add = await client.addPoolSecondaryReward(poolId, rewardMint, baseRate, lockTierRates)
183
+ const remove = await client.removePoolSecondaryReward(poolId, rewardIndex)
184
+ const fund = await client.fundSecondaryVault(poolId, rewardIndex, amount, rewardMint)
185
+ const withdraw = await client.withdrawSecondaryVault(poolId, rewardIndex, amount, rewardMint)
157
186
  ```
158
187
 
159
188
  ## User Instructions
160
189
 
161
190
  ### Staking Legacy/pNFT
162
191
 
163
- StakingMode is auto-resolved from pool:
192
+ StakingMode is auto-resolved from pool. Programmable-NFT token-record PDAs (`tokenRecord` + `destinationTokenRecord`)
193
+ are auto-detected on-chain by probing the source token-record account — legacy NFTs pass `null` for both, pNFTs pass the
194
+ derived PDAs. No client flag required.
164
195
 
165
196
  ```ts
166
- const stake = await client.stakeNft(poolId, nftMint, { lockTierIndex: 0, fee, gateProof });
167
- const unstake = await client.unstakeNft(poolId, nftMint, { fee });
197
+ const stake = await client.stakeNft(poolId, nftMint, { lockTierIndex: 0, fee, gateProof })
198
+ const unstake = await client.unstakeNft(poolId, nftMint, { fee })
168
199
  ```
169
200
 
201
+ An NFT can only be actively staked once across all pools. The program maintains a global asset lock PDA for each staked
202
+ mint/asset ID and closes it when the asset is unstaked or burned.
203
+
170
204
  ### Staking Core NFTs
171
205
 
172
206
  Collection is auto-resolved from pool:
173
207
 
174
208
  ```ts
175
- const stake = await client.stakeCoreNft(poolId, nftAsset, { lockTierIndex: 0, fee });
176
- const unstake = await client.unstakeCoreNft(poolId, nftAsset, { fee });
209
+ const stake = await client.stakeCoreNft(poolId, nftAsset, { lockTierIndex: 0, fee })
210
+ const unstake = await client.unstakeCoreNft(poolId, nftAsset, { fee })
177
211
  ```
178
212
 
179
213
  ### Staking cNFTs
180
214
 
181
215
  ```ts
182
216
  const cnftParams = {
183
- nftAssetId, merkleTree, cnftRoot, cnftDataHash,
184
- cnftCreatorHash, cnftNonce, cnftIndex, proofNodes,
185
- };
186
- const stake = await client.stakeCnft(poolId, cnftParams, { fee, gateProof });
187
- const unstake = await client.unstakeCnft(poolId, cnftParams, { fee });
217
+ nftAssetId,
218
+ merkleTree,
219
+ cnftRoot,
220
+ cnftDataHash,
221
+ cnftCreatorHash,
222
+ cnftNonce,
223
+ cnftIndex,
224
+ proofNodes,
225
+ }
226
+ const stake = await client.stakeCnft(poolId, cnftParams, { fee, gateProof })
227
+ const unstake = await client.unstakeCnft(poolId, cnftParams, { fee })
188
228
  ```
189
229
 
190
230
  ### Claiming Rewards
@@ -192,53 +232,152 @@ const unstake = await client.unstakeCnft(poolId, cnftParams, { fee });
192
232
  RewardMint is auto-resolved from pool:
193
233
 
194
234
  ```ts
195
- const claim = await client.claimRewards(poolId, { traitBonusRate, fee });
235
+ const claim = await client.claimRewards(poolId, { traitBonusRate, fee })
196
236
  ```
197
237
 
238
+ Current on-chain behavior: if any active stake entry in the pool has a live `claimOnlyAtEnd` lock, primary and secondary
239
+ claim instructions are blocked until `claimLockedUntil`. Use `fetchStakerRewardSummary()` to show claimable versus
240
+ unclaimable buckets for the intended per-NFT UX. The summary identifies base/unlocked rewards separately from
241
+ claim-at-end locked rewards, but the current claim instruction does not yet support a partial claim of only the unlocked
242
+ bucket.
243
+
244
+ Early-unstake penalties apply on exit when configured.
245
+
246
+ ### Trait Bonus Claims
247
+
248
+ `RewardConfig.traitBonusMode` controls how a signed `traitBonusRate` is interpreted:
249
+
250
+ - `0` / `None`: trait bonus proofs are disabled; nonzero `traitBonusRate` is rejected.
251
+ - `1` / `FixedExtra`: `traitBonusRate` is a raw extra reward rate per interval, added temporarily for the claim accrual
252
+ window.
253
+ - `2` / `BaseMultiplier`: `traitBonusRate` is bonus basis points over the staker's current base effective rate. `10_000`
254
+ means +100%, so total accrual for that window is 2x base.
255
+
256
+ Trait proof messages bind the pool, wallet, signed rate, and the staker account's strictly-monotonic `totalClaimed`
257
+ counter (audit fix F-H2 — replaces the prior `lastUpdateAt` nonce to eliminate same-second replay). A new proof is
258
+ required after every successful claim because `totalClaimed` advances.
259
+
260
+ Use the SDK's `buildTraitProofMessage` helper to assemble the exact 80-byte payload the on-chain verifier rebuilds
261
+ (`pool ‖ wallet ‖ rate u64 LE ‖ totalClaimed u64 LE`):
262
+
263
+ ```ts
264
+ import nacl from "tweetnacl"
265
+ import { Ed25519Program, Transaction } from "@solana/web3.js"
266
+ import { buildTraitProofMessage } from "@soltracer/nft-staking"
267
+
268
+ const stakerAccount = await client.fetchStakerAccount(poolId, wallet)
269
+ const message = buildTraitProofMessage({
270
+ pool,
271
+ wallet,
272
+ traitBonusRate, // BN
273
+ totalClaimed: stakerAccount.totalClaimed,
274
+ })
275
+ const signature = nacl.sign.detached(message, traitAuthority.secretKey)
276
+ const ed25519Ix = Ed25519Program.createInstructionWithPublicKey({
277
+ publicKey: traitAuthority.publicKey.toBytes(),
278
+ message,
279
+ signature,
280
+ })
281
+ const claimIx = await client.claimRewards(poolId, { traitBonusRate, fee })
282
+ await provider.sendAndConfirm(new Transaction().add(ed25519Ix, claimIx))
283
+ ```
284
+
285
+ The Ed25519 verifier instruction MUST be in the same transaction as `claimRewards` and the signing pubkey MUST match
286
+ `pool.traitAuthority` (or the global trait authority if configured).
287
+
288
+ ### Merkle Gate Proofs
289
+
290
+ Pools with a non-zero `merkleRoot` enforce wallet- or mint-allowlist gating at stake time. The on-chain verifier uses
291
+ `sha256(leaf)` for leaves and `sha256(min(a,b) ‖ max(a,b))` for internal nodes (canonical ordering — proofs do not carry
292
+ position bits). Build the tree once when seeding the root via `updateStakePool`, then derive per-stake proofs:
293
+
294
+ ```ts
295
+ import {
296
+ GATE_TYPE_WALLET,
297
+ buildMerkleTree,
298
+ getMerkleProof,
299
+ proofToAnchorArg,
300
+ rootToAnchorArg,
301
+ } from "@soltracer/nft-staking"
302
+
303
+ // One-time: seed pool gate
304
+ const tree = buildMerkleTree(allowedWallets) // PublicKey[]
305
+ await client.updateStakePool(poolId, {
306
+ merkleRoot: rootToAnchorArg(tree.root),
307
+ gateType: GATE_TYPE_WALLET,
308
+ })
309
+
310
+ // Per-stake call
311
+ const gateProof = proofToAnchorArg(getMerkleProof(tree, staker))
312
+ await client.stakeNft(poolId, nftMint, { gateProof })
313
+ ```
314
+
315
+ For `GATE_TYPE_TOKEN_MINT` pools, pass the gated mints to `buildMerkleTree` and supply `getMerkleProof(tree, nftMint)`.
316
+
198
317
  ### Secondary Reward Claims
199
318
 
200
319
  ```ts
201
- const init = await client.initStakerSecondary(poolId);
202
- const claim = await client.claimSecondaryRewards(poolId, [{ mint: mintPk }], { fee });
320
+ const init = await client.initStakerSecondary(poolId)
321
+ const claim = await client.claimSecondaryRewards(poolId, [{ mint: mintPk }], { fee })
203
322
  ```
204
323
 
324
+ Secondary reward claims use the same profile and claim-lock checks as primary rewards. Quantity thresholds also apply to
325
+ secondary payouts.
326
+
327
+ All secondary mints claimed in one transaction must use the same token program. If a pool uses both SPL Token and
328
+ Token-2022 secondary mints, claim them in separate transactions.
329
+
330
+ ### Reward Exhaustion
331
+
332
+ Primary token claims are all-or-nothing: if the reward vault balance is below the computed payout after trait and
333
+ quantity bonuses, the claim fails with `InsufficientRewardBalance`. The program does not do partial primary claims.
334
+ Primary vault withdrawals are protected by `totalObligationPending`, but trait and quantity bonus surplus still needs
335
+ operator funding beyond base obligations.
336
+
337
+ Secondary token claims are also all-or-nothing for the supplied secondary set. Secondary rewards do not currently
338
+ maintain a pool-wide obligation mirror, so operators should keep secondary vaults funded above the displayed claimable
339
+ totals from `fetchStakerRewardSummary({ includeVaultBalances: true })` and avoid withdrawing below pending user-facing
340
+ liabilities.
341
+
205
342
  ### Burn Permanently-Locked Assets
206
343
 
207
344
  StakingMode/collection auto-resolved from pool:
208
345
 
209
346
  ```ts
210
- const burnNft = await client.burnStakedNft(poolId, nftMint, { fee });
211
- const burnCore = await client.burnStakedCoreNft(poolId, nftAsset, { fee });
347
+ const burnNft = await client.burnStakedNft(poolId, nftMint, { fee })
348
+ const burnCore = await client.burnStakedCoreNft(poolId, nftAsset, { fee })
212
349
  ```
213
350
 
214
351
  ### Spend Points
215
352
 
216
353
  ```ts
217
- const spend = await client.spendPoints(poolId, amount, { fee });
354
+ const spend = await client.spendPoints(poolId, amount, { fee })
218
355
  ```
219
356
 
220
357
  ## What the SDK Resolves Internally
221
358
 
222
- | Parameter | Auto-resolved from |
223
- |---|---|
224
- | `rewardMint` | Pool `rewardConfig.rewardMint` |
225
- | `stakingMode` | Pool `stakingMode` |
226
- | `collectionMint` | Pool `collectionMint` |
227
- | `projectId` | Client instance (set via `create()` or `setProjectId()`) |
228
- | Fee accounts | PDA derivation from program ID |
229
- | Token program | Mint account owner detection (SPL vs Token-2022) |
230
- | ATAs | Derived from wallet + mint + token program |
231
- | All PDAs | Anchor seed derivation |
232
- | Decimal normalization | Mint account data byte 44 |
359
+ | Parameter | Auto-resolved from |
360
+ | --------------------- | -------------------------------------------------------- |
361
+ | `rewardMint` | Pool `rewardConfig.rewardMint` |
362
+ | `stakingMode` | Pool `stakingMode` |
363
+ | `collectionMint` | Pool `collectionMint` |
364
+ | `projectId` | Client instance (set via `create()` or `setProjectId()`) |
365
+ | Fee accounts | PDA derivation from program ID |
366
+ | Token program | Mint account owner detection (SPL vs Token-2022) |
367
+ | ATAs | Derived from wallet + mint + token program |
368
+ | User profile | Derived from wallet via user-management PDA |
369
+ | Global stake lock | Derived from NFT mint/Core asset/cNFT asset ID |
370
+ | All PDAs | Anchor seed derivation |
371
+ | Decimal normalization | Mint account data byte 44 |
233
372
 
234
373
  Pool data is cached for 30s to minimize redundant RPC calls.
235
374
 
236
375
  ## Exports
237
376
 
238
- | Export | Type |
239
- |---|---|
240
- | `NftStakingClient` | Class |
241
- | `FeeParams` | TypeScript interface |
377
+ | Export | Type |
378
+ | ------------------------ | ------------------------------------------------ |
379
+ | `NftStakingClient` | Class |
380
+ | `FeeParams` | TypeScript interface |
242
381
  | `NFT_STAKING_PROGRAM_ID` | `PublicKey` (re-exported from `@soltracer/core`) |
243
- | `NftStakingIDLType` | IDL type |
244
- | `NftStakingIDL` | IDL JSON |
382
+ | `NftStakingIDLType` | IDL type |
383
+ | `NftStakingIDL` | IDL JSON |