Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,64 @@ Versioning follows [Semantic Versioning](https://semver.org/).

---

## [5.0.0] — unreleased

`package.json` was bumped to 5.0.0 by `3704dfd` without a changelog section;
this collects that change and everything since.

### Fixed

- **`ACCOUNTS_NFT_BURN` / `ACCOUNTS_NFT_EMERGENCY_BURN`: the NFT holder is now
`[signer, writable]`, not `[signer]`.** The holder is the rent recipient for
every account those instructions close — the ATA, the mint, the PositionNft
PDA and the ExtraAccountMetaList — and percolator-nft rejects a read-only
holder outright via `require_writable_rent_recipient` (`processor.rs:825`,
`:1000`); its own ABI table documents account 0 as `[signer, writable]`
(`instruction.rs:44`, `:99`). Every burn built from these templates through
`buildNftAccountMetas` previously went on the wire with `isWritable: false`
and was rejected with `InvalidAccountData`. This was a live break against the
deployed programs. (dcccrypto/percolator-sdk#376)

### Breaking

- **`fix(stake)!`** (`3704dfd`): an ambiguous network now throws instead of
defaulting to mainnet.

### Changed

- **`ACCOUNTS_NFT_RECONCILE` 7 → 9 accounts**, mirroring
dcccrypto/percolator-nft#183: `extra_metas` (writable) at index 7 and the
Token-2022 program at index 8, with the NFT mint at index 1 becoming writable
because the program now closes it. Recovers 7,676,880 lamports per NFT that
Reconcile previously abandoned unrecoverably.

**Ahead of chain, but forward-compatible.** The currently deployed programs
(`FqhKJT9gtScjrmfUuRMjeg7cXNpif1fqsy5Jh65tJmTS` mainnet,
`CNGBPZRALk9Xu8BdgWNyrLJ7daQ9eJYFf1GnEEC7YCU3` devnet) pull seven accounts off
an iterator, never check `accounts.len()`, and never check
`nft_mint.is_writable` — so a nine-account call behaves identically on them.
The rent reclamation this documents only takes effect once percolator-nft#183
is deployed. Callers building the instruction by hand must pass nine keys:
`buildNftAccountMetas` throws `account count mismatch: expected 9, got 7`.

### Added

- **`PositionNftState.lastHolder`.** Bytes `[167..199]` were documented as
`_reserved`; #138 claimed them for `last_holder`, the field the transfer hook
rewrites on every transfer. It is the sole authorisation for
`ReconcileBurnedNft` — the program releases the escrowed portfolio and all
rent to whichever account matches it — and it is account 6 of
`ACCOUNTS_NFT_RECONCILE`, which cannot be derived. Without this the SDK
shipped the Reconcile account template but no way to obtain the one key in it.

- Account-list drift tests (`test/drift-check.test.ts`) that round-trip each
`ACCOUNTS_NFT_*` template through `buildNftAccountMetas` and assert the
resulting `{isSigner, isWritable}` booleans. The previous tests asserted the
shorthand string codes, at which level the holder defect above was invisible.
Nothing in this repo consumes these templates, so they had no coverage at all.

---

## [4.3.0] — 2026-07-24

Creator fee claim: the read side (`creatorFeeClaimableAtoms`) and the write side
Expand Down
53 changes: 45 additions & 8 deletions dist/abi/nft.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@
* - GetPositionValue (tag 3)
* - ExecuteTransferHook (tag 4, SPL interface — not called directly)
* - EmergencyBurn (tag 5)
* - RepairExtraMetas (tag 6)
* - ReconcileBurnedNft (tag 7)
*
* PDA seeds (matches percolator-nft/src/state_v16.rs):
* PositionNft state : ["position_nft", portfolio_account, asset_index_u16_LE]
* PositionNft state : ["position_nft", portfolio_account, market_id_u64_LE]
* Mint authority : ["mint_authority"]
*
* NOTE: the PositionNft seed is keyed on `market_id`, NOT `asset_index` — see
* #108 and `deriveNftPda` below. This header claimed `asset_index_u16_LE` until
* 2026-08-31; the code was always correct.
*/
import { PublicKey } from "@solana/web3.js";
/**
Expand Down Expand Up @@ -103,7 +109,8 @@ export declare const ACCOUNTS_NFT_MINT: AccountMeta[];
/**
* Account metas for BurnPositionNft (tag 1). 10 accounts.
*
* 0. [signer] NFT holder
* 0. [signer, writable] NFT holder (rent recipient — receives the ATA, mint,
* PositionNft PDA and ExtraAccountMetaList rent)
* 1. [writable] PositionNft PDA (closed)
* 2. [writable] NFT mint (supply → 0)
* 3. [writable] Holder's NFT ATA (closed)
Expand All @@ -121,7 +128,7 @@ export declare const ACCOUNTS_NFT_BURN: AccountMeta[];
/**
* Account metas for EmergencyBurn (tag 5). 10 accounts.
*
* 0. [signer] NFT holder
* 0. [signer, writable] NFT holder (rent recipient)
* 1. [writable] PositionNft PDA (closed)
* 2. [writable] NFT mint
* 3. [writable] Holder's NFT ATA
Expand All @@ -134,15 +141,30 @@ export declare const ACCOUNTS_NFT_BURN: AccountMeta[];
*/
export declare const ACCOUNTS_NFT_EMERGENCY_BURN: AccountMeta[];
/**
* Account metas for ReconcileBurnedNft (tag 7, #138). 7 accounts. Permissionless.
* Account metas for ReconcileBurnedNft (tag 7, #138). 9 accounts. Permissionless.
*
* 0. [writable] PositionNft PDA (closed)
* 1. [] NFT mint (Token-2022 — supply must be 0)
* 1. [writable] NFT mint (Token-2022 — supply must be 0; closed, #182)
* 2. [writable] Portfolio account (escrow released to the last holder)
* 3. [] Mint authority PDA (unwrap CPI signer)
* 3. [] Mint authority PDA (unwrap + mint-close CPI signer)
* 4. [] Per-market NftRegistry PDA
* 5. [] Percolator wrapper program (unwrap CPI target)
* 6. [writable] Recorded last-holder wallet (escrow + PDA-rent recipient)
* 6. [writable] Recorded last-holder wallet (escrow + all rent recipient)
* 7. [writable] ExtraAccountMetaList PDA (closed, #182)
* 8. [] Token-2022 program (mint-close CPI target, #182)
*
* dcccrypto/percolator-nft#182: Reconcile previously abandoned the NFT mint and
* the ExtraAccountMetaList PDA — 7,676,880 lamports per NFT, unrecoverable,
* because it closes the PositionNft PDA and every path that could later reclaim
* those two requires it to still be live. Accounts 7 and 8 are REQUIRED rather
* than optional: Reconcile is permissionless, irreversible and runs at most
* once, so an opt-in could be defeated permanently by whoever called first.
*
* Forward-compatible with the currently deployed programs: their handler pulls
* seven accounts off an iterator and never checks `accounts.len()`, so the two
* extra metas are simply unread, and it never checks `nft_mint.is_writable`.
* A nine-account call therefore behaves identically on both, which is why this
* can ship ahead of the program change rather than behind it.
*/
export declare const ACCOUNTS_NFT_RECONCILE: AccountMeta[];
/**
Expand Down Expand Up @@ -189,7 +211,13 @@ export declare function deriveExtraAccountMetas(nftMint: PublicKey, programId?:
* [119..127] epoch_snap_at_mint u64
* [127..159] position_owner_at_mint [u8; 32]
* [159..167] minted_at i64
* [167..199] _reserved
* [167..199] last_holder [u8; 32]
*
* NOTE: [167..199] is `last_holder`, not reserved space. #138 claimed those
* bytes for the field the transfer hook rewrites on every transfer, and
* `ReconcileBurnedNft` reads it to decide who receives the released escrow and
* the rent — it is account 6 of that instruction and cannot be derived, only
* read from here.
*/
export declare const POSITION_NFT_STATE_LEN = 199;
export interface PositionNftState {
Expand All @@ -207,6 +235,15 @@ export interface PositionNftState {
/** Backward-compatible alias for positionOwnerAtMint. */
positionOwner: PublicKey;
mintedAt: bigint;
/**
* The wallet the transfer hook last recorded as holding this NFT (#138).
*
* This is the sole authorisation for `ReconcileBurnedNft`: the program
* releases the escrowed portfolio and all rent to whichever account matches
* it, and refuses any other. Supply it as account 6 of
* `ACCOUNTS_NFT_RECONCILE` — there is no way to derive it.
*/
lastHolder: PublicKey;
}
/**
* Parse a PositionNft account from raw bytes.
Expand Down
13 changes: 8 additions & 5 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

60 changes: 49 additions & 11 deletions src/abi/nft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@
* - GetPositionValue (tag 3)
* - ExecuteTransferHook (tag 4, SPL interface — not called directly)
* - EmergencyBurn (tag 5)
* - RepairExtraMetas (tag 6)
* - ReconcileBurnedNft (tag 7)
*
* PDA seeds (matches percolator-nft/src/state_v16.rs):
* PositionNft state : ["position_nft", portfolio_account, asset_index_u16_LE]
* PositionNft state : ["position_nft", portfolio_account, market_id_u64_LE]
* Mint authority : ["mint_authority"]
*
* NOTE: the PositionNft seed is keyed on `market_id`, NOT `asset_index` — see
* #108 and `deriveNftPda` below. This header claimed `asset_index_u16_LE` until
* 2026-08-31; the code was always correct.
*/

import { PublicKey } from "@solana/web3.js";
Expand Down Expand Up @@ -177,7 +183,8 @@ export const ACCOUNTS_NFT_MINT: AccountMeta[] = [
/**
* Account metas for BurnPositionNft (tag 1). 10 accounts.
*
* 0. [signer] NFT holder
* 0. [signer, writable] NFT holder (rent recipient — receives the ATA, mint,
* PositionNft PDA and ExtraAccountMetaList rent)
* 1. [writable] PositionNft PDA (closed)
* 2. [writable] NFT mint (supply → 0)
* 3. [writable] Holder's NFT ATA (closed)
Expand All @@ -192,13 +199,13 @@ export const ACCOUNTS_NFT_MINT: AccountMeta[] = [
* release the escrow back to the holder, so #4 must be writable and #8/#9 are required.
*/
export const ACCOUNTS_NFT_BURN: AccountMeta[] = [
"s", "w", "w", "w", "w", "r", "r", "w", "r", "r",
"sw", "w", "w", "w", "w", "r", "r", "w", "r", "r",
];

/**
* Account metas for EmergencyBurn (tag 5). 10 accounts.
*
* 0. [signer] NFT holder
* 0. [signer, writable] NFT holder (rent recipient)
* 1. [writable] PositionNft PDA (closed)
* 2. [writable] NFT mint
* 3. [writable] Holder's NFT ATA
Expand All @@ -210,22 +217,37 @@ export const ACCOUNTS_NFT_BURN: AccountMeta[] = [
* 9. [] Percolator wrapper program (#105 — unwrap CPI target)
*/
export const ACCOUNTS_NFT_EMERGENCY_BURN: AccountMeta[] = [
"s", "w", "w", "w", "w", "r", "r", "w", "r", "r",
"sw", "w", "w", "w", "w", "r", "r", "w", "r", "r",
];

/**
* Account metas for ReconcileBurnedNft (tag 7, #138). 7 accounts. Permissionless.
* Account metas for ReconcileBurnedNft (tag 7, #138). 9 accounts. Permissionless.
*
* 0. [writable] PositionNft PDA (closed)
* 1. [] NFT mint (Token-2022 — supply must be 0)
* 1. [writable] NFT mint (Token-2022 — supply must be 0; closed, #182)
* 2. [writable] Portfolio account (escrow released to the last holder)
* 3. [] Mint authority PDA (unwrap CPI signer)
* 3. [] Mint authority PDA (unwrap + mint-close CPI signer)
* 4. [] Per-market NftRegistry PDA
* 5. [] Percolator wrapper program (unwrap CPI target)
* 6. [writable] Recorded last-holder wallet (escrow + PDA-rent recipient)
* 6. [writable] Recorded last-holder wallet (escrow + all rent recipient)
* 7. [writable] ExtraAccountMetaList PDA (closed, #182)
* 8. [] Token-2022 program (mint-close CPI target, #182)
*
* dcccrypto/percolator-nft#182: Reconcile previously abandoned the NFT mint and
* the ExtraAccountMetaList PDA — 7,676,880 lamports per NFT, unrecoverable,
* because it closes the PositionNft PDA and every path that could later reclaim
* those two requires it to still be live. Accounts 7 and 8 are REQUIRED rather
* than optional: Reconcile is permissionless, irreversible and runs at most
* once, so an opt-in could be defeated permanently by whoever called first.
*
* Forward-compatible with the currently deployed programs: their handler pulls
* seven accounts off an iterator and never checks `accounts.len()`, so the two
* extra metas are simply unread, and it never checks `nft_mint.is_writable`.
* A nine-account call therefore behaves identically on both, which is why this
* can ship ahead of the program change rather than behind it.
*/
export const ACCOUNTS_NFT_RECONCILE: AccountMeta[] = [
"w", "r", "w", "r", "r", "r", "w",
"w", "w", "w", "r", "r", "r", "w", "w", "r",
];

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -340,7 +362,13 @@ export function deriveExtraAccountMetas(
* [119..127] epoch_snap_at_mint u64
* [127..159] position_owner_at_mint [u8; 32]
* [159..167] minted_at i64
* [167..199] _reserved
* [167..199] last_holder [u8; 32]
*
* NOTE: [167..199] is `last_holder`, not reserved space. #138 claimed those
* bytes for the field the transfer hook rewrites on every transfer, and
* `ReconcileBurnedNft` reads it to decide who receives the released escrow and
* the rent — it is account 6 of that instruction and cannot be derived, only
* read from here.
*/
export const POSITION_NFT_STATE_LEN = 199;
const POSITION_NFT_MAGIC = 0x5045_5243_4e46_5400n;
Expand All @@ -361,6 +389,15 @@ export interface PositionNftState {
/** Backward-compatible alias for positionOwnerAtMint. */
positionOwner: PublicKey;
mintedAt: bigint;
/**
* The wallet the transfer hook last recorded as holding this NFT (#138).
*
* This is the sole authorisation for `ReconcileBurnedNft`: the program
* releases the escrowed portfolio and all rent to whichever account matches
* it, and refuses any other. Supply it as account 6 of
* `ACCOUNTS_NFT_RECONCILE` — there is no way to derive it.
*/
lastHolder: PublicKey;
}

/**
Expand Down Expand Up @@ -428,5 +465,6 @@ export function parsePositionNftAccount(data: Uint8Array): PositionNftState {
positionOwnerAtMint,
positionOwner: positionOwnerAtMint,
mintedAt: view.getBigInt64(159, true),
lastHolder: new PublicKey(data.subarray(167, 199)),
};
}
Loading