diff --git a/.github/.workspace-ignore b/.github/.workspace-ignore index 78234e002..8f8fadb2c 100644 --- a/.github/.workspace-ignore +++ b/.github/.workspace-ignore @@ -20,6 +20,7 @@ tokens/escrow/anchor/programs/escrow tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master tokens/merkle-tree-token-claimer/anchor/programs/merkle-tree-token-claimer tokens/nft-operations/anchor/programs/mint-nft +tokens/nft-staking/anchor/programs/nft-staking tokens/pda-mint-authority/anchor/programs/token-minter tokens/token-2022/basics/anchor/programs/basics tokens/token-2022/cpi-guard/anchor/programs/cpi-guard diff --git a/README.md b/README.md index afbd9cdca..b188cc29d 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,12 @@ Create an NFT collection, mint NFTs, and verify NFTs as part of a collection usi [anchor](./tokens/nft-operations/anchor) [pinocchio](./tokens/nft-operations/pinocchio) +### NFT staking + +[Stake an NFT to earn reward tokens over time, and claim those rewards without unstaking.](./tokens/nft-staking/README.md) The NFT is frozen in the owner's own wallet with a delegate authority rather than moved into a vault, and rewards accrue against a checkpoint so the same time can never be paid for twice. + +[anchor](./tokens/nft-staking/anchor) + ### Transferring Tokens [Create a token mint, mint tokens, and transfer tokens between accounts.](./tokens/transfer-tokens/README.md) diff --git a/tokens/nft-staking/README.md b/tokens/nft-staking/README.md new file mode 100644 index 000000000..15dbc518e --- /dev/null +++ b/tokens/nft-staking/README.md @@ -0,0 +1,123 @@ +# NFT Staking + +Stake an NFT to earn reward tokens over time, then claim those rewards without +having to unstake. + +The NFT never leaves the owner's wallet. Instead of transferring it into a +vault, the program is made the **delegate** of the owner's token account and +then **freezes** that account through Metaplex Token Metadata. The NFT stays +visible in the owner's wallet and in marketplace views, but cannot be +transferred or sold until it is unstaked. This is how NFT staking generally +works in production, and it is a different custody model from the vault used in +[escrow](../escrow). + +Note that this is not Solana's built-in staking. Native staking delegates SOL to +a validator through the Stake program and earns protocol inflation on an epoch +schedule. This is an application-level staking pool: the rewards are a token +this program mints under rules it sets itself, and none of it touches consensus. + +## What this example demonstrates + +- **Delegate-and-freeze custody** — `approve` to hand a PDA delegate authority, + then a `FreezeDelegatedAccount` CPI into Metaplex to immobilise the NFT in + place. Unstaking reverses it with `ThawDelegatedAccount` and `revoke`. +- **Checkpointed reward accrual** — paying out a balance that grows with time, + without ever paying for the same span twice. +- **PDA-signed CPIs** — the stake account signs the freeze and thaw; the config + PDA signs as the reward mint's authority. +- **Events** — `emit!` on every state change so indexers can follow a pool + without polling. +- **Verified collection gating** — only NFTs from a specific, _verified_ + collection can be staked. + +## Instructions + +| Instruction | What it does | +| ------------------- | ----------------------------------------------------------------------------------------- | +| `initialize_config` | Creates the pool and its reward mint, whose mint authority is the config PDA | +| `initialize_user` | Creates the caller's totals account for one pool | +| `stake` | Verifies collection membership, delegates the token account to a PDA, and freezes it | +| `claim` | Pays out rewards accrued since the last claim — callable while the NFT is still staked | +| `unstake` | Settles any outstanding rewards, thaws and un-delegates the NFT, closes the stake account | + +## Accounts + +| Account | Seeds | Holds | +| -------------- | ----------------------------- | -------------------------------------------------------------- | +| `StakeConfig` | `["config", admin]` | Collection, reward rate, stake cap, freeze period | +| `UserAccount` | `["user", config, user]` | Lifetime points earned, how many NFTs are currently staked | +| `StakeAccount` | `["stake", nft_mint, config]` | Owner, mint, `staked_at`, and the `last_claimed_at` checkpoint | + +`StakeAccount` doubles as the SPL delegate for the staked NFT's token account, +which is what lets the program freeze and thaw it. + +Pools are seeded by their admin rather than living at a single `["config"]` +address, so anyone can run one and no one can take the only slot. `UserAccount` +is scoped to its pool for the same reason — the stake cap and points total +belong to one pool, so hitting the cap in one does not lock a user out of another. +`initialize_config` also validates its own settings: `unstake` pays out before it thaws, +so a reward rate large enough to overflow `u64` would leave the NFT frozen with +no way to recover it. Those settings are rejected up front instead. + +## The part worth reading closely: paying for time, once + +Rewards here are a function of elapsed time rather than of a balance somebody +deposited. That makes the payout path the dangerous one, and it is worth being +explicit about why. + +`claim` computes what is owed as `(now - last_claimed_at) → whole days → points` +and then **advances `last_claimed_at` in the same instruction**. If it paid out +but forgot to move the checkpoint, the very next call would read the same span +again and pay for it a second time — draining the reward mint one repeated +transaction at a time. + +There is no lock to forget on Solana, and no reentrancy guard to add. The +defence is simply that settling and checkpointing happen together, on one +account, in one instruction. The whole of it lives in +[`instructions/shared.rs`](./anchor/programs/nft-staking/src/instructions/shared.rs). + +There is a second, quieter bug in the same few lines. Only whole days pay out, +so a claim at 1.5 days owes one day. If the checkpoint were then snapped to +`now`, that leftover half day would vanish — and anyone claiming every 23 hours +would earn nothing, forever. Advancing by exactly `full_days * SECONDS_PER_DAY` +keeps the remainder banked for next time. + +Both mistakes are covered by tests that fail if you reintroduce them: +_"Refuses to pay the same day twice"_ and _"Banks the part-day remainder instead +of forfeiting it"_. + +## One more trap: freezing an NFT you do not own + +Both `approve` and the Metaplex freeze succeed happily on a **zero-balance** +token account. Anyone can open an associated token account for any mint, so +without an explicit balance check a user could open an empty account for some +NFT in the collection, "stake" it, and farm rewards from an NFT they never +owned. + +```rust +require!(self.nft_token_account.amount == 1, StakeError::NftNotHeld); +``` + +Covered by _"Rejects staking an NFT the signer does not actually hold"_ — delete +that one line and the test suite reports the stake succeeding. + +## Building and testing + +```bash +pnpm install # also dumps Metaplex Token Metadata into tests/fixtures/ +anchor test +``` + +Tests run on [LiteSVM](https://github.com/LiteSVM/litesvm), which can move its +own clock — necessary here, since every interesting behaviour in a staking +program only shows up after time passes. The suite mints a real collection and +real NFTs against the actual Metaplex program loaded from a local fixture, so +the freeze and thaw paths are exercised for real rather than mocked. + +## Notes + +- `max_stake` caps how many NFTs one user may stake at once, per pool. +- `freeze_period_days` is a minimum staking duration; `unstake` rejects until it + has elapsed. Rewards still accrue and can be claimed during it. +- Reward token decimals are set when the pool is created, and points are scaled + by them at payout. diff --git a/tokens/nft-staking/anchor/.gitignore b/tokens/nft-staking/anchor/.gitignore new file mode 100644 index 000000000..2e0446b07 --- /dev/null +++ b/tokens/nft-staking/anchor/.gitignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn diff --git a/tokens/nft-staking/anchor/.mocharc.json b/tokens/nft-staking/anchor/.mocharc.json new file mode 100644 index 000000000..7068542ef --- /dev/null +++ b/tokens/nft-staking/anchor/.mocharc.json @@ -0,0 +1,4 @@ +{ + "extension": ["ts"], + "spec": "tests/**/*.ts" +} diff --git a/tokens/nft-staking/anchor/.prettierignore b/tokens/nft-staking/anchor/.prettierignore new file mode 100644 index 000000000..414258343 --- /dev/null +++ b/tokens/nft-staking/anchor/.prettierignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +node_modules +dist +build +test-ledger diff --git a/tokens/nft-staking/anchor/Anchor.toml b/tokens/nft-staking/anchor/Anchor.toml new file mode 100644 index 000000000..b09b4d05e --- /dev/null +++ b/tokens/nft-staking/anchor/Anchor.toml @@ -0,0 +1,24 @@ +[toolchain] +anchor_version = "1.0.2" +solana_version = "3.1.8" + +[features] +seeds = false +skip-lint = false + +[programs.localnet] +nft_staking = "gphHxoVFMHZXMfapWDGtCfQhX7cjmXYcFNn5mMnJTjJ" + +[programs.devnet] +nft_staking = "gphHxoVFMHZXMfapWDGtCfQhX7cjmXYcFNn5mMnJTjJ" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +# Staking is time-based, and litesvm is the only harness here that can warp its +# clock — so the whole lifecycle (accrual, claiming, the freeze period) is +# covered there. Metaplex Token Metadata comes from a local fixture +# (tests/fixtures/token_metadata.so), dumped by prepare.mjs. +test = "pnpm mocha --import=tsx -t 1000000 tests/litesvm.test.ts" diff --git a/tokens/nft-staking/anchor/Cargo.toml b/tokens/nft-staking/anchor/Cargo.toml new file mode 100644 index 000000000..14a951cee --- /dev/null +++ b/tokens/nft-staking/anchor/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 + +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tokens/nft-staking/anchor/package.json b/tokens/nft-staking/anchor/package.json new file mode 100644 index 000000000..7ae2e8118 --- /dev/null +++ b/tokens/nft-staking/anchor/package.json @@ -0,0 +1,32 @@ +{ + "type": "module", + "pnpm": { + "overrides": { + "litesvm": "^0.8.0" + } + }, + "scripts": { + "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check", + "postinstall": "zx prepare.mjs" + }, + "dependencies": { + "@anchor-lang/core": "1.0.0-rc.5", + "@solana/spl-token": "^0.4.14", + "@solana/web3.js": "^1.98.4" + }, + "license": "MIT", + "devDependencies": { + "@types/chai": "^5.2.3", + "@types/mocha": "^10.0.10", + "@types/node": "^26.1.0", + "anchor-litesvm": "^0.2.1", + "chai": "^6.2.2", + "litesvm": "^0.8.0", + "mocha": "^11.7.5", + "prettier": "^3.7.4", + "tsx": "^4.19.2", + "typescript": "^5.9.3", + "zx": "^8.1.4" + } +} diff --git a/tokens/nft-staking/anchor/pnpm-lock.yaml b/tokens/nft-staking/anchor/pnpm-lock.yaml new file mode 100644 index 000000000..fdbd6bee0 --- /dev/null +++ b/tokens/nft-staking/anchor/pnpm-lock.yaml @@ -0,0 +1,1830 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + litesvm: ^0.8.0 + +importers: + + .: + dependencies: + '@anchor-lang/core': + specifier: 1.0.0-rc.5 + version: 1.0.0-rc.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + '@solana/spl-token': + specifier: ^0.4.14 + version: 0.4.15(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6) + '@solana/web3.js': + specifier: ^1.98.4 + version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + devDependencies: + '@types/chai': + specifier: ^5.2.3 + version: 5.2.3 + '@types/mocha': + specifier: ^10.0.10 + version: 10.0.10 + '@types/node': + specifier: ^26.1.0 + version: 26.5.0 + anchor-litesvm: + specifier: ^0.2.1 + version: 0.2.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + chai: + specifier: ^6.2.2 + version: 6.2.2 + litesvm: + specifier: ^0.8.0 + version: 0.8.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + mocha: + specifier: ^11.7.5 + version: 11.8.0 + prettier: + specifier: ^3.7.4 + version: 3.9.6 + tsx: + specifier: ^4.19.2 + version: 4.23.13 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + zx: + specifier: ^8.1.4 + version: 8.8.5 + +packages: + + '@anchor-lang/borsh@1.2.0': + resolution: {integrity: sha512-qUC90JezAXyetwCqhLxkxh/r9ofRCd2D0fkEoEex7Vurw3pGDtf1r779v0cM4fsvOEIskK9nja/GkZOnlvJqYw==} + engines: {node: '>=10'} + peerDependencies: + '@solana/web3.js': ^1.69.1 + + '@anchor-lang/core@1.0.0-rc.5': + resolution: {integrity: sha512-4iPy4RiEFn6obzYY7zx8IaGAXz2fvJ0uCTF6agAcUBjGNZeypfEb4ZZh6TfLnJy78Lh06JeB7XGqKsaBCMEmQA==} + engines: {node: '>=17'} + + '@anchor-lang/errors@1.2.0': + resolution: {integrity: sha512-muEmHFs2UDrcmGUuj+IWiYW2/AazI3tMv6f6OT5KNwOuSLG4j9g4zv8Pqvo+Cj9F6deP4P2c6DWhSUBHdYL4+g==} + engines: {node: '>=10'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@coral-xyz/anchor-errors@0.31.1': + resolution: {integrity: sha512-NhNEku4F3zzUSBtrYz84FzYWm48+9OvmT1Hhnwr6GnPQry2dsEqH/ti/7ASjjpoFTWRnPXrjAIT1qM6Isop+LQ==} + engines: {node: '>=10'} + + '@coral-xyz/anchor@0.31.1': + resolution: {integrity: sha512-QUqpoEK+gi2S6nlYc2atgT2r41TT3caWr/cPUEL8n8Md9437trZ68STknq897b82p5mW0XrTBNOzRbmIRJtfsA==} + engines: {node: '>=17'} + + '@coral-xyz/borsh@0.31.1': + resolution: {integrity: sha512-9N8AU9F0ubriKfNE3g1WF0/4dtlGXoBN/hd1PvbNBamBNwRgHxH4P+o3Zt7rSEloW1HUs6LfZEchlx9fW7POYw==} + engines: {node: '>=10'} + peerDependencies: + '@solana/web3.js': ^1.69.0 + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@solana/buffer-layout-utils@0.3.0': + resolution: {integrity: sha512-MuQOCC1j0np1xH9yAv0ZWWfwvr7Bt7Sz4LId11Wi4wDdAmJ+lobE+vHg/mZmGcihF0BIkqVBNxGmlv8QE5DrtA==} + engines: {node: '>= 10'} + + '@solana/buffer-layout@4.0.1': + resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} + engines: {node: '>=5.10'} + + '@solana/codecs-core@2.0.0-rc.1': + resolution: {integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-core@2.3.0': + resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-data-structures@2.0.0-rc.1': + resolution: {integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-numbers@2.0.0-rc.1': + resolution: {integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-numbers@2.3.0': + resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-strings@2.0.0-rc.1': + resolution: {integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: '>=5' + + '@solana/codecs@2.0.0-rc.1': + resolution: {integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==} + peerDependencies: + typescript: '>=5' + + '@solana/errors@2.0.0-rc.1': + resolution: {integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==} + hasBin: true + peerDependencies: + typescript: '>=5' + + '@solana/errors@2.3.0': + resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.3.3' + + '@solana/options@2.0.0-rc.1': + resolution: {integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==} + peerDependencies: + typescript: '>=5' + + '@solana/spl-token-group@0.0.7': + resolution: {integrity: sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.3 + + '@solana/spl-token-metadata@0.1.6': + resolution: {integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.3 + + '@solana/spl-token@0.4.15': + resolution: {integrity: sha512-3Lof3mNov8NVQ3PalIWb1Jgr/TZ6lYM+/sexv2TLqdhNFVth2OfWmH3d7QucgMjSbokkjNiNlRr6I8Fd269uaw==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.5 + + '@solana/web3.js@1.98.4': + resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/mocha@10.0.10': + resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@26.5.0': + resolution: {integrity: sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/ws@7.4.7': + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + anchor-litesvm@0.2.1: + resolution: {integrity: sha512-WBFxuW982eXTZqxmoE0lewuPFpSipAd/MoyuRpPmYJ4bshU5eNM14XAsViQ2ARkIxeDP/W2BMoi9rRZlG/lzXw==} + engines: {node: '>= 20'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base-x@3.0.11: + resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bigint-buffer@1.1.5: + resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==} + engines: {node: '>= 10.0.0'} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bn.js@5.2.5: + resolution: {integrity: sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==} + + borsh@0.7.0: + resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + + buffer-layout@1.2.2: + resolution: {integrity: sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA==} + engines: {node: '>=4.5'} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} + engines: {node: '>=10'} + + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} + + fast-stable-stringify@1.0.0: + resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + + fastestsmallesttextencoderdecoder@1.0.22: + resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jayson@4.3.0: + resolution: {integrity: sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==} + engines: {node: '>=8'} + hasBin: true + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + litesvm-darwin-arm64@0.8.0: + resolution: {integrity: sha512-XYa0oOA7FVbMYKvIDbBznWUjkHQD8J/fn5kPMBSX4QWf4yfFda/+L4JHeSngx+dBCM0LyEyLeakVrnmdR1KYPQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + litesvm-darwin-x64@0.8.0: + resolution: {integrity: sha512-lrxU6VXEqY7MHHIskdjB88xM9OiGR2ZcXf+fMESnetCk6rVUM6Llfb386wsMiqvi1+DowJwShxluBsCutWo2Sw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + litesvm-linux-arm64-gnu@0.8.0: + resolution: {integrity: sha512-D0pdYTQkoibPCfza2x1urSjIpHdwHVagHs0fBMHpzxSEvXND2qqDR3jXYf6xcE0sAq0knXjQmGz7WzP/HhFeFw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + litesvm-linux-arm64-musl@0.8.0: + resolution: {integrity: sha512-g7vgYPJZC6cT1gaWA1M2SbZu+Sngs9FuxsybDSE1Mmelmaejlguf7X/ymUuhehaSjEY+QSi+ydWtwzgE95JL0w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + litesvm-linux-x64-gnu@0.8.0: + resolution: {integrity: sha512-nn599p+XuOJoQN2XTSaY4Yz1ZqYxLNUbvt30kImuRUs2ZlIv5FANzM+VUsZgSzWV0phW8m6stX3mpdVv0iIuFQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + litesvm-linux-x64-musl@0.8.0: + resolution: {integrity: sha512-UH4v1GM4hNOjEIzx/dBZQ5mxWGFOd85qs7IBou070dLjH0vdZMh4avQCQyc127+A7aRfdc8+wK7t4SEIn4EEgw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + litesvm@0.8.0: + resolution: {integrity: sha512-P0Ly11FSr1f77bKwaRf0VQQZYdsw6Oi3OLKBxgBN5iJcB7W7lTjFB1tnVcJqdn1NMz6upqU/04rZFCC/JfYBWg==} + engines: {node: '>= 20'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mocha@11.8.0: + resolution: {integrity: sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rpc-websockets@9.3.9: + resolution: {integrity: sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + superstruct@0.15.5: + resolution: {integrity: sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ==} + + superstruct@2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + text-encoding-utf-8@1.0.2: + resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + utf-8-validate@6.0.6: + resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} + engines: {node: '>=6.14.2'} + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + workerpool@9.3.4: + resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zx@8.8.5: + resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} + engines: {node: '>= 12.17.0'} + hasBin: true + +snapshots: + + '@anchor-lang/borsh@1.2.0(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))': + dependencies: + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bn.js: 5.2.5 + buffer-layout: 1.2.2 + + '@anchor-lang/core@1.0.0-rc.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': + dependencies: + '@anchor-lang/borsh': 1.2.0(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)) + '@anchor-lang/errors': 1.2.0 + '@noble/hashes': 1.8.0 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bn.js: 5.2.5 + bs58: 4.0.1 + buffer-layout: 1.2.2 + camelcase: 6.3.0 + cross-fetch: 3.2.0 + eventemitter3: 4.0.7 + pako: 2.2.0 + superstruct: 0.15.5 + toml: 3.0.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@anchor-lang/errors@1.2.0': {} + + '@babel/runtime@7.29.7': {} + + '@coral-xyz/anchor-errors@0.31.1': {} + + '@coral-xyz/anchor@0.31.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': + dependencies: + '@coral-xyz/anchor-errors': 0.31.1 + '@coral-xyz/borsh': 0.31.1(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)) + '@noble/hashes': 1.8.0 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bn.js: 5.2.5 + bs58: 4.0.1 + buffer-layout: 1.2.2 + camelcase: 6.3.0 + cross-fetch: 3.2.0 + eventemitter3: 4.0.7 + pako: 2.2.0 + superstruct: 0.15.5 + toml: 3.0.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@coral-xyz/borsh@0.31.1(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))': + dependencies: + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bn.js: 5.2.5 + buffer-layout: 1.2.2 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@iarna/toml@2.2.5': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@solana/buffer-layout-utils@0.3.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bigint-buffer: 1.1.5 + bignumber.js: 9.3.1 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@solana/buffer-layout@4.0.1': + dependencies: + buffer: 6.0.3 + + '@solana/codecs-core@2.0.0-rc.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-core@2.3.0(typescript@5.9.3)': + dependencies: + '@solana/errors': 2.3.0(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-data-structures@2.0.0-rc.1(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-numbers@2.0.0-rc.1(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-numbers@2.3.0(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.3.0(typescript@5.9.3) + '@solana/errors': 2.3.0(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-strings@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + fastestsmallesttextencoderdecoder: 1.0.22 + typescript: 5.9.3 + + '@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/errors@2.0.0-rc.1(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 12.1.0 + typescript: 5.9.3 + + '@solana/errors@2.3.0(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 5.9.3 + + '@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/spl-token-group@0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana/spl-token-metadata@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana/spl-token@0.4.15(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/buffer-layout-utils': 0.3.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + '@solana/spl-token-group': 0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/spl-token-metadata': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + buffer: 6.0.3 + transitivePeerDependencies: + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + + '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': + dependencies: + '@babel/runtime': 7.29.7 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@solana/buffer-layout': 4.0.1 + '@solana/codecs-numbers': 2.3.0(typescript@5.9.3) + agentkeepalive: 4.6.0 + bn.js: 5.2.5 + borsh: 0.7.0 + bs58: 4.0.1 + buffer: 6.0.3 + fast-stable-stringify: 1.0.0 + jayson: 4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + node-fetch: 2.7.0 + rpc-websockets: 9.3.9 + superstruct: 2.0.2 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.5.0 + + '@types/deep-eql@4.0.2': {} + + '@types/mocha@10.0.10': {} + + '@types/node@12.20.55': {} + + '@types/node@26.5.0': + dependencies: + undici-types: 8.9.0 + + '@types/uuid@10.0.0': {} + + '@types/ws@7.4.7': + dependencies: + '@types/node': 26.5.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.5.0 + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + anchor-litesvm@0.2.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6): + dependencies: + '@coral-xyz/anchor': 0.31.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + '@iarna/toml': 2.2.5 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + litesvm: 0.8.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + base-x@3.0.11: + dependencies: + safe-buffer: 5.2.1 + + base64-js@1.5.1: {} + + bigint-buffer@1.1.5: + dependencies: + bindings: 1.5.0 + + bignumber.js@9.3.1: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bn.js@5.2.5: {} + + borsh@0.7.0: + dependencies: + bn.js: 5.2.5 + bs58: 4.0.1 + text-encoding-utf-8: 1.0.2 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + browser-stdout@1.3.1: {} + + bs58@4.0.1: + dependencies: + base-x: 3.0.11 + + buffer-layout@1.2.2: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + optional: true + + camelcase@6.3.0: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@12.1.0: {} + + commander@14.0.3: {} + + commander@2.20.3: {} + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@4.0.0: {} + + delay@5.0.0: {} + + diff@7.0.0: {} + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + + eyes@0.1.8: {} + + fast-stable-stringify@1.0.0: {} + + fastestsmallesttextencoderdecoder@1.0.22: {} + + file-uri-to-path@1.0.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat@5.0.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + has-flag@4.0.0: {} + + he@1.2.0: {} + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + ieee754@1.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@2.1.0: {} + + is-unicode-supported@0.1.0: {} + + isexe@2.0.0: {} + + isomorphic-ws@4.0.1(ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + dependencies: + ws: 7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6) + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jayson@4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@types/connect': 3.4.38 + '@types/node': 12.20.55 + '@types/ws': 7.4.7 + commander: 2.20.3 + delay: 5.0.0 + es6-promisify: 5.0.0 + eyes: 0.1.8 + isomorphic-ws: 4.0.1(ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + json-stringify-safe: 5.0.1 + stream-json: 1.9.1 + uuid: 8.3.2 + ws: 7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-stringify-safe@5.0.1: {} + + litesvm-darwin-arm64@0.8.0: + optional: true + + litesvm-darwin-x64@0.8.0: + optional: true + + litesvm-linux-arm64-gnu@0.8.0: + optional: true + + litesvm-linux-arm64-musl@0.8.0: + optional: true + + litesvm-linux-x64-gnu@0.8.0: + optional: true + + litesvm-linux-x64-musl@0.8.0: + optional: true + + litesvm@0.8.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6): + dependencies: + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + fastestsmallesttextencoderdecoder: 1.0.22 + optionalDependencies: + litesvm-darwin-arm64: 0.8.0 + litesvm-darwin-x64: 0.8.0 + litesvm-linux-arm64-gnu: 0.8.0 + litesvm-linux-arm64-musl: 0.8.0 + litesvm-linux-x64-gnu: 0.8.0 + litesvm-linux-x64-musl: 0.8.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + lru-cache@10.4.3: {} + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minipass@7.1.3: {} + + mocha@11.8.0: + dependencies: + browser-stdout: 1.3.1 + chokidar: 4.0.3 + debug: 4.4.3(supports-color@8.1.1) + diff: 7.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 10.5.0 + he: 1.2.0 + is-path-inside: 3.0.3 + js-yaml: 4.3.2 + log-symbols: 4.1.0 + minimatch: 9.0.9 + ms: 2.1.3 + picocolors: 1.1.1 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 9.3.4 + yargs: 17.7.3 + yargs-parser: 21.1.1 + yargs-unparser: 2.0.0 + + ms@2.1.3: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: + optional: true + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + pako@2.2.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + prettier@3.9.6: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + readdirp@4.1.2: {} + + require-directory@2.1.1: {} + + rpc-websockets@9.3.9: + dependencies: + '@swc/helpers': 0.5.23 + '@types/uuid': 10.0.0 + '@types/ws': 8.18.1 + buffer: 6.0.3 + eventemitter3: 5.0.4 + uuid: 14.0.2 + ws: 8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + safe-buffer@5.2.1: {} + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-json-comments@3.1.1: {} + + superstruct@0.15.5: {} + + superstruct@2.0.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + text-encoding-utf-8@1.0.2: {} + + toml@3.0.0: {} + + tr46@0.0.3: {} + + tslib@2.8.1: {} + + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@8.9.0: {} + + utf-8-validate@6.0.6: + dependencies: + node-gyp-build: 4.8.4 + optional: true + + uuid@14.0.2: {} + + uuid@8.3.2: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + workerpool@9.3.4: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + ws@8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zx@8.8.5: {} diff --git a/tokens/nft-staking/anchor/prepare.mjs b/tokens/nft-staking/anchor/prepare.mjs new file mode 100644 index 000000000..cfa85603c --- /dev/null +++ b/tokens/nft-staking/anchor/prepare.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env zx + +import { mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { $ } from 'zx'; + +const programs = [ + { + id: 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s', + name: 'token_metadata.so', + }, +]; + +const outputDir = 'tests/fixtures'; + +// The cluster is passed per command rather than via `solana config set`, so +// installing this example never changes the machine's default cluster. +const cluster = 'https://api.mainnet-beta.solana.com'; + +for (const { id, name } of programs) { + const outputFile = join(outputDir, name); + + await mkdir(outputDir, { recursive: true }); + await rm(outputFile, { force: true }); + + // No try/catch: the tests load these fixtures unconditionally, so a failed + // dump has to fail the install rather than surface later as litesvm + // refusing to open a missing file. + await $`solana program dump ${id} ${outputFile} --url ${cluster}`; + console.log(`Program ${id} dumped to ${outputFile}`); +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/Cargo.toml b/tokens/nft-staking/anchor/programs/nft-staking/Cargo.toml new file mode 100644 index 000000000..9f68c1387 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "nft-staking" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "nft_staking" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = { version = "1.0.2", features = ["init-if-needed"] } +anchor-spl = { version = "1.0.2", features = ["metadata"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tokens/nft-staking/anchor/programs/nft-staking/Xargo.toml b/tokens/nft-staking/anchor/programs/nft-staking/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/constants.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/constants.rs new file mode 100644 index 000000000..94d3dfc32 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/constants.rs @@ -0,0 +1,12 @@ +pub const ANCHOR_DISCRIMINATOR: usize = 8; + +/// Rewards accrue per whole day staked. +pub const SECONDS_PER_DAY: i64 = 86_400; + +/// The most decimals a pool may give its reward mint, matching the SPL convention. +pub const MAX_REWARD_DECIMALS: u8 = 9; + +/// A pool must stay solvent in `u64` for at least this long. `unstake` pays out +/// before it thaws, so a reward rate that can overflow would strand the NFT +/// frozen with no way to recover it — the bound is enforced at config time. +pub const MAX_ACCRUAL_DAYS: u64 = 365 * 100; diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/error.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/error.rs new file mode 100644 index 000000000..3780983c4 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/error.rs @@ -0,0 +1,25 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum StakeError { + #[msg("This NFT does not belong to any collection")] + MissingCollection, + #[msg("This NFT's collection is not the one this pool accepts")] + InvalidCollection, + #[msg("This NFT's collection has not been verified by the collection authority")] + UnverifiedCollection, + #[msg("The token account does not hold the NFT being staked")] + NftNotHeld, + #[msg("This user has already staked the maximum number of NFTs")] + MaxStakeReached, + #[msg("This stake position belongs to another user")] + InvalidOwner, + #[msg("The freeze period has not elapsed yet")] + FreezePeriodNotPassed, + #[msg("No whole day has elapsed since the last claim")] + NothingToClaim, + #[msg("Arithmetic overflow")] + Overflow, + #[msg("Pool settings would let rewards overflow, or allow no stakes at all")] + InvalidConfig, +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/events.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/events.rs new file mode 100644 index 000000000..76a989e64 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/events.rs @@ -0,0 +1,28 @@ +use anchor_lang::prelude::*; + +/// Emitted by every state-changing instruction so indexers can follow a pool +/// from transaction logs instead of polling every stake account. +#[event] +pub struct NftStaked { + pub user: Pubkey, + pub mint: Pubkey, + pub staked_at: i64, +} + +#[event] +pub struct RewardsClaimed { + pub user: Pubkey, + pub mint: Pubkey, + pub points: u64, + /// The checkpoint after this claim: rewards are paid up to (not past) here. + pub claimed_through: i64, +} + +#[event] +pub struct NftUnstaked { + pub user: Pubkey, + pub mint: Pubkey, + /// Rewards settled by the unstake itself, on top of any earlier claims. + pub final_points: u64, + pub unstaked_at: i64, +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/claim.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/claim.rs new file mode 100644 index 000000000..5ee202c09 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/claim.rs @@ -0,0 +1,85 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token::{Mint, Token, TokenAccount}, +}; + +use crate::{ + instructions::shared::{mint_reward_tokens, settle_rewards}, + RewardsClaimed, StakeAccount, StakeConfig, StakeError, UserAccount, +}; + +#[derive(Accounts)] +pub struct Claim<'info> { + #[account(mut)] + pub user: Signer<'info>, + + pub nft_mint: Account<'info, Mint>, + + #[account( + seeds = [b"config", config.admin.as_ref()], + bump = config.bump, + )] + pub config: Account<'info, StakeConfig>, + + #[account( + mut, + seeds = [b"rewards", config.key().as_ref()], + bump = config.rewards_bump, + )] + pub rewards_mint: Account<'info, Mint>, + + #[account( + init_if_needed, + payer = user, + associated_token::mint = rewards_mint, + associated_token::authority = user, + )] + pub rewards_token_account: Account<'info, TokenAccount>, + + #[account( + mut, + seeds = [b"stake", nft_mint.key().as_ref(), config.key().as_ref()], + bump = stake_account.bump, + constraint = stake_account.owner == user.key() @ StakeError::InvalidOwner, + )] + pub stake_account: Account<'info, StakeAccount>, + + #[account( + mut, + seeds = [b"user", config.key().as_ref(), user.key().as_ref()], + bump = user_account.bump, + )] + pub user_account: Account<'info, UserAccount>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, +} + +impl<'info> Claim<'info> { + pub fn claim(&mut self) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let points_per_day = self.config.points_per_day; + + // Settling advances the checkpoint on `stake_account`. Calling this + // instruction twice in a row therefore pays out once and then finds + // nothing left to pay — the same seconds cannot be claimed again. + let points = settle_rewards(&mut self.stake_account, points_per_day, now)?; + require!(points > 0, StakeError::NothingToClaim); + + mint_reward_tokens(&self.config, &self.rewards_mint, &self.rewards_token_account, &self.token_program, points)?; + + self.user_account.points_earned = + self.user_account.points_earned.checked_add(points).ok_or(StakeError::Overflow)?; + + emit!(RewardsClaimed { + user: self.user.key(), + mint: self.nft_mint.key(), + points, + claimed_through: self.stake_account.last_claimed_at, + }); + + Ok(()) + } +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/initialize_config.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/initialize_config.rs new file mode 100644 index 000000000..3527822d4 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/initialize_config.rs @@ -0,0 +1,78 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{Mint, Token}; + +use crate::{StakeConfig, StakeError, ANCHOR_DISCRIMINATOR, MAX_ACCRUAL_DAYS, MAX_REWARD_DECIMALS}; + +#[derive(Accounts)] +#[instruction(points_per_day: u64, max_stake: u8, freeze_period_days: u32, reward_decimals: u8)] +pub struct InitializeConfig<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + /// The collection every staked NFT must belong to. + pub collection_mint: Account<'info, Mint>, + + /// Seeded by `admin`, so pools are per-operator. A single `["config"]` + /// address would be a land grab: whoever called first would own the only + /// pool the program can ever have. + #[account( + init, + payer = admin, + space = ANCHOR_DISCRIMINATOR + StakeConfig::INIT_SPACE, + seeds = [b"config", admin.key().as_ref()], + bump, + )] + pub config: Account<'info, StakeConfig>, + + /// The reward token. Its mint authority is the config PDA, so rewards can + /// only ever be minted by this program, from `claim` and `unstake`. + #[account( + init, + payer = admin, + seeds = [b"rewards", config.key().as_ref()], + bump, + mint::decimals = reward_decimals, + mint::authority = config, + )] + pub rewards_mint: Account<'info, Mint>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, +} + +impl<'info> InitializeConfig<'info> { + pub fn initialize_config( + &mut self, + points_per_day: u64, + max_stake: u8, + freeze_period_days: u32, + reward_decimals: u8, + bumps: &InitializeConfigBumps, + ) -> Result<()> { + require!(max_stake > 0, StakeError::InvalidConfig); + require!(reward_decimals <= MAX_REWARD_DECIMALS, StakeError::InvalidConfig); + + // `unstake` settles rewards before it thaws the NFT, so a rate that can + // overflow would leave the NFT frozen with no way out. Reject any pool + // whose payout cannot survive `MAX_ACCRUAL_DAYS` of accrual. + require!( + points_per_day + .checked_mul(10u64.pow(reward_decimals as u32)) + .and_then(|scaled| scaled.checked_mul(MAX_ACCRUAL_DAYS)) + .is_some(), + StakeError::InvalidConfig + ); + + self.config.set_inner(StakeConfig { + admin: self.admin.key(), + collection: self.collection_mint.key(), + points_per_day, + max_stake, + freeze_period_days, + rewards_bump: bumps.rewards_mint, + bump: bumps.config, + }); + + Ok(()) + } +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/initialize_user.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/initialize_user.rs new file mode 100644 index 000000000..bc6c93fc7 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/initialize_user.rs @@ -0,0 +1,37 @@ +use anchor_lang::prelude::*; + +use crate::{StakeConfig, UserAccount, ANCHOR_DISCRIMINATOR}; + +#[derive(Accounts)] +pub struct InitializeUser<'info> { + #[account(mut)] + pub user: Signer<'info>, + + #[account( + seeds = [b"config", config.admin.as_ref()], + bump = config.bump, + )] + pub config: Account<'info, StakeConfig>, + + /// Scoped to the pool as well as the user: the stake cap and the points + /// total belong to one pool, so a user hitting the cap in one must not + /// affect their standing in another. + #[account( + init, + payer = user, + space = ANCHOR_DISCRIMINATOR + UserAccount::INIT_SPACE, + seeds = [b"user", config.key().as_ref(), user.key().as_ref()], + bump, + )] + pub user_account: Account<'info, UserAccount>, + + pub system_program: Program<'info, System>, +} + +impl<'info> InitializeUser<'info> { + pub fn initialize_user(&mut self, bumps: &InitializeUserBumps) -> Result<()> { + self.user_account.set_inner(UserAccount { points_earned: 0, amount_staked: 0, bump: bumps.user_account }); + + Ok(()) + } +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/mod.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/mod.rs new file mode 100644 index 000000000..a3080e0ed --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/mod.rs @@ -0,0 +1,12 @@ +pub mod claim; +pub mod initialize_config; +pub mod initialize_user; +pub mod shared; +pub mod stake; +pub mod unstake; + +pub use claim::*; +pub use initialize_config::*; +pub use initialize_user::*; +pub use stake::*; +pub use unstake::*; diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/shared.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/shared.rs new file mode 100644 index 000000000..0b51c6c2c --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/shared.rs @@ -0,0 +1,70 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{mint_to, Mint, MintTo, Token, TokenAccount}; + +use crate::{StakeAccount, StakeConfig, StakeError, SECONDS_PER_DAY}; + +/// Settles the rewards accrued since the stake position's checkpoint, advancing +/// that checkpoint by exactly the span it pays for. +/// +/// Settling and checkpointing together is what stops the same seconds being +/// paid twice; advancing by whole days rather than to `now` is what keeps the +/// leftover part-day banked for the next claim. +/// +/// Zero means nothing has accrued yet: `claim` rejects it, `unstake` allows it. +pub fn settle_rewards(stake_account: &mut StakeAccount, points_per_day: u64, now: i64) -> Result { + let elapsed = now.checked_sub(stake_account.last_claimed_at).ok_or(StakeError::Overflow)?; + + // A validator clock can step backwards across a restart. + if elapsed <= 0 { + return Ok(0); + } + + let full_days = elapsed / SECONDS_PER_DAY; + if full_days == 0 { + return Ok(0); + } + + let points = (full_days as u64).checked_mul(points_per_day).ok_or(StakeError::Overflow)?; + let settled_span = full_days.checked_mul(SECONDS_PER_DAY).ok_or(StakeError::Overflow)?; + + stake_account.last_claimed_at = + stake_account.last_claimed_at.checked_add(settled_span).ok_or(StakeError::Overflow)?; + + Ok(points) +} + +/// Mints `points` reward tokens to `destination`, scaled by the reward mint's +/// decimals, with the config PDA signing as the mint authority. +pub fn mint_reward_tokens<'info>( + config: &Account<'info, StakeConfig>, + rewards_mint: &Account<'info, Mint>, + destination: &Account<'info, TokenAccount>, + token_program: &Program<'info, Token>, + points: u64, +) -> Result<()> { + if points == 0 { + return Ok(()); + } + + let amount = points + .checked_mul(10u64.checked_pow(rewards_mint.decimals as u32).ok_or(StakeError::Overflow)?) + .ok_or(StakeError::Overflow)?; + + // The config PDA is the reward mint's authority, so the program signs here. + let admin = config.admin; + let seeds = &[b"config".as_ref(), admin.as_ref(), &[config.bump]]; + let signer_seeds = &[&seeds[..]]; + + mint_to( + CpiContext::new_with_signer( + token_program.key(), + MintTo { + mint: rewards_mint.to_account_info(), + to: destination.to_account_info(), + authority: config.to_account_info(), + }, + signer_seeds, + ), + amount, + ) +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/stake.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/stake.rs new file mode 100644 index 000000000..20ba962d0 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/stake.rs @@ -0,0 +1,137 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + metadata::{freeze_delegated_account, FreezeDelegatedAccount, MasterEditionAccount, Metadata, MetadataAccount}, + token::{approve, Approve, Mint, Token, TokenAccount}, +}; + +use crate::{NftStaked, StakeAccount, StakeConfig, StakeError, UserAccount, ANCHOR_DISCRIMINATOR}; + +#[derive(Accounts)] +pub struct Stake<'info> { + #[account(mut)] + pub user: Signer<'info>, + + pub nft_mint: Account<'info, Mint>, + + /// The NFT stays here, in the owner's own token account, for the whole + /// stake. The program never takes custody of it — it takes *delegate + /// authority* over this account and freezes it in place, so the NFT is + /// immobilised while remaining visible in the owner's wallet. + #[account( + mut, + associated_token::mint = nft_mint, + associated_token::authority = user, + )] + pub nft_token_account: Account<'info, TokenAccount>, + + #[account( + seeds = [b"metadata", metadata_program.key().as_ref(), nft_mint.key().as_ref()], + seeds::program = metadata_program.key(), + bump, + )] + pub metadata: Account<'info, MetadataAccount>, + + /// Required by Metaplex to freeze: the master edition PDA is the NFT + /// mint's freeze authority, and Token Metadata signs as it on our behalf. + #[account( + seeds = [b"metadata", metadata_program.key().as_ref(), nft_mint.key().as_ref(), b"edition"], + seeds::program = metadata_program.key(), + bump, + )] + pub edition: Account<'info, MasterEditionAccount>, + + #[account( + seeds = [b"config", config.admin.as_ref()], + bump = config.bump, + )] + pub config: Account<'info, StakeConfig>, + + #[account( + init, + payer = user, + space = ANCHOR_DISCRIMINATOR + StakeAccount::INIT_SPACE, + seeds = [b"stake", nft_mint.key().as_ref(), config.key().as_ref()], + bump, + )] + pub stake_account: Account<'info, StakeAccount>, + + #[account( + mut, + seeds = [b"user", config.key().as_ref(), user.key().as_ref()], + bump = user_account.bump, + )] + pub user_account: Account<'info, UserAccount>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub metadata_program: Program<'info, Metadata>, +} + +impl<'info> Stake<'info> { + pub fn stake(&mut self, bumps: &StakeBumps) -> Result<()> { + // `approve` and the Metaplex freeze both succeed on a zero-balance + // account, so without this anyone could open an empty ATA for a + // collection NFT and farm rewards from an NFT they never owned. + require!(self.nft_token_account.amount == 1, StakeError::NftNotHeld); + + require!(self.user_account.amount_staked < self.config.max_stake, StakeError::MaxStakeReached); + + // `ok_or` rather than `unwrap`: an NFT with no collection is a caller + // mistake to report, not a panic. + let collection = self.metadata.collection.as_ref().ok_or(StakeError::MissingCollection)?; + require!(collection.verified, StakeError::UnverifiedCollection); + require_keys_eq!(collection.key, self.config.collection, StakeError::InvalidCollection); + + let now = Clock::get()?.unix_timestamp; + + self.stake_account.set_inner(StakeAccount { + owner: self.user.key(), + mint: self.nft_mint.key(), + staked_at: now, + // Accrual starts now, so the first claim can only ever pay for + // time after this instruction. + last_claimed_at: now, + bump: bumps.stake_account, + }); + + // Step 1: make this NFT's stake account the SPL delegate for the token + // account. The user signs this, because only the owner can delegate. + approve( + CpiContext::new( + self.token_program.key(), + Approve { + to: self.nft_token_account.to_account_info(), + delegate: self.stake_account.to_account_info(), + authority: self.user.to_account_info(), + }, + ), + 1, + )?; + + // Step 2: freeze the token account. Only the delegate may ask Metaplex + // to do this, and the delegate is a PDA — so the program signs as it. + let nft_mint_key = self.nft_mint.key(); + let config_key = self.config.key(); + let seeds = &[b"stake".as_ref(), nft_mint_key.as_ref(), config_key.as_ref(), &[bumps.stake_account]]; + let signer_seeds = &[&seeds[..]]; + + freeze_delegated_account(CpiContext::new_with_signer( + self.metadata_program.key(), + FreezeDelegatedAccount { + metadata: self.metadata.to_account_info(), + delegate: self.stake_account.to_account_info(), + token_account: self.nft_token_account.to_account_info(), + edition: self.edition.to_account_info(), + mint: self.nft_mint.to_account_info(), + token_program: self.token_program.to_account_info(), + }, + signer_seeds, + ))?; + + self.user_account.amount_staked = self.user_account.amount_staked.checked_add(1).ok_or(StakeError::Overflow)?; + + emit!(NftStaked { user: self.user.key(), mint: self.nft_mint.key(), staked_at: now }); + + Ok(()) + } +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/unstake.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/unstake.rs new file mode 100644 index 000000000..8c82a7025 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/instructions/unstake.rs @@ -0,0 +1,140 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + metadata::{thaw_delegated_account, MasterEditionAccount, Metadata, MetadataAccount, ThawDelegatedAccount}, + token::{revoke, Mint, Revoke, Token, TokenAccount}, +}; + +use crate::{ + instructions::shared::{mint_reward_tokens, settle_rewards}, + NftUnstaked, StakeAccount, StakeConfig, StakeError, UserAccount, SECONDS_PER_DAY, +}; + +#[derive(Accounts)] +pub struct Unstake<'info> { + #[account(mut)] + pub user: Signer<'info>, + + pub nft_mint: Box>, + + #[account( + mut, + associated_token::mint = nft_mint, + associated_token::authority = user, + )] + pub nft_token_account: Box>, + + // Metaplex's `Metadata` is a large struct, and this instruction carries a + // lot of accounts. Boxing the big ones keeps them on the heap instead of + // the (much smaller) instruction stack frame. + #[account( + seeds = [b"metadata", metadata_program.key().as_ref(), nft_mint.key().as_ref()], + seeds::program = metadata_program.key(), + bump, + )] + pub metadata: Box>, + + #[account( + seeds = [b"metadata", metadata_program.key().as_ref(), nft_mint.key().as_ref(), b"edition"], + seeds::program = metadata_program.key(), + bump, + )] + pub edition: Box>, + + #[account( + seeds = [b"config", config.admin.as_ref()], + bump = config.bump, + )] + pub config: Account<'info, StakeConfig>, + + #[account( + mut, + seeds = [b"rewards", config.key().as_ref()], + bump = config.rewards_bump, + )] + pub rewards_mint: Box>, + + #[account( + init_if_needed, + payer = user, + associated_token::mint = rewards_mint, + associated_token::authority = user, + )] + pub rewards_token_account: Box>, + + /// Closing this returns its rent to the user and, just as importantly, + /// makes the position un-claimable afterwards — there is no checkpoint + /// left to settle against. + #[account( + mut, + close = user, + seeds = [b"stake", nft_mint.key().as_ref(), config.key().as_ref()], + bump = stake_account.bump, + constraint = stake_account.owner == user.key() @ StakeError::InvalidOwner, + )] + pub stake_account: Account<'info, StakeAccount>, + + #[account( + mut, + seeds = [b"user", config.key().as_ref(), user.key().as_ref()], + bump = user_account.bump, + )] + pub user_account: Account<'info, UserAccount>, + + pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub metadata_program: Program<'info, Metadata>, +} + +impl<'info> Unstake<'info> { + pub fn unstake(&mut self) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + + let staked_for = now.checked_sub(self.stake_account.staked_at).ok_or(StakeError::Overflow)?; + let freeze_period = + (self.config.freeze_period_days as i64).checked_mul(SECONDS_PER_DAY).ok_or(StakeError::Overflow)?; + require!(staked_for >= freeze_period, StakeError::FreezePeriodNotPassed); + + // Pay out whatever is still owed before the position disappears, so + // unstaking never silently forfeits earned rewards. Unlike `claim`, + // zero is fine here — it just means nothing was outstanding. + let points_per_day = self.config.points_per_day; + let points = settle_rewards(&mut self.stake_account, points_per_day, now)?; + mint_reward_tokens(&self.config, &self.rewards_mint, &self.rewards_token_account, &self.token_program, points)?; + + self.user_account.points_earned = + self.user_account.points_earned.checked_add(points).ok_or(StakeError::Overflow)?; + + // Thaw before revoking: Metaplex requires the delegate to still be set + // and signing, and revoking first would strip exactly that authority. + let nft_mint_key = self.nft_mint.key(); + let config_key = self.config.key(); + let seeds = &[b"stake".as_ref(), nft_mint_key.as_ref(), config_key.as_ref(), &[self.stake_account.bump]]; + let signer_seeds = &[&seeds[..]]; + + thaw_delegated_account(CpiContext::new_with_signer( + self.metadata_program.key(), + ThawDelegatedAccount { + metadata: self.metadata.to_account_info(), + delegate: self.stake_account.to_account_info(), + token_account: self.nft_token_account.to_account_info(), + edition: self.edition.to_account_info(), + mint: self.nft_mint.to_account_info(), + token_program: self.token_program.to_account_info(), + }, + signer_seeds, + ))?; + + revoke(CpiContext::new( + self.token_program.key(), + Revoke { source: self.nft_token_account.to_account_info(), authority: self.user.to_account_info() }, + ))?; + + self.user_account.amount_staked = self.user_account.amount_staked.checked_sub(1).ok_or(StakeError::Overflow)?; + + emit!(NftUnstaked { user: self.user.key(), mint: self.nft_mint.key(), final_points: points, unstaked_at: now }); + + Ok(()) + } +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/lib.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/lib.rs new file mode 100644 index 000000000..99d54668c --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/lib.rs @@ -0,0 +1,60 @@ +pub mod constants; +pub mod error; +pub mod events; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use constants::*; +pub use error::*; +pub use events::*; +pub use instructions::*; +pub use state::*; + +declare_id!("gphHxoVFMHZXMfapWDGtCfQhX7cjmXYcFNn5mMnJTjJ"); + +#[program] +pub mod nft_staking { + use super::*; + + /// Creates the pool and its reward mint. The reward mint's authority is the + /// config PDA, so only this program can ever mint rewards. + pub fn initialize_config( + context: Context, + points_per_day: u64, + max_stake: u8, + freeze_period_days: u32, + reward_decimals: u8, + ) -> Result<()> { + context.accounts.initialize_config( + points_per_day, + max_stake, + freeze_period_days, + reward_decimals, + &context.bumps, + ) + } + + /// Creates the caller's per-user totals account. Called once per user. + pub fn initialize_user(context: Context) -> Result<()> { + context.accounts.initialize_user(&context.bumps) + } + + /// Stakes an NFT by delegating its token account to a PDA and freezing it + /// in place. The NFT never leaves the owner's wallet. + pub fn stake(context: Context) -> Result<()> { + context.accounts.stake(&context.bumps) + } + + /// Pays out the rewards accrued since the last claim, without unstaking. + pub fn claim(context: Context) -> Result<()> { + context.accounts.claim() + } + + /// Settles any outstanding rewards, thaws and un-delegates the NFT, and + /// closes the stake position. + pub fn unstake(context: Context) -> Result<()> { + context.accounts.unstake() + } +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/state/mod.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/state/mod.rs new file mode 100644 index 000000000..44ce482c0 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/state/mod.rs @@ -0,0 +1,7 @@ +pub mod stake_account; +pub mod stake_config; +pub mod user_account; + +pub use stake_account::*; +pub use stake_config::*; +pub use user_account::*; diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/state/stake_account.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/state/stake_account.rs new file mode 100644 index 000000000..f593d8af1 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/state/stake_account.rs @@ -0,0 +1,20 @@ +use anchor_lang::prelude::*; + +/// One staked NFT, at seeds `["stake", nft_mint, config]`. +/// +/// This account is also the SPL delegate for the staked NFT's token account, +/// which is what lets the program freeze and thaw it while it stays in the +/// owner's wallet. +#[account] +#[derive(InitSpace)] +pub struct StakeAccount { + pub owner: Pubkey, + pub mint: Pubkey, + pub staked_at: i64, + /// The accrual checkpoint: rewards have been paid out up to this instant. + /// + /// Every payout advances it by exactly the time it paid for, so the same + /// seconds can never be claimed twice. See `instructions::shared`. + pub last_claimed_at: i64, + pub bump: u8, +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/state/stake_config.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/state/stake_config.rs new file mode 100644 index 000000000..02693ddb9 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/state/stake_config.rs @@ -0,0 +1,18 @@ +use anchor_lang::prelude::*; + +/// Pool-wide settings. One per program, at seeds `["config"]`. +#[account] +#[derive(InitSpace)] +pub struct StakeConfig { + pub admin: Pubkey, + /// Only NFTs whose verified collection matches this mint may be staked. + pub collection: Pubkey, + /// Reward points earned per NFT, per whole day staked. + pub points_per_day: u64, + /// How many NFTs one user may stake at once. + pub max_stake: u8, + /// How long an NFT must stay staked before it can be unstaked. + pub freeze_period_days: u32, + pub rewards_bump: u8, + pub bump: u8, +} diff --git a/tokens/nft-staking/anchor/programs/nft-staking/src/state/user_account.rs b/tokens/nft-staking/anchor/programs/nft-staking/src/state/user_account.rs new file mode 100644 index 000000000..38aae37d7 --- /dev/null +++ b/tokens/nft-staking/anchor/programs/nft-staking/src/state/user_account.rs @@ -0,0 +1,17 @@ +use anchor_lang::prelude::*; + +/// Per-user totals within one pool, at seeds `["user", config, user]`. +/// Created once per pool and reused across every NFT that user stakes there. +#[account] +#[derive(InitSpace)] +pub struct UserAccount { + /// Lifetime points paid out to this user in this pool, across all their + /// stake positions in it. + /// A running total for display; the authoritative per-NFT accrual state + /// lives on each `StakeAccount`. + pub points_earned: u64, + /// How many NFTs this user currently has staked in this pool, checked + /// against `StakeConfig::max_stake`. + pub amount_staked: u8, + pub bump: u8, +} diff --git a/tokens/nft-staking/anchor/tests/litesvm.test.ts b/tokens/nft-staking/anchor/tests/litesvm.test.ts new file mode 100644 index 000000000..b4ace90bb --- /dev/null +++ b/tokens/nft-staking/anchor/tests/litesvm.test.ts @@ -0,0 +1,674 @@ +import * as anchor from '@anchor-lang/core'; +import { + AccountLayout, + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountInstruction, + createInitializeMint2Instruction, + createMintToInstruction, + getAssociatedTokenAddressSync, + MINT_SIZE, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token'; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; +import { LiteSVMProvider } from 'anchor-litesvm'; +import { assert } from 'chai'; +import { LiteSVM } from 'litesvm'; +import IDL from '../target/idl/nft_staking.json' with { type: 'json' }; +import type { NftStaking } from '../target/types/nft_staking.ts'; +import { + createMasterEditionV3, + createMetadataAccountV3, + masterEditionPda, + metadataPda, + TOKEN_METADATA_PROGRAM_ID, + verifyCollection, +} from './metaplex.ts'; +import { expectAnchorError } from './utils.ts'; + +const PROGRAM_ID = new PublicKey(IDL.address); + +const SECONDS_PER_DAY = 86_400n; + +// Pool settings used throughout. max_stake is 1 so the cap is easy to hit. +const POINTS_PER_DAY = 10; +const MAX_STAKE = 1; +const FREEZE_PERIOD_DAYS = 2; +const REWARD_DECIMALS = 6; +const ONE_DAY_OF_REWARDS = BigInt(POINTS_PER_DAY) * 10n ** BigInt(REWARD_DECIMALS); + +// SPL token account states. +const INITIALIZED = 1; +const FROZEN = 2; + +describe('nft-staking litesvm', () => { + const client = new LiteSVM(); + client.addProgramFromFile(PROGRAM_ID, 'target/deploy/nft_staking.so'); + client.addProgramFromFile(TOKEN_METADATA_PROGRAM_ID, 'tests/fixtures/token_metadata.so'); + const provider = new LiteSVMProvider(client); + anchor.setProvider(provider); + const wallet = provider.wallet as anchor.Wallet; + const program = new anchor.Program(IDL, provider); + + // The wallet is the pool admin and mints every NFT. `staker` is the person + // actually staking, so the tests exercise a real second signer. + const staker = Keypair.generate(); + const otherUser = Keypair.generate(); + const otherAdmin = Keypair.generate(); + + // Pools are seeded by their admin, so the derivation includes the wallet. + const config = PublicKey.findProgramAddressSync( + [Buffer.from('config'), wallet.publicKey.toBuffer()], + PROGRAM_ID, + )[0]; + const rewardsMint = PublicKey.findProgramAddressSync([Buffer.from('rewards'), config.toBuffer()], PROGRAM_ID)[0]; + const userPda = (poolConfig: PublicKey, owner: PublicKey) => + PublicKey.findProgramAddressSync([Buffer.from('user'), poolConfig.toBuffer(), owner.toBuffer()], PROGRAM_ID)[0]; + + const userAccount = userPda(config, staker.publicKey); + + // A second pool under a different admin, used to prove pools are isolated. + const otherConfig = PublicKey.findProgramAddressSync( + [Buffer.from('config'), otherAdmin.publicKey.toBuffer()], + PROGRAM_ID, + )[0]; + const otherRewardsMint = PublicKey.findProgramAddressSync( + [Buffer.from('rewards'), otherConfig.toBuffer()], + PROGRAM_ID, + )[0]; + + const stakePdaIn = (nftMint: PublicKey, poolConfig: PublicKey) => + PublicKey.findProgramAddressSync( + [Buffer.from('stake'), nftMint.toBuffer(), poolConfig.toBuffer()], + PROGRAM_ID, + )[0]; + + const stakePda = (nftMint: PublicKey) => stakePdaIn(nftMint, config); + + const tokenAccount = (address: PublicKey) => AccountLayout.decode(client.getAccount(address)!.data); + + const rewardsBalance = () => { + const ata = getAssociatedTokenAddressSync(rewardsMint, staker.publicKey); + const account = client.getAccount(ata); + return account === null ? 0n : AccountLayout.decode(account.data).amount; + }; + + /** Moves the validator clock forward and lets identical transactions resend. */ + const warpDays = (days: number) => { + const clock = client.getClock(); + clock.unixTimestamp += BigInt(Math.round(days * Number(SECONDS_PER_DAY))); + client.setClock(clock); + client.expireBlockhash(); + }; + + /** + * Mints a fresh NFT: mint account, one token to `owner`, metadata, and a + * master edition (which moves the mint's freeze authority to the edition + * PDA — the thing that makes freeze-in-place staking possible). + */ + const createNft = async ({ + owner, + collection, + verify = false, + }: { + owner: PublicKey; + collection?: PublicKey; + verify?: boolean; + }) => { + const mintKeypair = Keypair.generate(); + const mint = mintKeypair.publicKey; + const ata = getAssociatedTokenAddressSync(mint, owner); + const lamports = await provider.connection.getMinimumBalanceForRentExemption(MINT_SIZE); + + const tx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: mint, + space: MINT_SIZE, + lamports, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction(mint, 0, wallet.publicKey, wallet.publicKey), + createAssociatedTokenAccountInstruction(wallet.publicKey, ata, owner, mint), + // Supply must be exactly 1 before the master edition can be created. + createMintToInstruction(mint, ata, wallet.publicKey, 1), + createMetadataAccountV3({ + mint, + mintAuthority: wallet.publicKey, + payer: wallet.publicKey, + updateAuthority: wallet.publicKey, + name: 'Staking Test NFT', + symbol: 'STK', + uri: '', + collection, + }), + createMasterEditionV3({ + mint, + updateAuthority: wallet.publicKey, + mintAuthority: wallet.publicKey, + payer: wallet.publicKey, + }), + ); + + if (collection && verify) { + tx.add( + verifyCollection({ + mint, + collectionMint: collection, + collectionAuthority: wallet.publicKey, + payer: wallet.publicKey, + }), + ); + } + + await provider.sendAndConfirm(tx, [mintKeypair]); + return { mint, ata }; + }; + + const stakeAccounts = (nftMint: PublicKey, nftTokenAccount: PublicKey) => ({ + user: staker.publicKey, + nftMint, + nftTokenAccount, + metadata: metadataPda(nftMint), + edition: masterEditionPda(nftMint), + config, + stakeAccount: stakePda(nftMint), + userAccount, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + metadataProgram: TOKEN_METADATA_PROGRAM_ID, + }); + + const claimAccounts = (nftMint: PublicKey, user: Keypair, users: PublicKey) => ({ + user: user.publicKey, + nftMint, + config, + rewardsMint, + rewardsTokenAccount: getAssociatedTokenAddressSync(rewardsMint, user.publicKey), + stakeAccount: stakePda(nftMint), + userAccount: users, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + }); + + // NFTs used across the suite, filled in by the setup test. + let collectionMint: PublicKey; + let otherCollectionMint: PublicKey; + let nftA: { mint: PublicKey; ata: PublicKey }; + let nftB: { mint: PublicKey; ata: PublicKey }; + let nftC: { mint: PublicKey; ata: PublicKey }; + let nftNotHeld: { mint: PublicKey; ata: PublicKey }; + let nftNoCollection: { mint: PublicKey; ata: PublicKey }; + let nftUnverified: { mint: PublicKey; ata: PublicKey }; + let nftWrongCollection: { mint: PublicKey; ata: PublicKey }; + let stakedAt: bigint; + + it('Test preparation - mints a collection and the NFTs each case needs', async () => { + client.airdrop(staker.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); + client.airdrop(otherUser.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); + client.airdrop(otherAdmin.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); + + collectionMint = (await createNft({ owner: wallet.publicKey })).mint; + otherCollectionMint = (await createNft({ owner: wallet.publicKey })).mint; + + nftA = await createNft({ owner: staker.publicKey, collection: collectionMint, verify: true }); + nftB = await createNft({ owner: staker.publicKey, collection: collectionMint, verify: true }); + nftC = await createNft({ owner: staker.publicKey, collection: collectionMint, verify: true }); + nftNoCollection = await createNft({ owner: staker.publicKey }); + nftUnverified = await createNft({ owner: staker.publicKey, collection: collectionMint, verify: false }); + nftWrongCollection = await createNft({ + owner: staker.publicKey, + collection: otherCollectionMint, + verify: true, + }); + + // Held by the wallet, not the staker. The staker still gets an (empty) + // associated token account for it, which is the whole point of the + // "does not hold the NFT" case below. + const held = await createNft({ owner: wallet.publicKey, collection: collectionMint, verify: true }); + const emptyAta = getAssociatedTokenAddressSync(held.mint, staker.publicKey); + await provider.sendAndConfirm( + new Transaction().add( + createAssociatedTokenAccountInstruction(wallet.publicKey, emptyAta, staker.publicKey, held.mint), + ), + ); + nftNotHeld = { mint: held.mint, ata: emptyAta }; + + assert.strictEqual(tokenAccount(nftA.ata).amount, 1n, 'staker should hold nftA'); + assert.strictEqual(tokenAccount(nftNotHeld.ata).amount, 0n, 'staker should not hold nftNotHeld'); + }); + + it('Initializes the pool and its reward mint', async () => { + await program.methods + .initializeConfig(new anchor.BN(POINTS_PER_DAY), MAX_STAKE, FREEZE_PERIOD_DAYS, REWARD_DECIMALS) + .accountsPartial({ + admin: wallet.publicKey, + collectionMint, + config, + rewardsMint, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .rpc(); + + const configAccount = await program.account.stakeConfig.fetch(config); + assert.strictEqual(configAccount.collection.toBase58(), collectionMint.toBase58()); + assert.strictEqual(configAccount.pointsPerDay.toNumber(), POINTS_PER_DAY); + assert.strictEqual(configAccount.maxStake, MAX_STAKE); + assert.strictEqual(configAccount.freezePeriodDays, FREEZE_PERIOD_DAYS); + + // The reward mint must be controlled by the program, not the admin - + // otherwise the admin could mint rewards out of thin air. + const mintAccount = client.getAccount(rewardsMint)!; + assert.strictEqual(new PublicKey(mintAccount.data.subarray(4, 36)).toBase58(), config.toBase58()); + }); + + // A pool is seeded by its admin, so initializing one never blocks anyone + // else from running their own. + it('Lets a second admin run their own pool', async () => { + await program.methods + .initializeConfig(new anchor.BN(POINTS_PER_DAY), MAX_STAKE, FREEZE_PERIOD_DAYS, REWARD_DECIMALS) + .accountsPartial({ + admin: otherAdmin.publicKey, + collectionMint, + config: otherConfig, + rewardsMint: otherRewardsMint, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([otherAdmin]) + .rpc(); + + const account = await program.account.stakeConfig.fetch(otherConfig); + assert.strictEqual(account.admin.toBase58(), otherAdmin.publicKey.toBase58()); + }); + + // `unstake` settles rewards before it thaws, so a rate that can overflow + // would strand the NFT frozen. These have to be rejected up front. + it('Rejects pool settings that would strand a staked NFT', async () => { + const badAdmin = Keypair.generate(); + client.airdrop(badAdmin.publicKey, BigInt(10 * LAMPORTS_PER_SOL)); + + const badConfig = PublicKey.findProgramAddressSync( + [Buffer.from('config'), badAdmin.publicKey.toBuffer()], + PROGRAM_ID, + )[0]; + const badRewardsMint = PublicKey.findProgramAddressSync( + [Buffer.from('rewards'), badConfig.toBuffer()], + PROGRAM_ID, + )[0]; + + const initWith = (pointsPerDay: number | bigint, maxStake: number, decimals: number) => + program.methods + .initializeConfig(new anchor.BN(pointsPerDay.toString()), maxStake, FREEZE_PERIOD_DAYS, decimals) + .accountsPartial({ + admin: badAdmin.publicKey, + collectionMint, + config: badConfig, + rewardsMint: badRewardsMint, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([badAdmin]) + .rpc(); + + // A pool nobody can stake in. + await expectAnchorError(initWith(POINTS_PER_DAY, 0, REWARD_DECIMALS), 'InvalidConfig'); + + client.expireBlockhash(); + // More decimals than an SPL mint conventionally carries. + await expectAnchorError(initWith(POINTS_PER_DAY, MAX_STAKE, 20), 'InvalidConfig'); + + client.expireBlockhash(); + // Scaled payout that overflows u64 well inside the pool's lifetime. + await expectAnchorError(initWith(2n ** 60n, MAX_STAKE, REWARD_DECIMALS), 'InvalidConfig'); + + assert.isNull(client.getAccount(badConfig), 'no config should have been created'); + }); + + it('Initializes the user accounts', async () => { + await program.methods + .initializeUser() + .accountsPartial({ + user: staker.publicKey, + config, + userAccount, + systemProgram: SystemProgram.programId, + }) + .signers([staker]) + .rpc(); + + await program.methods + .initializeUser() + .accountsPartial({ + user: otherUser.publicKey, + config, + userAccount: userPda(config, otherUser.publicKey), + systemProgram: SystemProgram.programId, + }) + .signers([otherUser]) + .rpc(); + + const account = await program.account.userAccount.fetch(userAccount); + assert.strictEqual(account.amountStaked, 0); + assert.strictEqual(account.pointsEarned.toNumber(), 0); + }); + + // Both `approve` and the Metaplex freeze succeed on a zero-balance token + // account, so without an explicit balance check anyone could open an empty + // ATA for a collection NFT and farm rewards from an NFT they never owned. + it('Rejects staking an NFT the signer does not actually hold', async () => { + await expectAnchorError( + program.methods + .stake() + .accountsPartial(stakeAccounts(nftNotHeld.mint, nftNotHeld.ata)) + .signers([staker]) + .rpc(), + 'NftNotHeld', + ); + }); + + it('Rejects an NFT with no collection at all', async () => { + await expectAnchorError( + program.methods + .stake() + .accountsPartial(stakeAccounts(nftNoCollection.mint, nftNoCollection.ata)) + .signers([staker]) + .rpc(), + 'MissingCollection', + ); + }); + + it('Rejects an NFT whose collection is unverified', async () => { + await expectAnchorError( + program.methods + .stake() + .accountsPartial(stakeAccounts(nftUnverified.mint, nftUnverified.ata)) + .signers([staker]) + .rpc(), + 'UnverifiedCollection', + ); + }); + + it('Rejects an NFT from a different collection', async () => { + await expectAnchorError( + program.methods + .stake() + .accountsPartial(stakeAccounts(nftWrongCollection.mint, nftWrongCollection.ata)) + .signers([staker]) + .rpc(), + 'InvalidCollection', + ); + }); + + it('Stakes an NFT by freezing it in the owner wallet', async () => { + await program.methods.stake().accountsPartial(stakeAccounts(nftA.mint, nftA.ata)).signers([staker]).rpc(); + + const stake = await program.account.stakeAccount.fetch(stakePda(nftA.mint)); + stakedAt = BigInt(stake.stakedAt.toString()); + assert.strictEqual(stake.owner.toBase58(), staker.publicKey.toBase58()); + assert.strictEqual(stake.mint.toBase58(), nftA.mint.toBase58()); + assert.strictEqual( + stake.lastClaimedAt.toString(), + stake.stakedAt.toString(), + 'accrual must start at the moment of staking', + ); + + // The NFT is frozen but still owned by, and sitting in, the staker's + // own token account - the program never took custody of it. + const nft = tokenAccount(nftA.ata); + assert.strictEqual(nft.state, FROZEN, 'the NFT token account should be frozen'); + assert.strictEqual(nft.amount, 1n, 'the NFT should still be in the staker wallet'); + assert.strictEqual(nft.owner.toBase58(), staker.publicKey.toBase58()); + assert.strictEqual(nft.delegateOption, 1, 'the stake account should be the delegate'); + assert.strictEqual(nft.delegate.toBase58(), stakePda(nftA.mint).toBase58()); + + const account = await program.account.userAccount.fetch(userAccount); + assert.strictEqual(account.amountStaked, 1); + }); + + it('Enforces the per-user stake cap', async () => { + await expectAnchorError( + program.methods.stake().accountsPartial(stakeAccounts(nftB.mint, nftB.ata)).signers([staker]).rpc(), + 'MaxStakeReached', + ); + }); + + // The cap and the points total live on a per-pool account, so being maxed + // out in one pool must not affect another pool run by a different admin. + it('Enforces stake caps per pool, not globally', async () => { + const otherUserAccount = userPda(otherConfig, staker.publicKey); + + await program.methods + .initializeUser() + .accountsPartial({ + user: staker.publicKey, + config: otherConfig, + userAccount: otherUserAccount, + systemProgram: SystemProgram.programId, + }) + .signers([staker]) + .rpc(); + + // Already at max_stake in the first pool; this must still go through. + await program.methods + .stake() + .accountsPartial({ + user: staker.publicKey, + nftMint: nftC.mint, + nftTokenAccount: nftC.ata, + metadata: metadataPda(nftC.mint), + edition: masterEditionPda(nftC.mint), + config: otherConfig, + stakeAccount: stakePdaIn(nftC.mint, otherConfig), + userAccount: otherUserAccount, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + metadataProgram: TOKEN_METADATA_PROGRAM_ID, + }) + .signers([staker]) + .rpc(); + + assert.strictEqual(tokenAccount(nftC.ata).state, FROZEN, 'the second pool should have staked the NFT'); + assert.strictEqual( + (await program.account.userAccount.fetch(otherUserAccount)).amountStaked, + 1, + 'the second pool tracks its own count', + ); + assert.strictEqual( + (await program.account.userAccount.fetch(userAccount)).amountStaked, + 1, + 'the first pool is unaffected', + ); + }); + + it('Pays nothing before a whole day has passed', async () => { + await expectAnchorError( + program.methods + .claim() + .accountsPartial(claimAccounts(nftA.mint, staker, userAccount)) + .signers([staker]) + .rpc(), + 'NothingToClaim', + ); + assert.strictEqual(rewardsBalance(), 0n, 'a rejected claim must not mint anything'); + }); + + it('Pays one day of rewards after one day', async () => { + warpDays(1); + + await program.methods + .claim() + .accountsPartial(claimAccounts(nftA.mint, staker, userAccount)) + .signers([staker]) + .rpc(); + + assert.strictEqual(rewardsBalance(), ONE_DAY_OF_REWARDS, 'one day staked should pay one day of rewards'); + + const stake = await program.account.stakeAccount.fetch(stakePda(nftA.mint)); + assert.strictEqual( + BigInt(stake.lastClaimedAt.toString()), + stakedAt + SECONDS_PER_DAY, + 'the checkpoint should advance by exactly the day that was paid for', + ); + + const account = await program.account.userAccount.fetch(userAccount); + assert.strictEqual(account.pointsEarned.toNumber(), POINTS_PER_DAY); + }); + + // The core hazard of any accrual-over-time program: if a payout read the + // elapsed time but failed to record that it had paid for it, this second + // call would pay for the same day again. + it('Refuses to pay the same day twice', async () => { + const balanceBefore = rewardsBalance(); + client.expireBlockhash(); + + await expectAnchorError( + program.methods + .claim() + .accountsPartial(claimAccounts(nftA.mint, staker, userAccount)) + .signers([staker]) + .rpc(), + 'NothingToClaim', + ); + + assert.strictEqual(rewardsBalance(), balanceBefore, 'a repeated claim must not mint a second payout'); + }); + + it("Rejects a claim against another user's stake position", async () => { + const otherUserAccount = userPda(config, otherUser.publicKey); + + await expectAnchorError( + program.methods + .claim() + .accountsPartial(claimAccounts(nftA.mint, otherUser, otherUserAccount)) + .signers([otherUser]) + .rpc(), + 'InvalidOwner', + ); + }); + + it('Refuses to unstake before the freeze period has elapsed', async () => { + // One day in, against a two-day freeze period. + await expectAnchorError( + program.methods + .unstake() + .accountsPartial({ + user: staker.publicKey, + nftMint: nftA.mint, + nftTokenAccount: nftA.ata, + metadata: metadataPda(nftA.mint), + edition: masterEditionPda(nftA.mint), + config, + rewardsMint, + rewardsTokenAccount: getAssociatedTokenAddressSync(rewardsMint, staker.publicKey), + stakeAccount: stakePda(nftA.mint), + userAccount, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + metadataProgram: TOKEN_METADATA_PROGRAM_ID, + }) + .signers([staker]) + .rpc(), + 'FreezePeriodNotPassed', + ); + + assert.strictEqual(tokenAccount(nftA.ata).state, FROZEN, 'a rejected unstake must leave the NFT frozen'); + }); + + // Only whole days pay out. If a claim snapped its checkpoint to `now` + // instead of advancing it by the days it actually paid for, the leftover + // half day here would be swallowed and the next claim would come up short. + it('Banks the part-day remainder instead of forfeiting it', async () => { + warpDays(1.5); + + await program.methods + .claim() + .accountsPartial(claimAccounts(nftA.mint, staker, userAccount)) + .signers([staker]) + .rpc(); + + assert.strictEqual(rewardsBalance(), 2n * ONE_DAY_OF_REWARDS, 'one and a half days should pay one day'); + + const stake = await program.account.stakeAccount.fetch(stakePda(nftA.mint)); + assert.strictEqual( + BigInt(stake.lastClaimedAt.toString()), + stakedAt + 2n * SECONDS_PER_DAY, + 'the checkpoint should sit on the day boundary, not on `now`', + ); + + // Only another half day passes, but combined with the banked remainder + // that is a full day - so it must pay out. + warpDays(0.5); + + await program.methods + .claim() + .accountsPartial(claimAccounts(nftA.mint, staker, userAccount)) + .signers([staker]) + .rpc(); + + assert.strictEqual( + rewardsBalance(), + 3n * ONE_DAY_OF_REWARDS, + 'the half day banked earlier should combine with this one and pay out', + ); + }); + + it('Unstakes, settling the final rewards and returning the NFT', async () => { + warpDays(1); + + const rentBefore = client.getBalance(staker.publicKey)!; + const stakeAccountLamports = client.getAccount(stakePda(nftA.mint))!.lamports; + + await program.methods + .unstake() + .accountsPartial({ + user: staker.publicKey, + nftMint: nftA.mint, + nftTokenAccount: nftA.ata, + metadata: metadataPda(nftA.mint), + edition: masterEditionPda(nftA.mint), + config, + rewardsMint, + rewardsTokenAccount: getAssociatedTokenAddressSync(rewardsMint, staker.publicKey), + stakeAccount: stakePda(nftA.mint), + userAccount, + systemProgram: SystemProgram.programId, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + metadataProgram: TOKEN_METADATA_PROGRAM_ID, + }) + .signers([staker]) + .rpc(); + + // The last day is settled by the unstake itself, so nothing is lost by + // unstaking without claiming first. + assert.strictEqual(rewardsBalance(), 4n * ONE_DAY_OF_REWARDS, 'unstake should settle the outstanding day'); + + const nft = tokenAccount(nftA.ata); + assert.strictEqual(nft.state, INITIALIZED, 'the NFT should be thawed'); + assert.strictEqual(nft.amount, 1n, 'the NFT should still be in the staker wallet'); + assert.strictEqual(nft.delegateOption, 0, 'the delegate should be revoked'); + + assert.isNull(client.getAccount(stakePda(nftA.mint)), 'the stake account should be closed'); + assert.isAbove( + Number(client.getBalance(staker.publicKey)!), + Number(rentBefore), + 'closing the stake position should return its rent', + ); + assert.isAbove(stakeAccountLamports, 0, 'the stake account should have held rent while open'); + + const account = await program.account.userAccount.fetch(userAccount); + assert.strictEqual(account.amountStaked, 0); + assert.strictEqual(account.pointsEarned.toNumber(), 4 * POINTS_PER_DAY); + }); + + it('Can stake again after unstaking', async () => { + client.expireBlockhash(); + + await program.methods.stake().accountsPartial(stakeAccounts(nftB.mint, nftB.ata)).signers([staker]).rpc(); + + assert.strictEqual(tokenAccount(nftB.ata).state, FROZEN, 'the freed slot should allow a new stake'); + }); +}); diff --git a/tokens/nft-staking/anchor/tests/metaplex.ts b/tokens/nft-staking/anchor/tests/metaplex.ts new file mode 100644 index 000000000..8be35c76b --- /dev/null +++ b/tokens/nft-staking/anchor/tests/metaplex.ts @@ -0,0 +1,180 @@ +// Hand-rolled instructions for the Metaplex Token Metadata program. +// +// Built directly from the wire format (discriminator + borsh-encoded args + +// documented account order) so the tests need no `mpl-token-metadata` +// dependency, matching how the native examples in this repo do it. The program +// itself is loaded into litesvm from tests/fixtures/token_metadata.so. + +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js'; + +export const TOKEN_METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); + +// Variants of the Token Metadata program's instruction enum. +const CREATE_METADATA_ACCOUNT_V3 = 33; +const CREATE_MASTER_EDITION_V3 = 17; +const VERIFY_COLLECTION = 18; + +// --- minimal borsh writers ------------------------------------------------- + +const u8 = (n: number) => Buffer.from([n]); + +const u16 = (n: number) => { + const b = Buffer.alloc(2); + b.writeUInt16LE(n); + return b; +}; + +const u64 = (n: bigint) => { + const b = Buffer.alloc(8); + b.writeBigUInt64LE(n); + return b; +}; + +const borshString = (s: string) => { + const bytes = Buffer.from(s, 'utf8'); + const len = Buffer.alloc(4); + len.writeUInt32LE(bytes.length); + return Buffer.concat([len, bytes]); +}; + +/** `None` for any inner type is a single zero byte. */ +const NONE = u8(0); + +// --- PDAs ------------------------------------------------------------------ + +export const metadataPda = (mint: PublicKey) => + PublicKey.findProgramAddressSync( + [Buffer.from('metadata'), TOKEN_METADATA_PROGRAM_ID.toBuffer(), mint.toBuffer()], + TOKEN_METADATA_PROGRAM_ID, + )[0]; + +export const masterEditionPda = (mint: PublicKey) => + PublicKey.findProgramAddressSync( + [Buffer.from('metadata'), TOKEN_METADATA_PROGRAM_ID.toBuffer(), mint.toBuffer(), Buffer.from('edition')], + TOKEN_METADATA_PROGRAM_ID, + )[0]; + +// --- instructions ---------------------------------------------------------- + +/** + * `CreateMetadataAccountV3`: creates the metadata account for `mint`. + * + * When `collection` is given it is written with `verified: false` — Metaplex + * will not accept a self-asserted `true`. Verification is a separate, + * authority-signed step; see `verifyCollection`. + */ +export const createMetadataAccountV3 = ({ + mint, + mintAuthority, + payer, + updateAuthority, + name, + symbol, + uri, + collection, +}: { + mint: PublicKey; + mintAuthority: PublicKey; + payer: PublicKey; + updateAuthority: PublicKey; + name: string; + symbol: string; + uri: string; + collection?: PublicKey; +}): TransactionInstruction => { + const dataV2 = Buffer.concat([ + borshString(name), + borshString(symbol), + borshString(uri), + u16(0), // seller_fee_basis_points + NONE, // creators + collection ? Buffer.concat([u8(1), u8(0), collection.toBuffer()]) : NONE, + NONE, // uses + ]); + + const data = Buffer.concat([ + u8(CREATE_METADATA_ACCOUNT_V3), + dataV2, + u8(1), // is_mutable + NONE, // collection_details + ]); + + return new TransactionInstruction({ + programId: TOKEN_METADATA_PROGRAM_ID, + keys: [ + { pubkey: metadataPda(mint), isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: mintAuthority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: updateAuthority, isSigner: true, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + data, + }); +}; + +/** + * `CreateMasterEditionV3`: marks the mint as an NFT and moves its mint and + * freeze authorities to the master edition PDA. + * + * This is what makes staking possible at all: the freeze authority has to be + * the master edition for Token Metadata to freeze the token account on a + * delegate's behalf. + */ +export const createMasterEditionV3 = ({ + mint, + updateAuthority, + mintAuthority, + payer, +}: { + mint: PublicKey; + updateAuthority: PublicKey; + mintAuthority: PublicKey; + payer: PublicKey; +}): TransactionInstruction => + new TransactionInstruction({ + programId: TOKEN_METADATA_PROGRAM_ID, + keys: [ + { pubkey: masterEditionPda(mint), isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: true }, + { pubkey: updateAuthority, isSigner: true, isWritable: false }, + { pubkey: mintAuthority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: metadataPda(mint), isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + // max_supply: Some(0) — a one-of-one, no prints. + data: Buffer.concat([u8(CREATE_MASTER_EDITION_V3), u8(1), u64(0n)]), + }); + +/** + * `VerifyCollection`: the collection's update authority signs to confirm that + * `mint` really belongs to `collectionMint`, flipping `collection.verified` + * to true. Until this runs, the collection field on an NFT is just a claim — + * which is exactly why the staking program checks the flag. + */ +export const verifyCollection = ({ + mint, + collectionMint, + collectionAuthority, + payer, +}: { + mint: PublicKey; + collectionMint: PublicKey; + collectionAuthority: PublicKey; + payer: PublicKey; +}): TransactionInstruction => + new TransactionInstruction({ + programId: TOKEN_METADATA_PROGRAM_ID, + keys: [ + { pubkey: metadataPda(mint), isSigner: false, isWritable: true }, + { pubkey: collectionAuthority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: collectionMint, isSigner: false, isWritable: false }, + { pubkey: metadataPda(collectionMint), isSigner: false, isWritable: true }, + { pubkey: masterEditionPda(collectionMint), isSigner: false, isWritable: false }, + ], + data: u8(VERIFY_COLLECTION), + }); diff --git a/tokens/nft-staking/anchor/tests/utils.ts b/tokens/nft-staking/anchor/tests/utils.ts new file mode 100644 index 000000000..3c2d49213 --- /dev/null +++ b/tokens/nft-staking/anchor/tests/utils.ts @@ -0,0 +1,14 @@ +import { assert } from 'chai'; + +// Asserts `promise` rejects with the given Anchor custom error code, not just +// "something failed". +export const expectAnchorError = async (promise: Promise, code: string) => { + let caught: any; + try { + await promise; + } catch (error) { + caught = error; + } + assert.isDefined(caught, `expected the transaction to fail with ${code}`); + assert.strictEqual(caught?.error?.errorCode?.code, code, `expected ${code}, got: ${caught}`); +}; diff --git a/tokens/nft-staking/anchor/tsconfig.json b/tokens/nft-staking/anchor/tsconfig.json new file mode 100644 index 000000000..c02443141 --- /dev/null +++ b/tokens/nft-staking/anchor/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai", "node"], + "typeRoots": ["./node_modules/@types"], + "lib": ["esnext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true + } +}