diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/.gitignore b/tokens/token-2022/transfer-hook/allow-block-list-token/.gitignore new file mode 100644 index 00000000..d49b3971 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Anchor +/anchor/target/ diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/.prettierignore b/tokens/token-2022/transfer-hook/allow-block-list-token/.prettierignore new file mode 100644 index 00000000..bbd564b6 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/.prettierignore @@ -0,0 +1,14 @@ +# Add files here to ignore them from prettier formatting +/anchor/target/debug +/anchor/target/deploy +/anchor/target/release +/anchor/target/sbf-solana-solana +/anchor/target/test-ledger +/anchor/target/.rustc_info.json +/dist +/coverage +.next +/tmp +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/.prettierrc b/tokens/token-2022/transfer-hook/allow-block-list-token/.prettierrc new file mode 100644 index 00000000..d6c3437b --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/.prettierrc @@ -0,0 +1,7 @@ +{ + "arrowParens": "always", + "printWidth": 120, + "semi": false, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/README.md b/tokens/token-2022/transfer-hook/allow-block-list-token/README.md new file mode 100644 index 00000000..cd2d4047 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/README.md @@ -0,0 +1,50 @@ +# AllowBlockList Token + +An example of a allow / block list token using token extensions. + +## Features + +Allows the creation of an allow block list with a list authority. +The allow/block list is then consumed by a transfer-hook. + +The list is managed by a single authority and can be used by several token mints. This enables a separation of concerns between token management and allow/block list management, ideal for scenarios where an issuer wants a 3rd party managed allow/block list or wants to share the same list across a group of assets. + +Initializes new tokens with several configuration options: +- Permanent delegate +- Allow list +- Block list +- Metadata +- Authorities + +The issuer can configure the allow and block list with 3 distinct configurations: +- Force Allow: requires everyone receiving tokens to be explicitly allowed in +- Block: allows everyone to receive tokens unless explicitly blocked +- Threshold Allow: allows everyone to receive tokens unless explicitly blocked up until a given transfer amount threshold. Transfers larger than the threshold require explicitly allow + +These configurations are saved in the token mint metadata. + +This repo includes a UI to manage the allow/block list based on the `legacy-next-tailwind-basic` template. It also allows creating new token mints on the spot with transfer-hook enabled along with token transfers given that most wallets fail to fetch transfer-hook dependencies on devnet and locally. + +## Setup + +Install dependencies: +`yarn install` + +Compile the program: +`anchor build` + +Compile the UI: +`yarn run build` + +Serve the UI: +`yarn run dev` + +### Local testing + +There are a couple scripts to manage the local validator and deployment. + +To start the local validator and deploy the program: +`./scripts/start.sh` + +To stop the local validator: +`./scripts/stop.sh` diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/.gitignore b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/.gitignore new file mode 100644 index 00000000..ef0f05ec --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/.gitignore @@ -0,0 +1,13 @@ +.anchor +.DS_Store +target/debug +target/deploy +target/release +target/sbf-solana-solana +target/test-ledger +target/.rustc_info.json +**/*.rs.bk +node_modules +test-ledger +.yarn +ledger \ No newline at end of file diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/.prettierignore b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/.prettierignore new file mode 100644 index 00000000..41425834 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/.prettierignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +node_modules +dist +build +test-ledger diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml new file mode 100644 index 00000000..7d418ebf --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Anchor.toml @@ -0,0 +1,19 @@ +[toolchain] +package_manager = "yarn" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +abl-token = "LtkoMwPSKxAE714EY3V1oAEQ5LciqJcRwQQuQnzEhQQ" + +[registry] +url = "https://api.apr.dev" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "../node_modules/.bin/jest --preset ts-jest" diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Cargo.toml b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Cargo.toml new file mode 100644 index 00000000..f3977048 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Cargo.toml b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Cargo.toml new file mode 100644 index 00000000..95a473df --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "abl-token" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "abl_token" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] + + +[dependencies] +anchor-lang = { version = "0.31.1", features = ["interface-instructions"] } +anchor-spl = { version = "0.31.1", features = [ + "token_2022_extensions", + "token_2022", +] } + + +spl-tlv-account-resolution = "0.8.1" +spl-transfer-hook-interface = { version = "0.8.2" } +spl-discriminator = "0.3" + +[dev-dependencies] +litesvm = "0.6.1" + + +solana-instruction = "2.2.1" +solana-keypair = "2.2.1" +solana-native-token = "2.2.1" +solana-pubkey = "2.2.1" +solana-signer = "2.2.1" +solana-system-interface = "1.0.0" +solana-transaction = "2.2.1" +solana-message = "2.2.1" +solana-sdk-ids = "2.2.1" +spl-token-2022 = { version = "8.0.1", features = ["no-entrypoint"]} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Xargo.toml b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/constants.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/constants.rs new file mode 100644 index 00000000..d7cf5b48 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/constants.rs @@ -0,0 +1,4 @@ + +pub const META_LIST_ACCOUNT_SEED: &[u8] = b"extra-account-metas"; +pub const CONFIG_SEED: &[u8] = b"config"; +pub const AB_WALLET_SEED: &[u8] = b"ab_wallet"; \ No newline at end of file diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/errors.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/errors.rs new file mode 100644 index 00000000..7c7f499a --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/errors.rs @@ -0,0 +1,16 @@ +use anchor_lang::error_code; + +#[error_code] +pub enum ABListError { + #[msg("Invalid metadata")] + InvalidMetadata, + + #[msg("Wallet not allowed")] + WalletNotAllowed, + + #[msg("Amount not allowed")] + AmountNotAllowed, + + #[msg("Wallet blocked")] + WalletBlocked, +} \ No newline at end of file diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/attach_to_mint.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/attach_to_mint.rs new file mode 100644 index 00000000..2cb0c913 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/attach_to_mint.rs @@ -0,0 +1,139 @@ +use anchor_lang::{prelude::*, solana_program::program::invoke, solana_program::system_instruction::transfer}; +use anchor_spl::{ + token_2022::{spl_token_2022::{extension::{BaseStateWithExtensions, StateWithExtensions}, state::Mint as Mint2022}, Token2022}, + token_interface::{spl_token_metadata_interface::state::{Field, TokenMetadata}, token_metadata_initialize, token_metadata_update_field, Mint, TokenMetadataInitialize, TokenMetadataUpdateField}, +}; + +use spl_tlv_account_resolution::{ + state::ExtraAccountMetaList, +}; +use spl_transfer_hook_interface::instruction::ExecuteInstruction; + +use crate::{Mode, META_LIST_ACCOUNT_SEED, get_extra_account_metas, get_meta_list_size}; + + +#[derive(Accounts)] +#[instruction(args: AttachToMintArgs)] +pub struct AttachToMint<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + pub mint_authority: Signer<'info>, + + pub metadata_authority: Signer<'info>, + + #[account( + mut, + mint::token_program = token_program, + )] + pub mint: Box>, + + #[account( + init, + space = get_meta_list_size()?, + seeds = [META_LIST_ACCOUNT_SEED, mint.key().as_ref()], + bump, + payer = payer, + )] + /// CHECK: extra metas account + pub extra_metas_account: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, + + pub token_program: Program<'info, Token2022>, +} + +impl AttachToMint<'_> { + pub fn attach_to_mint(&mut self, args: AttachToMintArgs) -> Result<()> { + let mint_info = self.mint.to_account_info(); + let mint_data = mint_info.data.borrow(); + let mint = StateWithExtensions::::unpack(&mint_data)?; + + let metadata = mint.get_variable_len_extension::(); + + if metadata.is_err() { + // assume metadata is not initialized, so we need to initialize it + + let cpi_accounts = TokenMetadataInitialize { + program_id: self.token_program.to_account_info(), + mint: self.mint.to_account_info(), + metadata: self.mint.to_account_info(), // metadata account is the mint, since data is stored in mint + mint_authority: self.mint_authority.to_account_info(), + update_authority: self.metadata_authority.to_account_info(), + }; + let cpi_ctx = CpiContext::new( + self.token_program.to_account_info(), + cpi_accounts, + ); + token_metadata_initialize(cpi_ctx, args.name.unwrap(), args.symbol.unwrap(), args.uri.unwrap())?; + } + + let cpi_accounts = TokenMetadataUpdateField { + metadata: self.mint.to_account_info(), + update_authority: self.metadata_authority.to_account_info(), + program_id: self.token_program.to_account_info(), + }; + + let cpi_ctx = CpiContext::new( + self.token_program.to_account_info(), + cpi_accounts, + ); + + token_metadata_update_field(cpi_ctx, Field::Key("AB".to_string()), args.mode.to_string())?; + + if args.mode == Mode::Mixed { + let cpi_accounts = TokenMetadataUpdateField { + metadata: self.mint.to_account_info(), + update_authority: self.metadata_authority.to_account_info(), + program_id: self.token_program.to_account_info(), + }; + let cpi_ctx = CpiContext::new( + self.token_program.to_account_info(), + cpi_accounts, + ); + + token_metadata_update_field( + cpi_ctx, + Field::Key("threshold".to_string()), + args.threshold.to_string(), + )?; + } + + + let data = self.mint.to_account_info().data_len(); + let min_balance = Rent::get()?.minimum_balance(data); + if min_balance > self.mint.to_account_info().get_lamports() { + invoke( + &transfer( + &self.payer.key(), + &self.mint.to_account_info().key(), + min_balance - self.mint.to_account_info().get_lamports(), + ), + &[ + self.payer.to_account_info(), + self.mint.to_account_info(), + self.system_program.to_account_info(), + ], + )?; + } + + // initialize the extra metas account + let extra_metas_account = &self.extra_metas_account; + let metas = get_extra_account_metas()?; + let mut data = extra_metas_account.try_borrow_mut_data()?; + ExtraAccountMetaList::init::(&mut data, &metas)?; + + Ok(()) + + } +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct AttachToMintArgs { + pub name: Option, + pub symbol: Option, + pub uri: Option, + pub mode: Mode, + pub threshold: u64, +} + diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs new file mode 100644 index 00000000..2b6d036f --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/change_mode.rs @@ -0,0 +1,110 @@ +use anchor_lang::{prelude::*, solana_program::system_instruction::transfer }; +use anchor_lang::solana_program::program::invoke; +use anchor_spl::token_interface::spl_token_metadata_interface::state::TokenMetadata; +use anchor_spl::{ + token_2022::{ + spl_token_2022::extension::{BaseStateWithExtensions, StateWithExtensions}, + spl_token_2022::state::Mint, + Token2022, + + }, + token_interface::{ + Mint as MintAccount, + spl_token_metadata_interface::state::Field, token_metadata_update_field, + TokenMetadataUpdateField, + }, +}; + +use crate::Mode; + +#[derive(Accounts)] +pub struct ChangeMode<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + mut, + mint::token_program = token_program, + )] + pub mint: InterfaceAccount<'info, MintAccount>, + + pub token_program: Program<'info, Token2022>, + + pub system_program: Program<'info, System>, +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct ChangeModeArgs { + pub mode: Mode, + pub threshold: u64, +} + +impl ChangeMode<'_> { + pub fn change_mode(&mut self, args: ChangeModeArgs) -> Result<()> { + let cpi_accounts = TokenMetadataUpdateField { + metadata: self.mint.to_account_info(), + update_authority: self.authority.to_account_info(), + program_id: self.token_program.to_account_info(), + }; + let cpi_program = self.token_program.to_account_info(); + let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); + + token_metadata_update_field(cpi_ctx, Field::Key("AB".to_string()), args.mode.to_string())?; + + if args.mode == Mode::Mixed || self.has_threshold()? { + let threshold = if args.mode == Mode::Mixed { + args.threshold + } else { + 0 + }; + + let cpi_accounts = TokenMetadataUpdateField { + metadata: self.mint.to_account_info(), + update_authority: self.authority.to_account_info(), + program_id: self.token_program.to_account_info(), + }; + let cpi_program = self.token_program.to_account_info(); + let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); + + token_metadata_update_field( + cpi_ctx, + Field::Key("threshold".to_string()), + threshold.to_string(), + )?; + } + + + + let data = self.mint.to_account_info().data_len(); + let min_balance = Rent::get()?.minimum_balance(data); + if min_balance > self.mint.to_account_info().get_lamports() { + invoke( + &transfer( + &self.authority.key(), + &self.mint.to_account_info().key(), + min_balance - self.mint.to_account_info().get_lamports(), + ), + &[ + self.authority.to_account_info(), + self.mint.to_account_info(), + self.system_program.to_account_info(), + ], + )?; + } + + Ok(()) + } + + fn has_threshold(&self) -> Result { + let mint_info = self.mint.to_account_info(); + let mint_data = mint_info.data.borrow(); + let mint = StateWithExtensions::::unpack(&mint_data)?; + let metadata = mint.get_variable_len_extension::(); + Ok(metadata.is_ok() + && metadata + .unwrap() + .additional_metadata + .iter() + .any(|(key, _)| key == "threshold")) + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs new file mode 100644 index 00000000..2decd9af --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_config.rs @@ -0,0 +1,32 @@ +use anchor_lang::prelude::*; +use crate::{Config, CONFIG_SEED}; + + +#[derive(Accounts)] +pub struct InitConfig<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + #[account( + init, + payer = payer, + space = 8 + Config::INIT_SPACE, + seeds = [CONFIG_SEED], + bump, + )] + pub config: Box>, + + pub system_program: Program<'info, System>, +} + +impl InitConfig<'_> { + pub fn init_config(&mut self, config_bump: u8) -> Result<()> { + + self.config.set_inner(Config { + authority: self.payer.key(), + bump: config_bump, + }); + + Ok(()) + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs new file mode 100644 index 00000000..c0a03531 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_mint.rs @@ -0,0 +1,141 @@ +use anchor_lang::{ + prelude::*, solana_program::program::invoke, solana_program::system_instruction::transfer, +}; +use anchor_spl::{ + token_2022::Token2022, + token_interface::{ + spl_token_metadata_interface::state::Field, token_metadata_initialize, + token_metadata_update_field, Mint, TokenMetadataInitialize, TokenMetadataUpdateField, + }, +}; + +use spl_tlv_account_resolution:: + state::ExtraAccountMetaList +; +use spl_transfer_hook_interface::instruction::ExecuteInstruction; + +use crate::{get_extra_account_metas, get_meta_list_size, Mode, META_LIST_ACCOUNT_SEED}; + + +#[derive(Accounts)] +#[instruction(args: InitMintArgs)] +pub struct InitMint<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + #[account( + init, + payer = payer, + mint::token_program = token_program, + mint::decimals = args.decimals, + mint::authority = payer.key(), + mint::freeze_authority = args.freeze_authority, + extensions::permanent_delegate::delegate = args.permanent_delegate, + extensions::transfer_hook::authority = args.transfer_hook_authority, + extensions::transfer_hook::program_id = crate::id(), + extensions::metadata_pointer::authority = payer.key(), + extensions::metadata_pointer::metadata_address = mint.key(), + )] + pub mint: Box>, + + #[account( + init, + space = get_meta_list_size()?, + seeds = [META_LIST_ACCOUNT_SEED, mint.key().as_ref()], + bump, + payer = payer, + )] + /// CHECK: extra metas account + pub extra_metas_account: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, + + pub token_program: Program<'info, Token2022>, +} + +impl InitMint<'_> { + pub fn init_mint(&mut self, args: InitMintArgs) -> Result<()> { + let cpi_accounts = TokenMetadataInitialize { + program_id: self.token_program.to_account_info(), + mint: self.mint.to_account_info(), + metadata: self.mint.to_account_info(), // metadata account is the mint, since data is stored in mint + mint_authority: self.payer.to_account_info(), + update_authority: self.payer.to_account_info(), + }; + let cpi_ctx = CpiContext::new( + self.token_program.to_account_info(), + cpi_accounts, + ); + token_metadata_initialize(cpi_ctx, args.name, args.symbol, args.uri)?; + + let cpi_accounts = TokenMetadataUpdateField { + metadata: self.mint.to_account_info(), + update_authority: self.payer.to_account_info(), + program_id: self.token_program.to_account_info(), + }; + + let cpi_ctx = CpiContext::new( + self.token_program.to_account_info(), + cpi_accounts, + ); + + token_metadata_update_field(cpi_ctx, Field::Key("AB".to_string()), args.mode.to_string())?; + + if args.mode == Mode::Mixed { + let cpi_accounts = TokenMetadataUpdateField { + metadata: self.mint.to_account_info(), + update_authority: self.payer.to_account_info(), + program_id: self.token_program.to_account_info(), + }; + let cpi_ctx = CpiContext::new( + self.token_program.to_account_info(), + cpi_accounts, + ); + + token_metadata_update_field( + cpi_ctx, + Field::Key("threshold".to_string()), + args.threshold.to_string(), + )?; + } + + let data = self.mint.to_account_info().data_len(); + let min_balance = Rent::get()?.minimum_balance(data); + if min_balance > self.mint.to_account_info().get_lamports() { + invoke( + &transfer( + &self.payer.key(), + &self.mint.to_account_info().key(), + min_balance - self.mint.to_account_info().get_lamports(), + ), + &[ + self.payer.to_account_info(), + self.mint.to_account_info(), + self.system_program.to_account_info(), + ], + )?; + } + + // initialize the extra metas account + let extra_metas_account = &self.extra_metas_account; + let metas = get_extra_account_metas()?; + let mut data = extra_metas_account.try_borrow_mut_data()?; + ExtraAccountMetaList::init::(&mut data, &metas)?; + + Ok(()) + } +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct InitMintArgs { + pub decimals: u8, + pub mint_authority: Pubkey, + pub freeze_authority: Pubkey, + pub permanent_delegate: Pubkey, + pub transfer_hook_authority: Pubkey, + pub mode: Mode, + pub threshold: u64, + pub name: String, + pub symbol: String, + pub uri: String, +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_wallet.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_wallet.rs new file mode 100644 index 00000000..b310cfbb --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/init_wallet.rs @@ -0,0 +1,43 @@ +use anchor_lang::prelude::*; + +use crate::{ABWallet, Config, AB_WALLET_SEED, CONFIG_SEED}; + +#[derive(Accounts)] +pub struct InitWallet<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + seeds = [CONFIG_SEED], + bump = config.bump, + has_one = authority, + )] + pub config: Box>, + + pub wallet: SystemAccount<'info>, + + #[account( + init, + payer = authority, + space = 8 + ABWallet::INIT_SPACE, + seeds = [AB_WALLET_SEED, wallet.key().as_ref()], + bump, + )] + pub ab_wallet: Account<'info, ABWallet>, + + pub system_program: Program<'info, System>, +} + +impl InitWallet<'_> { + pub fn init_wallet(&mut self, args: InitWalletArgs) -> Result<()> { + let ab_wallet = &mut self.ab_wallet; + ab_wallet.wallet = self.wallet.key(); + ab_wallet.allowed = args.allowed; + Ok(()) + } +} + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct InitWalletArgs { + pub allowed: bool, +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/mod.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/mod.rs new file mode 100644 index 00000000..d58980ee --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/mod.rs @@ -0,0 +1,16 @@ +pub mod init_mint; +pub mod init_wallet; +pub mod tx_hook; +pub mod remove_wallet; +pub mod change_mode; +pub mod init_config; +pub mod attach_to_mint; + +pub use init_mint::*; +pub use init_wallet::*; +pub use tx_hook::*; +pub use remove_wallet::*; +pub use change_mode::*; +pub use init_config::*; +pub use attach_to_mint::*; + diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/remove_wallet.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/remove_wallet.rs new file mode 100644 index 00000000..a7c23a94 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/remove_wallet.rs @@ -0,0 +1,30 @@ +use anchor_lang::prelude::*; + +use crate::{ABWallet, Config}; + +#[derive(Accounts)] +pub struct RemoveWallet<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + seeds = [b"config"], + bump = config.bump, + has_one = authority, + )] + pub config: Box>, + + #[account( + mut, + close = authority, + )] + pub ab_wallet: Account<'info, ABWallet>, + + pub system_program: Program<'info, System>, +} + +impl RemoveWallet<'_> { + pub fn remove_wallet(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs new file mode 100644 index 00000000..01e807c4 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/instructions/tx_hook.rs @@ -0,0 +1,119 @@ +use std::str::FromStr; + +use anchor_lang::prelude::*; +use anchor_spl::{ + token_2022::spl_token_2022::{ + extension::{BaseStateWithExtensions, StateWithExtensions}, + state::Mint, + }, + token_interface::spl_token_metadata_interface::state::TokenMetadata, +}; + +use crate::{ABListError, ABWallet, Mode}; + +#[derive(Accounts)] +pub struct TxHook<'info> { + /// CHECK: + pub source_token_account: UncheckedAccount<'info>, + /// CHECK: + pub mint: UncheckedAccount<'info>, + /// CHECK: + pub destination_token_account: UncheckedAccount<'info>, + /// CHECK: + pub owner_delegate: UncheckedAccount<'info>, + /// CHECK: + pub meta_list: UncheckedAccount<'info>, + /// CHECK: + pub ab_wallet: UncheckedAccount<'info>, +} + +impl TxHook<'_> { + pub fn tx_hook(&self, amount: u64) -> Result<()> { + let mint_info = self.mint.to_account_info(); + let mint_data = mint_info.data.borrow(); + let mint = StateWithExtensions::::unpack(&mint_data)?; + + let metadata = mint.get_variable_len_extension::()?; + let decoded_mode = Self::decode_metadata(&metadata)?; + let decoded_wallet_mode = self.decode_wallet_mode()?; + + match (decoded_mode, decoded_wallet_mode) { + // first check the force allow modes + (DecodedMintMode::Allow, DecodedWalletMode::Allow) => Ok(()), + (DecodedMintMode::Allow, _) => Err(ABListError::WalletNotAllowed.into()), + // then check if the wallet is blocked + (_, DecodedWalletMode::Block) => Err(ABListError::WalletBlocked.into()), + (DecodedMintMode::Block, _) => Ok(()), + // lastly check the threshold mode + (DecodedMintMode::Threshold(threshold), DecodedWalletMode::None) + if amount >= threshold => + { + Err(ABListError::AmountNotAllowed.into()) + } + (DecodedMintMode::Threshold(_), _) => Ok(()), + } + } + + fn decode_wallet_mode(&self) -> Result { + if self.ab_wallet.data_is_empty() { + return Ok(DecodedWalletMode::None); + } + + let wallet_data = &mut self.ab_wallet.data.borrow(); + let wallet = ABWallet::try_deserialize(&mut &wallet_data[..])?; + + if wallet.allowed { + Ok(DecodedWalletMode::Allow) + } else { + Ok(DecodedWalletMode::Block) + } + } + + fn decode_metadata(metadata: &TokenMetadata) -> Result { + let mut mode = Mode::Allow; + let mut threshold = 0; + + for (key, value) in metadata.additional_metadata.iter() { + if key == "AB" { + mode = Mode::from_str(value).map_err(|_| ABListError::InvalidMetadata)?; + if mode == Mode::Allow { + return Ok(DecodedMintMode::Allow); + } else if mode == Mode::Block { + return Ok(DecodedMintMode::Block); + } else if mode == Mode::Mixed && threshold > 0 { + return Ok(DecodedMintMode::Threshold(threshold)); + } + } else if key == "threshold" { + threshold = u64::from_str(value).map_err(|_| ABListError::InvalidMetadata)?; + if threshold > 0 { + return Ok(DecodedMintMode::Threshold(threshold)); + } + } + } + + // we have early returns above, but we can reach here if metadata is meddled with + // which is why we have this fallback + // also, anchor doesn't yet support removing keys from metadata, which means that if we set threshold, we can never remove the KV pair + // only set it to 0 + + if mode == Mode::Allow { + return Ok(DecodedMintMode::Allow); + } else if mode == Mode::Block { + return Ok(DecodedMintMode::Block); + } + + Ok(DecodedMintMode::Threshold(threshold)) + } +} + +enum DecodedMintMode { + Allow, + Block, + Threshold(u64), +} + +enum DecodedWalletMode { + Allow, + Block, + None, +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs new file mode 100644 index 00000000..3bd693cf --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/lib.rs @@ -0,0 +1,51 @@ +use anchor_lang::prelude::*; +use spl_discriminator::SplDiscriminate; +use spl_transfer_hook_interface::instruction::ExecuteInstruction; + +pub mod errors; +pub mod instructions; +pub mod state; +pub mod constants; +pub mod utils; +pub use errors::*; +pub use instructions::*; +pub use state::*; +pub use constants::*; +pub use utils::*; + +declare_id!("LtkoMwPSKxAE714EY3V1oAEQ5LciqJcRwQQuQnzEhQQ"); + +#[program] +pub mod abl_token { + + use super::*; + + pub fn init_mint(ctx: Context, args: InitMintArgs) -> Result<()> { + ctx.accounts.init_mint(args) + } + + pub fn init_config(ctx: Context) -> Result<()> { + ctx.accounts.init_config(ctx.bumps.config) + } + + pub fn attach_to_mint(ctx: Context, args: AttachToMintArgs) -> Result<()> { + ctx.accounts.attach_to_mint(args) + } + + #[instruction(discriminator = ExecuteInstruction::SPL_DISCRIMINATOR_SLICE)] + pub fn tx_hook(ctx: Context, amount: u64) -> Result<()> { + ctx.accounts.tx_hook(amount) + } + + pub fn init_wallet(ctx: Context, args: InitWalletArgs) -> Result<()> { + ctx.accounts.init_wallet(args) + } + + pub fn remove_wallet(ctx: Context) -> Result<()> { + ctx.accounts.remove_wallet() + } + + pub fn change_mode(ctx: Context, args: ChangeModeArgs) -> Result<()> { + ctx.accounts.change_mode(args) + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/state.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/state.rs new file mode 100644 index 00000000..ef12ae58 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/state.rs @@ -0,0 +1,48 @@ +use std::{fmt::{self, Display}, str::FromStr}; + +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct ABWallet { + pub wallet: Pubkey, + pub allowed: bool, +} + +#[account] +#[derive(InitSpace)] +pub struct Config { + pub authority: Pubkey, + pub bump: u8, +} + +#[derive(AnchorSerialize, AnchorDeserialize, PartialEq)] +pub enum Mode { + Allow, + Block, + Mixed +} + +impl FromStr for Mode { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + match s { + "Allow" => Ok(Mode::Allow), + "Block" => Ok(Mode::Block), + "Mixed" => Ok(Mode::Mixed), + _ => Err(()), + } + } +} + +impl Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Mode::Allow => write!(f, "Allow"), + Mode::Block => write!(f, "Block"), + Mode::Mixed => write!(f, "Mixed"), + } + } +} + diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs new file mode 100644 index 00000000..33ed2c06 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/src/utils.rs @@ -0,0 +1,34 @@ +use anchor_lang::prelude::*; + +use spl_tlv_account_resolution::{ + account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList, +}; + +use crate::AB_WALLET_SEED; + + + + +pub fn get_meta_list_size() -> Result { + Ok(ExtraAccountMetaList::size_of(1).unwrap()) +} + +pub fn get_extra_account_metas() -> Result> { + Ok(vec![ + // [5] ab_wallet for destination token account wallet + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: AB_WALLET_SEED.to_vec(), + }, + Seed::AccountData { + account_index: 2, + data_index: 32, + length: 32, + }, + ], + false, + false, + )?, // [2] destination token account + ]) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs new file mode 100644 index 00000000..8c9684aa --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/programs/abl-token/tests/test.rs @@ -0,0 +1,111 @@ +use { + anchor_lang::ToAccountMetas, anchor_lang::InstructionData, solana_message::Message, + abl_token::{accounts::InitMint, accounts::InitConfig, instructions::InitMintArgs, Mode}, litesvm::LiteSVM, solana_instruction::Instruction, solana_keypair::Keypair, solana_native_token::LAMPORTS_PER_SOL, solana_pubkey::{pubkey, Pubkey}, solana_sdk_ids::system_program::ID as SYSTEM_PROGRAM_ID, solana_signer::Signer, solana_transaction::Transaction, spl_token_2022::ID as TOKEN_22_PROGRAM_ID, std::path::PathBuf +}; + +const PROGRAM_ID: Pubkey = abl_token::ID_CONST; + +fn setup() -> (LiteSVM, Keypair) { + let mut svm = LiteSVM::new(); + let admin_kp = Keypair::new(); + let admin_pk = admin_kp.pubkey(); + + svm.airdrop(&admin_pk, 10000 * LAMPORTS_PER_SOL).unwrap(); + + + let mut so_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + so_path.push("../../target/deploy/abl_token.so"); + + println!("Deploying program from {}", so_path.display()); + + let bytecode = std::fs::read(so_path).unwrap(); + + svm.add_program(PROGRAM_ID, &bytecode); + + (svm, admin_kp) +} + +#[test] +fn test() { + + let (mut svm, admin_kp) = setup(); + let admin_pk = admin_kp.pubkey(); + + let mint_kp = Keypair::new(); + let mint_pk = mint_kp.pubkey(); + let config = derive_config(); + let meta_list = derive_meta_list(&mint_pk); + + let init_cfg_ix = abl_token::instruction::InitConfig { }; + + let init_cfg_accounts = InitConfig { + payer: admin_pk, + config: config, + system_program: SYSTEM_PROGRAM_ID, + }; + + let accs = init_cfg_accounts.to_account_metas(None); + + let instruction = Instruction { + program_id: PROGRAM_ID, + accounts: accs, + data: init_cfg_ix.data(), + }; + let msg = Message::new(&[instruction], Some(&admin_pk)); + let tx = Transaction::new(&[&admin_kp], msg, svm.latest_blockhash()); + + svm.send_transaction(tx).unwrap(); + + let args: InitMintArgs = InitMintArgs { + name: "Test".to_string(), + symbol: "TEST".to_string(), + uri: "https://test.com".to_string(), + decimals: 6, + mint_authority: mint_pk, + freeze_authority: mint_pk, + permanent_delegate: mint_pk, + transfer_hook_authority: admin_pk, + mode: Mode::Mixed, + threshold: 100000, + }; + let init_mint_ix = abl_token::instruction::InitMint { + args: args, + }; + + let data = init_mint_ix.data(); + + let init_mint_accounts = InitMint { + payer: admin_pk, + mint: mint_pk, + extra_metas_account: meta_list, + system_program: SYSTEM_PROGRAM_ID, + token_program: TOKEN_22_PROGRAM_ID, + }; + + let accs = init_mint_accounts.to_account_metas(None); + + let instruction = Instruction { + program_id: PROGRAM_ID, + accounts: accs, + data: data, + }; + let msg = Message::new(&[instruction], Some(&admin_pk)); + let tx = Transaction::new(&[&admin_kp, &mint_kp], msg, svm.latest_blockhash()); + + let _res = svm.send_transaction(tx).unwrap(); + + + +} + +fn derive_config() -> Pubkey { + let seeds = &[b"config".as_ref()]; + Pubkey::find_program_address(seeds, &PROGRAM_ID).0 +} + +fn derive_meta_list(mint: &Pubkey) -> Pubkey { + let seeds = &[b"extra-account-metas", mint.as_ref()]; + Pubkey::find_program_address(seeds, &PROGRAM_ID).0 +} + + diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/src/abl-token-exports.ts b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/src/abl-token-exports.ts new file mode 100644 index 00000000..6dbb110c --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/src/abl-token-exports.ts @@ -0,0 +1,31 @@ +// Here we export some useful types and functions for interacting with the Anchor program. +import { AnchorProvider, Program } from '@coral-xyz/anchor' +import { Cluster, PublicKey } from '@solana/web3.js' +import ABLTokenIDL from '../target/idl/abl_token.json' +import type { AblToken } from '../target/types/abl_token' + +// Re-export the generated IDL and type +export { ABLTokenIDL } + +// The programId is imported from the program IDL. +export const ABL_TOKEN_PROGRAM_ID = new PublicKey(ABLTokenIDL.address) + +// This is a helper function to get the Basic Anchor program. +export function getABLTokenProgram(provider: AnchorProvider, address?: PublicKey): Program { + return new Program({ ...ABLTokenIDL, address: address ? address.toBase58() : ABLTokenIDL.address } as AblToken, provider) +} + +// This is a helper function to get the program ID for the Basic program depending on the cluster. +export function getABLTokenProgramId(cluster: Cluster) { + switch (cluster) { + case 'devnet': + case 'testnet': + // This is the program ID for the Basic program on devnet and testnet. + return new PublicKey('6z68wfurCMYkZG51s1Et9BJEd9nJGUusjHXNt4dGbNNF') + case 'mainnet-beta': + default: + return ABL_TOKEN_PROGRAM_ID + } +} + +//ABLTokenIDL.types["mode"] \ No newline at end of file diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/src/index.ts b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/src/index.ts new file mode 100644 index 00000000..5dbc64be --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/src/index.ts @@ -0,0 +1 @@ +export * from './abl-token-exports' diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/tests/basic.test.ts b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/tests/basic.test.ts new file mode 100644 index 00000000..3636996a --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/tests/basic.test.ts @@ -0,0 +1,14 @@ +import * as anchor from '@coral-xyz/anchor' +import { Program } from '@coral-xyz/anchor' +import { AblToken } from '../target/types/abl_token' + +describe('abl-token', () => { + // Configure the client to use the local cluster. + anchor.setProvider(anchor.AnchorProvider.env()) + + const program = anchor.workspace.ABLToken as Program + + it('should run the program', async () => { + // Add your test here. + }) +}) diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/tsconfig.json b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/tsconfig.json new file mode 100644 index 00000000..9abe03fa --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/anchor/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "types": ["jest", "node"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "target": "es6", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/components.json b/tokens/token-2022/transfer-hook/allow-block-list-token/components.json new file mode 100644 index 00000000..3289f237 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/eslint.config.mjs b/tokens/token-2022/transfer-hook/allow-block-list-token/eslint.config.mjs new file mode 100644 index 00000000..46f02aef --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from 'path' +import { fileURLToPath } from 'url' +import { FlatCompat } from '@eslint/eslintrc' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}) + +const eslintConfig = [...compat.extends('next/core-web-vitals', 'next/typescript')] + +export default eslintConfig diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/next.config.ts b/tokens/token-2022/transfer-hook/allow-block-list-token/next.config.ts new file mode 100644 index 00000000..73290639 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + /* config options here */ +} + +export default nextConfig diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/package.json b/tokens/token-2022/transfer-hook/allow-block-list-token/package.json new file mode 100644 index 00000000..cda99855 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/package.json @@ -0,0 +1,59 @@ +{ + "name": "legacy-next-tailwind-basic", + "version": "0.0.0", + "private": true, + "scripts": { + "anchor": "cd anchor && anchor", + "anchor-build": "cd anchor && anchor build", + "anchor-localnet": "cd anchor && anchor localnet", + "anchor-test": "cd anchor && anchor test", + "build": "next build", + "ci": "npm run build && npm run lint && npm run format:check", + "dev": "next dev --turbopack", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "next lint", + "start": "next start" + }, + "dependencies": { + "@coral-xyz/anchor": "^0.31.1", + "@radix-ui/react-dialog": "^1.1.11", + "@radix-ui/react-dropdown-menu": "^2.1.12", + "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-slot": "^1.2.0", + "@solana/spl-token": "0.4.13", + "@solana/wallet-adapter-base": "0.9.26", + "@solana/wallet-adapter-react": "0.15.38", + "@solana/wallet-adapter-react-ui": "0.9.38", + "@solana/web3.js": "1.98.2", + "@tanstack/react-query": "^5.74.7", + "bigint-buffer": "^1.1.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "jotai": "^2.12.3", + "lucide-react": "^0.503.0", + "next": "15.3.1", + "next-themes": "^0.4.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "sonner": "^2.0.3", + "tailwind-merge": "^3.2.0", + "tw-animate-css": "^1.2.8" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.1", + "@tailwindcss/postcss": "^4.1.4", + "@types/bn.js": "^5.1.6", + "@types/jest": "^29.5.14", + "@types/node": "^22.15.3", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "eslint": "^9.25.1", + "eslint-config-next": "15.3.1", + "jest": "^29.7.0", + "prettier": "^3.5.3", + "tailwindcss": "^4.1.4", + "ts-jest": "^29.3.2", + "typescript": "^5.8.3" + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/postcss.config.mjs b/tokens/token-2022/transfer-hook/allow-block-list-token/postcss.config.mjs new file mode 100644 index 00000000..86e8e3c4 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ['@tailwindcss/postcss'], +} + +export default config diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/public/.gitkeep b/tokens/token-2022/transfer-hook/allow-block-list-token/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/scripts/start.sh b/tokens/token-2022/transfer-hook/allow-block-list-token/scripts/start.sh new file mode 100755 index 00000000..df1dca0e --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/scripts/start.sh @@ -0,0 +1,15 @@ +#! /bin/bash + +# get root directory +ROOT_DIR=$(cd $(dirname $0)/.. && pwd) + +ROOT_ANCHOR_DIR=$ROOT_DIR/anchor + +cd $ROOT_ANCHOR_DIR + +solana-test-validator --reset --ledger $ROOT_ANCHOR_DIR/test-ledger --quiet & + +# wait for validator to start +sleep 5 + +anchor deploy --provider.cluster localnet diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/scripts/stop.sh b/tokens/token-2022/transfer-hook/allow-block-list-token/scripts/stop.sh new file mode 100755 index 00000000..0c792baa --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/scripts/stop.sh @@ -0,0 +1,4 @@ +#! /bin/bash + +# +ps ax | grep solana-test-validator | grep -v grep | awk '{print $1}' | xargs kill -9 \ No newline at end of file diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/account/[address]/page.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/account/[address]/page.tsx new file mode 100644 index 00000000..5bb6688e --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/account/[address]/page.tsx @@ -0,0 +1,5 @@ +import AccountDetailFeature from '@/components/account/account-detail-feature' + +export default function Page() { + return +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/account/page.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/account/page.tsx new file mode 100644 index 00000000..1b0e2768 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/account/page.tsx @@ -0,0 +1,5 @@ +import AccountListFeature from '@/components/account/account-list-feature' + +export default function Page() { + return +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/config/page.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/config/page.tsx new file mode 100644 index 00000000..e1cde08f --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/config/page.tsx @@ -0,0 +1,6 @@ + +import AblTokenConfig from '@/components/abl-token/abl-token-config' + +export default function Page() { + return +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/create-token/page.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/create-token/page.tsx new file mode 100644 index 00000000..39c70529 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/create-token/page.tsx @@ -0,0 +1,5 @@ +import AblTokenFeature from '@/components/abl-token/abl-token-feature' + +export default function Page() { + return +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/favicon.ico b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/favicon.ico new file mode 100644 index 00000000..3bc594ba Binary files /dev/null and b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/favicon.ico differ diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/globals.css b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/globals.css new file mode 100644 index 00000000..2edb31d8 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/globals.css @@ -0,0 +1,127 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } +} + +.wallet-adapter-button-trigger { + height: auto !important; + @apply !border !bg-background !shadow-xs hover:!bg-accent !text-accent-foreground hover:!text-accent-foreground dark:!bg-input/30 !border-input/10 dark:!border-input dark:hover:!bg-input/50; + @apply !px-2 !py-[6px] !rounded-md !text-sm !font-semibold !shadow-sm !transition-all; +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/layout.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/layout.tsx new file mode 100644 index 00000000..6cfdc1d1 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/layout.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from 'next' +import './globals.css' +import { AppProviders } from '@/components/app-providers' +import { AppLayout } from '@/components/app-layout' +import React from 'react' + +export const metadata: Metadata = { + title: 'ABL Token', + description: 'ABL Token', +} + +const links: { label: string; path: string }[] = [ + // More links... + { label: 'Home', path: '/' }, + { label: 'Account', path: '/account' }, + { label: 'Config', path: '/config' }, + { label: 'Create New Token', path: '/create-token' }, + { label: 'Manage Token', path: '/manage-token' }, +] + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + + {children} + + + + ) +} +// Patch BigInt so we can log it using JSON.stringify without any errors +declare global { + interface BigInt { + toJSON(): string + } +} + +BigInt.prototype.toJSON = function () { + return this.toString() +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/manage-token/[address]/page.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/manage-token/[address]/page.tsx new file mode 100644 index 00000000..7a5c53e1 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/manage-token/[address]/page.tsx @@ -0,0 +1,5 @@ +import AblTokenManageTokenDetail from '@/components/abl-token/abl-token-manage-token-detail' + +export default function Page() { + return +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/manage-token/page.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/manage-token/page.tsx new file mode 100644 index 00000000..17547684 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/manage-token/page.tsx @@ -0,0 +1,5 @@ +import AblTokenManageToken from '@/components/abl-token/abl-token-manage-token' + +export default function Page() { + return +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/page.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/page.tsx new file mode 100644 index 00000000..b0e9cd11 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/app/page.tsx @@ -0,0 +1,5 @@ +import { DashboardFeature } from '@/components/dashboard/dashboard-feature' + +export default function Home() { + return +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-config.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-config.tsx new file mode 100644 index 00000000..4cf8d610 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-config.tsx @@ -0,0 +1,360 @@ +'use client' + +import { useWallet } from '@solana/wallet-adapter-react' +import { ExplorerLink } from '../cluster/cluster-ui' +import { WalletButton } from '../solana/solana-provider' +import { useAblTokenProgram } from './abl-token-data-access' +import { AblTokenCreate, AblTokenProgram } from './abl-token-ui' +import { AppHero } from '../app-hero' +import { ellipsify } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import React from 'react' +import { PublicKey } from '@solana/web3.js' + +export default function AblTokenConfig() { + const { publicKey } = useWallet() + const { programId, getConfig, getAbWallets } = useAblTokenProgram() + const [lastUpdate, setLastUpdate] = React.useState(0) + + const config = getConfig.data; + let abWallets = getAbWallets.data; + + const handleWalletListUpdate = React.useCallback(async () => { + await getAbWallets.refetch(); + abWallets = getAbWallets.data; + setLastUpdate(Date.now()) + }, []) + + return publicKey ? ( +
+ +

+ +

+ {config ? ( + <> +
+ +
+
+ {config.authority.equals(publicKey) ? ( + + ) : ( +
+ UNAUTHORIZED: Only the config authority can modify the wallet list +
+ )} +
+ + ) : ( + + )} +
+
+ ) : ( +
+
+
+ +
+
+
+ ) +} + + +export function AblTokenConfigCreate() { + const { initConfig, getConfig } = useAblTokenProgram() + const { publicKey } = useWallet() + + const handleCreate = async () => { + if (!publicKey) return; + try { + await initConfig.mutateAsync(); + // Refresh the config list + getConfig.refetch(); + } catch (err) { + console.error('Failed to create config:', err); + } + }; + + return ( +
+

Create ABL Token Config

+

+ Initialize the ABL Token configuration. This will set up the necessary accounts for managing allow/block lists. +

+ +
+ ); +} + +export function AblTokenConfigList({ abWallets }: { abWallets: any[] | undefined }) { + return ( +
+

ABL Token Config List

+ {abWallets && abWallets.length > 0 ? ( +
+ + + + + + + + + {abWallets.map((wallet) => ( + + + + + ))} + +
Wallet AddressStatus
{wallet.account.wallet.toString()} + + {wallet.account.allowed ? 'Allowed' : 'Blocked'} + +
+
+ ) : ( +

No wallets configured yet.

+ )} +
+ ); +} + +interface WalletChange { + address: string; + mode: 'allow' | 'block' | 'remove'; + status?: 'pending' | 'success' | 'error'; + error?: string; +} + +export function AblTokenConfigListChange({ onWalletListUpdate }: { onWalletListUpdate: () => void }) { + const { getAbWallets, processBatchWallets } = useAblTokenProgram() + const [isEditing, setIsEditing] = React.useState(false) + const [walletChanges, setWalletChanges] = React.useState([]) + const [isProcessing, setIsProcessing] = React.useState(false) + const existingWallets = React.useMemo(() => { + const wallets = getAbWallets.data || [] + return new Map(wallets.map(w => [w.account.wallet.toString(), w.account.allowed])) + }, [getAbWallets.data]) + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + } + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + const file = e.dataTransfer.files[0] + if (file && file.type === 'text/csv') { + const text = await file.text() + const rows = text.split('\n') + + // Create a Set of existing wallet addresses for deduplication + const existingAddresses = new Set([ + ...Array.from(existingWallets.keys()), + ...walletChanges.map(w => w.address) + ]) + + const parsed: WalletChange[] = rows + .filter(row => row.trim()) + .map(row => { + const [address, mode] = row.split(',').map(field => field.trim()) + return { + address, + mode: mode.toLowerCase() as 'allow' | 'block' | 'remove' + } + }) + .filter(entry => { + try { + new PublicKey(entry.address) + return ['allow', 'block', 'remove'].includes(entry.mode) + } catch { + return false + } + }) + .filter(entry => { + // Only allow 'remove' for existing wallets + if (entry.mode === 'remove') { + return existingWallets.has(entry.address) + } + return true + }) + // Deduplicate entries, keeping the last occurrence of each address + .reduce((acc, entry) => { + const existingIndex = acc.findIndex(w => w.address === entry.address) + if (existingIndex >= 0) { + acc[existingIndex] = entry + } else { + acc.push(entry) + } + return acc + }, [] as WalletChange[]) + // Filter out entries that already exist in the current state + .filter(entry => !existingAddresses.has(entry.address)) + + if (parsed.length > 0) { + setWalletChanges(prev => [...prev, ...parsed]) + setIsEditing(true) + } + } + } + + const handleAddWallet = () => { + setWalletChanges(prev => [...prev, { address: '', mode: 'allow' }]) + setIsEditing(true) + } + + const handleUpdateWallet = (index: number, field: keyof WalletChange, value: string) => { + setWalletChanges(prev => prev.map((wallet, i) => + i === index ? { ...wallet, [field]: value } : wallet + )) + } + + const handleRemoveWallet = (index: number) => { + setWalletChanges(prev => prev.filter((_, i) => i !== index)) + } + + const processWallets = async () => { + setIsProcessing(true) + const batchSize = 10 + const batches = [] + + for (let i = 0; i < walletChanges.length; i += batchSize) { + batches.push(walletChanges.slice(i, i + batchSize)) + } + + for (const batch of batches) { + try { + await processBatchWallets.mutateAsync({ + wallets: batch.map(w => ({ + wallet: new PublicKey(w.address), + mode: w.mode + })) + }) + + // Mark batch as successful + setWalletChanges(prev => prev.map(wallet => + batch.some(b => b.address === wallet.address) + ? { ...wallet, status: 'success' } + : wallet + )) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + // Mark batch as failed + setWalletChanges(prev => prev.map(wallet => + batch.some(b => b.address === wallet.address) + ? { ...wallet, status: 'error', error: errorMessage } + : wallet + )) + } + } + + // Refresh wallet list and clear successful changes + await getAbWallets.refetch() + setWalletChanges(prev => prev.filter(w => w.status !== 'success')) + setIsProcessing(false) + // Notify parent component to update the wallet list + onWalletListUpdate() + } + + return ( +
+
+

Edit Wallet List

+
+ + {isEditing && ( + + )} +
+
+ +
+ Drop CSV file here (address,mode) +

+ Mode can be: allow, block, or remove (remove only works for existing wallets) +

+
+ + {walletChanges.length > 0 && ( +
+ + + + + + + + + + + {walletChanges.map((wallet, index) => ( + + + + + + + ))} + +
Wallet AddressModeStatusActions
+ handleUpdateWallet(index, 'address', e.target.value)} + placeholder="Wallet address" + disabled={isProcessing} + /> + + + + {wallet.status === 'success' && ( + ✓ + )} + {wallet.status === 'error' && ( + ✗ + )} + + +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-data-access.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-data-access.tsx new file mode 100644 index 00000000..1fc16564 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-data-access.tsx @@ -0,0 +1,330 @@ +'use client' + +import { getABLTokenProgram, getABLTokenProgramId } from '@project/anchor' +import { useConnection } from '@solana/wallet-adapter-react' +import { Cluster, Keypair, PublicKey, Transaction } from '@solana/web3.js' +import { useMutation, useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import { useCluster } from '../cluster/cluster-data-access' +import { useAnchorProvider } from '../solana/solana-provider' +import { useTransactionToast } from '../use-transaction-toast' +import { toast } from 'sonner' +import { BN } from '@coral-xyz/anchor' +import { amountToUiAmount, createAssociatedTokenAccountIdempotentInstruction, createAssociatedTokenAccountIdempotentInstructionWithDerivation, createMintToCheckedInstruction, decodeMintToCheckedInstruction, getAssociatedTokenAddressSync, getMint, getPermanentDelegate, getTokenMetadata, getTransferHook, mintToChecked, mintToCheckedInstructionData, TOKEN_2022_PROGRAM_ID } from '@solana/spl-token' + + +export function useHasTransferHookEnabled(mint: PublicKey) { + const { connection } = useConnection() + const provider = useAnchorProvider() + const { cluster } = useCluster() + const programId = useMemo(() => getABLTokenProgramId(cluster.network as Cluster), [cluster]) + const program = useMemo(() => getABLTokenProgram(provider, programId), [provider, programId]) + return useQuery({ + queryKey: ['has-transfer-hook', { cluster }], + queryFn: async () => { + const mintInfo = await getMint( + connection, + mint, + "confirmed", + TOKEN_2022_PROGRAM_ID, + ); + const transferHook = getTransferHook(mintInfo); + return transferHook !== null && programId.equals(transferHook.programId); + }, + }) +} +export function useAblTokenProgram() { + const { connection } = useConnection() + const { cluster } = useCluster() + const transactionToast = useTransactionToast() + const provider = useAnchorProvider() + const programId = useMemo(() => getABLTokenProgramId(cluster.network as Cluster), [cluster]) + const program = useMemo(() => getABLTokenProgram(provider, programId), [provider, programId]) + + const getProgramAccount = useQuery({ + queryKey: ['get-program-account', { cluster }], + queryFn: () => connection.getParsedAccountInfo(programId), + }) + + const initToken = useMutation({ + mutationKey: ['abl-token', 'init-token', { cluster }], + mutationFn: (args: { + mintAuthority: PublicKey, + freezeAuthority: PublicKey, + permanentDelegate: PublicKey, + transferHookAuthority: PublicKey, + mode: string, + threshold: BN, + name: string, + symbol: string, + uri: string, + decimals: number, + }) => { + const modeEnum = args.mode === 'allow' ? { allow: {} } : args.mode === 'block' ? { block: {}} : { mixed: {}}; + const mint = Keypair.generate(); + + return program.methods.initMint({ + decimals: args.decimals, + mintAuthority: args.mintAuthority, + freezeAuthority: args.freezeAuthority, + permanentDelegate: args.permanentDelegate, + transferHookAuthority: args.mintAuthority, + mode: modeEnum, + threshold: args.threshold, + name: args.name, + symbol: args.symbol, + uri: args.uri, + }).accounts({ + mint: mint.publicKey, + }).signers([mint]).rpc().then((signature) => ({ signature, mintAddress: mint.publicKey })) + }, + onSuccess: ({ signature, mintAddress }) => { + transactionToast(signature) + window.location.href = `/manage-token/${mintAddress.toString()}` + }, + onError: () => toast.error('Failed to initialize token'), + }) + + const attachToExistingToken = useMutation({ + mutationKey: ['abl-token', 'attach-to-existing-token', { cluster }], + mutationFn: (args: { + mint: PublicKey, + mode: string, + threshold: BN, + name: string | null, + symbol: string | null, + uri: string | null, + }) => { + const modeEnum = args.mode === 'allow' ? { allow: {} } : args.mode === 'block' ? { block: {}} : { mixed: {}}; + + return program.methods.attachToMint({ + mode: modeEnum, + threshold: args.threshold, + name: args.name, + symbol: args.symbol, + uri: args.uri, + }).accounts({ + mint: args.mint, + }).rpc() + }, + onSuccess: (signature) => { + transactionToast(signature) + }, + onError: () => toast.error('Failed to initialize token'), + }) + + const changeMode = useMutation({ + mutationKey: ['abl-token', 'change-mode', { cluster }], + mutationFn: (args: { + mode: string, + threshold: BN, + mint: PublicKey, + }) => { + const modeEnum = args.mode === 'allow' ? { allow: {} } : args.mode === 'block' ? { block: {}} : { mixed: {}} + return program.methods.changeMode({ + mode: modeEnum, + threshold: args.threshold, + }).accounts({ + mint: args.mint, + }).rpc() + }, + onSuccess: (signature) => { + transactionToast(signature) + }, + onError: () => toast.error('Failed to run program'), + }) + + const initWallet = useMutation({ + mutationKey: ['abl-token', 'change-mode', { cluster }], + mutationFn: (args: { + wallet: PublicKey, + allowed: boolean, + }) => { + return program.methods.initWallet({ + allowed: args.allowed, + }).accounts({ + wallet: args.wallet, + }).rpc() + }, + onSuccess: (signature) => { + transactionToast(signature) + }, + onError: () => toast.error('Failed to run program'), + }) + + const processBatchWallets = useMutation({ + mutationKey: ['abl-token', 'process-batch-wallets', { cluster }], + mutationFn: async (args: { + wallets: {wallet: PublicKey, mode: "allow" | "block" | "remove"}[], + }) => { + const instructions = await Promise.all(args.wallets.map((wallet) => { + if (wallet.mode === "remove") { + const [abWalletPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from('ab_wallet'), + wallet.wallet.toBuffer(), + ], + program.programId + ); + return program.methods.removeWallet().accounts({ + abWallet: abWalletPda, + }).instruction() + } + return program.methods.initWallet({ + allowed: wallet.mode === "allow", + }).accounts({ + wallet: wallet.wallet, + }).instruction() + })); + + const transaction = new Transaction(); + transaction.add(...instructions); + transaction.feePayer = provider.wallet.publicKey; + transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; + //transaction.sign(provider.wallet); + + let signedTx = await provider.wallet.signTransaction(transaction); + + return connection.sendRawTransaction(signedTx.serialize()); + + }, + onSuccess: (signature) => { + transactionToast(signature) + }, + onError: () => toast.error('Failed to run program'), + }) + + + const removeWallet = useMutation({ + mutationKey: ['abl-token', 'change-mode', { cluster }], + mutationFn: (args: { + wallet: PublicKey, + }) => { + const [abWalletPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from('ab_wallet'), + args.wallet.toBuffer(), + ], + program.programId + ); + return program.methods.removeWallet().accounts({ + abWallet: abWalletPda, + }).rpc() + }, + onSuccess: (signature) => { + transactionToast(signature) + }, + onError: () => toast.error('Failed to run program'), + }) + + + const initConfig = useMutation({ + mutationKey: ['abl-token', 'init-config', { cluster }], + mutationFn: () => { + return program.methods.initConfig().rpc() + }, + }) + + const getConfig = useQuery({ + queryKey: ['get-config', { cluster }], + queryFn: () => { + const [configPda] = PublicKey.findProgramAddressSync( + [Buffer.from('config')], + program.programId + ); + return program.account.config.fetch(configPda) + }, + }) + + const getAbWallets = useQuery({ + queryKey: ['get-ab-wallets', { cluster }], + queryFn: () => { + return program.account.abWallet.all() + }, + }) + + const getToken = (mint: PublicKey) => useQuery({ + queryKey: ['get-token', { endpoint: connection.rpcEndpoint, mint }], + queryFn: async () => { + const mintInfo = await getMint( + connection, + mint, + "confirmed", + TOKEN_2022_PROGRAM_ID, + ); + + const metadata = await getTokenMetadata( + connection, + mint, + "confirmed", + TOKEN_2022_PROGRAM_ID, + ); + + const permanentDelegate = await getPermanentDelegate(mintInfo); + + return { + name: metadata?.name, + symbol: metadata?.symbol, + uri: metadata?.uri, + decimals: mintInfo.decimals, + mintAuthority: mintInfo.mintAuthority, + freezeAuthority: mintInfo.freezeAuthority, + permanentDelegate: permanentDelegate, + } + }, + }) +/* + const getBalance = useQuery({ + queryKey: ['get-balance', { cluster }], + queryFn: () => { + getbal + }, + })*/ + + const mintTo = useMutation({ + mutationKey: ['abl-token', 'mint-to', { cluster }], + mutationFn: async (args: { + mint: PublicKey, + amount: BN, + recipient: PublicKey, + }) => { + const mintInfo = await getMint( + connection, + args.mint, + "confirmed", + TOKEN_2022_PROGRAM_ID, + ); + const ata = getAssociatedTokenAddressSync(args.mint, args.recipient, true, TOKEN_2022_PROGRAM_ID); + + const ix = createAssociatedTokenAccountIdempotentInstruction(provider.publicKey, ata, args.recipient, args.mint, TOKEN_2022_PROGRAM_ID); + const ix2 = createMintToCheckedInstruction(args.mint, ata, provider.publicKey, args.amount.toNumber(), mintInfo.decimals, undefined, TOKEN_2022_PROGRAM_ID); + const tx = new Transaction(); + tx.add(ix, ix2); + tx.feePayer = provider.wallet.publicKey; + tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; + let signedTx = await provider.wallet.signTransaction(tx); + return connection.sendRawTransaction(signedTx.serialize()) + }, + onSuccess: (signature) => { + transactionToast(signature) + }, + onError: () => toast.error('Failed to run program'), + }) + + return { + program, + programId, + getProgramAccount, + initToken, + changeMode, + initWallet, + removeWallet, + initConfig, + getConfig, + getAbWallets, + getToken, + processBatchWallets, + mintTo, + attachToExistingToken, + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-feature.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-feature.tsx new file mode 100644 index 00000000..f49c2f90 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-feature.tsx @@ -0,0 +1,34 @@ +'use client' + +import { useWallet } from '@solana/wallet-adapter-react' +import { ExplorerLink } from '../cluster/cluster-ui' +import { WalletButton } from '../solana/solana-provider' +import { useAblTokenProgram } from './abl-token-data-access' +import { AblTokenCreate, AblTokenProgram } from './abl-token-ui' +import { AppHero } from '../app-hero' +import { ellipsify } from '@/lib/utils' + +export default function AblTokenFeature() { + const { publicKey } = useWallet() + const { programId } = useAblTokenProgram() + + return publicKey ? ( +
+ +

+ +

+ +
+ +
+ ) : ( +
+
+
+ +
+
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token-detail.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token-detail.tsx new file mode 100644 index 00000000..398a6705 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token-detail.tsx @@ -0,0 +1,281 @@ +'use client' + +import { useWallet } from '@solana/wallet-adapter-react' +import { WalletButton } from '../solana/solana-provider' +import { useParams } from 'next/navigation' +import React from 'react' +import { useAblTokenProgram, useHasTransferHookEnabled } from './abl-token-data-access' +import { PublicKey } from '@solana/web3.js' +import { PermanentDelegate } from '@solana/spl-token' +import { BN } from '@coral-xyz/anchor' +import { Button } from '@/components/ui/button' + +interface TokenInfo { + address: string; + name: string | undefined; + symbol: string | undefined; + uri: string | undefined; + decimals: number; + supply: number; + mintAuthority: PublicKey | null; + freezeAuthority: PublicKey | null; + permanentDelegate: PermanentDelegate | null; +} + +function TokenInfo({ tokenInfo }: { tokenInfo: TokenInfo | null }) { + return ( +
+

Token Information

+ {tokenInfo ? ( +
+
Address: {tokenInfo.address}
+
Name: {tokenInfo.name}
+
Symbol: {tokenInfo.symbol}
+
Decimals: {tokenInfo.decimals}
+
URI: {tokenInfo.uri}
+
Supply: {tokenInfo.supply}
+
Mint Authority: {tokenInfo.mintAuthority?.toString()}
+
Freeze Authority: {tokenInfo.freezeAuthority?.toString()}
+
Permanent Delegate: {tokenInfo.permanentDelegate?.delegate.toString()}
+
+ ) : ( +

No token information available.

+ )} +
+ ) +} + +function TokenManagement({ tokenInfo }: { tokenInfo: TokenInfo }) { + const { publicKey } = useWallet() + const { changeMode, mintTo, attachToExistingToken } = useAblTokenProgram() + const [mode, setMode] = React.useState<'allow' | 'block' | 'mixed'>('allow') + const [threshold, setThreshold] = React.useState('100000') + const [destinationWallet, setDestinationWallet] = React.useState('') + const hasTransferHookEnabled = useHasTransferHookEnabled(new PublicKey(tokenInfo.address)) + const [name, setName] = React.useState('') + const [symbol, setSymbol] = React.useState('') + const [uri, setUri] = React.useState('') + + + const handleApplyChanges = async () => { + if (!publicKey || !tokenInfo) return; + + try { + await changeMode.mutateAsync({ + mode, + threshold: new BN(threshold), + mint: new PublicKey(tokenInfo.address), + }); + } catch (err) { + console.error('Failed to apply changes:', err); + } + }; + + const setTransferHook = async () => { + if (!publicKey || !tokenInfo) return; + + try { + await attachToExistingToken.mutateAsync({ + mint: new PublicKey(tokenInfo.address), + mode, + threshold: new BN(threshold), + name, + symbol, + uri, + }); + } catch (err) { + console.error('Failed to set transfer hook:', err); + } + }; + + const [mintAmount, setMintAmount] = React.useState('0') + + const handleMint = async () => { + if (!publicKey || !tokenInfo) return; + + try { + await mintTo.mutateAsync({ + mint: new PublicKey(tokenInfo.address), + amount: new BN(mintAmount), + recipient: publicKey, + }); + console.log('Minted successfully'); + } catch (err) { + console.error('Failed to mint tokens:', err); + } + }; + + return ( +
+

Token Management

+
+
+ {hasTransferHookEnabled.data ? ( +
+ +
+ + + +
+ + {mode === 'mixed' && ( +
+ + setThreshold(e.target.value)} + min="0" + /> +
+ )} + +
+ +
+ +
+ ) : ( +
+
+
+ + setName(e.target.value)} + placeholder="Enter token name" + /> +
+
+ + setSymbol(e.target.value)} + placeholder="Enter token symbol" + /> +
+
+ + setUri(e.target.value)} + placeholder="Enter token URI" + /> +
+
+
+ +
+
+ )} +
+ +
+

Mint New Tokens

+
+
+ + setDestinationWallet(e.target.value)} + placeholder="Enter destination wallet address" + /> +
+
+ setMintAmount(e.target.value)} + min="0" + placeholder="Amount to mint" + /> + +
+
+
+
+
+ ) +} + +export default function ManageTokenDetail() { + const { publicKey } = useWallet() + const { getToken } = useAblTokenProgram() + const params = useParams() + const tokenAddress = params?.address as string + + const tokenQuery = getToken(new PublicKey(tokenAddress)); + + const tokenInfo = React.useMemo(() => { + if (!tokenQuery?.data || !tokenAddress) return null; + return { + ...tokenQuery.data, + address: tokenAddress, + supply: 0, // TODO: Get supply from token account + }; + }, [tokenQuery?.data, tokenAddress]); + + if (!publicKey) { + return ( +
+
+ +
+
+ ) + } + + return ( +
+ {tokenQuery?.isLoading ? ( +

Loading token information...

+ ) : tokenQuery?.isError ? ( +

Error loading token information. Please check the token address.

+ ) : ( + <> + + {tokenInfo && } + + + )} +
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token-input.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token-input.tsx new file mode 100644 index 00000000..1f5a1695 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token-input.tsx @@ -0,0 +1,55 @@ +'use client' + +import { useWallet } from '@solana/wallet-adapter-react' +import { WalletButton } from '../solana/solana-provider' + +import { redirect } from 'next/navigation' +import React from 'react' +import { Button } from '@/components/ui/button' + +export default function ManageTokenInput() { + const { publicKey } = useWallet() + const [tokenAddress, setTokenAddress] = React.useState('') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (tokenAddress) { + redirect(`/manage-token/${tokenAddress.toString()}`) + } + } + + if (!publicKey) { + return ( +
+
+ +
+
+ ) + } + + return ( +
+
+
+
+ + +
+
+
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token.tsx new file mode 100644 index 00000000..c5fedc73 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-manage-token.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useWallet } from '@solana/wallet-adapter-react' +import { ExplorerLink } from '../cluster/cluster-ui' +import { WalletButton } from '../solana/solana-provider' +import { useAblTokenProgram } from './abl-token-data-access' +import { AblTokenCreate, AblTokenProgram } from './abl-token-ui' +import { AppHero } from '../app-hero' +import { ellipsify } from '@/lib/utils' +import ManageTokenInput from './abl-token-manage-token-input' +export default function AblTokenFeature() { + const { publicKey } = useWallet() + const { programId } = useAblTokenProgram() + + return publicKey ? ( +
+ + + +
+ ) : ( +
+
+
+ +
+
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-new-token.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-new-token.tsx new file mode 100644 index 00000000..1e1a7567 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-new-token.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useWallet } from '@solana/wallet-adapter-react' +import { ExplorerLink } from '../cluster/cluster-ui' +import { WalletButton } from '../solana/solana-provider' +import { useAblTokenProgram } from './abl-token-data-access' +import { AblTokenCreate, AblTokenProgram } from './abl-token-ui' +import { AppHero } from '../app-hero' +import { ellipsify } from '@/lib/utils' + +export default function AblTokenFeature() { + const { publicKey } = useWallet() + const { programId } = useAblTokenProgram() + + return publicKey ? ( +
+ +

+ +

+ +
+
+ ) : ( +
+
+
+ +
+
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-ui.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-ui.tsx new file mode 100644 index 00000000..eac24dc4 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/abl-token/abl-token-ui.tsx @@ -0,0 +1,290 @@ +'use client' + +import { PublicKey } from '@solana/web3.js' +import { useAblTokenProgram } from './abl-token-data-access' +import { Button } from '@/components/ui/button' +import { BN } from '@coral-xyz/anchor' +import React from 'react' +import { useWallet } from '@solana/wallet-adapter-react' + +export function AblTokenCreate() { + + const { publicKey } = useWallet() + const { initToken } = useAblTokenProgram() + const [mode, setMode] = React.useState<'allow' | 'block' | 'threshold'>('allow') + const [threshold, setThreshold] = React.useState('100000') + const [formData, setFormData] = React.useState({ + mintAuthority: publicKey ? publicKey.toString() : '', + freezeAuthority: publicKey ? publicKey.toString() : '', + permanentDelegate: publicKey ? publicKey.toString() : '', + transferHookAuthority: publicKey ? publicKey.toString() : '', + name: '', + symbol: '', + uri: '', + decimals: '6' + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + try { + initToken.mutateAsync({ + decimals: parseInt(formData.decimals), + mintAuthority: new PublicKey(formData.mintAuthority), + freezeAuthority: new PublicKey(formData.freezeAuthority), + permanentDelegate: new PublicKey(formData.permanentDelegate), + transferHookAuthority: new PublicKey(formData.transferHookAuthority), + mode, + threshold: new BN(threshold), + name: formData.name, + symbol: formData.symbol, + uri: formData.uri + }) + } catch (err) { + console.error('Invalid form data:', err) + } + } + + return ( +
+
+ + + + + + + + + + + + + + + + +
+ +
+ + + +
+
+ + {mode === 'threshold' && ( + + )} +
+ + +
+ ) +} + +export function AblTokenProgram() { + const { getProgramAccount } = useAblTokenProgram() + + if (getProgramAccount.isLoading) { + return + } + if (!getProgramAccount.data?.value) { + return ( +
+ Program account not found. Make sure you have deployed the program and are on the correct cluster. +
+ ) + } + return ( +
+
{JSON.stringify(getProgramAccount.data.value, null, 2)}
+
+ ) +} + +interface WalletEntry { + address: string; + mode: 'allow' | 'block'; +} + +export function AblTokenWalletTable() { + const [wallets, setWallets] = React.useState([]); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + + const file = e.dataTransfer.files[0]; + if (file && file.type === 'text/csv') { + const text = await file.text(); + const rows = text.split('\n'); + + const parsed: WalletEntry[] = rows + .filter(row => row.trim()) // Skip empty rows + .map(row => { + const [address, mode] = row.split(',').map(field => field.trim()); + return { + address, + mode: mode.toLowerCase() as 'allow' | 'block' + }; + }) + .filter(entry => { + // Basic validation + try { + new PublicKey(entry.address); + return ['allow', 'block'].includes(entry.mode); + } catch { + return false; + } + }); + + setWallets(parsed); + } + }; + + return ( +
+
+ Drop CSV file here (address,mode) +
+ + {wallets.length > 0 && ( +
+ + + + + + + + + {wallets.map((wallet, index) => ( + + + + + ))} + +
AddressMode
{wallet.address} + + {wallet.mode} + +
+
+ )} +
+ ); +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-data-access.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-data-access.tsx new file mode 100644 index 00000000..bb5875ef --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-data-access.tsx @@ -0,0 +1,255 @@ +'use client' + +import { createAssociatedTokenAccountIdempotentInstruction, createTransferCheckedInstruction, createTransferCheckedWithTransferHookInstruction, getAssociatedTokenAddressSync, getExtraAccountMetaAddress, getExtraAccountMetas, getMint, getTransferHook, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { useConnection, useWallet } from '@solana/wallet-adapter-react' +import { + Connection, + LAMPORTS_PER_SOL, + PublicKey, + SendTransactionError, + SystemProgram, + Transaction, + TransactionMessage, + TransactionSignature, + VersionedTransaction, +} from '@solana/web3.js' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTransactionErrorToast, useTransactionToast } from '../use-transaction-toast' +import { useAnchorProvider } from '../solana/solana-provider' +import { toast } from 'sonner' +import { Buffer } from "buffer" + +export function useGetBalance({ address }: { address: PublicKey }) { + const { connection } = useConnection() + + return useQuery({ + queryKey: ['get-balance', { endpoint: connection.rpcEndpoint, address }], + queryFn: () => connection.getBalance(address), + }) +} + +export function useGetSignatures({ address }: { address: PublicKey }) { + const { connection } = useConnection() + + return useQuery({ + queryKey: ['get-signatures', { endpoint: connection.rpcEndpoint, address }], + queryFn: () => connection.getSignaturesForAddress(address), + }) +} + +export function useSendTokens() { + const { connection } = useConnection() + const { publicKey } = useWallet() + const transactionToast = useTransactionToast() + const provider = useAnchorProvider() + const transactionErrorToast = useTransactionErrorToast() + + return useMutation({ + mutationFn: async (args: { + mint: PublicKey, + destination: PublicKey, + amount: number, + }) => { + if (!publicKey) throw new Error('No public key found'); + const { mint, destination, amount } = args; + const mintInfo = await getMint(connection, mint, 'confirmed', TOKEN_2022_PROGRAM_ID); + const ataDestination = getAssociatedTokenAddressSync(mint, destination, true, TOKEN_2022_PROGRAM_ID); + const ataSource = getAssociatedTokenAddressSync(mint, publicKey, true, TOKEN_2022_PROGRAM_ID); + const ix = createAssociatedTokenAccountIdempotentInstruction(publicKey, ataDestination, destination, mint, TOKEN_2022_PROGRAM_ID); + const bi = BigInt(amount); + const decimals = mintInfo.decimals; + console.log("BI: ", bi); + console.log("AMOUNT: ", amount); + console.log("DECIMALS: ", decimals); + const buf = Buffer.alloc(10); + console.dir(buf); + + buf.writeBigUInt64LE(bi, 0); + console.log(buf); + const ix3 = await createTransferCheckedInstruction(ataSource, mint, ataDestination, publicKey, bi, decimals, undefined, TOKEN_2022_PROGRAM_ID); + + const transferHook = getTransferHook(mintInfo); + if (!transferHook) throw new Error('bad token'); + const extraMetas = getExtraAccountMetaAddress(mint, transferHook.programId); + + const seeds = [Buffer.from('ab_wallet'), destination.toBuffer()]; + const abWallet = PublicKey.findProgramAddressSync(seeds, transferHook.programId)[0]; + + ix3.keys.push({ pubkey: abWallet, isSigner: false, isWritable: false }); + ix3.keys.push({ pubkey: transferHook.programId, isSigner: false, isWritable: false }); + ix3.keys.push({ pubkey: extraMetas, isSigner: false, isWritable: false }); + + console.log("tx-hook: ", transferHook.programId.toString()); + console.log("extra-metas: ", extraMetas.toString()); + console.log("ab-wallet: ", abWallet.toString()); + console.log("KEYS: ", ix3.keys); + + const validateStateAccount = await connection.getAccountInfo(extraMetas, 'confirmed'); + if (!validateStateAccount) throw new Error('validate-state-account not found'); + const validateStateData = getExtraAccountMetas(validateStateAccount); + console.log("validate-state-data: ", validateStateData); + + //const ix2 = await createTransferCheckedWithTransferHookInstruction(connection, ataSource, mint, ataDestination, publicKey, bi, decimals, undefined, 'confirmed', TOKEN_2022_PROGRAM_ID); + + const transaction = new Transaction(); + transaction.add(ix, ix3); + transaction.feePayer = provider.wallet.publicKey; + transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; + + let signedTx = await provider.wallet.signTransaction(transaction); + + return connection.sendRawTransaction(signedTx.serialize()); + }, + onSuccess: (signature) => { + transactionToast(signature) + }, + onError: (error) => { transactionErrorToast(error, connection) }, + }) +} + +export function useGetTokenAccounts({ address }: { address: PublicKey }) { + const { connection } = useConnection() + + return useQuery({ + queryKey: ['get-token-accounts', { endpoint: connection.rpcEndpoint, address }], + queryFn: async () => { + const [tokenAccounts, token2022Accounts] = await Promise.all([ + connection.getParsedTokenAccountsByOwner(address, { + programId: TOKEN_PROGRAM_ID, + }), + connection.getParsedTokenAccountsByOwner(address, { + programId: TOKEN_2022_PROGRAM_ID, + }), + ]) + return [...tokenAccounts.value, ...token2022Accounts.value] + }, + }) +} + +export function useTransferSol({ address }: { address: PublicKey }) { + const { connection } = useConnection() + // const transactionToast = useTransactionToast() + const wallet = useWallet() + const client = useQueryClient() + + return useMutation({ + mutationKey: ['transfer-sol', { endpoint: connection.rpcEndpoint, address }], + mutationFn: async (input: { destination: PublicKey; amount: number }) => { + let signature: TransactionSignature = '' + try { + const { transaction, latestBlockhash } = await createTransaction({ + publicKey: address, + destination: input.destination, + amount: input.amount, + connection, + }) + + // Send transaction and await for signature + signature = await wallet.sendTransaction(transaction, connection) + + // Send transaction and await for signature + await connection.confirmTransaction({ signature, ...latestBlockhash }, 'confirmed') + + console.log(signature) + return signature + } catch (error: unknown) { + console.log('error', `Transaction failed! ${error}`, signature) + + return + } + }, + onSuccess: (signature) => { + if (signature) { + // TODO: Add back Toast + // transactionToast(signature) + console.log('Transaction sent', signature) + } + return Promise.all([ + client.invalidateQueries({ + queryKey: ['get-balance', { endpoint: connection.rpcEndpoint, address }], + }), + client.invalidateQueries({ + queryKey: ['get-signatures', { endpoint: connection.rpcEndpoint, address }], + }), + ]) + }, + onError: (error) => { + // TODO: Add Toast + console.error(`Transaction failed! ${error}`) + }, + }) +} + +export function useRequestAirdrop({ address }: { address: PublicKey }) { + const { connection } = useConnection() + // const transactionToast = useTransactionToast() + const client = useQueryClient() + + return useMutation({ + mutationKey: ['airdrop', { endpoint: connection.rpcEndpoint, address }], + mutationFn: async (amount: number = 1) => { + const [latestBlockhash, signature] = await Promise.all([ + connection.getLatestBlockhash(), + connection.requestAirdrop(address, amount * LAMPORTS_PER_SOL), + ]) + + await connection.confirmTransaction({ signature, ...latestBlockhash }, 'confirmed') + return signature + }, + onSuccess: (signature) => { + // TODO: Add back Toast + // transactionToast(signature) + console.log('Airdrop sent', signature) + return Promise.all([ + client.invalidateQueries({ + queryKey: ['get-balance', { endpoint: connection.rpcEndpoint, address }], + }), + client.invalidateQueries({ + queryKey: ['get-signatures', { endpoint: connection.rpcEndpoint, address }], + }), + ]) + }, + }) +} + +async function createTransaction({ + publicKey, + destination, + amount, + connection, +}: { + publicKey: PublicKey + destination: PublicKey + amount: number + connection: Connection +}): Promise<{ + transaction: VersionedTransaction + latestBlockhash: { blockhash: string; lastValidBlockHeight: number } +}> { + // Get the latest blockhash to use in our transaction + const latestBlockhash = await connection.getLatestBlockhash() + + // Create instructions to send, in this case a simple transfer + const instructions = [ + SystemProgram.transfer({ + fromPubkey: publicKey, + toPubkey: destination, + lamports: amount * LAMPORTS_PER_SOL, + }), + ] + + // Create a new TransactionMessage with version and compile it to legacy + const messageLegacy = new TransactionMessage({ + payerKey: publicKey, + recentBlockhash: latestBlockhash.blockhash, + instructions, + }).compileToLegacyMessage() + + // Create a new VersionedTransaction which supports legacy and v0 + const transaction = new VersionedTransaction(messageLegacy) + + return { + transaction, + latestBlockhash, + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-detail-feature.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-detail-feature.tsx new file mode 100644 index 00000000..4aa2efc7 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-detail-feature.tsx @@ -0,0 +1,47 @@ +'use client' + +import { PublicKey } from '@solana/web3.js' +import { useMemo } from 'react' +import { useParams } from 'next/navigation' +import { ExplorerLink } from '../cluster/cluster-ui' +import { AccountBalance, AccountButtons, AccountTokens, AccountTransactions } from './account-ui' +import { AppHero } from '../app-hero' +import { ellipsify } from '@/lib/utils' + +export default function AccountDetailFeature() { + const params = useParams() + const address = useMemo(() => { + if (!params.address) { + return + } + try { + return new PublicKey(params.address) + } catch (e) { + console.log(`Invalid public key`, e) + } + }, [params]) + if (!address) { + return
Error loading account
+ } + + return ( +
+ } + subtitle={ +
+ +
+ } + > +
+ +
+
+
+ + +
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-list-feature.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-list-feature.tsx new file mode 100644 index 00000000..4ccb1039 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-list-feature.tsx @@ -0,0 +1,22 @@ +'use client' + +import { useWallet } from '@solana/wallet-adapter-react' +import { WalletButton } from '../solana/solana-provider' + +import { redirect } from 'next/navigation' + +export default function AccountListFeature() { + const { publicKey } = useWallet() + + if (publicKey) { + return redirect(`/account/${publicKey.toString()}`) + } + + return ( +
+
+ +
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-ui.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-ui.tsx new file mode 100644 index 00000000..5b972960 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/account/account-ui.tsx @@ -0,0 +1,358 @@ +'use client' + +import { useWallet } from '@solana/wallet-adapter-react' +import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js' +import { RefreshCw } from 'lucide-react' +import { useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' + +import { useCluster } from '../cluster/cluster-data-access' +import { ExplorerLink } from '../cluster/cluster-ui' +import { + useGetBalance, + useGetSignatures, + useGetTokenAccounts, + useRequestAirdrop, + useSendTokens, + useTransferSol, +} from './account-data-access' +import { ellipsify } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { AppAlert } from '@/components/app-alert' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { AppModal } from '@/components/app-modal' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +export function AccountBalance({ address }: { address: PublicKey }) { + const query = useGetBalance({ address }) + + return ( +

query.refetch()}> + {query.data ? : '...'} SOL +

+ ) +} + +export function AccountChecker() { + const { publicKey } = useWallet() + if (!publicKey) { + return null + } + return +} + +export function AccountBalanceCheck({ address }: { address: PublicKey }) { + const { cluster } = useCluster() + const mutation = useRequestAirdrop({ address }) + const query = useGetBalance({ address }) + + if (query.isLoading) { + return null + } + if (query.isError || !query.data) { + return ( + mutation.mutateAsync(1).catch((err) => console.log(err))}> + Request Airdrop + + } + > + You are connected to {cluster.name} but your account is not found on this cluster. + + ) + } + return null +} + +export function AccountButtons({ address }: { address: PublicKey }) { + const { cluster } = useCluster() + return ( +
+
+ {cluster.network?.includes('mainnet') ? null : } + + +
+
+ ) +} + +export function AccountTokens({ address }: { address: PublicKey }) { + const [showAll, setShowAll] = useState(false) + const query = useGetTokenAccounts({ address }) + const client = useQueryClient() + const sendTokens = useSendTokens() + const items = useMemo(() => { + if (showAll) return query.data + return query.data?.slice(0, 5) + }, [query.data, showAll]) + + return ( +
+
+
+

Token Accounts

+
+ {query.isLoading ? ( + + ) : ( + + )} +
+
+
+ {query.isError &&
Error: {query.error?.message.toString()}
} + {query.isSuccess && ( +
+ {query.data.length === 0 ? ( +
No token accounts found.
+ ) : ( + + + + Public Key + Mint + Balance + + + + + {items?.map(({ account, pubkey }) => ( + + +
+ + + +
+
+ +
+ + + +
+
+ + {account.data.parsed.info.tokenAmount.uiAmount} + + + + + + +
+ ))} + + {(query.data?.length ?? 0) > 5 && ( + + + + + + )} +
+
+ )} +
+ )} +
+ ) +} + +export function AccountTransactions({ address }: { address: PublicKey }) { + const query = useGetSignatures({ address }) + const [showAll, setShowAll] = useState(false) + + const items = useMemo(() => { + if (showAll) return query.data + return query.data?.slice(0, 5) + }, [query.data, showAll]) + + return ( +
+
+

Transaction History

+
+ {query.isLoading ? ( + + ) : ( + + )} +
+
+ {query.isError &&
Error: {query.error?.message.toString()}
} + {query.isSuccess && ( +
+ {query.data.length === 0 ? ( +
No transactions found.
+ ) : ( + + + + Signature + Slot + Block Time + Status + + + + {items?.map((item) => ( + + + + + + + + {new Date((item.blockTime ?? 0) * 1000).toISOString()} + + {item.err ? ( + + Failed + + ) : ( + Success + )} + + + ))} + {(query.data?.length ?? 0) > 5 && ( + + + + + + )} + +
+ )} +
+ )} +
+ ) +} + +function BalanceSol({ balance }: { balance: number }) { + return {Math.round((balance / LAMPORTS_PER_SOL) * 100000) / 100000} +} + +function ModalReceive({ address }: { address: PublicKey }) { + return ( + +

Receive assets by sending them to your public key:

+ {address.toString()} +
+ ) +} + +function ModalAirdrop({ address }: { address: PublicKey }) { + const mutation = useRequestAirdrop({ address }) + const [amount, setAmount] = useState('2') + + return ( + mutation.mutateAsync(parseFloat(amount))} + > + + setAmount(e.target.value)} + placeholder="Amount" + step="any" + type="number" + value={amount} + /> + + ) +} + +function ModalSend({ address }: { address: PublicKey }) { + const wallet = useWallet() + const mutation = useTransferSol({ address }) + const [destination, setDestination] = useState('') + const [amount, setAmount] = useState('1') + + if (!address || !wallet.sendTransaction) { + return
Wallet not connected
+ } + + return ( + { + mutation.mutateAsync({ + destination: new PublicKey(destination), + amount: parseFloat(amount), + }) + }} + > + + setDestination(e.target.value)} + placeholder="Destination" + type="text" + value={destination} + /> + + setAmount(e.target.value)} + placeholder="Amount" + step="any" + type="number" + value={amount} + /> + + ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-alert.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-alert.tsx new file mode 100644 index 00000000..008d7aaa --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-alert.tsx @@ -0,0 +1,13 @@ +import { AlertCircle } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { ReactNode } from 'react' + +export function AppAlert({ action, children }: { action: ReactNode; children: ReactNode }) { + return ( + + + {children} + {action} + + ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-footer.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-footer.tsx new file mode 100644 index 00000000..f871f95a --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-footer.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +export function AppFooter() { + return ( + + ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-header.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-header.tsx new file mode 100644 index 00000000..1a3fa9e9 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-header.tsx @@ -0,0 +1,79 @@ +'use client' +import { usePathname } from 'next/navigation' +import { useState } from 'react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Menu, X } from 'lucide-react' +import { ThemeSelect } from '@/components/theme-select' +import { ClusterUiSelect } from './cluster/cluster-ui' +import { WalletButton } from '@/components/solana/solana-provider' + +export function AppHeader({ links = [] }: { links: { label: string; path: string }[] }) { + const pathname = usePathname() + const [showMenu, setShowMenu] = useState(false) + + function isActive(path: string) { + return path === '/' ? pathname === '/' : pathname.startsWith(path) + } + + return ( +
+
+
+ + ABL Token + +
+
    + {links.map(({ label, path }) => ( +
  • + + {label} + +
  • + ))} +
+
+
+ + + +
+ + + +
+ + {showMenu && ( +
+
+
    + {links.map(({ label, path }) => ( +
  • + setShowMenu(false)} + > + {label} + +
  • + ))} +
+
+ + + +
+
+
+ )} +
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-hero.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-hero.tsx new file mode 100644 index 00000000..3e0b2946 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-hero.tsx @@ -0,0 +1,23 @@ +import React from 'react' + +export function AppHero({ + children, + subtitle, + title, +}: { + children?: React.ReactNode + subtitle?: React.ReactNode + title?: React.ReactNode +}) { + return ( +
+
+
+ {typeof title === 'string' ?

{title}

: title} + {typeof subtitle === 'string' ?

{subtitle}

: subtitle} + {children} +
+
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-layout.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-layout.tsx new file mode 100644 index 00000000..19400868 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-layout.tsx @@ -0,0 +1,33 @@ +'use client' + +import { ThemeProvider } from './theme-provider' +import { Toaster } from './ui/sonner' +import { AppHeader } from '@/components/app-header' +import React from 'react' +import { AppFooter } from '@/components/app-footer' +import { ClusterChecker } from '@/components/cluster/cluster-ui' +import { AccountChecker } from '@/components/account/account-ui' + +export function AppLayout({ + children, + links, +}: { + children: React.ReactNode + links: { label: string; path: string }[] +}) { + return ( + +
+ +
+ + + + {children} +
+ +
+ +
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-modal.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-modal.tsx new file mode 100644 index 00000000..a302cd72 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-modal.tsx @@ -0,0 +1,38 @@ +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { ReactNode } from 'react' + +export function AppModal({ + children, + title, + submit, + submitDisabled, + submitLabel, +}: { + children: ReactNode + title: string + submit?: () => void + submitDisabled?: boolean + submitLabel?: string +}) { + return ( + + + + + + + {title} + +
{children}
+ + {submit ? ( + + ) : null} + +
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-providers.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-providers.tsx new file mode 100644 index 00000000..a359b654 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/app-providers.tsx @@ -0,0 +1,19 @@ +'use client' + +import { ThemeProvider } from '@/components/theme-provider' +import { ReactQueryProvider } from './react-query-provider' +import { ClusterProvider } from '@/components/cluster/cluster-data-access' +import { SolanaProvider } from '@/components/solana/solana-provider' +import React from 'react' + +export function AppProviders({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + + {children} + + + + ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/cluster/cluster-data-access.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/cluster/cluster-data-access.tsx new file mode 100644 index 00000000..5a8d772b --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/cluster/cluster-data-access.tsx @@ -0,0 +1,117 @@ +'use client' + +import { clusterApiUrl, Connection } from '@solana/web3.js' +import { atom, useAtomValue, useSetAtom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' +import { createContext, ReactNode, useContext } from 'react' + +export interface SolanaCluster { + name: string + endpoint: string + network?: ClusterNetwork + active?: boolean +} + +export enum ClusterNetwork { + Mainnet = 'mainnet-beta', + Testnet = 'testnet', + Devnet = 'devnet', + Custom = 'custom', +} + +// By default, we don't configure the mainnet-beta cluster +// The endpoint provided by clusterApiUrl('mainnet-beta') does not allow access from the browser due to CORS restrictions +// To use the mainnet-beta cluster, provide a custom endpoint +export const defaultClusters: SolanaCluster[] = [ + { + name: 'devnet', + endpoint: clusterApiUrl('devnet'), + network: ClusterNetwork.Devnet, + }, + { name: 'local', endpoint: 'http://localhost:8899' }, + { + name: 'testnet', + endpoint: clusterApiUrl('testnet'), + network: ClusterNetwork.Testnet, + }, +] + +const clusterAtom = atomWithStorage('solana-cluster', defaultClusters[0]) +const clustersAtom = atomWithStorage('solana-clusters', defaultClusters) + +const activeClustersAtom = atom((get) => { + const clusters = get(clustersAtom) + const cluster = get(clusterAtom) + return clusters.map((item) => ({ + ...item, + active: item.name === cluster.name, + })) +}) + +const activeClusterAtom = atom((get) => { + const clusters = get(activeClustersAtom) + + return clusters.find((item) => item.active) || clusters[0] +}) + +export interface ClusterProviderContext { + cluster: SolanaCluster + clusters: SolanaCluster[] + addCluster: (cluster: SolanaCluster) => void + deleteCluster: (cluster: SolanaCluster) => void + setCluster: (cluster: SolanaCluster) => void + + getExplorerUrl(path: string): string +} + +const Context = createContext({} as ClusterProviderContext) + +export function ClusterProvider({ children }: { children: ReactNode }) { + const cluster = useAtomValue(activeClusterAtom) + const clusters = useAtomValue(activeClustersAtom) + const setCluster = useSetAtom(clusterAtom) + const setClusters = useSetAtom(clustersAtom) + + const value: ClusterProviderContext = { + cluster, + clusters: clusters.sort((a, b) => (a.name > b.name ? 1 : -1)), + addCluster: (cluster: SolanaCluster) => { + try { + new Connection(cluster.endpoint) + setClusters([...clusters, cluster]) + } catch (err) { + console.error(`${err}`) + } + }, + deleteCluster: (cluster: SolanaCluster) => { + setClusters(clusters.filter((item) => item.name !== cluster.name)) + }, + setCluster: (cluster: SolanaCluster) => setCluster(cluster), + getExplorerUrl: (path: string) => `https://explorer.solana.com/${path}${getClusterUrlParam(cluster)}`, + } + return {children} +} + +export function useCluster() { + return useContext(Context) +} + +function getClusterUrlParam(cluster: SolanaCluster): string { + let suffix = '' + switch (cluster.network) { + case ClusterNetwork.Devnet: + suffix = 'devnet' + break + case ClusterNetwork.Mainnet: + suffix = '' + break + case ClusterNetwork.Testnet: + suffix = 'testnet' + break + default: + suffix = `custom&customUrl=${encodeURIComponent(cluster.endpoint)}` + break + } + + return suffix.length ? `?cluster=${suffix}` : '' +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/cluster/cluster-ui.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/cluster/cluster-ui.tsx new file mode 100644 index 00000000..66ac4ce0 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/cluster/cluster-ui.tsx @@ -0,0 +1,72 @@ +'use client' + +import { useConnection } from '@solana/wallet-adapter-react' + +import { useQuery } from '@tanstack/react-query' +import * as React from 'react' +import { ReactNode } from 'react' + +import { useCluster } from './cluster-data-access' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { AppAlert } from '@/components/app-alert' + +export function ExplorerLink({ path, label, className }: { path: string; label: string; className?: string }) { + const { getExplorerUrl } = useCluster() + return ( + + {label} + + ) +} + +export function ClusterChecker({ children }: { children: ReactNode }) { + const { cluster } = useCluster() + const { connection } = useConnection() + + const query = useQuery({ + queryKey: ['version', { cluster, endpoint: connection.rpcEndpoint }], + queryFn: () => connection.getVersion(), + retry: 1, + }) + if (query.isLoading) { + return null + } + if (query.isError || !query.data) { + return ( + query.refetch()}> + Refresh + + } + > + Error connecting to cluster {cluster.name}. + + ) + } + return children +} + +export function ClusterUiSelect() { + const { clusters, setCluster, cluster } = useCluster() + return ( + + + + + + {clusters.map((item) => ( + setCluster(item)}> + {item.name} + + ))} + + + ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/dashboard/dashboard-feature.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/dashboard/dashboard-feature.tsx new file mode 100644 index 00000000..f6ddf48c --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/dashboard/dashboard-feature.tsx @@ -0,0 +1,34 @@ +import { AppHero } from '@/components/app-hero' + +const links: { label: string; href: string }[] = [ + { label: 'Solana Docs', href: 'https://docs.solana.com/' }, + { label: 'Solana Faucet', href: 'https://faucet.solana.com/' }, + { label: 'Solana Cookbook', href: 'https://solana.com/developers/cookbook/' }, + { label: 'Solana Stack Overflow', href: 'https://solana.stackexchange.com/' }, + { label: 'Solana Developers GitHub', href: 'https://github.com/solana-developers/' }, +] + +export function DashboardFeature() { + return ( +
+ +
+
+

Here are some helpful links to get you started.

+ {links.map((link, index) => ( + + ))} +
+
+
+ ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/react-query-provider.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/react-query-provider.tsx new file mode 100644 index 00000000..2d4523ce --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/react-query-provider.tsx @@ -0,0 +1,31 @@ +// Taken from https://tanstack.com/query/5/docs/framework/react/guides/advanced-ssr +'use client' + +import { isServer, QueryClient, QueryClientProvider } from '@tanstack/react-query' + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + }, + }) +} + +let browserQueryClient: QueryClient | undefined = undefined + +function getQueryClient() { + if (isServer) { + return makeQueryClient() + } else { + if (!browserQueryClient) browserQueryClient = makeQueryClient() + return browserQueryClient + } +} + +export function ReactQueryProvider({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient() + + return {children} +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/solana/solana-provider.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/solana/solana-provider.tsx new file mode 100644 index 00000000..58b31c33 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/solana/solana-provider.tsx @@ -0,0 +1,43 @@ +'use client' + +import { WalletError } from '@solana/wallet-adapter-base' +import { + AnchorWallet, + ConnectionProvider, + useConnection, + useWallet, + WalletProvider, +} from '@solana/wallet-adapter-react' +import { WalletModalProvider } from '@solana/wallet-adapter-react-ui' +import dynamic from 'next/dynamic' +import { ReactNode, useCallback, useMemo } from 'react' +import { useCluster } from '../cluster/cluster-data-access' +import '@solana/wallet-adapter-react-ui/styles.css' +import { AnchorProvider } from '@coral-xyz/anchor' + +export const WalletButton = dynamic(async () => (await import('@solana/wallet-adapter-react-ui')).WalletMultiButton, { + ssr: false, +}) + +export function SolanaProvider({ children }: { children: ReactNode }) { + const { cluster } = useCluster() + const endpoint = useMemo(() => cluster.endpoint, [cluster]) + const onError = useCallback((error: WalletError) => { + console.error(error) + }, []) + + return ( + + + {children} + + + ) +} + +export function useAnchorProvider() { + const { connection } = useConnection() + const wallet = useWallet() + + return new AnchorProvider(connection, wallet as AnchorWallet, { commitment: 'confirmed' }) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/theme-provider.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/theme-provider.tsx new file mode 100644 index 00000000..f7328c86 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/theme-provider.tsx @@ -0,0 +1,8 @@ +'use client' + +import * as React from 'react' +import { ThemeProvider as NextThemesProvider } from 'next-themes' + +export function ThemeProvider({ children, ...props }: React.ComponentProps) { + return {children} +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/theme-select.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/theme-select.tsx new file mode 100644 index 00000000..7c8331cc --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/theme-select.tsx @@ -0,0 +1,29 @@ +'use client' + +import * as React from 'react' +import { Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' + +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' + +export function ThemeSelect() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme('light')}>Light + setTheme('dark')}>Dark + setTheme('system')}>System + + + ) +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/alert.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/alert.tsx new file mode 100644 index 00000000..cbf8da2b --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/alert.tsx @@ -0,0 +1,51 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + warning: + 'text-yellow-500 bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-yellow-500/90 border-yellow-500 dark:bg-yellow-900/10', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps) { + return
+} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/button.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/button.tsx new file mode 100644 index 00000000..43a47906 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/button.tsx @@ -0,0 +1,50 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + destructive: + 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : 'button' + + return +} + +export { Button, buttonVariants } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/card.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/card.tsx new file mode 100644 index 00000000..bf20394f --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/card.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/dialog.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/dialog.tsx new file mode 100644 index 00000000..f35cad9d --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/dialog.tsx @@ -0,0 +1,111 @@ +'use client' + +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Dialog({ ...props }: React.ComponentProps) { + return +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return +} + +function DialogClose({ ...props }: React.ComponentProps) { + return +} + +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ className, children, ...props }: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/dropdown-menu.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..6c34345f --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,219 @@ +'use client' + +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function DropdownMenu({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuTrigger({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/input.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/input.tsx new file mode 100644 index 00000000..75fee7df --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ) +} + +export { Input } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/label.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/label.tsx new file mode 100644 index 00000000..a50c5694 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/label.tsx @@ -0,0 +1,21 @@ +'use client' + +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' + +import { cn } from '@/lib/utils' + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/sonner.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/sonner.tsx new file mode 100644 index 00000000..0626cafa --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useTheme } from 'next-themes' +import { Toaster as Sonner, ToasterProps } from 'sonner' + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/table.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/table.tsx new file mode 100644 index 00000000..e5351ccd --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/ui/table.tsx @@ -0,0 +1,75 @@ +'use client' + +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Table({ className, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return +} + +function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return +} + +function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { + return ( + tr]:last:border-b-0', className)} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( +
[role=checkbox]]:translate-y-[2px]', + className, + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<'td'>) { + return ( + [role=checkbox]]:translate-y-[2px]', + className, + )} + {...props} + /> + ) +} + +function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) { + return ( +
+ ) +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/use-transaction-toast.tsx b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/use-transaction-toast.tsx new file mode 100644 index 00000000..d354a5d1 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/components/use-transaction-toast.tsx @@ -0,0 +1,33 @@ +import { toast } from 'sonner' +import { ExplorerLink } from './cluster/cluster-ui' +import { Connection, SendTransactionError } from '@solana/web3.js' + +export function useTransactionToast() { + return (signature: string) => { + toast('Transaction sent', { + description: , + }) + } +} + +export function useTransactionErrorToast() { + return async (error: Error, connection: Connection) => { + const logs = await (error as SendTransactionError).getLogs(connection); + const anchorError = logs.find((l) => l.startsWith("Program log: AnchorError occurred")); + if (anchorError) { + if (anchorError.includes("WalletBlocked")) { + toast.error(`Destination wallet is blocked from receiving funds.`) + } else if (anchorError.includes("WalletNotAllowed")) { + toast.error(`Destination wallet is not allowed to receive funds.`) + } else if (anchorError.includes("AmountNotAllowed")) { + toast.error(`Destination wallet is not authorized to receive this amount.`) + } else { + console.log("ERROR: ", error) + toast.error(`Failed to run program: ${error}`) + } + } else { + console.log("ERROR: ", error) + toast.error(`Failed to run program: ${error}`) + } + } +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/src/lib/utils.ts b/tokens/token-2022/transfer-hook/allow-block-list-token/src/lib/utils.ts new file mode 100644 index 00000000..ae138dc4 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function ellipsify(str = '', len = 4, delimiter = '..') { + const strLen = str.length + const limit = len * 2 + delimiter.length + + return strLen >= limit ? str.substring(0, len) + delimiter + str.substring(strLen - len, strLen) : str +} diff --git a/tokens/token-2022/transfer-hook/allow-block-list-token/tsconfig.json b/tokens/token-2022/transfer-hook/allow-block-list-token/tsconfig.json new file mode 100644 index 00000000..c1bfb1e8 --- /dev/null +++ b/tokens/token-2022/transfer-hook/allow-block-list-token/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": ".", + "paths": { + "@project/anchor": ["anchor/src"], + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}