diff --git a/.travis.yml b/.travis.yml index a60f0640..3bbf8e5d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -67,6 +67,7 @@ jobs: - <<: *examples name: Runs the examples 3 script: + - pushd examples/escrow && yarn && anchor test && popd - pushd examples/pyth && yarn && anchor test && popd - pushd examples/tutorial/basic-0 && anchor test && popd - pushd examples/tutorial/basic-1 && anchor test && popd diff --git a/examples/escrow/Anchor.toml b/examples/escrow/Anchor.toml new file mode 100644 index 00000000..b9705f03 --- /dev/null +++ b/examples/escrow/Anchor.toml @@ -0,0 +1,3 @@ +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" diff --git a/examples/escrow/Cargo.toml b/examples/escrow/Cargo.toml new file mode 100644 index 00000000..a60de986 --- /dev/null +++ b/examples/escrow/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +members = [ + "programs/*" +] diff --git a/examples/escrow/package.json b/examples/escrow/package.json new file mode 100644 index 00000000..5d02e144 --- /dev/null +++ b/examples/escrow/package.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "@project-serum/anchor": "^0.9.0", + "@project-serum/serum": "0.13.38", + "@solana/web3.js": "^1.18.0", + "@solana/spl-token": "^0.1.6" + }, + "devDependencies": { + "ts-mocha": "^8.0.0" + } +} diff --git a/examples/escrow/programs/escrow/Cargo.toml b/examples/escrow/programs/escrow/Cargo.toml new file mode 100644 index 00000000..e641fba7 --- /dev/null +++ b/examples/escrow/programs/escrow/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "escrow" +version = "0.1.0" +description = "Created with Anchor" +edition = "2018" + +[lib] +crate-type = ["cdylib", "lib"] +name = "escrow" + +[features] +no-entrypoint = [] +no-idl = [] +cpi = ["no-entrypoint"] +default = [] + +[dependencies] +anchor-lang = { path = "../../../../lang" } +anchor-spl = { path = "../../../../spl" } +spl-token = { version = "3.1.1", features = ["no-entrypoint"] } diff --git a/examples/escrow/programs/escrow/Xargo.toml b/examples/escrow/programs/escrow/Xargo.toml new file mode 100644 index 00000000..1744f098 --- /dev/null +++ b/examples/escrow/programs/escrow/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/examples/escrow/programs/escrow/src/lib.rs b/examples/escrow/programs/escrow/src/lib.rs new file mode 100644 index 00000000..fa8dcc6b --- /dev/null +++ b/examples/escrow/programs/escrow/src/lib.rs @@ -0,0 +1,228 @@ +//! An example of an escrow program, inspired by PaulX tutorial seen here +//! https://paulx.dev/blog/2021/01/14/programming-on-solana-an-introduction/ +//! This example has some changes to implementation, but more or less should be the same overall +//! Also gives examples on how to use some newer anchor features and CPI +//! +//! User (Initializer) constructs an escrow deal: +//! - SPL token (X) they will offer and amount +//! - SPL token (Y) count they want in return and amount +//! - Program will take ownership of initializer's token X account +//! +//! Once this escrow is initialised, either: +//! 1. User (Taker) can call the exchange function to exchange their Y for X +//! - This will close the escrow account and no longer be usable +//! OR +//! 2. If no one has exchanged, the initializer can close the escrow account +//! - Initializer will get back ownership of their token X account + +use anchor_lang::prelude::*; +use anchor_spl::token::{self, SetAuthority, TokenAccount, Transfer}; +use spl_token::instruction::AuthorityType; + +#[program] +pub mod escrow { + use super::*; + + pub fn initialize_escrow( + ctx: Context, + initializer_amount: u64, + taker_amount: u64, + ) -> ProgramResult { + ctx.accounts.escrow_account.initializer_key = *ctx.accounts.initializer.key; + ctx.accounts + .escrow_account + .initializer_deposit_token_account = *ctx + .accounts + .initializer_deposit_token_account + .to_account_info() + .key; + ctx.accounts + .escrow_account + .initializer_receive_token_account = *ctx + .accounts + .initializer_receive_token_account + .to_account_info() + .key; + ctx.accounts.escrow_account.initializer_amount = initializer_amount; + ctx.accounts.escrow_account.taker_amount = taker_amount; + + let (pda, _bump_seed) = Pubkey::find_program_address(&[b"escrow"], ctx.program_id); + token::set_authority(ctx.accounts.into(), AuthorityType::AccountOwner, Some(pda))?; + Ok(()) + } + + pub fn cancel_escrow(ctx: Context) -> ProgramResult { + let (_pda, bump_seed) = Pubkey::find_program_address(&[b"escrow"], ctx.program_id); + let seeds = &[&b"escrow"[..], &[bump_seed]]; + + token::set_authority( + ctx.accounts + .into_set_authority_context() + .with_signer(&[&seeds[..]]), + AuthorityType::AccountOwner, + Some(ctx.accounts.escrow_account.initializer_key), + )?; + + Ok(()) + } + + pub fn exchange(ctx: Context) -> ProgramResult { + // Transferring from initializer to taker + let (_pda, bump_seed) = Pubkey::find_program_address(&[b"escrow"], ctx.program_id); + let seeds = &[&b"escrow"[..], &[bump_seed]]; + + token::transfer( + ctx.accounts + .into_transfer_to_taker_context() + .with_signer(&[&seeds[..]]), + ctx.accounts.escrow_account.initializer_amount, + )?; + + token::transfer( + ctx.accounts.into_transfer_to_initializer_context(), + ctx.accounts.escrow_account.taker_amount, + )?; + + token::set_authority( + ctx.accounts + .into_set_authority_context() + .with_signer(&[&seeds[..]]), + AuthorityType::AccountOwner, + Some(ctx.accounts.escrow_account.initializer_key), + )?; + + Ok(()) + } +} + +#[derive(Accounts)] +#[instruction(initializer_amount: u64)] +pub struct InitializeEscrow<'info> { + #[account(signer)] + pub initializer: AccountInfo<'info>, + #[account( + mut, + constraint = initializer_deposit_token_account.amount >= initializer_amount + )] + pub initializer_deposit_token_account: CpiAccount<'info, TokenAccount>, + pub initializer_receive_token_account: CpiAccount<'info, TokenAccount>, + #[account(init)] + pub escrow_account: ProgramAccount<'info, EscrowAccount>, + pub token_program: AccountInfo<'info>, + pub rent: Sysvar<'info, Rent>, +} + +#[derive(Accounts)] +pub struct Exchange<'info> { + #[account(signer)] + pub taker: AccountInfo<'info>, + #[account(mut)] + pub taker_deposit_token_account: CpiAccount<'info, TokenAccount>, + #[account(mut)] + pub taker_receive_token_account: CpiAccount<'info, TokenAccount>, + #[account(mut)] + pub pda_deposit_token_account: CpiAccount<'info, TokenAccount>, + #[account(mut)] + pub initializer_receive_token_account: CpiAccount<'info, TokenAccount>, + #[account(mut)] + pub initializer_main_account: AccountInfo<'info>, + #[account( + mut, + constraint = escrow_account.taker_amount <= taker_deposit_token_account.amount, + constraint = escrow_account.initializer_deposit_token_account == *pda_deposit_token_account.to_account_info().key, + constraint = escrow_account.initializer_receive_token_account == *initializer_receive_token_account.to_account_info().key, + constraint = escrow_account.initializer_key == *initializer_main_account.key, + close = initializer_main_account + )] + pub escrow_account: ProgramAccount<'info, EscrowAccount>, + pub pda_account: AccountInfo<'info>, + pub token_program: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct CancelEscrow<'info> { + pub initializer: AccountInfo<'info>, + #[account(mut)] + pub pda_deposit_token_account: CpiAccount<'info, TokenAccount>, + pub pda_account: AccountInfo<'info>, + #[account( + mut, + constraint = escrow_account.initializer_key == *initializer.key, + constraint = escrow_account.initializer_deposit_token_account == *pda_deposit_token_account.to_account_info().key, + close = initializer + )] + pub escrow_account: ProgramAccount<'info, EscrowAccount>, + pub token_program: AccountInfo<'info>, +} + +#[account] +pub struct EscrowAccount { + pub initializer_key: Pubkey, + pub initializer_deposit_token_account: Pubkey, + pub initializer_receive_token_account: Pubkey, + pub initializer_amount: u64, + pub taker_amount: u64, +} + +impl<'info> From<&mut InitializeEscrow<'info>> + for CpiContext<'_, '_, '_, 'info, SetAuthority<'info>> +{ + fn from(accounts: &mut InitializeEscrow<'info>) -> Self { + let cpi_accounts = SetAuthority { + account_or_mint: accounts + .initializer_deposit_token_account + .to_account_info() + .clone(), + current_authority: accounts.initializer.clone(), + }; + let cpi_program = accounts.token_program.clone(); + CpiContext::new(cpi_program, cpi_accounts) + } +} + +impl<'info> CancelEscrow<'info> { + fn into_set_authority_context(&self) -> CpiContext<'_, '_, '_, 'info, SetAuthority<'info>> { + let cpi_accounts = SetAuthority { + account_or_mint: self.pda_deposit_token_account.to_account_info().clone(), + current_authority: self.pda_account.clone(), + }; + CpiContext::new(self.token_program.clone(), cpi_accounts) + } +} + +impl<'info> Exchange<'info> { + fn into_set_authority_context(&self) -> CpiContext<'_, '_, '_, 'info, SetAuthority<'info>> { + let cpi_accounts = SetAuthority { + account_or_mint: self.pda_deposit_token_account.to_account_info().clone(), + current_authority: self.pda_account.clone(), + }; + CpiContext::new(self.token_program.clone(), cpi_accounts) + } +} + +impl<'info> Exchange<'info> { + fn into_transfer_to_taker_context(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> { + let cpi_accounts = Transfer { + from: self.pda_deposit_token_account.to_account_info().clone(), + to: self.taker_receive_token_account.to_account_info().clone(), + authority: self.pda_account.clone(), + }; + CpiContext::new(self.token_program.clone(), cpi_accounts) + } +} + +impl<'info> Exchange<'info> { + fn into_transfer_to_initializer_context( + &self, + ) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> { + let cpi_accounts = Transfer { + from: self.taker_deposit_token_account.to_account_info().clone(), + to: self + .initializer_receive_token_account + .to_account_info() + .clone(), + authority: self.taker.clone(), + }; + CpiContext::new(self.token_program.clone(), cpi_accounts) + } +} diff --git a/examples/escrow/tests/escrow.js b/examples/escrow/tests/escrow.js new file mode 100644 index 00000000..5cc474ee --- /dev/null +++ b/examples/escrow/tests/escrow.js @@ -0,0 +1,207 @@ +const anchor = require("@project-serum/anchor"); +const { TOKEN_PROGRAM_ID, Token } = require("@solana/spl-token"); +const assert = require("assert"); + +describe("escrow", () => { + const provider = anchor.Provider.env(); + anchor.setProvider(provider); + + const program = anchor.workspace.Escrow; + + let mintA = null; + let mintB = null; + let initializerTokenAccountA = null; + let initializerTokenAccountB = null; + let takerTokenAccountA = null; + let takerTokenAccountB = null; + let pda = null; + + const takerAmount = 1000; + const initializerAmount = 500; + + const escrowAccount = anchor.web3.Keypair.generate(); + const payer = anchor.web3.Keypair.generate(); + const mintAuthority = anchor.web3.Keypair.generate(); + + it("Initialise escrow state", async () => { + // Airdropping tokens to a payer. + await provider.connection.confirmTransaction( + await provider.connection.requestAirdrop(payer.publicKey, 10000000000), + "confirmed" + ); + + mintA = await Token.createMint( + provider.connection, + payer, + mintAuthority.publicKey, + null, + 0, + TOKEN_PROGRAM_ID + ); + + mintB = await Token.createMint( + provider.connection, + payer, + mintAuthority.publicKey, + null, + 0, + TOKEN_PROGRAM_ID + ); + + initializerTokenAccountA = await mintA.createAccount(provider.wallet.publicKey); + takerTokenAccountA = await mintA.createAccount(provider.wallet.publicKey); + + initializerTokenAccountB = await mintB.createAccount(provider.wallet.publicKey); + takerTokenAccountB = await mintB.createAccount(provider.wallet.publicKey); + + await mintA.mintTo( + initializerTokenAccountA, + mintAuthority.publicKey, + [mintAuthority], + initializerAmount + ); + + await mintB.mintTo( + takerTokenAccountB, + mintAuthority.publicKey, + [mintAuthority], + takerAmount + ); + + let _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA); + let _takerTokenAccountB = await mintB.getAccountInfo(takerTokenAccountB); + + assert.ok(_initializerTokenAccountA.amount.toNumber() == initializerAmount); + assert.ok(_takerTokenAccountB.amount.toNumber() == takerAmount); + }); + + it("Initialize escrow", async () => { + await program.rpc.initializeEscrow( + new anchor.BN(initializerAmount), + new anchor.BN(takerAmount), + { + accounts: { + initializer: provider.wallet.publicKey, + initializerDepositTokenAccount: initializerTokenAccountA, + initializerReceiveTokenAccount: initializerTokenAccountB, + escrowAccount: escrowAccount.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + instructions: [ + await program.account.escrowAccount.createInstruction(escrowAccount), + ], + signers: [escrowAccount], + } + ); + + // Get the PDA that is assigned authority to token account. + const [_pda, _nonce] = await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from(anchor.utils.bytes.utf8.encode("escrow"))], + program.programId + ); + + pda = _pda; + + let _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA); + + let _escrowAccount = await program.account.escrowAccount.fetch( + escrowAccount.publicKey + ); + + // Check that the new owner is the PDA. + assert.ok(_initializerTokenAccountA.owner.equals(pda)); + + // Check that the values in the escrow account match what we expect. + assert.ok(_escrowAccount.initializerKey.equals(provider.wallet.publicKey)); + assert.ok(_escrowAccount.initializerAmount.toNumber() == initializerAmount); + assert.ok(_escrowAccount.takerAmount.toNumber() == takerAmount); + assert.ok( + _escrowAccount.initializerDepositTokenAccount.equals(initializerTokenAccountA) + ); + assert.ok( + _escrowAccount.initializerReceiveTokenAccount.equals(initializerTokenAccountB) + ); + }); + + it("Exchange escrow", async () => { + await program.rpc.exchange({ + accounts: { + taker: provider.wallet.publicKey, + takerDepositTokenAccount: takerTokenAccountB, + takerReceiveTokenAccount: takerTokenAccountA, + pdaDepositTokenAccount: initializerTokenAccountA, + initializerReceiveTokenAccount: initializerTokenAccountB, + initializerMainAccount: provider.wallet.publicKey, + escrowAccount: escrowAccount.publicKey, + pdaAccount: pda, + tokenProgram: TOKEN_PROGRAM_ID, + }, + }); + + let _takerTokenAccountA = await mintA.getAccountInfo(takerTokenAccountA); + let _takerTokenAccountB = await mintB.getAccountInfo(takerTokenAccountB); + let _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA); + let _initializerTokenAccountB = await mintB.getAccountInfo(initializerTokenAccountB); + + // Check that the initializer gets back ownership of their token account. + assert.ok(_takerTokenAccountA.owner.equals(provider.wallet.publicKey)); + + assert.ok(_takerTokenAccountA.amount.toNumber() == initializerAmount); + assert.ok(_initializerTokenAccountA.amount.toNumber() == 0); + assert.ok(_initializerTokenAccountB.amount.toNumber() == takerAmount); + assert.ok(_takerTokenAccountB.amount.toNumber() == 0); + }); + + let newEscrow = anchor.web3.Keypair.generate(); + + it("Initialize escrow and cancel escrow", async () => { + // Put back tokens into initializer token A account. + await mintA.mintTo( + initializerTokenAccountA, + mintAuthority.publicKey, + [mintAuthority], + initializerAmount + ); + + await program.rpc.initializeEscrow( + new anchor.BN(initializerAmount), + new anchor.BN(takerAmount), + { + accounts: { + initializer: provider.wallet.publicKey, + initializerDepositTokenAccount: initializerTokenAccountA, + initializerReceiveTokenAccount: initializerTokenAccountB, + escrowAccount: newEscrow.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + rent: anchor.web3.SYSVAR_RENT_PUBKEY, + }, + instructions: [await program.account.escrowAccount.createInstruction(newEscrow)], + signers: [newEscrow], + } + ); + + let _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA); + + // Check that the new owner is the PDA. + assert.ok(_initializerTokenAccountA.owner.equals(pda)); + + // Cancel the escrow. + await program.rpc.cancelEscrow({ + accounts: { + initializer: provider.wallet.publicKey, + pdaDepositTokenAccount: initializerTokenAccountA, + pdaAccount: pda, + escrowAccount: newEscrow.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + }, + }); + + // Check the final owner should be the provider public key. + _initializerTokenAccountA = await mintA.getAccountInfo(initializerTokenAccountA); + assert.ok(_initializerTokenAccountA.owner.equals(provider.wallet.publicKey)); + + // Check all the funds are still there. + assert.ok(_initializerTokenAccountA.amount.toNumber() == initializerAmount); + }); +});