diff --git a/basics/favorites/anchor/favorites/anchor/favorites/.gitignore b/basics/favorites/anchor/favorites/anchor/favorites/.gitignore new file mode 100644 index 000000000..6e66c84b7 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/.gitignore @@ -0,0 +1,9 @@ + +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn +.env diff --git a/basics/favorites/anchor/favorites/anchor/favorites/.prettierignore b/basics/favorites/anchor/favorites/anchor/favorites/.prettierignore new file mode 100644 index 000000000..c1a0b75f0 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/.prettierignore @@ -0,0 +1,8 @@ + +.anchor +.DS_Store +target +node_modules +dist +build +test-ledger diff --git a/basics/favorites/anchor/favorites/anchor/favorites/Anchor.toml b/basics/favorites/anchor/favorites/anchor/favorites/Anchor.toml new file mode 100644 index 000000000..b66e7b646 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/Anchor.toml @@ -0,0 +1,18 @@ +[toolchain] + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +favorites = "ww9C83noARSQVBnqmCUmaVdbJjmiwcV9j2LkXYMoUCV" + +[registry] +url = "https://api.apr.dev" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" diff --git a/basics/favorites/anchor/favorites/anchor/favorites/Cargo.toml b/basics/favorites/anchor/favorites/anchor/favorites/Cargo.toml new file mode 100644 index 000000000..14a951cee --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 + +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/basics/favorites/anchor/favorites/anchor/favorites/README.md b/basics/favorites/anchor/favorites/anchor/favorites/README.md new file mode 100644 index 000000000..6edd69541 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/README.md @@ -0,0 +1,9 @@ +# Favorites + +This is a basic Anchor app using PDAs to store data for a user, and Anchor's account checks to ensure each user is only allowed to modify their own data. + +It's used by the [https://github.com/solana-developers/professional-education](Solana Professional Education) course. + +## Usage + +`anchor test`, `anchor deploy` etc. diff --git a/basics/favorites/anchor/favorites/anchor/favorites/migrations/deploy.ts b/basics/favorites/anchor/favorites/anchor/favorites/migrations/deploy.ts new file mode 100644 index 000000000..64a1c3599 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/migrations/deploy.ts @@ -0,0 +1,12 @@ +// Migrations are an early feature. Currently, they're nothing more than this +// single deploy script that's invoked from the CLI, injecting a provider +// configured from the workspace's Anchor.toml. + +const anchor = require('@coral-xyz/anchor'); + +module.exports = async (provider) => { + // Configure client to use the provider. + anchor.setProvider(provider); + + // Add your deploy script here. +}; diff --git a/basics/favorites/anchor/favorites/anchor/favorites/package.json b/basics/favorites/anchor/favorites/anchor/favorites/package.json new file mode 100644 index 000000000..a31796aee --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/package.json @@ -0,0 +1,21 @@ +{ + "scripts": { + "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" + }, + "dependencies": { + "@coral-xyz/anchor": "^0.30.0", + "@solana-developers/helpers": "^2.0.0" + }, + "license": "UNLICENSED", + "devDependencies": { + "@types/bn.js": "^5.1.0", + "@types/chai": "^4.3.0", + "@types/mocha": "^9.0.0", + "chai": "^4.3.4", + "mocha": "^9.0.3", + "prettier": "^2.6.2", + "ts-mocha": "^10.0.0", + "typescript": "^4.3.5" + } +} diff --git a/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/Cargo.toml b/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/Cargo.toml new file mode 100644 index 000000000..4c1cb893f --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "favorites" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "favorites" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = {version = "0.30.0", features = ["init-if-needed"]} +solana-program = "=1.18.5" diff --git a/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/Xargo.toml b/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/src/lib.rs b/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/src/lib.rs new file mode 100644 index 000000000..6552ef859 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/programs/favorites/src/lib.rs @@ -0,0 +1,65 @@ +use anchor_lang::prelude::*; +// Our program's address! +// This matches the key in the target/deploy directory +declare_id!("ww9C83noARSQVBnqmCUmaVdbJjmiwcV9j2LkXYMoUCV"); + +// Anchor programs always use 8 bits for the discriminator +pub const ANCHOR_DISCRIMINATOR_SIZE: usize = 8; + +// Our Solana program! +#[program] +pub mod favorites { + use super::*; + + // Our instruction handler! It sets the user's favorite number and color + pub fn set_favorites(context: Context, number: u64, color: String, hobbies: Vec) -> Result<()> { + let user_public_key = context.accounts.user.key(); + msg!("Greetings from {}", context.program_id); + msg!( + "User {user_public_key}'s favorite number is {number}, favorite color is: {color}", + ); + + msg!( + "User's hobbies are: {:?}", + hobbies + ); + + context.accounts.favorites.set_inner(Favorites { + number, + color, + hobbies + }); + Ok(()) + } + + // We can also add a get_favorites instruction handler to return the user's favorite number and color +} + +// What we will put inside the Favorites PDA +#[account] +#[derive(InitSpace)] +pub struct Favorites { + pub number: u64, + + #[max_len(50)] + pub color: String, + + #[max_len(5, 50)] + pub hobbies: Vec +} +// When people call the set_favorites instruction, they will need to provide the accounts that will be modifed. This keeps Solana fast! +#[derive(Accounts)] +pub struct SetFavorites<'info> { + #[account(mut)] + pub user: Signer<'info>, + + #[account( + init_if_needed, + payer = user, + space = ANCHOR_DISCRIMINATOR_SIZE + Favorites::INIT_SPACE, + seeds=[b"favorites", user.key().as_ref()], + bump)] + pub favorites: Account<'info, Favorites>, + + pub system_program: Program<'info, System>, +} diff --git a/basics/favorites/anchor/favorites/anchor/favorites/tests/favorites.ts b/basics/favorites/anchor/favorites/anchor/favorites/tests/favorites.ts new file mode 100644 index 000000000..5c1ea4ed5 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/tests/favorites.ts @@ -0,0 +1,77 @@ +import * as anchor from '@coral-xyz/anchor'; +import type { Program } from '@coral-xyz/anchor'; +import { getCustomErrorMessage } from '@solana-developers/helpers'; +import { assert } from 'chai'; +import type { Favorites } from '../target/types/favorites'; +import { systemProgramErrors } from './system-errors'; +const web3 = anchor.web3; + +describe('Favorites', () => { + // Use the cluster and the keypair from Anchor.toml + const provider = anchor.AnchorProvider.env(); + anchor.setProvider(provider); + const user = (provider.wallet as anchor.Wallet).payer; + const someRandomGuy = anchor.web3.Keypair.generate(); + const program = anchor.workspace.Favorites as Program; + + // Here's what we want to write to the blockchain + const favoriteNumber = new anchor.BN(23); + const favoriteColor = 'purple'; + const favoriteHobbies = ['skiing', 'skydiving', 'biking']; + + // We don't need to airdrop if we're using the local cluster + // because the local cluster gives us 85 billion dollars worth of SOL + before(async () => { + const balance = await provider.connection.getBalance(user.publicKey); + const balanceInSOL = balance / web3.LAMPORTS_PER_SOL; + const formattedBalance = new Intl.NumberFormat().format(balanceInSOL); + console.log(`Balance: ${formattedBalance} SOL`); + }); + + it('Writes our favorites to the blockchain', async () => { + await program.methods + // set_favourites in Rust becomes setFavorites in TypeScript + .setFavorites(favoriteNumber, favoriteColor, favoriteHobbies) + // Sign the transaction + .signers([user]) + // Send the transaction to the cluster or RPC + .rpc(); + + // Find the PDA for the user's favorites + const favoritesPdaAndBump = web3.PublicKey.findProgramAddressSync([Buffer.from('favorites'), user.publicKey.toBuffer()], program.programId); + const favoritesPda = favoritesPdaAndBump[0]; + const dataFromPda = await program.account.favorites.fetch(favoritesPda); + // And make sure it matches! + assert.equal(dataFromPda.color, favoriteColor); + // A little extra work to make sure the BNs are equal + assert.equal(dataFromPda.number.toString(), favoriteNumber.toString()); + // And check the hobbies too + assert.deepEqual(dataFromPda.hobbies, favoriteHobbies); + }); + + it('Updates the favorites', async () => { + const newFavoriteHobbies = ['skiing', 'skydiving', 'biking', 'swimming']; + try { + await program.methods.setFavorites(favoriteNumber, favoriteColor, newFavoriteHobbies).signers([user]).rpc(); + } catch (error) { + console.error((error as Error).message); + const customErrorMessage = getCustomErrorMessage(systemProgramErrors, error); + throw new Error(customErrorMessage); + } + }); + + it('Rejects transactions from unauthorized signers', async () => { + try { + await program.methods + // set_favourites in Rust becomes setFavorites in TypeScript + .setFavorites(favoriteNumber, favoriteColor, favoriteHobbies) + // Sign the transaction + .signers([someRandomGuy]) + // Send the transaction to the cluster or RPC + .rpc(); + } catch (error) { + const errorMessage = (error as Error).message; + assert.isTrue(errorMessage.includes('unknown signer')); + } + }); +}); diff --git a/basics/favorites/anchor/favorites/anchor/favorites/tests/system-errors.ts b/basics/favorites/anchor/favorites/anchor/favorites/tests/system-errors.ts new file mode 100644 index 000000000..845ce2f30 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/tests/system-errors.ts @@ -0,0 +1,20 @@ +// From https://github.com/solana-labs/solana/blob/a94920a4eadf1008fc292e47e041c1b3b0d949df/sdk/program/src/system_instruction.rs +export const systemProgramErrors = [ + 'an account with the same address already exists', + + 'account does not have enough SOL to perform the operation', + + 'cannot assign account to this program id', + + 'cannot allocate account data of this length', + + 'length of requested seed is too long', + + 'provided address does not match addressed derived from seed', + + 'advancing stored nonce requires a populated RecentBlockhashes sysvar', + + 'stored nonce is still in recent_blockhashes', + + 'specified nonce does not match stored nonce', +]; diff --git a/basics/favorites/anchor/favorites/anchor/favorites/tsconfig.json b/basics/favorites/anchor/favorites/anchor/favorites/tsconfig.json new file mode 100644 index 000000000..cd5d2e3d0 --- /dev/null +++ b/basics/favorites/anchor/favorites/anchor/favorites/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true + } +} diff --git a/tokens/escrow/anchor/escrow/.gitignore b/tokens/escrow/anchor/escrow/.gitignore new file mode 100644 index 000000000..2e0446b07 --- /dev/null +++ b/tokens/escrow/anchor/escrow/.gitignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn diff --git a/tokens/escrow/anchor/escrow/.prettierignore b/tokens/escrow/anchor/escrow/.prettierignore new file mode 100644 index 000000000..414258343 --- /dev/null +++ b/tokens/escrow/anchor/escrow/.prettierignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +node_modules +dist +build +test-ledger diff --git a/tokens/escrow/anchor/escrow/Anchor.toml b/tokens/escrow/anchor/escrow/Anchor.toml new file mode 100644 index 000000000..8ce02a94b --- /dev/null +++ b/tokens/escrow/anchor/escrow/Anchor.toml @@ -0,0 +1,18 @@ +[toolchain] + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +escrow = "qbuMdeYxYJXBjU6C6qFKjZKjXmrU83eDQomHdrch826" + +[registry] +url = "https://api.apr.dev" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" diff --git a/tokens/escrow/anchor/escrow/Cargo.toml b/tokens/escrow/anchor/escrow/Cargo.toml new file mode 100644 index 000000000..14a951cee --- /dev/null +++ b/tokens/escrow/anchor/escrow/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 + +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tokens/escrow/anchor/escrow/README.md b/tokens/escrow/anchor/escrow/README.md new file mode 100644 index 000000000..2eb74834f --- /dev/null +++ b/tokens/escrow/anchor/escrow/README.md @@ -0,0 +1,48 @@ +# Anchor Escrow + +## Introduction + +This Solana program is called an **_escrow_** - it allows a user to swap a specific amount of one token for a desired amount of another token. + +For example, Alice is offering 10 USDC, and wants 100 WIF in return. + +Without our program, users would have to engage in manual token swapping. Imagine the potential problems if Bob promised to send Alice 100 WIF, but instead took the 10 USDC and ran? Or what if Alice was dishonest, received the 10 USDC from Bob, and decided not to send the 100 WIF? Our Escrow program handles these complexities by acting a trusted entity that will only release tokens to both parties at the right time. + +Our Escrow program is designed to provide a secure environment for users to swap a specific amount of one token with a specific amount of another token without having to trust each other. + +Better yet, since our program allows Alice and Bob to transact directly with each other, they both get a hundred percent of the token they desire! + +## Usage + +`anchor test`, `anchor deploy` etc. + +## Credit + +This project is based on [Dean Little's Anchor Escrow,](https://github.com/deanmlittle/anchor-escrow-2024) with a few changes to make discussion in class easier. + +### Changes from original + +One of the challenges when teaching is avoiding ambiguity — names have to be carefully chosen to be clear and not possible to confuse with other times. + +- Custom instructions were replaced by `@solana-developers/helpers` for many tasks to reduce the file size. +- The upstream project has a custom file layout. We use the 'multiple files' Anchor layout. +- Contexts are separate data structures from functions that use the contexts. There is no need for OO-like `impl` patterns here - there's no mutable state stored in the Context, and the 'methods' do not mutate that state. Besides, it's easier to type! +- The name 'deposit' was being used in multiple contexts, and `deposit` can be tough because it's a verb and a noun: + + - Renamed deposit #1 -> 'token_a_offered_amount' + - Renamed deposit #2 (in make() ) -> 'send_offered_tokens_to_vault' + - Renamed deposit #3 (in take() ) -> 'send_wanted_tokens_to_maker' + +- 'seed' was renamed to 'id' because 'seed' as it conflicted with the 'seeds' used for PDA address generation. +- 'Escrow' was used for the program's name and the account that records details of the offer. This wasn't great because people would confuse 'Escrow' with the 'Vault'. + + - Escrow (the program) -> remains Escrow + - Escrow (the offer) -> Offer. + +- 'receive' was renamed to 'token_b_wanted_amount' as 'receive' is a verb and not a suitable name for an integer. +- mint_a -> token_mint_a (ie, what the maker has offered and what the taker wants) +- mint_b -> token_mint_b (ie, what that maker wants and what the taker must offer) +- makerAtaA -> makerTokenAccountA, +- makerAtaB -> makerTokenAccountB +- takerAtaA -> takerTokenAccountA +- takerAtaB -> takerTokenAccountB diff --git a/tokens/escrow/anchor/escrow/migrations/deploy.ts b/tokens/escrow/anchor/escrow/migrations/deploy.ts new file mode 100644 index 000000000..64a1c3599 --- /dev/null +++ b/tokens/escrow/anchor/escrow/migrations/deploy.ts @@ -0,0 +1,12 @@ +// Migrations are an early feature. Currently, they're nothing more than this +// single deploy script that's invoked from the CLI, injecting a provider +// configured from the workspace's Anchor.toml. + +const anchor = require('@coral-xyz/anchor'); + +module.exports = async (provider) => { + // Configure client to use the provider. + anchor.setProvider(provider); + + // Add your deploy script here. +}; diff --git a/tokens/escrow/anchor/escrow/package.json b/tokens/escrow/anchor/escrow/package.json new file mode 100644 index 000000000..47e6ce7da --- /dev/null +++ b/tokens/escrow/anchor/escrow/package.json @@ -0,0 +1,22 @@ +{ + "scripts": { + "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" + }, + "dependencies": { + "@coral-xyz/anchor": "^0.30.0", + "@solana-developers/helpers": "^2.3.0", + "@solana/spl-token": "^0.4.6" + }, + "license": "MIT", + "devDependencies": { + "@types/bn.js": "^5.1.0", + "@types/chai": "^4.3.0", + "@types/mocha": "^9.0.0", + "chai": "^4.3.4", + "mocha": "^9.0.3", + "prettier": "^2.6.2", + "ts-mocha": "^10.0.0", + "typescript": "^4.3.5" + } +} diff --git a/tokens/escrow/anchor/escrow/programs/escrow/Cargo.toml b/tokens/escrow/anchor/escrow/programs/escrow/Cargo.toml new file mode 100644 index 000000000..da676058b --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "escrow" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "escrow" + +[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.30.0", features = ["init-if-needed"]} +anchor-spl = "0.30.0" +solana-program = "=1.18.5" diff --git a/tokens/escrow/anchor/escrow/programs/escrow/Xargo.toml b/tokens/escrow/anchor/escrow/programs/escrow/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tokens/escrow/anchor/escrow/programs/escrow/src/constants.rs b/tokens/escrow/anchor/escrow/programs/escrow/src/constants.rs new file mode 100644 index 000000000..019371b64 --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/src/constants.rs @@ -0,0 +1,6 @@ +use anchor_lang::prelude::*; + +#[constant] +pub const SEED: &str = "anchor"; + +pub const ANCHOR_DISCRIMINATOR: usize = 8; diff --git a/tokens/escrow/anchor/escrow/programs/escrow/src/error.rs b/tokens/escrow/anchor/escrow/programs/escrow/src/error.rs new file mode 100644 index 000000000..c37199a44 --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/src/error.rs @@ -0,0 +1,7 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum ErrorCode { + #[msg("Custom error message")] + CustomError, +} diff --git a/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/make_offer.rs b/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/make_offer.rs new file mode 100644 index 000000000..5d4c1e57b --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/make_offer.rs @@ -0,0 +1,89 @@ +use anchor_lang::prelude::*; + +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, +}; + +use crate::{Offer, ANCHOR_DISCRIMINATOR}; + +// See https://www.anchor-lang.com/docs/account-constraints#instruction-attribute +#[derive(Accounts)] +#[instruction(id: u64)] +pub struct MakeOffer<'info> { + #[account(mut)] + pub maker: Signer<'info>, + + #[account(mint::token_program = token_program)] + pub token_mint_a: InterfaceAccount<'info, Mint>, + + #[account(mint::token_program = token_program)] + pub token_mint_b: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = token_mint_a, + associated_token::authority = maker, + associated_token::token_program = token_program + )] + pub maker_token_account_a: InterfaceAccount<'info, TokenAccount>, + + #[account( + init, + payer = maker, + space = ANCHOR_DISCRIMINATOR + Offer::INIT_SPACE, + seeds = [b"offer", maker.key().as_ref(), id.to_le_bytes().as_ref()], + bump + )] + pub offer: Account<'info, Offer>, + + #[account( + init, + payer = maker, + associated_token::mint = token_mint_a, + associated_token::authority = offer, + associated_token::token_program = token_program + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +// Move the tokens from the maker's ATA to the vault +pub fn send_offered_tokens_to_vault( + context: &Context, + token_a_offered_amount: u64, +) -> Result<()> { + let transfer_accounts = TransferChecked { + from: context.accounts.maker_token_account_a.to_account_info(), + mint: context.accounts.token_mint_a.to_account_info(), + to: context.accounts.vault.to_account_info(), + authority: context.accounts.maker.to_account_info(), + }; + + let cpi_context = CpiContext::new( + context.accounts.token_program.to_account_info(), + transfer_accounts, + ); + + transfer_checked( + cpi_context, + token_a_offered_amount, + context.accounts.token_mint_a.decimals, + ) +} + +// Save the details of the offer to the offer account +pub fn save_offer(context: Context, id: u64, token_b_wanted_amount: u64) -> Result<()> { + context.accounts.offer.set_inner(Offer { + id, + maker: context.accounts.maker.key(), + token_mint_a: context.accounts.token_mint_a.key(), + token_mint_b: context.accounts.token_mint_b.key(), + token_b_wanted_amount, + bump: context.bumps.offer, + }); + Ok(()) +} diff --git a/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/mod.rs b/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/mod.rs new file mode 100644 index 000000000..bac9a6549 --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/mod.rs @@ -0,0 +1,5 @@ +pub mod make_offer; +pub use make_offer::*; + +pub mod take_offer; +pub use take_offer::*; diff --git a/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/take_offer.rs b/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/take_offer.rs new file mode 100644 index 000000000..9243e76e5 --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/src/instructions/take_offer.rs @@ -0,0 +1,135 @@ +use anchor_lang::prelude::*; + +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{ + close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, + TransferChecked, + }, +}; + +use crate::Offer; + +#[derive(Accounts)] +pub struct TakeOffer<'info> { + #[account(mut)] + pub taker: Signer<'info>, + + #[account(mut)] + pub maker: SystemAccount<'info>, + + pub token_mint_a: InterfaceAccount<'info, Mint>, + + pub token_mint_b: InterfaceAccount<'info, Mint>, + + #[account( + init_if_needed, + payer = taker, + associated_token::mint = token_mint_a, + associated_token::authority = taker, + associated_token::token_program = token_program, + )] + pub taker_token_account_a: Box>, + + #[account( + mut, + associated_token::mint = token_mint_b, + associated_token::authority = taker, + associated_token::token_program = token_program, + )] + pub taker_token_account_b: Box>, + + #[account( + init_if_needed, + payer = taker, + associated_token::mint = token_mint_b, + associated_token::authority = maker, + associated_token::token_program = token_program, + )] + pub maker_token_account_b: Box>, + + #[account( + mut, + close = maker, + has_one = maker, + has_one = token_mint_a, + has_one = token_mint_b, + seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()], + bump = offer.bump + )] + offer: Account<'info, Offer>, + + #[account( + mut, + associated_token::mint = token_mint_a, + associated_token::authority = offer, + associated_token::token_program = token_program, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn send_wanted_tokens_to_maker(ctx: &Context) -> Result<()> { + let transfer_accounts = TransferChecked { + from: ctx.accounts.taker_token_account_b.to_account_info(), + mint: ctx.accounts.token_mint_b.to_account_info(), + to: ctx.accounts.maker_token_account_b.to_account_info(), + authority: ctx.accounts.taker.to_account_info(), + }; + + let cpi_ctx = CpiContext::new( + ctx.accounts.token_program.to_account_info(), + transfer_accounts, + ); + + transfer_checked( + cpi_ctx, + ctx.accounts.offer.token_b_wanted_amount, + ctx.accounts.token_mint_b.decimals, + ) +} + +pub fn withdraw_and_close_vault(ctx: Context) -> Result<()> { + let signer_seeds: [&[&[u8]]; 1] = [&[ + b"offer", + ctx.accounts.maker.to_account_info().key.as_ref(), + &ctx.accounts.offer.id.to_le_bytes()[..], + &[ctx.accounts.offer.bump], + ]]; + + let accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + mint: ctx.accounts.token_mint_a.to_account_info(), + to: ctx.accounts.taker_token_account_a.to_account_info(), + authority: ctx.accounts.offer.to_account_info(), + }; + + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + accounts, + &signer_seeds, + ); + + transfer_checked( + cpi_context, + ctx.accounts.vault.amount, + ctx.accounts.token_mint_a.decimals, + )?; + + let accounts = CloseAccount { + account: ctx.accounts.vault.to_account_info(), + destination: ctx.accounts.taker.to_account_info(), + authority: ctx.accounts.offer.to_account_info(), + }; + + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + accounts, + &signer_seeds, + ); + + close_account(cpi_context) +} diff --git a/tokens/escrow/anchor/escrow/programs/escrow/src/lib.rs b/tokens/escrow/anchor/escrow/programs/escrow/src/lib.rs new file mode 100644 index 000000000..686b770d6 --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/src/lib.rs @@ -0,0 +1,32 @@ +pub mod constants; +pub mod error; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use constants::*; +pub use instructions::*; +pub use state::*; + +declare_id!("qbuMdeYxYJXBjU6C6qFKjZKjXmrU83eDQomHdrch826"); + +#[program] +pub mod escrow { + use super::*; + + pub fn make_offer( + context: Context, + id: u64, + token_a_offered_amount: u64, + token_b_wanted_amount: u64, + ) -> Result<()> { + instructions::make_offer::send_offered_tokens_to_vault(&context, token_a_offered_amount)?; + instructions::make_offer::save_offer(context, id, token_b_wanted_amount) + } + + pub fn take_offer(context: Context) -> Result<()> { + instructions::take_offer::send_wanted_tokens_to_maker(&context)?; + instructions::take_offer::withdraw_and_close_vault(context) + } +} diff --git a/tokens/escrow/anchor/escrow/programs/escrow/src/state/mod.rs b/tokens/escrow/anchor/escrow/programs/escrow/src/state/mod.rs new file mode 100644 index 000000000..cfaa08e9f --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/src/state/mod.rs @@ -0,0 +1,3 @@ +pub mod offer; + +pub use offer::*; diff --git a/tokens/escrow/anchor/escrow/programs/escrow/src/state/offer.rs b/tokens/escrow/anchor/escrow/programs/escrow/src/state/offer.rs new file mode 100644 index 000000000..65f90af73 --- /dev/null +++ b/tokens/escrow/anchor/escrow/programs/escrow/src/state/offer.rs @@ -0,0 +1,12 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct Offer { + pub id: u64, + pub maker: Pubkey, + pub token_mint_a: Pubkey, + pub token_mint_b: Pubkey, + pub token_b_wanted_amount: u64, + pub bump: u8, +} diff --git a/tokens/escrow/anchor/escrow/tests/escrow.ts b/tokens/escrow/anchor/escrow/tests/escrow.ts new file mode 100644 index 000000000..54c357b45 --- /dev/null +++ b/tokens/escrow/anchor/escrow/tests/escrow.ts @@ -0,0 +1,174 @@ +import { randomBytes } from 'node:crypto'; +import * as anchor from '@coral-xyz/anchor'; +import { BN, type Program } from '@coral-xyz/anchor'; +import { + MINT_SIZE, + TOKEN_2022_PROGRAM_ID, + type TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + createInitializeMint2Instruction, + createMintToInstruction, + getAssociatedTokenAddressSync, + getMinimumBalanceForRentExemptMint, +} from '@solana/spl-token'; +import { LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction, type TransactionInstruction } from '@solana/web3.js'; +import { assert } from 'chai'; +import type { Escrow } from '../target/types/escrow'; + +import { confirmTransaction, makeKeypairs } from '@solana-developers/helpers'; + +const TOKEN_PROGRAM: typeof TOKEN_2022_PROGRAM_ID | typeof TOKEN_PROGRAM_ID = TOKEN_2022_PROGRAM_ID; + +const getRandomBigNumber = (size = 8) => { + return new BN(randomBytes(size)); +}; + +describe('escrow', async () => { + anchor.setProvider(anchor.AnchorProvider.env()); + + const provider = anchor.getProvider(); + + const connection = provider.connection; + + const program = anchor.workspace.Escrow as Program; + + // We're going to reuse these accounts across multiple tests + const accounts: Record = { + tokenProgram: TOKEN_PROGRAM, + }; + + const [alice, bob, tokenMintA, tokenMintB] = makeKeypairs(4); + + before('Creates Alice and Bob accounts, 2 token mints, and associated token accounts for both tokens for both users', async () => { + const [aliceTokenAccountA, aliceTokenAccountB, bobTokenAccountA, bobTokenAccountB] = [alice, bob].flatMap((keypair) => + [tokenMintA, tokenMintB].map((mint) => getAssociatedTokenAddressSync(mint.publicKey, keypair.publicKey, false, TOKEN_PROGRAM)), + ); + + // Airdrops to users, and creates two tokens mints 'A' and 'B'" + const minimumLamports = await getMinimumBalanceForRentExemptMint(connection); + + const sendSolInstructions: Array = [alice, bob].map((account) => + SystemProgram.transfer({ + fromPubkey: provider.publicKey, + toPubkey: account.publicKey, + lamports: 10 * LAMPORTS_PER_SOL, + }), + ); + + const createMintInstructions: Array = [tokenMintA, tokenMintB].map((mint) => + SystemProgram.createAccount({ + fromPubkey: provider.publicKey, + newAccountPubkey: mint.publicKey, + lamports: minimumLamports, + space: MINT_SIZE, + programId: TOKEN_PROGRAM, + }), + ); + + // Make tokenA and tokenB mints, mint tokens and create ATAs + const mintTokensInstructions: Array = [ + { + mint: tokenMintA.publicKey, + authority: alice.publicKey, + ata: aliceTokenAccountA, + }, + { + mint: tokenMintB.publicKey, + authority: bob.publicKey, + ata: bobTokenAccountB, + }, + ].flatMap((mintDetails) => [ + createInitializeMint2Instruction(mintDetails.mint, 6, mintDetails.authority, null, TOKEN_PROGRAM), + createAssociatedTokenAccountIdempotentInstruction(provider.publicKey, mintDetails.ata, mintDetails.authority, mintDetails.mint, TOKEN_PROGRAM), + createMintToInstruction(mintDetails.mint, mintDetails.ata, mintDetails.authority, 1_000_000_000, [], TOKEN_PROGRAM), + ]); + + // Add all these instructions to our transaction + const tx = new Transaction(); + tx.instructions = [...sendSolInstructions, ...createMintInstructions, ...mintTokensInstructions]; + + const transactionSignature = await provider.sendAndConfirm(tx, [tokenMintA, tokenMintB, alice, bob]); + + // Save the accounts for later use + accounts.maker = alice.publicKey; + accounts.taker = bob.publicKey; + accounts.tokenMintA = tokenMintA.publicKey; + accounts.makerTokenAccountA = aliceTokenAccountA; + accounts.takerTokenAccountA = bobTokenAccountA; + accounts.tokenMintB = tokenMintB.publicKey; + accounts.makerTokenAccountB = aliceTokenAccountB; + accounts.takerTokenAccountB = bobTokenAccountB; + }); + + const tokenAOfferedAmount = new BN(1_000_000); + const tokenBWantedAmount = new BN(1_000_000); + + // We'll call this function from multiple tests, so let's seperate it out + const make = async () => { + // Pick a random ID for the offer we'll make + const offerId = getRandomBigNumber(); + + // Then determine the account addresses we'll use for the offer and the vault + const offer = PublicKey.findProgramAddressSync( + [Buffer.from('offer'), accounts.maker.toBuffer(), offerId.toArrayLike(Buffer, 'le', 8)], + program.programId, + )[0]; + + const vault = getAssociatedTokenAddressSync(accounts.tokenMintA, offer, true, TOKEN_PROGRAM); + + accounts.offer = offer; + accounts.vault = vault; + + const transactionSignature = await program.methods + .makeOffer(offerId, tokenAOfferedAmount, tokenBWantedAmount) + .accounts({ ...accounts }) + .signers([alice]) + .rpc(); + + await confirmTransaction(connection, transactionSignature); + + // Check our vault contains the tokens offered + const vaultBalanceResponse = await connection.getTokenAccountBalance(vault); + const vaultBalance = new BN(vaultBalanceResponse.value.amount); + assert(vaultBalance.eq(tokenAOfferedAmount)); + + // Check our Offer account contains the correct data + const offerAccount = await program.account.offer.fetch(offer); + + assert(offerAccount.maker.equals(alice.publicKey)); + assert(offerAccount.tokenMintA.equals(accounts.tokenMintA)); + assert(offerAccount.tokenMintB.equals(accounts.tokenMintB)); + assert(offerAccount.tokenBWantedAmount.eq(tokenBWantedAmount)); + }; + + // We'll call this function from multiple tests, so let's seperate it out + const take = async () => { + const transactionSignature = await program.methods + .takeOffer() + .accounts({ ...accounts }) + .signers([bob]) + .rpc(); + + await confirmTransaction(connection, transactionSignature); + + // Check the offered tokens are now in Bob's account + // (note: there is no before balance as Bob didn't have any offered tokens before the transaction) + const bobTokenAccountBalanceAfterResponse = await connection.getTokenAccountBalance(accounts.takerTokenAccountA); + const bobTokenAccountBalanceAfter = new BN(bobTokenAccountBalanceAfterResponse.value.amount); + assert(bobTokenAccountBalanceAfter.eq(tokenAOfferedAmount)); + + // Check the wanted tokens are now in Alice's account + // (note: there is no before balance as Alice didn't have any wanted tokens before the transaction) + const aliceTokenAccountBalanceAfterResponse = await connection.getTokenAccountBalance(accounts.makerTokenAccountB); + const aliceTokenAccountBalanceAfter = new BN(aliceTokenAccountBalanceAfterResponse.value.amount); + assert(aliceTokenAccountBalanceAfter.eq(tokenBWantedAmount)); + }; + + it('Puts the tokens Alice offers into the vault when Alice makes an offer', async () => { + await make(); + }); + + it("Puts the tokens from the vault into Bob's account, and gives Alice Bob's tokens, when Bob takes an offer", async () => { + await take(); + }); +}); diff --git a/tokens/escrow/anchor/escrow/tsconfig.json b/tokens/escrow/anchor/escrow/tsconfig.json new file mode 100644 index 000000000..cd5d2e3d0 --- /dev/null +++ b/tokens/escrow/anchor/escrow/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true + } +}