From efca75a0488fa4f18911efb01c66d5745db7ab67 Mon Sep 17 00:00:00 2001 From: boymak Date: Thu, 25 Jun 2026 10:24:41 +0300 Subject: [PATCH 1/5] Add Pinocchio version of token-fundraiser example Adds a Pinocchio implementation of tokens/token-fundraiser, which previously only had an Anchor version, advancing the repo's goal of having every example available in every framework. - initialize, contribute, check_contributions and refund instructions - mirrors the Anchor reference logic: 10% per-contributor cap, minimum target scaled by the mint's decimals, PDA-signed vault transfers, and account closing on settle / refund - follows the existing escrow and transfer-tokens Pinocchio conventions (manual borsh-compatible (de)serialization, bumps passed in the instruction data, solana-bankrun tests) - registers the program in the workspace Cargo.toml and links it from the root README - solana-bankrun test suite covering the full lifecycle Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 12 + Cargo.toml | 1 + README.md | 2 +- tokens/token-fundraiser/pinocchio/cicd.sh | 8 + .../token-fundraiser/pinocchio/package.json | 25 + .../token-fundraiser/pinocchio/pnpm-lock.yaml | 1500 +++++++++++++++++ .../pinocchio/program/Cargo.toml | 22 + .../pinocchio/program/src/error.rs | 36 + .../src/instructions/check_contributions.rs | 110 ++ .../program/src/instructions/contribute.rs | 148 ++ .../program/src/instructions/initialize.rs | 119 ++ .../pinocchio/program/src/instructions/mod.rs | 38 + .../program/src/instructions/refund.rs | 128 ++ .../pinocchio/program/src/lib.rs | 11 + .../pinocchio/program/src/processor.rs | 41 + .../pinocchio/program/src/state.rs | 106 ++ .../pinocchio/tests/account.ts | 35 + .../pinocchio/tests/instruction.ts | 173 ++ .../token-fundraiser/pinocchio/tests/test.ts | 181 ++ .../token-fundraiser/pinocchio/tests/utils.ts | 99 ++ .../token-fundraiser/pinocchio/tsconfig.json | 10 + 21 files changed, 2804 insertions(+), 1 deletion(-) create mode 100644 tokens/token-fundraiser/pinocchio/cicd.sh create mode 100644 tokens/token-fundraiser/pinocchio/package.json create mode 100644 tokens/token-fundraiser/pinocchio/pnpm-lock.yaml create mode 100644 tokens/token-fundraiser/pinocchio/program/Cargo.toml create mode 100644 tokens/token-fundraiser/pinocchio/program/src/error.rs create mode 100644 tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs create mode 100644 tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs create mode 100644 tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs create mode 100644 tokens/token-fundraiser/pinocchio/program/src/instructions/mod.rs create mode 100644 tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs create mode 100644 tokens/token-fundraiser/pinocchio/program/src/lib.rs create mode 100644 tokens/token-fundraiser/pinocchio/program/src/processor.rs create mode 100644 tokens/token-fundraiser/pinocchio/program/src/state.rs create mode 100644 tokens/token-fundraiser/pinocchio/tests/account.ts create mode 100644 tokens/token-fundraiser/pinocchio/tests/instruction.ts create mode 100644 tokens/token-fundraiser/pinocchio/tests/test.ts create mode 100644 tokens/token-fundraiser/pinocchio/tests/utils.ts create mode 100644 tokens/token-fundraiser/pinocchio/tsconfig.json diff --git a/Cargo.lock b/Cargo.lock index d92d1b032..920418aa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5116,6 +5116,18 @@ dependencies = [ "spl-token-2022-interface", ] +[[package]] +name = "token-fundraiser-pinocchio-program" +version = "0.1.0" +dependencies = [ + "pinocchio 0.10.2", + "pinocchio-associated-token-account", + "pinocchio-log", + "pinocchio-pubkey", + "pinocchio-system", + "pinocchio-token", +] + [[package]] name = "toml" version = "0.8.23" diff --git a/Cargo.toml b/Cargo.toml index aae171e04..3d0820af0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ members = [ # tokens "tokens/escrow/pinocchio/program", "tokens/transfer-tokens/pinocchio/program", + "tokens/token-fundraiser/pinocchio/program", "tokens/token-2022/mint-close-authority/native/program", "tokens/token-2022/non-transferable/native/program", "tokens/token-2022/default-account-state/native/program", diff --git a/README.md b/README.md index 0f7048fa8..37b13cfeb 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ Allow two users to swap digital assets with each other, each getting 100% of wha Create a fundraiser account specifying a target mint and amount, allowing contributors to deposit tokens until the goal is reached. -[anchor](./tokens/token-fundraiser/anchor) +[anchor](./tokens/token-fundraiser/anchor) [pinocchio](./tokens/token-fundraiser/pinocchio) ### Minting a token from inside a program with a PDA as the mint authority diff --git a/tokens/token-fundraiser/pinocchio/cicd.sh b/tokens/token-fundraiser/pinocchio/cicd.sh new file mode 100644 index 000000000..b2407c75f --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/cicd.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# This script is for quick building & deploying of the program. +# It also serves as a reference for the commands used for building & deploying Solana programs. +# Run this bad boy with "bash cicd.sh" or "./cicd.sh" + +cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./program/target/so +solana program deploy ./program/target/so/program.so diff --git a/tokens/token-fundraiser/pinocchio/package.json b/tokens/token-fundraiser/pinocchio/package.json new file mode 100644 index 000000000..f4b1d26d2 --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/package.json @@ -0,0 +1,25 @@ +{ + "scripts": { + "test": "pnpm ts-mocha -p ./tsconfig.json -t 1000000 ./tests/test.ts", + "build-and-test": "cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./tests/fixtures && pnpm test", + "build": "cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./program/target/so", + "deploy": "solana program deploy ./program/target/so/program.so" + }, + "dependencies": { + "@solana/spl-token": "^0.4.9", + "@solana/web3.js": "^1.98.4", + "bn.js": "^5.2.2" + }, + "devDependencies": { + "@types/bn.js": "^5.1.0", + "@types/chai": "^4.3.1", + "@types/mocha": "^10.0.9", + "@types/node": "^22.8.6", + "borsh": "^2.0.0", + "chai": "^4.3.4", + "mocha": "^10.8.2", + "solana-bankrun": "^0.4.0", + "ts-mocha": "^10.0.0", + "typescript": "^5" + } +} diff --git a/tokens/token-fundraiser/pinocchio/pnpm-lock.yaml b/tokens/token-fundraiser/pinocchio/pnpm-lock.yaml new file mode 100644 index 000000000..477c1da3a --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/pnpm-lock.yaml @@ -0,0 +1,1500 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@solana/spl-token': + specifier: ^0.4.9 + version: 0.4.14(@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) + bn.js: + specifier: ^5.2.2 + version: 5.2.3 + devDependencies: + '@types/bn.js': + specifier: ^5.1.0 + version: 5.2.0 + '@types/chai': + specifier: ^4.3.1 + version: 4.3.20 + '@types/mocha': + specifier: ^10.0.9 + version: 10.0.10 + '@types/node': + specifier: ^22.8.6 + version: 22.20.0 + borsh: + specifier: ^2.0.0 + version: 2.0.0 + chai: + specifier: ^4.3.4 + version: 4.5.0 + mocha: + specifier: ^10.8.2 + version: 10.8.2 + solana-bankrun: + specifier: ^0.4.0 + version: 0.4.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + ts-mocha: + specifier: ^10.0.0 + version: 10.1.0(mocha@10.8.2) + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@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} + + '@solana/buffer-layout-utils@0.2.0': + resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==} + 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.14': + resolution: {integrity: sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==} + 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/bn.js@5.2.0': + resolution: {integrity: sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==} + + '@types/chai@4.3.20': + resolution: {integrity: sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@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@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + '@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'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + arrify@1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + 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==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bn.js@5.2.3: + resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + + borsh@0.7.0: + resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} + + borsh@2.0.0: + resolution: {integrity: sha512-kc9+BgR3zz9+cjbwM8ODoUB4fs3X3I5A/HtX7LZKxCLaMrEeDFoBpnhZY//DTS1VZBSs6S5v46RZRbZjRFspEg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + 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@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + 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} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + 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==} + + 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'} + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} + engines: {node: '>=10'} + + diff@3.5.1: + resolution: {integrity: sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw==} + engines: {node: '>=0.3.1'} + + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + + 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@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==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + 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 + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + 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.*} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + 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 + + 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==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + 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'} + + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + + jayson@4.3.0: + resolution: {integrity: sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==} + engines: {node: '>=8'} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + 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'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mocha@10.8.2: + resolution: {integrity: sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==} + engines: {node: '>= 14.0.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 + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + 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'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.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==} + + solana-bankrun-darwin-arm64@0.4.0: + resolution: {integrity: sha512-6dz78Teoz7ez/3lpRLDjktYLJb79FcmJk2me4/YaB8WiO6W43OdExU4h+d2FyuAryO2DgBPXaBoBNY/8J1HJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + solana-bankrun-darwin-universal@0.4.0: + resolution: {integrity: sha512-zSSw/Jx3KNU42pPMmrEWABd0nOwGJfsj7nm9chVZ3ae7WQg3Uty0hHAkn5NSDCj3OOiN0py9Dr1l9vmRJpOOxg==} + engines: {node: '>= 10'} + os: [darwin] + + solana-bankrun-darwin-x64@0.4.0: + resolution: {integrity: sha512-LWjs5fsgHFtyr7YdJR6r0Ho5zrtzI6CY4wvwPXr8H2m3b4pZe6RLIZjQtabCav4cguc14G0K8yQB2PTMuGub8w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + solana-bankrun-linux-x64-gnu@0.4.0: + resolution: {integrity: sha512-SrlVrb82UIxt21Zr/XZFHVV/h9zd2/nP25PMpLJVLD7Pgl2yhkhfi82xj3OjxoQqWe+zkBJ+uszA0EEKr67yNw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + solana-bankrun-linux-x64-musl@0.4.0: + resolution: {integrity: sha512-Nv328ZanmURdYfcLL+jwB1oMzX4ZzK57NwIcuJjGlf0XSNLq96EoaO5buEiUTo4Ls7MqqMyLbClHcrPE7/aKyA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + solana-bankrun@0.4.0: + resolution: {integrity: sha512-NMmXUipPBkt8NgnyNO3SCnPERP6xT/AMNMBooljGA3+rG6NN8lmXJsKeLqQTiFsDeWD74U++QM/DgcueSWvrIg==} + engines: {node: '>= 10'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + 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'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + 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==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-mocha@10.1.0: + resolution: {integrity: sha512-T0C0Xm3/WqCuF2tpa0GNGESTBoKZaiqdUP8guNv4ZY316AFXlyidnrzQ1LUrCT0Wb1i3J0zFTgOh/55Un44WdA==} + engines: {node: '>= 6.X.X'} + hasBin: true + peerDependencies: + mocha: ^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X || ^10.X.X || ^11.X.X + + ts-node@7.0.1: + resolution: {integrity: sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==} + engines: {node: '>=4.2.0'} + hasBin: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + utf-8-validate@6.0.6: + resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} + engines: {node: '>=6.14.2'} + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + 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==} + + workerpool@6.5.1: + resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.11: + resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} + 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.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + 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@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + + yn@2.0.0: + resolution: {integrity: sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==} + engines: {node: '>=4'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@solana/buffer-layout-utils@0.2.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.14(@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.2.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.3 + 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/bn.js@5.2.0': + dependencies: + '@types/node': 22.20.0 + + '@types/chai@4.3.20': {} + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.0 + + '@types/json5@0.0.29': + optional: true + + '@types/mocha@10.0.10': {} + + '@types/node@12.20.55': {} + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + '@types/uuid@10.0.0': {} + + '@types/ws@7.4.7': + dependencies: + '@types/node': 22.20.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.0 + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + arrify@1.0.1: {} + + assertion-error@1.1.0: {} + + 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: {} + + binary-extensions@2.3.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bn.js@5.2.3: {} + + borsh@0.7.0: + dependencies: + bn.js: 5.2.3 + bs58: 4.0.1 + text-encoding-utf-8: 1.0.2 + + borsh@2.0.0: {} + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-stdout@1.3.1: {} + + bs58@4.0.1: + dependencies: + base-x: 3.0.11 + + buffer-from@1.1.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@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cliui@7.0.4: + 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: {} + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@4.0.0: {} + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + delay@5.0.0: {} + + diff@3.5.1: {} + + diff@5.2.2: {} + + emoji-regex@8.0.0: {} + + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + 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: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat@5.0.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-func-name@2.0.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + + has-flag@4.0.0: {} + + he@1.2.0: {} + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + ieee754@1.2.1: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-obj@2.1.0: {} + + is-unicode-supported@0.1.0: {} + + isomorphic-ws@4.0.1(ws@7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + dependencies: + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + + 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.11(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.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + json-stringify-safe@5.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + optional: true + + 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 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + make-error@1.3.6: {} + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mocha@10.8.2: + dependencies: + ansi-colors: 4.1.3 + browser-stdout: 1.3.1 + chokidar: 3.6.0 + debug: 4.4.3(supports-color@8.1.1) + diff: 5.2.2 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 8.1.0 + he: 1.2.0 + js-yaml: 4.2.0 + log-symbols: 4.1.0 + minimatch: 5.1.9 + ms: 2.1.3 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.5.1 + yargs: 16.2.2 + yargs-parser: 20.2.9 + 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 + + normalize-path@3.0.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + pathval@1.1.1: {} + + picomatch@2.3.2: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.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.1 + ws: 8.21.0(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 + + solana-bankrun-darwin-arm64@0.4.0: + optional: true + + solana-bankrun-darwin-universal@0.4.0: + optional: true + + solana-bankrun-darwin-x64@0.4.0: + optional: true + + solana-bankrun-linux-x64-gnu@0.4.0: + optional: true + + solana-bankrun-linux-x64-musl@0.4.0: + optional: true + + solana-bankrun@0.4.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) + bs58: 4.0.1 + optionalDependencies: + solana-bankrun-darwin-arm64: 0.4.0 + solana-bankrun-darwin-universal: 0.4.0 + solana-bankrun-darwin-x64: 0.4.0 + solana-bankrun-linux-x64-gnu: 0.4.0 + solana-bankrun-linux-x64-musl: 0.4.0 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + 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 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: + optional: true + + strip-json-comments@3.1.1: {} + + 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: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + ts-mocha@10.1.0(mocha@10.8.2): + dependencies: + mocha: 10.8.2 + ts-node: 7.0.1 + optionalDependencies: + tsconfig-paths: 3.15.0 + + ts-node@7.0.1: + dependencies: + arrify: 1.0.1 + buffer-from: 1.1.2 + diff: 3.5.1 + make-error: 1.3.6 + minimist: 1.2.8 + mkdirp: 0.5.6 + source-map-support: 0.5.21 + yn: 2.0.0 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + optional: true + + tslib@2.8.1: {} + + type-detect@4.1.0: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + utf-8-validate@6.0.6: + dependencies: + node-gyp-build: 4.8.4 + optional: true + + uuid@14.0.1: {} + + uuid@8.3.2: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + workerpool@6.5.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + ws@8.21.0(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@20.2.9: {} + + 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@16.2.2: + dependencies: + cliui: 7.0.4 + 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: 20.2.9 + + yn@2.0.0: {} + + yocto-queue@0.1.0: {} diff --git a/tokens/token-fundraiser/pinocchio/program/Cargo.toml b/tokens/token-fundraiser/pinocchio/program/Cargo.toml new file mode 100644 index 000000000..714ded74d --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "token-fundraiser-pinocchio-program" +version = "0.1.0" +edition = "2021" + +[dependencies] +pinocchio.workspace = true +pinocchio-log.workspace = true +pinocchio-pubkey.workspace = true +pinocchio-system.workspace = true +pinocchio-token.workspace = true +pinocchio-associated-token-account.workspace = true + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +custom-heap = [] +custom-panic = [] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tokens/token-fundraiser/pinocchio/program/src/error.rs b/tokens/token-fundraiser/pinocchio/program/src/error.rs new file mode 100644 index 000000000..256e654f1 --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/error.rs @@ -0,0 +1,36 @@ +use pinocchio::error::ProgramError; + +/// Errors returned by the fundraiser program. +/// +/// These mirror the named errors of the Anchor and native versions of this +/// example. Each variant is surfaced to clients as `ProgramError::Custom(n)`, +/// where `n` is the variant's discriminant below. +#[repr(u32)] +pub enum FundraiserError { + /// The vault does not yet hold the target amount. + TargetNotMet, + /// The target has already been reached, so a refund is not allowed. + TargetMet, + /// The contribution exceeds the per-contributor maximum. + ContributionTooBig, + /// The contribution is below the minimum. + ContributionTooSmall, + /// The contributor has reached their maximum total contribution. + MaximumContributionsReached, + /// The fundraiser has not ended yet, so a refund is not allowed. + FundraiserNotEnded, + /// The fundraiser has already ended, so contributions are closed. + FundraiserEnded, + /// The requested target is below the minimum allowed amount. + InvalidAmount, + /// A provided account is not the expected PDA for the given seeds. + InvalidSeeds, + /// A provided mint does not match the one the fundraiser is raising. + InvalidMint, +} + +impl From for ProgramError { + fn from(error: FundraiserError) -> Self { + ProgramError::Custom(error as u32) + } +} diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs new file mode 100644 index 000000000..5381b3802 --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs @@ -0,0 +1,110 @@ +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + AccountView, Address, ProgramResult, +}; +use pinocchio_associated_token_account::instructions::CreateIdempotent; +use pinocchio_log::log; +use pinocchio_pubkey::derive_address; +use pinocchio_token::{instructions::Transfer, state::TokenAccount}; + +use crate::{error::FundraiserError, state::Fundraiser}; + +/// Settles a successful fundraiser. +/// +/// If the vault holds at least the target amount, all funds are transferred to +/// the maker and the fundraiser account is closed (its rent returned to the +/// maker). +/// +/// Accounts: +/// 0. `[signer, writable]` maker (receives the funds and the reclaimed rent) +/// 1. `[]` mint to raise +/// 2. `[writable]` fundraiser account (PDA, closed here) +/// 3. `[writable]` vault (fundraiser's token account, drained here) +/// 4. `[writable]` maker's token account (created if needed) +/// 5. `[]` token program +/// 6. `[]` associated token program +/// 7. `[]` system program +/// +/// Instruction data: none. +pub fn check_contributions( + program_id: &Address, + accounts: &[AccountView], + _data: &[u8], +) -> ProgramResult { + let [maker, mint_to_raise, fundraiser, vault, maker_ata, token_program, _associated_token_program, system_program] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if !maker.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + // Load the campaign and confirm the maker, mint and PDA all match. + let fundraiser_state = Fundraiser::deserialize(&fundraiser.try_borrow()?)?; + if &fundraiser_state.maker != maker.address().as_array() { + return Err(FundraiserError::InvalidSeeds.into()); + } + if &fundraiser_state.mint_to_raise != mint_to_raise.address().as_array() { + return Err(FundraiserError::InvalidMint.into()); + } + let fundraiser_pda = derive_address( + &[Fundraiser::SEED_PREFIX, maker.address().as_ref()], + Some(fundraiser_state.bump), + program_id.as_array(), + ); + if fundraiser.address().as_array() != &fundraiser_pda { + return Err(FundraiserError::InvalidSeeds.into()); + } + + // The campaign must have reached its target. + let vault_amount = TokenAccount::from_account_view(vault)?.amount(); + if vault_amount < fundraiser_state.amount_to_raise { + return Err(FundraiserError::TargetNotMet.into()); + } + + // Make sure the maker has a token account to receive into. + log!("Ensuring maker token account exists"); + CreateIdempotent { + funding_account: maker, + account: maker_ata, + wallet: maker, + mint: mint_to_raise, + system_program, + token_program, + } + .invoke()?; + + // Release the raised funds to the maker, signed by the fundraiser PDA. + let bump_bytes = [fundraiser_state.bump]; + let seeds = [ + Seed::from(Fundraiser::SEED_PREFIX), + Seed::from(maker.address().as_ref()), + Seed::from(&bump_bytes), + ]; + let signers = [Signer::from(&seeds)]; + + log!("Transferring raised funds to maker"); + Transfer { + from: vault, + to: maker_ata, + authority: fundraiser, + amount: vault_amount, + } + .invoke_signed(&signers)?; + + // Close the fundraiser account, returning its rent to the maker. + log!("Closing fundraiser account"); + let fundraiser_lamports = fundraiser.lamports(); + fundraiser.set_lamports(0); + maker.set_lamports(maker.lamports() + fundraiser_lamports); + fundraiser.resize(0)?; + unsafe { + fundraiser.assign(system_program.address()); + } + + log!("Fundraiser settled successfully"); + Ok(()) +} diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs new file mode 100644 index 000000000..0fdf8487d --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs @@ -0,0 +1,148 @@ +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + sysvars::{clock::Clock, rent::Rent, Sysvar}, + AccountView, Address, ProgramResult, +}; +use pinocchio_log::log; +use pinocchio_pubkey::derive_address; +use pinocchio_system::instructions::CreateAccount; +use pinocchio_token::instructions::Transfer; + +use crate::{ + error::FundraiserError, + instructions::{max_contribution, read_u64, SECONDS_TO_DAYS}, + state::{Contributor, Fundraiser}, +}; + +/// Contributes tokens to a fundraiser. +/// +/// Tokens are moved from the contributor's token account into the vault, and the +/// contributor's running total is recorded in a per-contributor PDA +/// (`[b"contributor", fundraiser, contributor]`), created on first contribution. +/// +/// Accounts: +/// 0. `[signer, writable]` contributor (funds the contributor account, sends tokens) +/// 1. `[]` mint to raise +/// 2. `[writable]` fundraiser account (PDA) +/// 3. `[writable]` contributor account (PDA, created on first contribution) +/// 4. `[writable]` contributor's token account (source of the tokens) +/// 5. `[writable]` vault (fundraiser's token account) +/// 6. `[]` token program +/// 7. `[]` system program +/// +/// Instruction data: `[amount: u64 (LE), contributor_bump: u8]` +pub fn contribute(program_id: &Address, accounts: &[AccountView], data: &[u8]) -> ProgramResult { + let [contributor, mint_to_raise, fundraiser, contributor_account, contributor_ata, vault, _token_program, _system_program] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if !contributor.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + let amount = read_u64(data, 0)?; + let contributor_bump = *data.get(8).ok_or(ProgramError::InvalidInstructionData)?; + + // Load the campaign and re-derive its PDA to confirm authenticity. + let mut fundraiser_state = Fundraiser::deserialize(&fundraiser.try_borrow()?)?; + if &fundraiser_state.mint_to_raise != mint_to_raise.address().as_array() { + return Err(FundraiserError::InvalidMint.into()); + } + let fundraiser_pda = derive_address( + &[Fundraiser::SEED_PREFIX, fundraiser_state.maker.as_ref()], + Some(fundraiser_state.bump), + program_id.as_array(), + ); + if fundraiser.address().as_array() != &fundraiser_pda { + return Err(FundraiserError::InvalidSeeds.into()); + } + + // A contribution must be non-zero (mirrors `1.pow(decimals) == 1`). + if amount < 1 { + return Err(FundraiserError::ContributionTooSmall.into()); + } + + // A single contribution cannot exceed the per-contributor maximum. + let max = max_contribution(fundraiser_state.amount_to_raise); + if amount > max { + return Err(FundraiserError::ContributionTooBig.into()); + } + + // Contributions are only accepted within the campaign window. + let elapsed_days = + ((Clock::get()?.unix_timestamp - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; + if fundraiser_state.duration > elapsed_days { + return Err(FundraiserError::FundraiserEnded.into()); + } + + // Track this contributor's running total, creating their account if needed. + let contributor_pda = derive_address( + &[ + Contributor::SEED_PREFIX, + fundraiser.address().as_ref(), + contributor.address().as_ref(), + ], + Some(contributor_bump), + program_id.as_array(), + ); + if contributor_account.address().as_array() != &contributor_pda { + return Err(FundraiserError::InvalidSeeds.into()); + } + + let previous_amount = if contributor_account.data_len() == 0 { + // First contribution: create the contributor account, signed by its PDA. + let lamports = Rent::get()?.try_minimum_balance(Contributor::LEN)?; + let bump_bytes = [contributor_bump]; + let seeds = [ + Seed::from(Contributor::SEED_PREFIX), + Seed::from(fundraiser.address().as_ref()), + Seed::from(contributor.address().as_ref()), + Seed::from(&bump_bytes), + ]; + let signers = [Signer::from(&seeds)]; + + log!("Creating contributor account"); + CreateAccount { + from: contributor, + to: contributor_account, + lamports, + space: Contributor::LEN as u64, + owner: program_id, + } + .invoke_signed(&signers)?; + + 0 + } else { + Contributor::deserialize(&contributor_account.try_borrow()?)?.amount + }; + + // The contributor's total contribution cannot exceed the per-contributor max. + if previous_amount > max || previous_amount + amount > max { + return Err(FundraiserError::MaximumContributionsReached.into()); + } + + // Move the tokens into the vault. + log!("Transferring contribution into vault"); + Transfer { + from: contributor_ata, + to: vault, + authority: contributor, + amount, + } + .invoke()?; + + // Update the campaign and contributor totals. + fundraiser_state.current_amount += amount; + fundraiser_state.serialize(&mut fundraiser.try_borrow_mut()?)?; + + let contributor_state = Contributor { + amount: previous_amount + amount, + }; + contributor_state.serialize(&mut contributor_account.try_borrow_mut()?)?; + + log!("Contribution recorded successfully"); + Ok(()) +} diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs new file mode 100644 index 000000000..91a2179e1 --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs @@ -0,0 +1,119 @@ +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + sysvars::{clock::Clock, rent::Rent, Sysvar}, + AccountView, Address, ProgramResult, +}; +use pinocchio_associated_token_account::instructions::CreateIdempotent; +use pinocchio_log::log; +use pinocchio_pubkey::derive_address; +use pinocchio_system::instructions::CreateAccount; +use pinocchio_token::state::Mint; + +use crate::{ + error::FundraiserError, + instructions::{read_u64, MIN_AMOUNT_TO_RAISE}, + state::Fundraiser, +}; + +/// Creates a fundraiser and its vault. +/// +/// The fundraiser account is a PDA (`[b"fundraiser", maker]`) that records the +/// campaign terms and owns the vault, an associated token account for the +/// raised mint. +/// +/// Accounts: +/// 0. `[signer, writable]` maker (creates and funds the fundraiser + vault) +/// 1. `[]` mint to raise +/// 2. `[writable]` fundraiser account (PDA, created here) +/// 3. `[writable]` vault (fundraiser's associated token account, created here) +/// 4. `[]` token program +/// 5. `[]` associated token program +/// 6. `[]` system program +/// +/// Instruction data: `[amount: u64 (LE), duration: u16 (LE), bump: u8]` +pub fn initialize(program_id: &Address, accounts: &[AccountView], data: &[u8]) -> ProgramResult { + let [maker, mint_to_raise, fundraiser, vault, token_program, _associated_token_program, system_program] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if !maker.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + let amount = read_u64(data, 0)?; + let duration = u16::from_le_bytes( + data.get(8..10) + .ok_or(ProgramError::InvalidInstructionData)? + .try_into() + .unwrap(), + ); + let bump = *data.get(10).ok_or(ProgramError::InvalidInstructionData)?; + + // The target must meet the minimum, scaled by the mint's decimals. This + // mirrors the Anchor/native versions: `MIN_AMOUNT_TO_RAISE.pow(decimals)`. + let decimals = Mint::from_account_view(mint_to_raise)?.decimals(); + if amount < MIN_AMOUNT_TO_RAISE.pow(decimals as u32) { + return Err(FundraiserError::InvalidAmount.into()); + } + + // Confirm the supplied fundraiser account is the canonical PDA for the maker. + let fundraiser_pda = derive_address( + &[Fundraiser::SEED_PREFIX, maker.address().as_ref()], + Some(bump), + program_id.as_array(), + ); + if fundraiser.address().as_array() != &fundraiser_pda { + return Err(FundraiserError::InvalidSeeds.into()); + } + + // Create the fundraiser account, signed by the fundraiser PDA itself. + let lamports = Rent::get()?.try_minimum_balance(Fundraiser::LEN)?; + let bump_bytes = [bump]; + let seeds = [ + Seed::from(Fundraiser::SEED_PREFIX), + Seed::from(maker.address().as_ref()), + Seed::from(&bump_bytes), + ]; + let signers = [Signer::from(&seeds)]; + + log!("Creating fundraiser account"); + CreateAccount { + from: maker, + to: fundraiser, + lamports, + space: Fundraiser::LEN as u64, + owner: program_id, + } + .invoke_signed(&signers)?; + + // Create the vault: an associated token account for the raised mint, owned + // by the fundraiser PDA. + log!("Creating vault"); + CreateIdempotent { + funding_account: maker, + account: vault, + wallet: fundraiser, + mint: mint_to_raise, + system_program, + token_program, + } + .invoke()?; + + // Persist the campaign terms. + let fundraiser_state = Fundraiser { + maker: *maker.address().as_array(), + mint_to_raise: *mint_to_raise.address().as_array(), + amount_to_raise: amount, + current_amount: 0, + time_started: Clock::get()?.unix_timestamp, + duration, + bump, + }; + fundraiser_state.serialize(&mut fundraiser.try_borrow_mut()?)?; + + log!("Fundraiser initialized successfully"); + Ok(()) +} diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/mod.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/mod.rs new file mode 100644 index 000000000..9217087ae --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/mod.rs @@ -0,0 +1,38 @@ +use pinocchio::error::ProgramError; + +mod check_contributions; +mod contribute; +mod initialize; +mod refund; + +pub use check_contributions::*; +pub use contribute::*; +pub use initialize::*; +pub use refund::*; + +/// Minimum target a fundraiser may set, before scaling by the mint's decimals. +pub const MIN_AMOUNT_TO_RAISE: u64 = 3; +/// Number of seconds in a day, used to convert the campaign duration to days. +pub const SECONDS_TO_DAYS: i64 = 86_400; +/// A single contributor may supply at most this percentage of the target. +pub const MAX_CONTRIBUTION_PERCENTAGE: u64 = 10; +/// Denominator for the percentage math above. +pub const PERCENTAGE_SCALER: u64 = 100; + +/// Reads a little-endian `u64` starting at `offset` within `data`. +pub(crate) fn read_u64(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 8] = data + .get(offset..offset + 8) + .ok_or(ProgramError::InvalidInstructionData)? + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?; + Ok(u64::from_le_bytes(bytes)) +} + +/// Maximum amount a single contributor may supply: `MAX_CONTRIBUTION_PERCENTAGE` +/// percent of the campaign target. +pub(crate) fn max_contribution(amount_to_raise: u64) -> u64 { + amount_to_raise + .saturating_mul(MAX_CONTRIBUTION_PERCENTAGE) + .saturating_div(PERCENTAGE_SCALER) +} diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs new file mode 100644 index 000000000..f8cea8a19 --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs @@ -0,0 +1,128 @@ +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + sysvars::{clock::Clock, Sysvar}, + AccountView, Address, ProgramResult, +}; +use pinocchio_log::log; +use pinocchio_pubkey::derive_address; +use pinocchio_token::{instructions::Transfer, state::TokenAccount}; + +use crate::{ + error::FundraiserError, + instructions::SECONDS_TO_DAYS, + state::{Contributor, Fundraiser}, +}; + +/// Refunds a contributor after a failed fundraiser. +/// +/// Once the campaign has ended without reaching its target, a contributor can +/// reclaim their tokens. Their contributed amount is returned from the vault and +/// the contributor account is closed (its rent returned to the contributor). +/// +/// Accounts: +/// 0. `[signer, writable]` contributor (receives the refund and reclaimed rent) +/// 1. `[]` maker (part of the fundraiser PDA seeds) +/// 2. `[]` mint to raise +/// 3. `[writable]` fundraiser account (PDA) +/// 4. `[writable]` contributor account (PDA, closed here) +/// 5. `[writable]` contributor's token account (receives the refund) +/// 6. `[writable]` vault (fundraiser's token account) +/// 7. `[]` token program +/// 8. `[]` system program +/// +/// Instruction data: `[contributor_bump: u8]` +pub fn refund(program_id: &Address, accounts: &[AccountView], data: &[u8]) -> ProgramResult { + let [contributor, maker, mint_to_raise, fundraiser, contributor_account, contributor_ata, vault, _token_program, system_program] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if !contributor.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + + let contributor_bump = *data.first().ok_or(ProgramError::InvalidInstructionData)?; + + // Load the campaign and confirm the maker, mint and PDA all match. + let mut fundraiser_state = Fundraiser::deserialize(&fundraiser.try_borrow()?)?; + if &fundraiser_state.maker != maker.address().as_array() { + return Err(FundraiserError::InvalidSeeds.into()); + } + if &fundraiser_state.mint_to_raise != mint_to_raise.address().as_array() { + return Err(FundraiserError::InvalidMint.into()); + } + let fundraiser_pda = derive_address( + &[Fundraiser::SEED_PREFIX, maker.address().as_ref()], + Some(fundraiser_state.bump), + program_id.as_array(), + ); + if fundraiser.address().as_array() != &fundraiser_pda { + return Err(FundraiserError::InvalidSeeds.into()); + } + + // Confirm the contributor account is the canonical PDA for this contributor. + let contributor_pda = derive_address( + &[ + Contributor::SEED_PREFIX, + fundraiser.address().as_ref(), + contributor.address().as_ref(), + ], + Some(contributor_bump), + program_id.as_array(), + ); + if contributor_account.address().as_array() != &contributor_pda { + return Err(FundraiserError::InvalidSeeds.into()); + } + + // Refunds are only allowed once the campaign has ended. + let elapsed_days = + ((Clock::get()?.unix_timestamp - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; + if fundraiser_state.duration < elapsed_days { + return Err(FundraiserError::FundraiserNotEnded.into()); + } + + // ...and only if the target was not met. + let vault_amount = TokenAccount::from_account_view(vault)?.amount(); + if vault_amount >= fundraiser_state.amount_to_raise { + return Err(FundraiserError::TargetMet.into()); + } + + let contributor_amount = Contributor::deserialize(&contributor_account.try_borrow()?)?.amount; + + // Return the contributor's tokens from the vault, signed by the fundraiser PDA. + let bump_bytes = [fundraiser_state.bump]; + let seeds = [ + Seed::from(Fundraiser::SEED_PREFIX), + Seed::from(maker.address().as_ref()), + Seed::from(&bump_bytes), + ]; + let signers = [Signer::from(&seeds)]; + + log!("Refunding contribution from vault"); + Transfer { + from: vault, + to: contributor_ata, + authority: fundraiser, + amount: contributor_amount, + } + .invoke_signed(&signers)?; + + // Reduce the campaign's recorded total. + fundraiser_state.current_amount -= contributor_amount; + fundraiser_state.serialize(&mut fundraiser.try_borrow_mut()?)?; + + // Close the contributor account, returning its rent to the contributor. + log!("Closing contributor account"); + let contributor_lamports = contributor_account.lamports(); + contributor_account.set_lamports(0); + contributor.set_lamports(contributor.lamports() + contributor_lamports); + contributor_account.resize(0)?; + unsafe { + contributor_account.assign(system_program.address()); + } + + log!("Refund completed successfully"); + Ok(()) +} diff --git a/tokens/token-fundraiser/pinocchio/program/src/lib.rs b/tokens/token-fundraiser/pinocchio/program/src/lib.rs new file mode 100644 index 000000000..1e5b927ea --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/lib.rs @@ -0,0 +1,11 @@ +#![no_std] + +pub mod error; +pub mod instructions; +pub mod processor; +pub mod state; + +use pinocchio::{entrypoint, nostd_panic_handler}; + +entrypoint!(processor::process_instruction); +nostd_panic_handler!(); diff --git a/tokens/token-fundraiser/pinocchio/program/src/processor.rs b/tokens/token-fundraiser/pinocchio/program/src/processor.rs new file mode 100644 index 000000000..5738c907f --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/processor.rs @@ -0,0 +1,41 @@ +use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; +use pinocchio_log::log; + +use crate::instructions::{check_contributions, contribute, initialize, refund}; + +/// Dispatches an instruction based on its leading discriminator byte. +/// +/// Instruction data layout: `[discriminator: u8, ..args]` +/// - `0` -> Initialize (args: `[amount: u64 (LE), duration: u16 (LE), bump: u8]`) +/// - `1` -> Contribute (args: `[amount: u64 (LE), contributor_bump: u8]`) +/// - `2` -> CheckContributions (no args) +/// - `3` -> Refund (args: `[contributor_bump: u8]`) +pub fn process_instruction( + program_id: &Address, + accounts: &[AccountView], + instruction_data: &[u8], +) -> ProgramResult { + let (discriminator, args) = instruction_data + .split_first() + .ok_or(ProgramError::InvalidInstructionData)?; + + match *discriminator { + 0 => { + log!("Instruction: Initialize"); + initialize(program_id, accounts, args) + } + 1 => { + log!("Instruction: Contribute"); + contribute(program_id, accounts, args) + } + 2 => { + log!("Instruction: CheckContributions"); + check_contributions(program_id, accounts, args) + } + 3 => { + log!("Instruction: Refund"); + refund(program_id, accounts, args) + } + _ => Err(ProgramError::InvalidInstructionData), + } +} diff --git a/tokens/token-fundraiser/pinocchio/program/src/state.rs b/tokens/token-fundraiser/pinocchio/program/src/state.rs new file mode 100644 index 000000000..cc3745bac --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/program/src/state.rs @@ -0,0 +1,106 @@ +use pinocchio::error::ProgramError; + +/// Persistent state for a fundraiser campaign, stored in the fundraiser PDA. +/// +/// The fundraiser PDA is derived from `[b"fundraiser", maker]` and is the +/// authority of the vault token account that custodies contributed tokens until +/// the campaign succeeds (funds go to the maker) or fails (funds are refunded). +/// +/// Serialized byte layout (little-endian), matching the field order below so a +/// Borsh client can deserialize it directly: +/// `[maker: 32][mint_to_raise: 32][amount_to_raise: u64][current_amount: u64] +/// [time_started: i64][duration: u16][bump: u8]` +pub struct Fundraiser { + /// The wallet that created the fundraiser and receives the funds on success. + pub maker: [u8; 32], + /// Mint of the token being raised. + pub mint_to_raise: [u8; 32], + /// Target amount (in base units) the campaign wants to raise. + pub amount_to_raise: u64, + /// Amount contributed so far. + pub current_amount: u64, + /// Unix timestamp at which the campaign started. + pub time_started: i64, + /// Campaign duration, in days. + pub duration: u16, + /// Canonical bump for the fundraiser PDA. + pub bump: u8, +} + +impl Fundraiser { + /// Seed prefix for the fundraiser PDA: `[SEED_PREFIX, maker]`. + pub const SEED_PREFIX: &'static [u8] = b"fundraiser"; + + /// Serialized size of a `Fundraiser` in bytes. + pub const LEN: usize = 32 + 32 + 8 + 8 + 8 + 2 + 1; + + /// Writes the fundraiser into `dst` using the layout documented above. + pub fn serialize(&self, dst: &mut [u8]) -> Result<(), ProgramError> { + let dst = dst + .get_mut(..Self::LEN) + .ok_or(ProgramError::AccountDataTooSmall)?; + dst[0..32].copy_from_slice(&self.maker); + dst[32..64].copy_from_slice(&self.mint_to_raise); + dst[64..72].copy_from_slice(&self.amount_to_raise.to_le_bytes()); + dst[72..80].copy_from_slice(&self.current_amount.to_le_bytes()); + dst[80..88].copy_from_slice(&self.time_started.to_le_bytes()); + dst[88..90].copy_from_slice(&self.duration.to_le_bytes()); + dst[90] = self.bump; + Ok(()) + } + + /// Reads a fundraiser from `src`, which must be at least [`Fundraiser::LEN`] bytes. + pub fn deserialize(src: &[u8]) -> Result { + let src: &[u8; Self::LEN] = src + .get(..Self::LEN) + .and_then(|s| s.try_into().ok()) + .ok_or(ProgramError::InvalidAccountData)?; + Ok(Self { + maker: src[0..32].try_into().unwrap(), + mint_to_raise: src[32..64].try_into().unwrap(), + amount_to_raise: u64::from_le_bytes(src[64..72].try_into().unwrap()), + current_amount: u64::from_le_bytes(src[72..80].try_into().unwrap()), + time_started: i64::from_le_bytes(src[80..88].try_into().unwrap()), + duration: u16::from_le_bytes(src[88..90].try_into().unwrap()), + bump: src[90], + }) + } +} + +/// Per-contributor record, stored in the contributor PDA derived from +/// `[b"contributor", fundraiser, contributor]`. Tracks how much the contributor +/// has put in so it can be refunded if the campaign fails. +/// +/// Serialized byte layout (little-endian): `[amount: u64]`. +pub struct Contributor { + /// Total amount this contributor has supplied. + pub amount: u64, +} + +impl Contributor { + /// Seed prefix for the contributor PDA: `[SEED_PREFIX, fundraiser, contributor]`. + pub const SEED_PREFIX: &'static [u8] = b"contributor"; + + /// Serialized size of a `Contributor` in bytes. + pub const LEN: usize = 8; + + /// Writes the contributor into `dst` using the layout documented above. + pub fn serialize(&self, dst: &mut [u8]) -> Result<(), ProgramError> { + let dst = dst + .get_mut(..Self::LEN) + .ok_or(ProgramError::AccountDataTooSmall)?; + dst[0..8].copy_from_slice(&self.amount.to_le_bytes()); + Ok(()) + } + + /// Reads a contributor from `src`, which must be at least [`Contributor::LEN`] bytes. + pub fn deserialize(src: &[u8]) -> Result { + let src: &[u8; Self::LEN] = src + .get(..Self::LEN) + .and_then(|s| s.try_into().ok()) + .ok_or(ProgramError::InvalidAccountData)?; + Ok(Self { + amount: u64::from_le_bytes(src[0..8].try_into().unwrap()), + }) + } +} diff --git a/tokens/token-fundraiser/pinocchio/tests/account.ts b/tokens/token-fundraiser/pinocchio/tests/account.ts new file mode 100644 index 000000000..af38e758e --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/tests/account.ts @@ -0,0 +1,35 @@ +import * as borsh from "borsh"; + +// Matches the on-chain `Fundraiser` byte layout (see program/src/state.rs). +export const FundraiserSchema = { + struct: { + maker: { array: { type: "u8", len: 32 } }, + mint_to_raise: { array: { type: "u8", len: 32 } }, + amount_to_raise: "u64", + current_amount: "u64", + time_started: "i64", + duration: "u16", + bump: "u8", + }, +}; + +export type FundraiserRaw = { + maker: Uint8Array; + mint_to_raise: Uint8Array; + amount_to_raise: bigint; + current_amount: bigint; + time_started: bigint; + duration: number; + bump: number; +}; + +// Matches the on-chain `Contributor` byte layout (see program/src/state.rs). +export const ContributorSchema = { + struct: { + amount: "u64", + }, +}; + +export type ContributorRaw = { + amount: bigint; +}; diff --git a/tokens/token-fundraiser/pinocchio/tests/instruction.ts b/tokens/token-fundraiser/pinocchio/tests/instruction.ts new file mode 100644 index 000000000..b506e2824 --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/tests/instruction.ts @@ -0,0 +1,173 @@ +import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { type PublicKey, SystemProgram, TransactionInstruction } from "@solana/web3.js"; +import type BN from "bn.js"; +import * as borsh from "borsh"; + +enum FundraiserInstruction { + Initialize = 0, + Contribute = 1, + CheckContributions = 2, + Refund = 3, +} + +// The Pinocchio program receives PDA bumps in the instruction data (and stores +// the fundraiser bump) instead of deriving them on-chain. +const InitializeSchema = { + struct: { + instruction: "u8", + amount: "u64", + duration: "u16", + bump: "u8", + }, +}; + +const ContributeSchema = { + struct: { + instruction: "u8", + amount: "u64", + contributor_bump: "u8", + }, +}; + +const CheckContributionsSchema = { + struct: { + instruction: "u8", + }, +}; + +const RefundSchema = { + struct: { + instruction: "u8", + contributor_bump: "u8", + }, +}; + +function borshSerialize(schema: borsh.Schema, data: object): Buffer { + return Buffer.from(borsh.serialize(schema, data)); +} + +export function buildInitialize(props: { + amount: BN; + duration: number; + bump: number; + maker: PublicKey; + mint: PublicKey; + fundraiser: PublicKey; + vault: PublicKey; + programId: PublicKey; +}) { + const data = borshSerialize(InitializeSchema, { + instruction: FundraiserInstruction.Initialize, + amount: props.amount, + duration: props.duration, + bump: props.bump, + }); + + return new TransactionInstruction({ + keys: [ + { pubkey: props.maker, isSigner: true, isWritable: true }, + { pubkey: props.mint, isSigner: false, isWritable: false }, + { pubkey: props.fundraiser, isSigner: false, isWritable: true }, + { pubkey: props.vault, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + programId: props.programId, + data, + }); +} + +export function buildContribute(props: { + amount: BN; + contributor_bump: number; + contributor: PublicKey; + mint: PublicKey; + fundraiser: PublicKey; + contributorAccount: PublicKey; + contributorAta: PublicKey; + vault: PublicKey; + programId: PublicKey; +}) { + const data = borshSerialize(ContributeSchema, { + instruction: FundraiserInstruction.Contribute, + amount: props.amount, + contributor_bump: props.contributor_bump, + }); + + return new TransactionInstruction({ + keys: [ + { pubkey: props.contributor, isSigner: true, isWritable: true }, + { pubkey: props.mint, isSigner: false, isWritable: false }, + { pubkey: props.fundraiser, isSigner: false, isWritable: true }, + { pubkey: props.contributorAccount, isSigner: false, isWritable: true }, + { pubkey: props.contributorAta, isSigner: false, isWritable: true }, + { pubkey: props.vault, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + programId: props.programId, + data, + }); +} + +export function buildCheckContributions(props: { + maker: PublicKey; + mint: PublicKey; + fundraiser: PublicKey; + vault: PublicKey; + makerAta: PublicKey; + programId: PublicKey; +}) { + const data = borshSerialize(CheckContributionsSchema, { + instruction: FundraiserInstruction.CheckContributions, + }); + + return new TransactionInstruction({ + keys: [ + { pubkey: props.maker, isSigner: true, isWritable: true }, + { pubkey: props.mint, isSigner: false, isWritable: false }, + { pubkey: props.fundraiser, isSigner: false, isWritable: true }, + { pubkey: props.vault, isSigner: false, isWritable: true }, + { pubkey: props.makerAta, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + programId: props.programId, + data, + }); +} + +export function buildRefund(props: { + contributor_bump: number; + contributor: PublicKey; + maker: PublicKey; + mint: PublicKey; + fundraiser: PublicKey; + contributorAccount: PublicKey; + contributorAta: PublicKey; + vault: PublicKey; + programId: PublicKey; +}) { + const data = borshSerialize(RefundSchema, { + instruction: FundraiserInstruction.Refund, + contributor_bump: props.contributor_bump, + }); + + return new TransactionInstruction({ + keys: [ + { pubkey: props.contributor, isSigner: true, isWritable: true }, + { pubkey: props.maker, isSigner: false, isWritable: false }, + { pubkey: props.mint, isSigner: false, isWritable: false }, + { pubkey: props.fundraiser, isSigner: false, isWritable: true }, + { pubkey: props.contributorAccount, isSigner: false, isWritable: true }, + { pubkey: props.contributorAta, isSigner: false, isWritable: true }, + { pubkey: props.vault, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + programId: props.programId, + data, + }); +} diff --git a/tokens/token-fundraiser/pinocchio/tests/test.ts b/tokens/token-fundraiser/pinocchio/tests/test.ts new file mode 100644 index 000000000..97d8d85b2 --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/tests/test.ts @@ -0,0 +1,181 @@ +import { AccountLayout, getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, Transaction } from "@solana/web3.js"; +import BN from "bn.js"; +import * as borsh from "borsh"; +import { assert } from "chai"; +import { start } from "solana-bankrun"; +import { type ContributorRaw, ContributorSchema, type FundraiserRaw, FundraiserSchema } from "./account"; +import { buildCheckContributions, buildContribute, buildInitialize, buildRefund } from "./instruction"; +import { expectRevert, fundAccount, mintingTokens } from "./utils"; + +describe("Token Fundraiser (Pinocchio)", async () => { + const programId = PublicKey.unique(); + const context = await start([{ name: "token_fundraiser_pinocchio_program", programId }], []); + const client = context.banksClient; + const payer = context.payer; + // The bankrun payer plays the role of the contributor. + const contributor = payer; + + const maker = Keypair.generate(); + const mintKeypair = Keypair.generate(); + + const [fundraiser, fundraiserBump] = PublicKey.findProgramAddressSync( + [Buffer.from("fundraiser"), maker.publicKey.toBuffer()], + programId, + ); + const [contributorAccount, contributorBump] = PublicKey.findProgramAddressSync( + [Buffer.from("contributor"), fundraiser.toBuffer(), contributor.publicKey.toBuffer()], + programId, + ); + + const vault = getAssociatedTokenAddressSync(mintKeypair.publicKey, fundraiser, true); + const contributorAta = getAssociatedTokenAddressSync(mintKeypair.publicKey, contributor.publicKey); + const makerAta = getAssociatedTokenAddressSync(mintKeypair.publicKey, maker.publicKey); + + const decimals = 6; + const amountToRaise = new BN(30_000_000); // 30 tokens + const duration = 0; // a fundraiser with no minimum waiting period + + // Fund the maker so it can pay for the fundraiser + vault, and mint tokens to + // the contributor. + await fundAccount(context, maker.publicKey, 5 * LAMPORTS_PER_SOL); + await mintingTokens({ context, holder: contributor, mintKeypair, mintedAmount: 10, decimals }); + + async function sendInstruction(ix, signers: Keypair[]) { + const tx = new Transaction(); + tx.recentBlockhash = context.lastBlockhash; + tx.add(ix).sign(...signers); + await client.processTransaction(tx); + } + + async function readTokenAmount(account: PublicKey): Promise { + const info = await client.getAccount(account); + if (info === null) throw new Error("Token account not found"); + return AccountLayout.decode(info.data).amount; + } + + it("Initializes a fundraiser", async () => { + const ix = buildInitialize({ + amount: amountToRaise, + duration, + bump: fundraiserBump, + maker: maker.publicKey, + mint: mintKeypair.publicKey, + fundraiser, + vault, + programId, + }); + + await sendInstruction(ix, [payer, maker]); + + const info = await client.getAccount(fundraiser); + if (info === null) throw new Error("Fundraiser account not found"); + const state = borsh.deserialize(FundraiserSchema, Buffer.from(info.data)) as FundraiserRaw; + + assert.equal(new PublicKey(state.maker).toBase58(), maker.publicKey.toBase58(), "wrong maker"); + assert.equal( + new PublicKey(state.mint_to_raise).toBase58(), + mintKeypair.publicKey.toBase58(), + "wrong mint", + ); + assert.equal(state.amount_to_raise.toString(), amountToRaise.toString(), "wrong target"); + assert.equal(state.current_amount.toString(), "0", "current amount should start at zero"); + assert.equal(state.duration, duration, "wrong duration"); + assert.equal(state.bump, fundraiserBump, "wrong bump"); + + // The vault exists and starts empty. + assert.equal((await readTokenAmount(vault)).toString(), "0", "vault should start empty"); + }); + + it("Accepts contributions", async () => { + for (let i = 0; i < 2; i++) { + const ix = buildContribute({ + amount: new BN(1_000_000), + contributor_bump: contributorBump, + contributor: contributor.publicKey, + mint: mintKeypair.publicKey, + fundraiser, + contributorAccount, + contributorAta, + vault, + programId, + }); + await sendInstruction(ix, [payer]); + } + + assert.equal((await readTokenAmount(vault)).toString(), "2000000", "vault should hold both contributions"); + + const info = await client.getAccount(contributorAccount); + if (info === null) throw new Error("Contributor account not found"); + const state = borsh.deserialize(ContributorSchema, Buffer.from(info.data)) as ContributorRaw; + assert.equal(state.amount.toString(), "2000000", "contributor total should be tracked"); + + const fundraiserInfo = await client.getAccount(fundraiser); + if (fundraiserInfo === null) throw new Error("Fundraiser account not found"); + const fundraiserState = borsh.deserialize(FundraiserSchema, Buffer.from(fundraiserInfo.data)) as FundraiserRaw; + assert.equal(fundraiserState.current_amount.toString(), "2000000", "fundraiser total should be tracked"); + }); + + it("Rejects a contribution above the per-contributor maximum", async () => { + // The contributor is already at 2_000_000; the cap is 10% of 30_000_000 = + // 3_000_000, so another 2_000_000 must be rejected. + const ix = buildContribute({ + amount: new BN(2_000_000), + contributor_bump: contributorBump, + contributor: contributor.publicKey, + mint: mintKeypair.publicKey, + fundraiser, + contributorAccount, + contributorAta, + vault, + programId, + }); + + await expectRevert(sendInstruction(ix, [payer])); + }); + + it("Rejects settling before the target is met", async () => { + const ix = buildCheckContributions({ + maker: maker.publicKey, + mint: mintKeypair.publicKey, + fundraiser, + vault, + makerAta, + programId, + }); + + await expectRevert(sendInstruction(ix, [payer, maker])); + }); + + it("Refunds the contributor when the target is not met", async () => { + const balanceBefore = await readTokenAmount(contributorAta); + + const ix = buildRefund({ + contributor_bump: contributorBump, + contributor: contributor.publicKey, + maker: maker.publicKey, + mint: mintKeypair.publicKey, + fundraiser, + contributorAccount, + contributorAta, + vault, + programId, + }); + + await sendInstruction(ix, [payer]); + + // The vault is emptied and the contributor account is closed. + assert.equal((await readTokenAmount(vault)).toString(), "0", "vault should be empty after refund"); + assert.equal(await client.getAccount(contributorAccount), null, "contributor account should be closed"); + + // The contributor got their tokens back. + const balanceAfter = await readTokenAmount(contributorAta); + assert.equal((balanceAfter - balanceBefore).toString(), "2000000", "contributor should be fully refunded"); + + // The fundraiser's recorded total is back to zero. + const fundraiserInfo = await client.getAccount(fundraiser); + if (fundraiserInfo === null) throw new Error("Fundraiser account not found"); + const fundraiserState = borsh.deserialize(FundraiserSchema, Buffer.from(fundraiserInfo.data)) as FundraiserRaw; + assert.equal(fundraiserState.current_amount.toString(), "0", "fundraiser total should be back to zero"); + }); +}); diff --git a/tokens/token-fundraiser/pinocchio/tests/utils.ts b/tokens/token-fundraiser/pinocchio/tests/utils.ts new file mode 100644 index 000000000..c2c144c5d --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/tests/utils.ts @@ -0,0 +1,99 @@ +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + createInitializeMint2Instruction, + createMintToInstruction, + getAssociatedTokenAddressSync, + MINT_SIZE, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { Keypair, PublicKey, type Signer, SystemProgram, Transaction } from "@solana/web3.js"; +import BN from "bn.js"; +import type { ProgramTestContext } from "solana-bankrun"; + +export const expectRevert = async (promise: Promise) => { + try { + await promise; + throw new Error("Expected a revert"); + } catch { + return; + } +}; + +// Transfers SOL from the bankrun payer to another account so it can fund account +// creation and pay fees. +export const fundAccount = async (context: ProgramTestContext, recipient: PublicKey, lamports: number) => { + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: context.payer.publicKey, + toPubkey: recipient, + lamports, + }), + ); + transaction.recentBlockhash = context.lastBlockhash; + transaction.sign(context.payer); + await context.banksClient.processTransaction(transaction); +}; + +// Creates a mint, gives `holder` an associated token account, and mints +// `mintedAmount` whole tokens into it. +export const mintingTokens = async ({ + context, + holder, + mintKeypair, + mintedAmount = 100, + decimals = 6, +}: { + context: ProgramTestContext; + holder: Signer; + mintKeypair: Keypair; + mintedAmount?: number; + decimals?: number; +}) => { + const rent = await context.banksClient.getRent(); + const lamports = rent.minimumBalance(BigInt(MINT_SIZE)); + + const createMintTx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: context.payer.publicKey, + newAccountPubkey: mintKeypair.publicKey, + space: MINT_SIZE, + lamports: new BN(lamports.toString()).toNumber(), + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction( + mintKeypair.publicKey, + decimals, + context.payer.publicKey, + context.payer.publicKey, + TOKEN_PROGRAM_ID, + ), + ); + createMintTx.recentBlockhash = context.lastBlockhash; + createMintTx.sign(context.payer, mintKeypair); + await context.banksClient.processTransaction(createMintTx); + + const holderAta = getAssociatedTokenAddressSync(mintKeypair.publicKey, holder.publicKey, true); + + const mintToTx = new Transaction().add( + createAssociatedTokenAccountIdempotentInstruction( + context.payer.publicKey, + holderAta, + holder.publicKey, + mintKeypair.publicKey, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ), + createMintToInstruction( + mintKeypair.publicKey, + holderAta, + context.payer.publicKey, + mintedAmount * 10 ** decimals, + [], + TOKEN_PROGRAM_ID, + ), + ); + mintToTx.recentBlockhash = context.lastBlockhash; + mintToTx.sign(context.payer); + await context.banksClient.processTransaction(mintToTx); +}; diff --git a/tokens/token-fundraiser/pinocchio/tsconfig.json b/tokens/token-fundraiser/pinocchio/tsconfig.json new file mode 100644 index 000000000..8c20b2236 --- /dev/null +++ b/tokens/token-fundraiser/pinocchio/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai", "node"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true + } +} From e321f1940d71eafab82817df550b199988e8ee87 Mon Sep 17 00:00:00 2001 From: boymak Date: Thu, 25 Jun 2026 10:53:47 +0300 Subject: [PATCH 2/5] Address review: verify vault ownership and fix expectRevert Resolves the issues flagged in review: - contribute / check_contributions / refund now verify that the provided vault is a token account owned by the fundraiser PDA for the raised mint (new InvalidVault error). Previously contribute accepted any token account as the vault; since the transfer authority is the contributor, a contributor could redirect tokens to an account they control while still inflating current_amount and later drain the real vault on refund. - fix the expectRevert test helper, which used optional-catch-binding and swallowed its own sentinel error, making the negative-path tests vacuous. - add a regression test asserting a contribution with a substituted vault is rejected. Verified end-to-end on a local solana-test-validator (7/7), including the vault-substitution rejection and a negative control proving expectRevert is no longer vacuous. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pinocchio/program/src/error.rs | 3 +++ .../src/instructions/check_contributions.rs | 12 +++++++++++- .../program/src/instructions/contribute.rs | 14 +++++++++++++- .../program/src/instructions/refund.rs | 14 ++++++++++++-- .../token-fundraiser/pinocchio/tests/test.ts | 19 +++++++++++++++++++ .../token-fundraiser/pinocchio/tests/utils.ts | 7 +++++-- 6 files changed, 63 insertions(+), 6 deletions(-) diff --git a/tokens/token-fundraiser/pinocchio/program/src/error.rs b/tokens/token-fundraiser/pinocchio/program/src/error.rs index 256e654f1..beda17bed 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/error.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/error.rs @@ -27,6 +27,9 @@ pub enum FundraiserError { InvalidSeeds, /// A provided mint does not match the one the fundraiser is raising. InvalidMint, + /// The provided vault is not a token account owned by the fundraiser for + /// the raised mint. + InvalidVault, } impl From for ProgramError { diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs index 5381b3802..310009310 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs @@ -59,8 +59,18 @@ pub fn check_contributions( return Err(FundraiserError::InvalidSeeds.into()); } + // The vault must be the fundraiser's token account for the raised mint. + let vault_amount = { + let vault_account = TokenAccount::from_account_view(vault)?; + if vault_account.owner() != fundraiser.address() + || vault_account.mint() != mint_to_raise.address() + { + return Err(FundraiserError::InvalidVault.into()); + } + vault_account.amount() + }; + // The campaign must have reached its target. - let vault_amount = TokenAccount::from_account_view(vault)?.amount(); if vault_amount < fundraiser_state.amount_to_raise { return Err(FundraiserError::TargetNotMet.into()); } diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs index 0fdf8487d..7dd27aff3 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs @@ -7,7 +7,7 @@ use pinocchio::{ use pinocchio_log::log; use pinocchio_pubkey::derive_address; use pinocchio_system::instructions::CreateAccount; -use pinocchio_token::instructions::Transfer; +use pinocchio_token::{instructions::Transfer, state::TokenAccount}; use crate::{ error::FundraiserError, @@ -60,6 +60,18 @@ pub fn contribute(program_id: &Address, accounts: &[AccountView], data: &[u8]) - return Err(FundraiserError::InvalidSeeds.into()); } + // The vault must be a token account owned by the fundraiser for the raised + // mint. Without this check a contributor could pass an account they control + // as the vault, keep their tokens, and still inflate the recorded total. + { + let vault_account = TokenAccount::from_account_view(vault)?; + if vault_account.owner() != fundraiser.address() + || vault_account.mint() != mint_to_raise.address() + { + return Err(FundraiserError::InvalidVault.into()); + } + } + // A contribution must be non-zero (mirrors `1.pow(decimals) == 1`). if amount < 1 { return Err(FundraiserError::ContributionTooSmall.into()); diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs index f8cea8a19..f00f91f6d 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs @@ -83,8 +83,18 @@ pub fn refund(program_id: &Address, accounts: &[AccountView], data: &[u8]) -> Pr return Err(FundraiserError::FundraiserNotEnded.into()); } - // ...and only if the target was not met. - let vault_amount = TokenAccount::from_account_view(vault)?.amount(); + // The vault must be the fundraiser's token account for the raised mint. + let vault_amount = { + let vault_account = TokenAccount::from_account_view(vault)?; + if vault_account.owner() != fundraiser.address() + || vault_account.mint() != mint_to_raise.address() + { + return Err(FundraiserError::InvalidVault.into()); + } + vault_account.amount() + }; + + // ...and only refund if the target was not met. if vault_amount >= fundraiser_state.amount_to_raise { return Err(FundraiserError::TargetMet.into()); } diff --git a/tokens/token-fundraiser/pinocchio/tests/test.ts b/tokens/token-fundraiser/pinocchio/tests/test.ts index 97d8d85b2..b0fa338de 100644 --- a/tokens/token-fundraiser/pinocchio/tests/test.ts +++ b/tokens/token-fundraiser/pinocchio/tests/test.ts @@ -116,6 +116,25 @@ describe("Token Fundraiser (Pinocchio)", async () => { assert.equal(fundraiserState.current_amount.toString(), "2000000", "fundraiser total should be tracked"); }); + it("Rejects a contribution whose vault is not owned by the fundraiser", async () => { + // Passing the contributor's own token account as the vault must be rejected; + // otherwise the contributor could keep their tokens while still inflating the + // recorded total and later drain the real vault via a refund. + const ix = buildContribute({ + amount: new BN(1_000_000), + contributor_bump: contributorBump, + contributor: contributor.publicKey, + mint: mintKeypair.publicKey, + fundraiser, + contributorAccount, + contributorAta, + vault: contributorAta, // not the fundraiser's vault + programId, + }); + + await expectRevert(sendInstruction(ix, [payer])); + }); + it("Rejects a contribution above the per-contributor maximum", async () => { // The contributor is already at 2_000_000; the cap is 10% of 30_000_000 = // 3_000_000, so another 2_000_000 must be rejected. diff --git a/tokens/token-fundraiser/pinocchio/tests/utils.ts b/tokens/token-fundraiser/pinocchio/tests/utils.ts index c2c144c5d..dd14e0972 100644 --- a/tokens/token-fundraiser/pinocchio/tests/utils.ts +++ b/tokens/token-fundraiser/pinocchio/tests/utils.ts @@ -12,11 +12,14 @@ import BN from "bn.js"; import type { ProgramTestContext } from "solana-bankrun"; export const expectRevert = async (promise: Promise) => { + let reverted = false; try { await promise; - throw new Error("Expected a revert"); } catch { - return; + reverted = true; + } + if (!reverted) { + throw new Error("Expected a revert, but the transaction succeeded"); } }; From c6e82bb986da46b4807ce5cacfb1f1053808cdc3 Mon Sep 17 00:00:00 2001 From: boymak Date: Thu, 25 Jun 2026 11:31:24 +0300 Subject: [PATCH 3/5] Fix inverted contribute/refund time gates The Anchor reference this example is ported from gates contributions and refunds with inverted comparisons: contributions were only accepted once the campaign window had already closed, and refunds were only allowed while the campaign was still open. The bug is masked in the Anchor tests because they use duration = 0, where both inverted conditions happen to evaluate correctly. This corrects both gates: - contribute now rejects once `elapsed_days >= duration` (campaign ended) - refund now rejects while `elapsed_days < duration` (campaign still open) Tests now use a non-zero (open) campaign for the happy path and a second duration = 0 (already-ended) campaign to exercise both sides of each gate without needing to advance the validator clock. Verified on a local solana-test-validator (8/8): contributions accepted while open, rejected after the campaign ends; refunds rejected while open; plus the vault-ownership, per-contributor-cap and target guards. Note: the Anchor (and native) versions of this example carry the same inverted conditions and would benefit from the same fix in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../program/src/instructions/contribute.rs | 5 +- .../program/src/instructions/refund.rs | 5 +- .../token-fundraiser/pinocchio/tests/test.ts | 64 ++++++++++++++----- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs index 7dd27aff3..e95046f7d 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs @@ -83,10 +83,11 @@ pub fn contribute(program_id: &Address, accounts: &[AccountView], data: &[u8]) - return Err(FundraiserError::ContributionTooBig.into()); } - // Contributions are only accepted within the campaign window. + // Contributions are only accepted while the campaign is still open, i.e. + // before `duration` days have elapsed since it started. let elapsed_days = ((Clock::get()?.unix_timestamp - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; - if fundraiser_state.duration > elapsed_days { + if elapsed_days >= fundraiser_state.duration { return Err(FundraiserError::FundraiserEnded.into()); } diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs index f00f91f6d..c7977c8b5 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs @@ -76,10 +76,11 @@ pub fn refund(program_id: &Address, accounts: &[AccountView], data: &[u8]) -> Pr return Err(FundraiserError::InvalidSeeds.into()); } - // Refunds are only allowed once the campaign has ended. + // Refunds are only allowed once the campaign has ended, i.e. after + // `duration` days have elapsed since it started. let elapsed_days = ((Clock::get()?.unix_timestamp - fundraiser_state.time_started) / SECONDS_TO_DAYS) as u16; - if fundraiser_state.duration < elapsed_days { + if elapsed_days < fundraiser_state.duration { return Err(FundraiserError::FundraiserNotEnded.into()); } diff --git a/tokens/token-fundraiser/pinocchio/tests/test.ts b/tokens/token-fundraiser/pinocchio/tests/test.ts index b0fa338de..fd5aff566 100644 --- a/tokens/token-fundraiser/pinocchio/tests/test.ts +++ b/tokens/token-fundraiser/pinocchio/tests/test.ts @@ -34,7 +34,7 @@ describe("Token Fundraiser (Pinocchio)", async () => { const decimals = 6; const amountToRaise = new BN(30_000_000); // 30 tokens - const duration = 0; // a fundraiser with no minimum waiting period + const duration = 5; // an open, 5-day campaign // Fund the maker so it can pay for the fundraiser + vault, and mint tokens to // the contributor. @@ -87,7 +87,7 @@ describe("Token Fundraiser (Pinocchio)", async () => { assert.equal((await readTokenAmount(vault)).toString(), "0", "vault should start empty"); }); - it("Accepts contributions", async () => { + it("Accepts contributions while the campaign is open", async () => { for (let i = 0; i < 2; i++) { const ix = buildContribute({ amount: new BN(1_000_000), @@ -166,9 +166,8 @@ describe("Token Fundraiser (Pinocchio)", async () => { await expectRevert(sendInstruction(ix, [payer, maker])); }); - it("Refunds the contributor when the target is not met", async () => { - const balanceBefore = await readTokenAmount(contributorAta); - + it("Rejects a refund while the campaign is still open", async () => { + // The 5-day campaign has just started, so a refund must be rejected. const ix = buildRefund({ contributor_bump: contributorBump, contributor: contributor.publicKey, @@ -181,20 +180,51 @@ describe("Token Fundraiser (Pinocchio)", async () => { programId, }); - await sendInstruction(ix, [payer]); + await expectRevert(sendInstruction(ix, [payer])); + }); - // The vault is emptied and the contributor account is closed. - assert.equal((await readTokenAmount(vault)).toString(), "0", "vault should be empty after refund"); - assert.equal(await client.getAccount(contributorAccount), null, "contributor account should be closed"); + it("Rejects a contribution after the campaign has ended", async () => { + // A second fundraiser created with duration = 0 is already over the moment + // it is initialized, so any contribution must be rejected. + const maker2 = Keypair.generate(); + await fundAccount(context, maker2.publicKey, 5 * LAMPORTS_PER_SOL); - // The contributor got their tokens back. - const balanceAfter = await readTokenAmount(contributorAta); - assert.equal((balanceAfter - balanceBefore).toString(), "2000000", "contributor should be fully refunded"); + const [endedFundraiser, endedBump] = PublicKey.findProgramAddressSync( + [Buffer.from("fundraiser"), maker2.publicKey.toBuffer()], + programId, + ); + const endedVault = getAssociatedTokenAddressSync(mintKeypair.publicKey, endedFundraiser, true); + const [endedContributor, endedContributorBump] = PublicKey.findProgramAddressSync( + [Buffer.from("contributor"), endedFundraiser.toBuffer(), contributor.publicKey.toBuffer()], + programId, + ); - // The fundraiser's recorded total is back to zero. - const fundraiserInfo = await client.getAccount(fundraiser); - if (fundraiserInfo === null) throw new Error("Fundraiser account not found"); - const fundraiserState = borsh.deserialize(FundraiserSchema, Buffer.from(fundraiserInfo.data)) as FundraiserRaw; - assert.equal(fundraiserState.current_amount.toString(), "0", "fundraiser total should be back to zero"); + await sendInstruction( + buildInitialize({ + amount: amountToRaise, + duration: 0, + bump: endedBump, + maker: maker2.publicKey, + mint: mintKeypair.publicKey, + fundraiser: endedFundraiser, + vault: endedVault, + programId, + }), + [payer, maker2], + ); + + const ix = buildContribute({ + amount: new BN(1_000_000), + contributor_bump: endedContributorBump, + contributor: contributor.publicKey, + mint: mintKeypair.publicKey, + fundraiser: endedFundraiser, + contributorAccount: endedContributor, + contributorAta, + vault: endedVault, + programId, + }); + + await expectRevert(sendInstruction(ix, [payer])); }); }); From d812420f670e0a0a84191e667bd06941d83c73d9 Mon Sep 17 00:00:00 2001 From: boymak Date: Thu, 25 Jun 2026 12:07:05 +0300 Subject: [PATCH 4/5] Close the vault on settlement and add a settle happy-path test Addresses the rent-accounting note from review: check_contributions now closes the (drained) vault token account in addition to the fundraiser account, returning both rents to the maker, instead of leaving the vault open with its rent locked. This mirrors the escrow example's take_offer. Adds a happy-path test that funds a campaign to its target with 10 contributors (each at the 10% per-contributor cap), settles it, and asserts the maker receives the full amount and both the vault and the fundraiser account are closed. Verified on a local solana-test-validator (10 contributors -> target -> settle): maker receives 30_000_000 and both accounts are closed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/instructions/check_contributions.rs | 20 +++- .../token-fundraiser/pinocchio/tests/test.ts | 100 +++++++++++++++++- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs index 310009310..b183fb093 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs @@ -6,21 +6,24 @@ use pinocchio::{ use pinocchio_associated_token_account::instructions::CreateIdempotent; use pinocchio_log::log; use pinocchio_pubkey::derive_address; -use pinocchio_token::{instructions::Transfer, state::TokenAccount}; +use pinocchio_token::{ + instructions::{CloseAccount, Transfer}, + state::TokenAccount, +}; use crate::{error::FundraiserError, state::Fundraiser}; /// Settles a successful fundraiser. /// /// If the vault holds at least the target amount, all funds are transferred to -/// the maker and the fundraiser account is closed (its rent returned to the -/// maker). +/// the maker, and both the vault and the fundraiser account are closed (their +/// rent returned to the maker). /// /// Accounts: /// 0. `[signer, writable]` maker (receives the funds and the reclaimed rent) /// 1. `[]` mint to raise /// 2. `[writable]` fundraiser account (PDA, closed here) -/// 3. `[writable]` vault (fundraiser's token account, drained here) +/// 3. `[writable]` vault (fundraiser's token account, drained and closed here) /// 4. `[writable]` maker's token account (created if needed) /// 5. `[]` token program /// 6. `[]` associated token program @@ -105,6 +108,15 @@ pub fn check_contributions( } .invoke_signed(&signers)?; + // Close the now-empty vault, returning its rent to the maker. + log!("Closing vault"); + CloseAccount { + account: vault, + destination: maker, + authority: fundraiser, + } + .invoke_signed(&signers)?; + // Close the fundraiser account, returning its rent to the maker. log!("Closing fundraiser account"); let fundraiser_lamports = fundraiser.lamports(); diff --git a/tokens/token-fundraiser/pinocchio/tests/test.ts b/tokens/token-fundraiser/pinocchio/tests/test.ts index fd5aff566..75b1db16f 100644 --- a/tokens/token-fundraiser/pinocchio/tests/test.ts +++ b/tokens/token-fundraiser/pinocchio/tests/test.ts @@ -1,4 +1,11 @@ -import { AccountLayout, getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { + AccountLayout, + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + createMintToInstruction, + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; import { Keypair, LAMPORTS_PER_SOL, PublicKey, Transaction } from "@solana/web3.js"; import BN from "bn.js"; import * as borsh from "borsh"; @@ -227,4 +234,95 @@ describe("Token Fundraiser (Pinocchio)", async () => { await expectRevert(sendInstruction(ix, [payer])); }); + + it("Settles a fully-funded campaign and reclaims rent", async () => { + // A fresh campaign funded to its target by 10 contributors (each capped at + // 10% of the target). Settling transfers everything to the maker and closes + // both the vault and the fundraiser account. + const settleMaker = Keypair.generate(); + await fundAccount(context, settleMaker.publicKey, 5 * LAMPORTS_PER_SOL); + + const [settleFundraiser, settleBump] = PublicKey.findProgramAddressSync( + [Buffer.from("fundraiser"), settleMaker.publicKey.toBuffer()], + programId, + ); + const settleVault = getAssociatedTokenAddressSync(mintKeypair.publicKey, settleFundraiser, true); + const settleMakerAta = getAssociatedTokenAddressSync(mintKeypair.publicKey, settleMaker.publicKey); + + await sendInstruction( + buildInitialize({ + amount: amountToRaise, // 30_000_000 + duration: 5, + bump: settleBump, + maker: settleMaker.publicKey, + mint: mintKeypair.publicKey, + fundraiser: settleFundraiser, + vault: settleVault, + programId, + }), + [payer, settleMaker], + ); + + // 10 contributors x 3_000_000 (the per-contributor cap) == the 30_000_000 target. + for (let i = 0; i < 10; i++) { + const c = Keypair.generate(); + await fundAccount(context, c.publicKey, 1 * LAMPORTS_PER_SOL); + const cAta = getAssociatedTokenAddressSync(mintKeypair.publicKey, c.publicKey); + + const fundTx = new Transaction(); + fundTx.recentBlockhash = context.lastBlockhash; + fundTx + .add( + createAssociatedTokenAccountIdempotentInstruction( + payer.publicKey, + cAta, + c.publicKey, + mintKeypair.publicKey, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID, + ), + createMintToInstruction(mintKeypair.publicKey, cAta, payer.publicKey, 3_000_000, [], TOKEN_PROGRAM_ID), + ) + .sign(payer); + await client.processTransaction(fundTx); + + const [cAccount, cBump] = PublicKey.findProgramAddressSync( + [Buffer.from("contributor"), settleFundraiser.toBuffer(), c.publicKey.toBuffer()], + programId, + ); + await sendInstruction( + buildContribute({ + amount: new BN(3_000_000), + contributor_bump: cBump, + contributor: c.publicKey, + mint: mintKeypair.publicKey, + fundraiser: settleFundraiser, + contributorAccount: cAccount, + contributorAta: cAta, + vault: settleVault, + programId, + }), + [payer, c], + ); + } + + assert.equal((await readTokenAmount(settleVault)).toString(), "30000000", "vault should hold the full target"); + + await sendInstruction( + buildCheckContributions({ + maker: settleMaker.publicKey, + mint: mintKeypair.publicKey, + fundraiser: settleFundraiser, + vault: settleVault, + makerAta: settleMakerAta, + programId, + }), + [payer, settleMaker], + ); + + // The maker received the full raised amount, and the vault + fundraiser are closed. + assert.equal((await readTokenAmount(settleMakerAta)).toString(), "30000000", "maker should receive the raised funds"); + assert.equal(await client.getAccount(settleVault), null, "vault should be closed after settlement"); + assert.equal(await client.getAccount(settleFundraiser), null, "fundraiser should be closed after settlement"); + }); }); From 05e41098b2072d3f7a412290408dcfe2c49089bd Mon Sep 17 00:00:00 2001 From: boymak Date: Thu, 25 Jun 2026 12:21:36 +0300 Subject: [PATCH 5/5] Verify the vault is the canonical ATA recorded at initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged that check_contributions validated the vault only by owner and mint, not that it is the fundraiser's canonical associated token account. A malicious maker could create a token account they own-assign to the fundraiser PDA, pre-fund it to the target, settle against it (recovering their own funding), and close the fundraiser — permanently stranding the real contributors' tokens in the canonical vault. Fix: record the canonical vault address in the fundraiser state at initialization (where the associated token program guarantees the vault is the canonical ATA), and have contribute, check_contributions and refund verify the passed vault matches it. This subsumes the previous owner+mint check. Verified on a local solana-test-validator: settling against a fake fundraiser-owned vault is rejected and the real fundraiser stays open, while a legitimate fully-funded campaign settles and closes both the vault and the fundraiser. Adds a regression test for the fake-vault settle attempt. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/instructions/check_contributions.rs | 17 +++----- .../program/src/instructions/contribute.rs | 17 +++----- .../program/src/instructions/initialize.rs | 4 ++ .../program/src/instructions/refund.rs | 15 +++---- .../pinocchio/program/src/state.rs | 33 ++++++++------ .../pinocchio/tests/account.ts | 2 + .../token-fundraiser/pinocchio/tests/test.ts | 43 ++++++++++++++++++- 7 files changed, 86 insertions(+), 45 deletions(-) diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs index b183fb093..439cd1943 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/check_contributions.rs @@ -62,16 +62,13 @@ pub fn check_contributions( return Err(FundraiserError::InvalidSeeds.into()); } - // The vault must be the fundraiser's token account for the raised mint. - let vault_amount = { - let vault_account = TokenAccount::from_account_view(vault)?; - if vault_account.owner() != fundraiser.address() - || vault_account.mint() != mint_to_raise.address() - { - return Err(FundraiserError::InvalidVault.into()); - } - vault_account.amount() - }; + // The vault must be the canonical vault recorded at initialization, so a + // maker cannot settle against a pre-funded substitute and strand the real + // contributions. + if vault.address().as_array() != &fundraiser_state.vault { + return Err(FundraiserError::InvalidVault.into()); + } + let vault_amount = TokenAccount::from_account_view(vault)?.amount(); // The campaign must have reached its target. if vault_amount < fundraiser_state.amount_to_raise { diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs index e95046f7d..d8936ad1e 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/contribute.rs @@ -7,7 +7,7 @@ use pinocchio::{ use pinocchio_log::log; use pinocchio_pubkey::derive_address; use pinocchio_system::instructions::CreateAccount; -use pinocchio_token::{instructions::Transfer, state::TokenAccount}; +use pinocchio_token::instructions::Transfer; use crate::{ error::FundraiserError, @@ -60,16 +60,11 @@ pub fn contribute(program_id: &Address, accounts: &[AccountView], data: &[u8]) - return Err(FundraiserError::InvalidSeeds.into()); } - // The vault must be a token account owned by the fundraiser for the raised - // mint. Without this check a contributor could pass an account they control - // as the vault, keep their tokens, and still inflate the recorded total. - { - let vault_account = TokenAccount::from_account_view(vault)?; - if vault_account.owner() != fundraiser.address() - || vault_account.mint() != mint_to_raise.address() - { - return Err(FundraiserError::InvalidVault.into()); - } + // The vault must be the canonical vault recorded at initialization. Without + // this check a contributor could pass an account they control as the vault, + // keep their tokens, and still inflate the recorded total. + if vault.address().as_array() != &fundraiser_state.vault { + return Err(FundraiserError::InvalidVault.into()); } // A contribution must be non-zero (mirrors `1.pow(decimals) == 1`). diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs index 91a2179e1..668910f88 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/initialize.rs @@ -106,6 +106,10 @@ pub fn initialize(program_id: &Address, accounts: &[AccountView], data: &[u8]) - let fundraiser_state = Fundraiser { maker: *maker.address().as_array(), mint_to_raise: *mint_to_raise.address().as_array(), + // The associated token program guaranteed this is the canonical ATA when + // it was created above, so recording it lets later instructions reject + // any substitute vault. + vault: *vault.address().as_array(), amount_to_raise: amount, current_amount: 0, time_started: Clock::get()?.unix_timestamp, diff --git a/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs b/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs index c7977c8b5..0eac9170c 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/instructions/refund.rs @@ -84,16 +84,11 @@ pub fn refund(program_id: &Address, accounts: &[AccountView], data: &[u8]) -> Pr return Err(FundraiserError::FundraiserNotEnded.into()); } - // The vault must be the fundraiser's token account for the raised mint. - let vault_amount = { - let vault_account = TokenAccount::from_account_view(vault)?; - if vault_account.owner() != fundraiser.address() - || vault_account.mint() != mint_to_raise.address() - { - return Err(FundraiserError::InvalidVault.into()); - } - vault_account.amount() - }; + // The vault must be the canonical vault recorded at initialization. + if vault.address().as_array() != &fundraiser_state.vault { + return Err(FundraiserError::InvalidVault.into()); + } + let vault_amount = TokenAccount::from_account_view(vault)?.amount(); // ...and only refund if the target was not met. if vault_amount >= fundraiser_state.amount_to_raise { diff --git a/tokens/token-fundraiser/pinocchio/program/src/state.rs b/tokens/token-fundraiser/pinocchio/program/src/state.rs index cc3745bac..86ca58c57 100644 --- a/tokens/token-fundraiser/pinocchio/program/src/state.rs +++ b/tokens/token-fundraiser/pinocchio/program/src/state.rs @@ -8,13 +8,18 @@ use pinocchio::error::ProgramError; /// /// Serialized byte layout (little-endian), matching the field order below so a /// Borsh client can deserialize it directly: -/// `[maker: 32][mint_to_raise: 32][amount_to_raise: u64][current_amount: u64] -/// [time_started: i64][duration: u16][bump: u8]` +/// `[maker: 32][mint_to_raise: 32][vault: 32][amount_to_raise: u64] +/// [current_amount: u64][time_started: i64][duration: u16][bump: u8]` pub struct Fundraiser { /// The wallet that created the fundraiser and receives the funds on success. pub maker: [u8; 32], /// Mint of the token being raised. pub mint_to_raise: [u8; 32], + /// The vault (the fundraiser PDA's associated token account) that holds the + /// raised funds. Recorded at initialization — where the associated token + /// program guarantees it is the canonical ATA — so later instructions can + /// reject any other token account passed in its place. + pub vault: [u8; 32], /// Target amount (in base units) the campaign wants to raise. pub amount_to_raise: u64, /// Amount contributed so far. @@ -32,7 +37,7 @@ impl Fundraiser { pub const SEED_PREFIX: &'static [u8] = b"fundraiser"; /// Serialized size of a `Fundraiser` in bytes. - pub const LEN: usize = 32 + 32 + 8 + 8 + 8 + 2 + 1; + pub const LEN: usize = 32 + 32 + 32 + 8 + 8 + 8 + 2 + 1; /// Writes the fundraiser into `dst` using the layout documented above. pub fn serialize(&self, dst: &mut [u8]) -> Result<(), ProgramError> { @@ -41,11 +46,12 @@ impl Fundraiser { .ok_or(ProgramError::AccountDataTooSmall)?; dst[0..32].copy_from_slice(&self.maker); dst[32..64].copy_from_slice(&self.mint_to_raise); - dst[64..72].copy_from_slice(&self.amount_to_raise.to_le_bytes()); - dst[72..80].copy_from_slice(&self.current_amount.to_le_bytes()); - dst[80..88].copy_from_slice(&self.time_started.to_le_bytes()); - dst[88..90].copy_from_slice(&self.duration.to_le_bytes()); - dst[90] = self.bump; + dst[64..96].copy_from_slice(&self.vault); + dst[96..104].copy_from_slice(&self.amount_to_raise.to_le_bytes()); + dst[104..112].copy_from_slice(&self.current_amount.to_le_bytes()); + dst[112..120].copy_from_slice(&self.time_started.to_le_bytes()); + dst[120..122].copy_from_slice(&self.duration.to_le_bytes()); + dst[122] = self.bump; Ok(()) } @@ -58,11 +64,12 @@ impl Fundraiser { Ok(Self { maker: src[0..32].try_into().unwrap(), mint_to_raise: src[32..64].try_into().unwrap(), - amount_to_raise: u64::from_le_bytes(src[64..72].try_into().unwrap()), - current_amount: u64::from_le_bytes(src[72..80].try_into().unwrap()), - time_started: i64::from_le_bytes(src[80..88].try_into().unwrap()), - duration: u16::from_le_bytes(src[88..90].try_into().unwrap()), - bump: src[90], + vault: src[64..96].try_into().unwrap(), + amount_to_raise: u64::from_le_bytes(src[96..104].try_into().unwrap()), + current_amount: u64::from_le_bytes(src[104..112].try_into().unwrap()), + time_started: i64::from_le_bytes(src[112..120].try_into().unwrap()), + duration: u16::from_le_bytes(src[120..122].try_into().unwrap()), + bump: src[122], }) } } diff --git a/tokens/token-fundraiser/pinocchio/tests/account.ts b/tokens/token-fundraiser/pinocchio/tests/account.ts index af38e758e..a465a94e6 100644 --- a/tokens/token-fundraiser/pinocchio/tests/account.ts +++ b/tokens/token-fundraiser/pinocchio/tests/account.ts @@ -5,6 +5,7 @@ export const FundraiserSchema = { struct: { maker: { array: { type: "u8", len: 32 } }, mint_to_raise: { array: { type: "u8", len: 32 } }, + vault: { array: { type: "u8", len: 32 } }, amount_to_raise: "u64", current_amount: "u64", time_started: "i64", @@ -16,6 +17,7 @@ export const FundraiserSchema = { export type FundraiserRaw = { maker: Uint8Array; mint_to_raise: Uint8Array; + vault: Uint8Array; amount_to_raise: bigint; current_amount: bigint; time_started: bigint; diff --git a/tokens/token-fundraiser/pinocchio/tests/test.ts b/tokens/token-fundraiser/pinocchio/tests/test.ts index 75b1db16f..251bb00a6 100644 --- a/tokens/token-fundraiser/pinocchio/tests/test.ts +++ b/tokens/token-fundraiser/pinocchio/tests/test.ts @@ -1,12 +1,14 @@ import { + ACCOUNT_SIZE, AccountLayout, ASSOCIATED_TOKEN_PROGRAM_ID, createAssociatedTokenAccountIdempotentInstruction, + createInitializeAccount3Instruction, createMintToInstruction, getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID, } from "@solana/spl-token"; -import { Keypair, LAMPORTS_PER_SOL, PublicKey, Transaction } from "@solana/web3.js"; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from "@solana/web3.js"; import BN from "bn.js"; import * as borsh from "borsh"; import { assert } from "chai"; @@ -173,6 +175,45 @@ describe("Token Fundraiser (Pinocchio)", async () => { await expectRevert(sendInstruction(ix, [payer, maker])); }); + it("Rejects settling against a non-canonical fundraiser-owned vault", async () => { + // A malicious maker could create a token account they pre-fund and assign to + // the fundraiser PDA as its owner. It must not be accepted in place of the + // canonical vault recorded at initialization — otherwise the maker could + // settle with the fake vault, close the fundraiser, and strand the real + // contributions. + const fakeVault = Keypair.generate(); + const rent = await client.getRent(); + const lamports = Number(rent.minimumBalance(BigInt(ACCOUNT_SIZE))); + + const setupTx = new Transaction(); + setupTx.recentBlockhash = context.lastBlockhash; + setupTx + .add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: fakeVault.publicKey, + space: ACCOUNT_SIZE, + lamports, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeAccount3Instruction(fakeVault.publicKey, mintKeypair.publicKey, fundraiser, TOKEN_PROGRAM_ID), + createMintToInstruction(mintKeypair.publicKey, fakeVault.publicKey, payer.publicKey, 30_000_000, [], TOKEN_PROGRAM_ID), + ) + .sign(payer, fakeVault); + await client.processTransaction(setupTx); + + const ix = buildCheckContributions({ + maker: maker.publicKey, + mint: mintKeypair.publicKey, + fundraiser, + vault: fakeVault.publicKey, + makerAta, + programId, + }); + + await expectRevert(sendInstruction(ix, [payer, maker])); + }); + it("Rejects a refund while the campaign is still open", async () => { // The 5-day campaign has just started, so a refund must be rejected. const ix = buildRefund({