From ed7b710ef436c046df9d30558b1c1633b410c822 Mon Sep 17 00:00:00 2001 From: abishekk92 Date: Thu, 11 Aug 2022 16:24:58 -0400 Subject: [PATCH 1/2] Transfer profile and their connections from one wallet to another. We already had the ability to transfer profile from one wallet to another. However, our existing schema doesn't render the connections protable. Perhaps, time we made it portable. Here is how we are going to do it. 1. Create CBox 2. Create a connection and set authority to CBox. (authority can no longer be a signer, upon closing a connection the rent deposit should go back to the owner of the cbox) 3. For existing connections, migrate connection from user authority to connection box 4. Whenever a user wants to migrate their profile next, they should be able to do so by changing the authority of the profile and then of the connection box. 5. Existing users should intialize a connection box. 6. Move their existing connections in the following way. 1. Initialize a connection box if it doesn't exist. 2. Close the current connection account 3. Create a new connection account with the right connection box. 4. Preferably do all of this in a single ix so that multiple connections can be batched in a tx. --- programs/wordcel/src/instructions.rs | 153 +++++++++++++ programs/wordcel/src/lib.rs | 47 +++- programs/wordcel/src/state.rs | 24 ++ tests/invite.spec.ts | 284 +++++++++++++----------- tests/transfer.spec.ts | 261 ++++++++++++++++++++++ tests/wordcel.spec.ts | 317 ++++++++++++++++++--------- 6 files changed, 850 insertions(+), 236 deletions(-) create mode 100644 tests/transfer.spec.ts diff --git a/programs/wordcel/src/instructions.rs b/programs/wordcel/src/instructions.rs index f168810..b895384 100644 --- a/programs/wordcel/src/instructions.rs +++ b/programs/wordcel/src/instructions.rs @@ -32,6 +32,44 @@ pub struct Initialize<'info> { pub invitation_program: Program<'info, InvitationProgram>, } +#[derive(Accounts)] +pub struct TransferProfile<'info> { + #[account( + mut, + seeds = [ + b"profile".as_ref(), + &profile.random_hash + ], + bump, + )] + pub profile: Account<'info, Profile>, + #[account( + owner = invitation_program.key(), + seeds = [ + Invite::PREFIX.as_bytes().as_ref(), + authority.key().as_ref() + ], + seeds::program = invitation_program.key(), + bump = invitation.bump + )] + pub invitation: Account<'info, Invite>, + #[account( + owner = invitation_program.key(), + seeds = [ + Invite::PREFIX.as_bytes().as_ref(), + new_authority.key().as_ref() + ], + seeds::program = invitation_program.key(), + bump = new_authority_invitation.bump + )] + pub new_authority_invitation: Account<'info, Invite>, + #[account(mut)] + pub authority: Signer<'info>, + pub new_authority: SystemAccount<'info>, + pub system_program: Program<'info, System>, + pub invitation_program: Program<'info, InvitationProgram>, +} + #[derive(Accounts)] #[instruction(metadata_uri: String, random_hash: [u8;32])] pub struct CreatePost<'info> { @@ -162,3 +200,118 @@ pub struct CloseConnection<'info> { pub authority: Signer<'info>, pub system_program: Program<'info, System>, } + +#[derive(Accounts)] +#[instruction(random_hash: [u8; 32])] +pub struct InitializeConnectionBox<'info> { + #[account( + init, + seeds = [ + b"connection_box".as_ref(), + &random_hash + ], + bump, + payer = authority, + space = ConnectionBox::LEN + )] + pub connection_box: Account<'info, ConnectionBox>, + #[account(mut)] + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct TranferConnectionBox<'info> { + #[account( + mut, + seeds = [ + b"connection_box".as_ref(), + &connection_box.random_hash + ], + bump, + )] + pub connection_box: Account<'info, ConnectionBox>, + #[account(mut)] + pub authority: Signer<'info>, + pub new_authority: SystemAccount<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct InitializeConnectionV2<'info> { + #[account( + init, + seeds = [ + b"connection_v2".as_ref(), + profile.key().as_ref() + ], + bump, + payer = authority, + // Don't allow the user to follow themselves + constraint = profile.authority.key() != connection_box.authority.key() @ConnectionError::SelfFollow, + space = ConnectionV2::LEN + )] + pub connection: Account<'info, ConnectionV2>, + #[account(has_one=authority)] + pub connection_box: Account<'info, ConnectionBox>, + pub profile: Account<'info, Profile>, + #[account(mut)] + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct CloseConnectionV2<'info> { + #[account( + mut, + seeds = [ + b"connection_v2".as_ref(), + profile.key().as_ref() + ], + bump = connection.bump, + has_one = connection_box, + close = authority + )] + pub connection: Account<'info, ConnectionV2>, + #[account(has_one=authority)] + pub connection_box: Account<'info, ConnectionBox>, + pub profile: Account<'info, Profile>, + #[account(mut)] + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct MigrateConnectionToV2<'info> { + #[account( + mut, + seeds = [ + b"connection".as_ref(), + authority.key().as_ref(), + profile.key().as_ref() + ], + bump = connection_v1.bump, + has_one = authority, + close = authority + )] + pub connection_v1: Account<'info, Connection>, + #[account( + init, + seeds = [ + b"connection_v2".as_ref(), + profile.key().as_ref() + ], + bump, + payer = authority, + // Don't allow the user to follow themselves + constraint = profile.authority.key() != connection_box.authority.key() @ConnectionError::SelfFollow, + space = ConnectionV2::LEN + )] + pub connection_v2: Account<'info, ConnectionV2>, + #[account(has_one=authority)] + pub connection_box: Account<'info, ConnectionBox>, + pub profile: Account<'info, Profile>, + #[account(mut)] + pub authority: Signer<'info>, + pub system_program: Program<'info, System>, +} diff --git a/programs/wordcel/src/lib.rs b/programs/wordcel/src/lib.rs index 1584ee9..07391c9 100644 --- a/programs/wordcel/src/lib.rs +++ b/programs/wordcel/src/lib.rs @@ -10,7 +10,7 @@ use events::*; use instructions::*; use state::*; -#[cfg(not(any(feature = "mainnet", feature = "devnet")))] +#[cfg(not(any(feature = ": mainnet", feature = "devnet")))] declare_id!("v4enuof3drNvU2Y3b5m7K62hMq3QUP6qQSV2jjxAhkp"); #[cfg(feature = "devnet")] @@ -31,6 +31,12 @@ pub mod wordcel { Ok(()) } + pub fn transfer_profile(ctx: Context) -> Result<()> { + let profile = &mut ctx.accounts.profile; + profile.authority = *ctx.accounts.new_authority.to_account_info().key; + Ok(()) + } + pub fn create_post( ctx: Context, metadata_uri: String, @@ -83,6 +89,7 @@ pub mod wordcel { Ok(()) } + // Marked for deprecation pub fn initialize_connection(ctx: Context) -> Result<()> { let connection = &mut ctx.accounts.connection; connection.bump = *ctx.bumps.get("connection").unwrap(); @@ -100,7 +107,45 @@ pub mod wordcel { Ok(()) } + // Marked for deprecation pub fn close_connection(_ctx: Context) -> Result<()> { Ok(()) } + + pub fn initialize_connection_v2(ctx: Context) -> Result<()> { + let connection = &mut ctx.accounts.connection; + connection.bump = *ctx.bumps.get("connection").unwrap(); + connection.profile = *ctx.accounts.profile.to_account_info().key; + connection.connection_box = *ctx.accounts.connection_box.to_account_info().key; + Ok(()) + } + + pub fn close_connection_v2(_ctx: Context) -> Result<()> { + Ok(()) + } + + pub fn initialize_connection_box( + ctx: Context, + random_hash: [u8; 32], + ) -> Result<()> { + let connection_box = &mut ctx.accounts.connection_box; + connection_box.bump = *ctx.bumps.get("connection_box").unwrap(); + connection_box.random_hash = random_hash; + connection_box.authority = *ctx.accounts.authority.to_account_info().key; + Ok(()) + } + + pub fn transfer_connection_box(ctx: Context) -> Result<()> { + let connection_box = &mut ctx.accounts.connection_box; + connection_box.authority = *ctx.accounts.new_authority.to_account_info().key; + Ok(()) + } + + pub fn migrate_to_connectionv2(ctx: Context) -> Result<()> { + let connection = &mut ctx.accounts.connection_v2; + connection.bump = *ctx.bumps.get("connection_v2").unwrap(); + connection.profile = *ctx.accounts.profile.to_account_info().key; + connection.connection_box = *ctx.accounts.connection_box.to_account_info().key; + Ok(()) + } } diff --git a/programs/wordcel/src/state.rs b/programs/wordcel/src/state.rs index 0eaa819..00c587f 100644 --- a/programs/wordcel/src/state.rs +++ b/programs/wordcel/src/state.rs @@ -41,3 +41,27 @@ pub struct Connection { impl Connection { pub const LEN: usize = 8 + size_of::(); } + +#[account] +#[derive(Default)] +pub struct ConnectionBox { + pub authority: Pubkey, + pub random_hash: [u8; 32], + pub bump: u8, +} + +impl ConnectionBox { + pub const LEN: usize = 8 + size_of::(); +} + +#[account] +#[derive(Default)] +pub struct ConnectionV2 { + pub profile: Pubkey, + pub connection_box: Pubkey, + pub bump: u8, +} + +impl ConnectionV2 { + pub const LEN: usize = 8 + size_of::(); +} diff --git a/tests/invite.spec.ts b/tests/invite.spec.ts index 9feaacd..1563d01 100644 --- a/tests/invite.spec.ts +++ b/tests/invite.spec.ts @@ -1,137 +1,159 @@ -import * as anchor from '@project-serum/anchor'; -import {Program, AnchorError} from '@project-serum/anchor'; -import {Invite} from '../target/types/invite'; -import {expect} from 'chai'; -import {PublicKey} from '@solana/web3.js'; -import {getInviteAccount, sendInvite} from "./utils/invite"; -import {airdrop} from './utils'; - -const {SystemProgram} = anchor.web3; +import * as anchor from "@project-serum/anchor"; +import { Program, AnchorError } from "@project-serum/anchor"; +import { Invite } from "../target/types/invite"; +import { expect } from "chai"; +import { PublicKey } from "@solana/web3.js"; +import { getInviteAccount, sendInvite } from "./utils/invite"; +import { airdrop } from "./utils"; + +const { SystemProgram } = anchor.web3; const provider = anchor.getProvider(); const program = anchor.workspace.Invite as Program; const user = provider.wallet.publicKey; - -describe('Invitation', async () => { - // Prepare test user. - const testUser = anchor.web3.Keypair.generate(); - let oneInviteAccount: PublicKey; - - before(async () => { - await airdrop(testUser.publicKey); - oneInviteAccount = await getInviteAccount(testUser.publicKey); - }); - - - it("should initialize", async () => { - - await program.methods.initialize() - .accounts({ - inviteAccount: oneInviteAccount, - authority: testUser.publicKey, - payer: user, - systemProgram: SystemProgram.programId - }) - .rpc(); - const data = await program.account.invite.fetch(oneInviteAccount); - expect(data.authority.toString()).to.equal(testUser.publicKey.toString()); - }); - - it("should send invite to others", async () => { - const randomUser = anchor.web3.Keypair.generate(); - const [inviter, invited] = await sendInvite(testUser, randomUser.publicKey, user) - const data = await program.account.invite.fetch(inviter); - expect(data.invitesLeft).to.equal(1); - expect(data.invitesSent).to.equal(1); - const toInviteData = await program.account.invite.fetch(invited); - expect(toInviteData.invitedBy.toString()).to.equal(testUser.publicKey.toString()); - expect(toInviteData.authority.toString()).to.equal(randomUser.publicKey.toString()); - }); - - it("should not be able to send more than 2 invites", async () => { - // Set up new user - const newUser = anchor.web3.Keypair.generate(); - await airdrop(newUser.publicKey); - const inviteAccount = await getInviteAccount(newUser.publicKey); - await program.methods.initialize() - .accounts({ - inviteAccount: inviteAccount, - authority: newUser.publicKey, - payer: user, - systemProgram: SystemProgram.programId - }) - .rpc(); - - //First Invite - const randomUser = anchor.web3.Keypair.generate(); - await sendInvite(newUser, randomUser.publicKey, user); - - //Second Invite - const randomUser1 = anchor.web3.Keypair.generate(); - await sendInvite(newUser, randomUser1.publicKey, user); - - // Third Invite - const randomUser2 = anchor.web3.Keypair.generate(); - try { - await sendInvite(newUser, randomUser2.publicKey, user); - } catch (error) { - const anchorError = AnchorError.parse(error.logs); - expect(anchorError.error.errorCode.code).to.equal('NoInvitesLeft'); - } - }); - - - it("should not allow random user to initialize", async () => { - const randomUser = anchor.web3.Keypair.generate(); - const seed = [Buffer.from("invite"), randomUser.publicKey.toBuffer()]; - const [account, _] = await anchor.web3.PublicKey.findProgramAddress(seed, program.programId); - const tx = await program.methods.initialize() - .accounts({ - inviteAccount: account, - authority: randomUser.publicKey, - payer: testUser.publicKey, - systemProgram: SystemProgram.programId - }) - .transaction(); - tx.feePayer = user; - tx.recentBlockhash = (await provider.connection.getRecentBlockhash()).blockhash; - tx.sign(testUser); - try { - await provider.sendAndConfirm(tx) - } catch (error) { - const anchorError = AnchorError.parse(error.logs); - expect(anchorError.error.errorCode.code).to.equal('UnAuthorizedInitialization'); - } - }); - - it("should allow admin to initialize as many accounts as required", async () => { - for (let index = 0; index < 5; index++) { - const randomUser = anchor.web3.Keypair.generate(); - const seed = [Buffer.from("invite"), randomUser.publicKey.toBuffer()]; - const [account, _] = await anchor.web3.PublicKey.findProgramAddress(seed, program.programId); - await program.methods.initialize() - .accounts({ - inviteAccount: account, - authority: randomUser.publicKey, - payer: user, - systemProgram: SystemProgram.programId - }) - .rpc(); - const data = await program.account.invite.fetch(account); - expect(data.authority.toString()).to.equal(randomUser.publicKey.toString()); - } - }); - - it("should not allow uninitialized invite account to send an invite", async () => { - const randomUser = anchor.web3.Keypair.generate(); - const randomUser1 = anchor.web3.Keypair.generate(); - await airdrop(randomUser1.publicKey); - try { - await sendInvite(randomUser, randomUser1.publicKey, user) - } catch (error) { - const anchorError = AnchorError.parse(error.logs); - expect(anchorError.error.errorCode.code).to.equal('AccountNotInitialized'); - } - }); +describe("Invitation", async () => { + // Prepare test user. + const testUser = anchor.web3.Keypair.generate(); + let oneInviteAccount: PublicKey; + + before(async () => { + await airdrop(testUser.publicKey); + oneInviteAccount = await getInviteAccount(testUser.publicKey); + }); + + it("should initialize", async () => { + await program.methods + .initialize() + .accounts({ + inviteAccount: oneInviteAccount, + authority: testUser.publicKey, + payer: user, + systemProgram: SystemProgram.programId, + }) + .rpc(); + const data = await program.account.invite.fetch(oneInviteAccount); + expect(data.authority.toString()).to.equal(testUser.publicKey.toString()); + }); + + it("should send invite to others", async () => { + const randomUser = anchor.web3.Keypair.generate(); + const [inviter, invited] = await sendInvite( + testUser, + randomUser.publicKey, + user + ); + const data = await program.account.invite.fetch(inviter); + expect(data.invitesLeft).to.equal(1); + expect(data.invitesSent).to.equal(1); + const toInviteData = await program.account.invite.fetch(invited); + expect(toInviteData.invitedBy.toString()).to.equal( + testUser.publicKey.toString() + ); + expect(toInviteData.authority.toString()).to.equal( + randomUser.publicKey.toString() + ); + }); + + it("should not be able to send more than 2 invites", async () => { + // Set up new user + const newUser = anchor.web3.Keypair.generate(); + await airdrop(newUser.publicKey); + const inviteAccount = await getInviteAccount(newUser.publicKey); + await program.methods + .initialize() + .accounts({ + inviteAccount: inviteAccount, + authority: newUser.publicKey, + payer: user, + systemProgram: SystemProgram.programId, + }) + .rpc(); + + //First Invite + const randomUser = anchor.web3.Keypair.generate(); + await sendInvite(newUser, randomUser.publicKey, user); + + //Second Invite + const randomUser1 = anchor.web3.Keypair.generate(); + await sendInvite(newUser, randomUser1.publicKey, user); + + // Third Invite + const randomUser2 = anchor.web3.Keypair.generate(); + try { + await sendInvite(newUser, randomUser2.publicKey, user); + } catch (error) { + const anchorError = AnchorError.parse(error.logs); + expect(anchorError.error.errorCode.code).to.equal("NoInvitesLeft"); + } + }); + + it("should not allow random user to initialize", async () => { + const randomUser = anchor.web3.Keypair.generate(); + const seed = [Buffer.from("invite"), randomUser.publicKey.toBuffer()]; + const [account, _] = await anchor.web3.PublicKey.findProgramAddress( + seed, + program.programId + ); + const tx = await program.methods + .initialize() + .accounts({ + inviteAccount: account, + authority: randomUser.publicKey, + payer: testUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .transaction(); + tx.feePayer = user; + tx.recentBlockhash = ( + await provider.connection.getRecentBlockhash() + ).blockhash; + tx.sign(testUser); + try { + await provider.sendAndConfirm(tx); + } catch (error) { + const anchorError = AnchorError.parse(error.logs); + expect(anchorError.error.errorCode.code).to.equal( + "UnAuthorizedInitialization" + ); + } + }); + + it("should allow admin to initialize as many accounts as required", async () => { + for (let index = 0; index < 5; index++) { + const randomUser = anchor.web3.Keypair.generate(); + const seed = [Buffer.from("invite"), randomUser.publicKey.toBuffer()]; + const [account, _] = await anchor.web3.PublicKey.findProgramAddress( + seed, + program.programId + ); + await program.methods + .initialize() + .accounts({ + inviteAccount: account, + authority: randomUser.publicKey, + payer: user, + systemProgram: SystemProgram.programId, + }) + .rpc(); + const data = await program.account.invite.fetch(account); + expect(data.authority.toString()).to.equal( + randomUser.publicKey.toString() + ); + } + }); + + it("should not allow uninitialized invite account to send an invite", async () => { + const randomUser = anchor.web3.Keypair.generate(); + const randomUser1 = anchor.web3.Keypair.generate(); + await airdrop(randomUser1.publicKey); + try { + await sendInvite(randomUser, randomUser1.publicKey, user); + } catch (error) { + const anchorError = AnchorError.parse(error.logs); + expect(anchorError.error.errorCode.code).to.equal( + "AccountNotInitialized" + ); + } + }); }); diff --git a/tests/transfer.spec.ts b/tests/transfer.spec.ts new file mode 100644 index 0000000..fbf5a28 --- /dev/null +++ b/tests/transfer.spec.ts @@ -0,0 +1,261 @@ +import * as anchor from "@project-serum/anchor"; +import { Program, AnchorError } from "@project-serum/anchor"; +import { Wordcel } from "../target/types/wordcel"; +import { expect } from "chai"; +import { PublicKey } from "@solana/web3.js"; +import randombytes from "randombytes"; +import { getInviteAccount, invitationProgram } from "./utils/invite"; +import { airdrop } from "./utils"; +const { SystemProgram } = anchor.web3; +const provider = anchor.getProvider(); + +const program = anchor.workspace.Wordcel as Program; +const user = provider.wallet.publicKey; + +// NOTE: +// State Transtion happens as follows: +// New Invite -> New Profile -> New Post -> Identify new authority -> Send invite -> Transfer authority -> Old authority can't edit -> New authority can edit. +// How will the transfer work for connections? + +describe("Wordcel", async () => { + const originalAuthority = anchor.web3.Keypair.generate(); + const newAuthority = anchor.web3.Keypair.generate(); + + let originalAuthorityInvite: PublicKey; + let newAuthorityInvite: PublicKey; + + let lastPostHash: Buffer; + let lastPostAccount: PublicKey; + + let profileHash = randombytes(32); + const profileSeed = [Buffer.from("profile"), profileHash]; + const [profileAccount, _] = await anchor.web3.PublicKey.findProgramAddress( + profileSeed, + program.programId + ); + + before(async () => { + await airdrop(originalAuthority.publicKey); + await airdrop(newAuthority.publicKey); + + originalAuthorityInvite = await getInviteAccount( + originalAuthority.publicKey + ); + + await invitationProgram.methods + .initialize() + .accounts({ + inviteAccount: originalAuthorityInvite, + authority: originalAuthority.publicKey, + payer: user, + systemProgram: SystemProgram.programId, + }) + .rpc(); + + newAuthorityInvite = await getInviteAccount(newAuthority.publicKey); + await invitationProgram.methods + .initialize() + .accounts({ + inviteAccount: newAuthorityInvite, + authority: newAuthority.publicKey, + payer: user, + systemProgram: SystemProgram.programId, + }) + .rpc(); + + await program.methods + .initialize(profileHash) + .accounts({ + profile: profileAccount, + user: originalAuthority.publicKey, + invitation: originalAuthorityInvite, + invitationProgram: invitationProgram.programId, + systemProgram: SystemProgram.programId, + }) + .signers([originalAuthority]) + .rpc(); + + const postRandomHash = randombytes(32); + const postSeeds = [Buffer.from("post"), postRandomHash]; + const [postAccount, _bump] = await anchor.web3.PublicKey.findProgramAddress( + postSeeds, + program.programId + ); + + const metadataUri = + "https://gist.githubusercontent.com/abishekk92/10593977/raw/589238c3d48e654347d6cbc1e29c1e10dadc7cea/monoid.md"; + + const newPostIX = await program.methods + .createPost(metadataUri, postRandomHash) + .accounts({ + post: postAccount, + profile: profileAccount, + authority: originalAuthority.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([originalAuthority]) + .rpc(); + + lastPostHash = postRandomHash; + lastPostAccount = postAccount; + }); + + describe("Transfer Profile", async () => { + it("should execute transfer", async () => { + await program.methods + .transferProfile() + .accounts({ + profile: profileAccount, + invitation: originalAuthorityInvite, + newAuthorityInvitation: newAuthorityInvite, + authority: originalAuthority.publicKey, + newAuthority: newAuthority.publicKey, + systemProgram: SystemProgram.programId, + invitationProgram: invitationProgram.programId, + }) + .signers([originalAuthority]) + .rpc(); + + const profileData = await program.account.profile.fetch(profileAccount); + expect(profileData.authority.toString()).to.equal( + newAuthority.publicKey.toString() + ); + }); + + it("should not allow the old authority to edit", async () => { + const metadataUri = + "https://gist.githubusercontent.com/shekdev/10593977/raw/589238c3d48e654347d6cbc1e29c1e10dadc7cea/monoid.md"; + try { + await program.methods + .updatePost(metadataUri) + .accounts({ + post: lastPostAccount, + profile: profileAccount, + authority: originalAuthority.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([originalAuthority]) + .rpc(); + } catch (e) { + const { error } = AnchorError.parse(e.logs); + expect(error.errorMessage).to.equal( + "A has one constraint was violated" + ); + } + }); + + it("should allow the new authority to edit", async () => { + const metadataUri = + "https://gist.githubusercontent.com/shekdev/10593977/raw/589238c3d48e654347d6cbc1e29c1e10dadc7cea/monoid.md"; + await program.methods + .updatePost(metadataUri) + .accounts({ + post: lastPostAccount, + profile: profileAccount, + authority: newAuthority.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([newAuthority]) + .rpc(); + const postData = await program.account.post.fetch(lastPostAccount); + expect(postData.metadataUri).to.equal(metadataUri); + }); + + it("should not allow the old authority to create a new post", async () => { + const postRandomHash = randombytes(32); + const postSeeds = [Buffer.from("post"), postRandomHash]; + const [postAccount, _bump] = + await anchor.web3.PublicKey.findProgramAddress( + postSeeds, + program.programId + ); + const metadataUri = + "https://gist.githubusercontent.com/shekdev/10593977/raw/589238c3d48e654347d6cbc1e29c1e10dadc7cea/monoid.md"; + try { + await program.methods + .createPost(metadataUri, postRandomHash) + .accounts({ + post: postAccount, + profile: profileAccount, + authority: originalAuthority.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([originalAuthority]) + .rpc(); + } catch (e) { + const { error } = AnchorError.parse(e.logs); + expect(error.errorMessage).to.equal( + "A has one constraint was violated" + ); + } + }); + + it("should allow the new authority to create a new post", async () => { + const postRandomHash = randombytes(32); + const postSeeds = [Buffer.from("post"), postRandomHash]; + const [postAccount, _bump] = + await anchor.web3.PublicKey.findProgramAddress( + postSeeds, + program.programId + ); + const metadataUri = + "https://gist.githubusercontent.com/shekdev/10593977/raw/589238c3d48e654347d6cbc1e29c1e10dadc7cea/monoid.md"; + await program.methods + .createPost(metadataUri, postRandomHash) + .accounts({ + post: postAccount, + profile: profileAccount, + authority: newAuthority.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([newAuthority]) + .rpc(); + const postData = await program.account.post.fetch(postAccount); + expect(postData.metadataUri).to.equal(metadataUri); + }); + }); + + describe("Transfer Connections", async () => { + it("should transfer connections", async () => { + const connectionBoxHash = randombytes(32); + const connectionBoxSeeds = [ + Buffer.from("connection_box"), + connectionBoxHash, + ]; + const [connectionBoxAccount, _] = + await anchor.web3.PublicKey.findProgramAddress( + connectionBoxSeeds, + program.programId + ); + + const newConnectionBoxIX = await program.methods + .initializeConnectionBox(connectionBoxHash) + .accounts({ + connectionBox: connectionBoxAccount, + authority: originalAuthority.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([originalAuthority]) + .instruction(); + + await program.methods + .transferConnectionBox() + .accounts({ + connectionBox: connectionBoxAccount, + authority: originalAuthority.publicKey, + newAuthority: newAuthority.publicKey, + systemProgram: SystemProgram.programId, + }) + .preInstructions([newConnectionBoxIX]) + .signers([originalAuthority]) + .rpc(); + + const connectionBoxData = await program.account.connectionBox.fetch( + connectionBoxAccount + ); + expect(connectionBoxData.authority.toString()).to.equal( + newAuthority.publicKey.toString() + ); + }); + }); +}); diff --git a/tests/wordcel.spec.ts b/tests/wordcel.spec.ts index bef1ee8..fdd847a 100644 --- a/tests/wordcel.spec.ts +++ b/tests/wordcel.spec.ts @@ -22,20 +22,22 @@ describe("wordcel", async () => { let onePostAccount: PublicKey; let inviteAccount: PublicKey; + before(async () => { + // Initialize Invitation Account + inviteAccount = await getInviteAccount(user); + await invitationProgram.methods + .initialize() + .accounts({ + inviteAccount: inviteAccount, + authority: user, + payer: user, + systemProgram: SystemProgram.programId, + }) + .rpc(); + }); + describe("Profile", async () => { it("should initialize", async () => { - // Initialize Invitation Account - inviteAccount = await getInviteAccount(user); - await invitationProgram.methods - .initialize() - .accounts({ - inviteAccount: inviteAccount, - authority: user, - payer: user, - systemProgram: SystemProgram.programId, - }) - .rpc(); - await program.methods .initialize(randomHash) .accounts({ @@ -61,29 +63,17 @@ describe("wordcel", async () => { ); const metadataUri = "https://gist.githubusercontent.com/abishekk92/10593977/raw/589238c3d48e654347d6cbc1e29c1e10dadc7cea/monoid.md"; - let listener = null; - let [event, slot] = await new Promise(async (resolve) => { - listener = program.addEventListener("NewPost", async (event, slot) => { - resolve([event, slot]); - }); - // Create Post - await program.methods - .createPost(metadataUri, randomHash) - .accounts({ - post: postAccount, - profile: profileAccount, - authority: user, - systemProgram: SystemProgram.programId, - }) - .rpc(); - }); - await program.removeEventListener(listener); - + await program.methods + .createPost(metadataUri, randomHash) + .accounts({ + post: postAccount, + profile: profileAccount, + authority: user, + systemProgram: SystemProgram.programId, + }) + .rpc(); const post = await program.account.post.fetch(postAccount); expect(post.metadataUri).to.equal(metadataUri); - expect(event.post.toString()).to.equal(postAccount.toString()); - expect(event.profile.toString()).to.equal(post.profile.toString()); - expect(slot).to.be.above(0); onePostAccount = postAccount; }); @@ -189,40 +179,20 @@ describe("wordcel", async () => { }); it("should create a connection", async () => { - let listener = null; - let [event, slot] = await new Promise(async (resolve) => { - listener = program.addEventListener( - "NewFollower", - async (event, slot) => { - resolve([event, slot]); - } - ); - // Initialize Connection - const tx = await program.methods - .initializeConnection() - .accounts({ - connection: connectionAccount, - profile: profileAccount, - authority: randomUser.publicKey, - systemProgram: SystemProgram.programId, - }) - .transaction(); - tx.feePayer = user; - tx.recentBlockhash = ( - await provider.connection.getRecentBlockhash() - ).blockhash; - tx.sign(randomUser); - await provider.sendAndConfirm(tx); - }); - await program.removeEventListener(listener); - + await program.methods + .initializeConnection() + .accounts({ + connection: connectionAccount, + profile: profileAccount, + authority: randomUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([randomUser]) + .rpc(); const connection = await program.account.connection.fetch( connectionAccount ); expect(connection.profile.toString()).to.equal(profileAccount.toString()); - expect(event.user.toString()).to.equal(connection.authority.toString()); - expect(event.followed.toString()).to.equal(connectionAccount.toString()); - expect(slot).to.be.above(0); }); it("should not let a user to follow themselves", async () => { @@ -253,24 +223,18 @@ describe("wordcel", async () => { }); it("should not create a connection again", async () => { - const tx = await program.methods - .initializeConnection() - .accounts({ - connection: connectionAccount, - profile: profileAccount, - authority: randomUser.publicKey, - systemProgram: SystemProgram.programId, - }) - .transaction(); - tx.feePayer = user; - tx.recentBlockhash = ( - await provider.connection.getRecentBlockhash() - ).blockhash; - tx.sign(randomUser); try { - await provider.sendAndConfirm(tx); + await program.methods + .initializeConnection() + .accounts({ + connection: connectionAccount, + profile: profileAccount, + authority: randomUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([randomUser]) + .rpc(); } catch (error) { - expect(error).to.be.an("error"); expect(error.toString()).to.contain("custom program error: 0x0"); } }); @@ -279,30 +243,25 @@ describe("wordcel", async () => { // Test must be run synchronously and in the specified order to avoid attepting to close an account that doesn't exist. it("should not allow unauthorized closing of connection", async () => { const closeUser = anchor.web3.Keypair.generate(); - const tx = await program.methods - .closeConnection() - .accounts({ - connection: connectionAccount, - profile: profileAccount, - authority: closeUser.publicKey, - systemProgram: SystemProgram.programId, - }) - .transaction(); - tx.feePayer = user; - tx.recentBlockhash = ( - await provider.connection.getRecentBlockhash() - ).blockhash; - tx.sign(closeUser); try { - await provider.sendAndConfirm(tx); + await program.methods + .closeConnection() + .accounts({ + connection: connectionAccount, + profile: profileAccount, + authority: closeUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([closeUser]) + .rpc(); } catch (error) { - expect(error).to.be.an("error"); - expect(error.toString()).to.contain("custom program error: 0x7d6"); + const anchorError = AnchorError.parse(error.logs); + expect(anchorError.error.errorCode.code).to.equal("ConstraintSeeds"); } }); it("should only allow the user to close the connection", async () => { - const tx = await program.methods + await program.methods .closeConnection() .accounts({ connection: connectionAccount, @@ -310,13 +269,8 @@ describe("wordcel", async () => { authority: randomUser.publicKey, systemProgram: SystemProgram.programId, }) - .transaction(); - tx.feePayer = user; - tx.recentBlockhash = ( - await provider.connection.getRecentBlockhash() - ).blockhash; - tx.sign(randomUser); - await provider.sendAndConfirm(tx); + .signers([randomUser]) + .rpc(); try { await program.account.connection.fetch(connectionAccount); } catch (error) { @@ -326,4 +280,159 @@ describe("wordcel", async () => { }); }); }); + + describe("Connections V2", async () => { + const profileRandomHash = randombytes(32); + const [testProfileAccount, _bump] = + await anchor.web3.PublicKey.findProgramAddress( + [Buffer.from("profile"), profileRandomHash], + program.programId + ); + let testUser = anchor.web3.Keypair.generate(); + const connectionBoxHash = randombytes(32); + const connectionBoxSeeds = [ + Buffer.from("connection_box"), + connectionBoxHash, + ]; + + const connectionSeeds = [ + Buffer.from("connection"), + testUser.publicKey.toBuffer(), + testProfileAccount.toBuffer(), + ]; + + const connectionV2Seeds = [ + Buffer.from("connection_v2"), + testProfileAccount.toBuffer(), + ]; + + const [connectionBoxAccount, _bump1] = + await anchor.web3.PublicKey.findProgramAddress( + connectionBoxSeeds, + program.programId + ); + + const [connectionV2Account, _bump2] = + await anchor.web3.PublicKey.findProgramAddress( + connectionV2Seeds, + program.programId + ); + + const [connectionAccount, _bump3] = + await anchor.web3.PublicKey.findProgramAddress( + connectionSeeds, + program.programId + ); + + before(async () => { + await airdrop(testUser.publicKey); + await program.methods + .initialize(profileRandomHash) + .accounts({ + profile: testProfileAccount, + user: user, + invitation: inviteAccount, + invitationProgram: invitationProgram.programId, + systemProgram: SystemProgram.programId, + }) + .rpc(); + }); + + it("should initialize connection box", async () => { + await program.methods + .initializeConnectionBox(connectionBoxHash) + .accounts({ + connectionBox: connectionBoxAccount, + authority: testUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([testUser]) + .rpc(); + const connectionBoxData = await program.account.connectionBox.fetch( + connectionBoxAccount + ); + expect(connectionBoxData.authority.toString()).to.equal( + testUser.publicKey.toString() + ); + }); + + it("should initialize connections_v2", async () => { + await program.methods + .initializeConnectionV2() + .accounts({ + connection: connectionV2Account, + connectionBox: connectionBoxAccount, + profile: testProfileAccount, + authority: testUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([testUser]) + .rpc(); + const connectionV2Data = await program.account.connectionV2.fetch( + connectionV2Account + ); + expect(connectionV2Data.connectionBox.toString()).to.equal( + connectionBoxAccount.toString() + ); + }); + + it("should close connections_v2", async () => { + await program.methods + .closeConnectionV2() + .accounts({ + connection: connectionV2Account, + connectionBox: connectionBoxAccount, + profile: testProfileAccount, + authority: testUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([testUser]) + .rpc(); + + try { + await program.account.connectionV2.fetch(connectionV2Account); + } catch (error) { + expect(error.toString()).to.contain("Error: Account does not exist"); + } + }); + + it("should seemelessly migrate connections from v1 to v2", async () => { + const connectionV1IX = await program.methods + .initializeConnection() + .accounts({ + connection: connectionAccount, + profile: testProfileAccount, + authority: testUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([testUser]) + .instruction(); + + await program.methods + .migrateToConnectionv2() + .accounts({ + connectionV1: connectionAccount, + connectionV2: connectionV2Account, + profile: testProfileAccount, + connectionBox: connectionBoxAccount, + authority: testUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .preInstructions([connectionV1IX]) + .signers([testUser]) + .rpc(); + + try { + await program.account.connection.fetch(connectionAccount); + } catch (error) { + expect(error.toString()).to.contain("Error: Account does not exist"); + } + const connectionV2Data = await program.account.connectionV2.fetch( + connectionV2Account + ); + expect(connectionV2Data.connectionBox.toString()).to.equal( + connectionBoxAccount.toString() + ); + }); + }); }); From b4e8e4180039bd8308bee0af0009dd3a8729e6c3 Mon Sep 17 00:00:00 2001 From: abishekk92 Date: Thu, 11 Aug 2022 20:26:30 -0400 Subject: [PATCH 2/2] add: Admin mode to migrate behind the scenes --- programs/wordcel/src/error.rs | 5 + programs/wordcel/src/instructions.rs | 61 ++++++++++ programs/wordcel/src/lib.rs | 20 ++- tests/wordcel.spec.ts | 176 ++++++++++++++++++++++++++- 4 files changed, 260 insertions(+), 2 deletions(-) diff --git a/programs/wordcel/src/error.rs b/programs/wordcel/src/error.rs index 7b8c043..5395811 100644 --- a/programs/wordcel/src/error.rs +++ b/programs/wordcel/src/error.rs @@ -9,3 +9,8 @@ pub enum PostError { pub enum ConnectionError { SelfFollow, } + +#[error_code] +pub enum AdminError { + UnAuthorizedAccess, +} diff --git a/programs/wordcel/src/instructions.rs b/programs/wordcel/src/instructions.rs index b895384..85c497f 100644 --- a/programs/wordcel/src/instructions.rs +++ b/programs/wordcel/src/instructions.rs @@ -1,6 +1,7 @@ use crate::*; use invite::program::Invite as InvitationProgram; use invite::Invite; +use std::str::FromStr; #[derive(Accounts)] #[instruction(random_hash: [u8;32])] @@ -315,3 +316,63 @@ pub struct MigrateConnectionToV2<'info> { pub authority: Signer<'info>, pub system_program: Program<'info, System>, } + +// NOTE: This instruction doesn't close the existing connection, but merely copies it over to +// connection v2. +// This is added so that the admin can easily migrate the conenctions if they chose to by paying +// for it and this helps us offer a better user experience. + +#[derive(Accounts)] +#[instruction(random_hash: [u8; 32])] +pub struct MigrateConnectionToV2Admin<'info> { + #[account( + seeds = [ + b"connection".as_ref(), + authority.key().as_ref(), + profile.key().as_ref() + ], + bump = connection_v1.bump, + has_one = authority, + )] + pub connection_v1: Account<'info, Connection>, + #[account( + init, + seeds = [ + b"connection_v2".as_ref(), + profile.key().as_ref() + ], + bump, + payer = payer, + // Don't allow the user to follow themselves + constraint = profile.authority.key() != authority.key() @ConnectionError::SelfFollow, + space = ConnectionV2::LEN + )] + pub connection_v2: Account<'info, ConnectionV2>, + #[account( + init, + seeds = [ + b"connection_box".as_ref(), + &random_hash + ], + bump, + payer = payer, + space = ConnectionBox::LEN + )] + pub connection_box: Account<'info, ConnectionBox>, + pub profile: Account<'info, Profile>, + pub authority: SystemAccount<'info>, + #[account(mut, constraint = is_admin(payer.key()) @AdminError::UnAuthorizedAccess)] + pub payer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +fn is_admin(key: Pubkey) -> bool { + let admin_keys: Vec = [ + // Wordcel Admin + "8f2yAM5ufEC9WgHYdAxeDgpZqE1B1Q47CciPRZaDN3jc", + ] + .iter() + .map(|k| Pubkey::from_str(k).unwrap()) + .collect(); + admin_keys.contains(&key) +} diff --git a/programs/wordcel/src/lib.rs b/programs/wordcel/src/lib.rs index 07391c9..bfb324b 100644 --- a/programs/wordcel/src/lib.rs +++ b/programs/wordcel/src/lib.rs @@ -10,7 +10,7 @@ use events::*; use instructions::*; use state::*; -#[cfg(not(any(feature = ": mainnet", feature = "devnet")))] +#[cfg(not(any(feature = "mainnet", feature = "devnet")))] declare_id!("v4enuof3drNvU2Y3b5m7K62hMq3QUP6qQSV2jjxAhkp"); #[cfg(feature = "devnet")] @@ -148,4 +148,22 @@ pub mod wordcel { connection.connection_box = *ctx.accounts.connection_box.to_account_info().key; Ok(()) } + + pub fn migrate_to_connectionv2_admin( + ctx: Context, + random_hash: [u8; 32], + ) -> Result<()> { + // Set up connection box + let connection_box = &mut ctx.accounts.connection_box; + connection_box.bump = *ctx.bumps.get("connection_box").unwrap(); + connection_box.random_hash = random_hash; + connection_box.authority = *ctx.accounts.authority.to_account_info().key; + + // Set connection + let connection = &mut ctx.accounts.connection_v2; + connection.bump = *ctx.bumps.get("connection_v2").unwrap(); + connection.profile = *ctx.accounts.profile.to_account_info().key; + connection.connection_box = *ctx.accounts.connection_box.to_account_info().key; + Ok(()) + } } diff --git a/tests/wordcel.spec.ts b/tests/wordcel.spec.ts index fdd847a..81e234f 100644 --- a/tests/wordcel.spec.ts +++ b/tests/wordcel.spec.ts @@ -396,7 +396,7 @@ describe("wordcel", async () => { } }); - it("should seemelessly migrate connections from v1 to v2", async () => { + it("should allow the user to migrate connections from v1 to v2", async () => { const connectionV1IX = await program.methods .initializeConnection() .accounts({ @@ -433,6 +433,180 @@ describe("wordcel", async () => { expect(connectionV2Data.connectionBox.toString()).to.equal( connectionBoxAccount.toString() ); + await program.methods + .closeConnectionV2() + .accounts({ + connection: connectionV2Account, + connectionBox: connectionBoxAccount, + profile: testProfileAccount, + authority: testUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([testUser]) + .rpc(); + }); + + it("should allow the admin to seemelessly migrate connections from v1 to v2", async () => { + const randomUser = anchor.web3.Keypair.generate(); + await airdrop(randomUser.publicKey); + + const connectionBoxHash = randombytes(32); + const connectionBoxSeeds = [ + Buffer.from("connection_box"), + connectionBoxHash, + ]; + + const connectionSeeds = [ + Buffer.from("connection"), + randomUser.publicKey.toBuffer(), + testProfileAccount.toBuffer(), + ]; + + const connectionV2Seeds = [ + Buffer.from("connection_v2"), + testProfileAccount.toBuffer(), + ]; + + const [connectionBoxAccount, _bump1] = + await anchor.web3.PublicKey.findProgramAddress( + connectionBoxSeeds, + program.programId + ); + + const [connectionV2Account, _bump2] = + await anchor.web3.PublicKey.findProgramAddress( + connectionV2Seeds, + program.programId + ); + + const [connectionAccount, _bump3] = + await anchor.web3.PublicKey.findProgramAddress( + connectionSeeds, + program.programId + ); + + await program.methods + .initializeConnection() + .accounts({ + connection: connectionAccount, + profile: testProfileAccount, + authority: randomUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([randomUser]) + .rpc(); + + await program.methods + .migrateToConnectionv2Admin(connectionBoxHash) + .accounts({ + connectionV1: connectionAccount, + connectionV2: connectionV2Account, + profile: testProfileAccount, + connectionBox: connectionBoxAccount, + authority: randomUser.publicKey, + payer: user, + systemProgram: SystemProgram.programId, + }) + .rpc(); + + try { + await program.account.connection.fetch(connectionAccount); + } catch (error) { + expect(error.toString()).to.contain("Error: Account does not exist"); + } + const connectionBoxData = await program.account.connectionBox.fetch( + connectionBoxAccount + ); + expect(connectionBoxData.authority.toString()).to.equal( + randomUser.publicKey.toString() + ); + + const connectionV2Data = await program.account.connectionV2.fetch( + connectionV2Account + ); + expect(connectionV2Data.connectionBox.toString()).to.equal( + connectionBoxAccount.toString() + ); + await program.methods + .closeConnectionV2() + .accounts({ + connection: connectionV2Account, + connectionBox: connectionBoxAccount, + profile: testProfileAccount, + authority: randomUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([randomUser]) + .rpc(); + }); + + it("should not allow anyone other than the admin to seemelessly migrate connections from v1 to v2", async () => { + const randomUser = anchor.web3.Keypair.generate(); + await airdrop(randomUser.publicKey); + + const connectionBoxHash = randombytes(32); + const connectionBoxSeeds = [ + Buffer.from("connection_box"), + connectionBoxHash, + ]; + + const connectionSeeds = [ + Buffer.from("connection"), + randomUser.publicKey.toBuffer(), + testProfileAccount.toBuffer(), + ]; + + const connectionV2Seeds = [ + Buffer.from("connection_v2"), + testProfileAccount.toBuffer(), + ]; + + const [connectionBoxAccount, _bump1] = + await anchor.web3.PublicKey.findProgramAddress( + connectionBoxSeeds, + program.programId + ); + + const [connectionV2Account, _bump2] = + await anchor.web3.PublicKey.findProgramAddress( + connectionV2Seeds, + program.programId + ); + const [connectionAccount, _bump3] = + await anchor.web3.PublicKey.findProgramAddress( + connectionSeeds, + program.programId + ); + + await program.methods + .initializeConnection() + .accounts({ + connection: connectionAccount, + profile: testProfileAccount, + authority: randomUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([randomUser]) + .rpc(); + + try { + await program.methods + .migrateToConnectionv2Admin(connectionBoxHash) + .accounts({ + connectionV1: connectionAccount, + connectionV2: connectionV2Account, + profile: testProfileAccount, + connectionBox: connectionBoxAccount, + authority: randomUser.publicKey, + payer: testUser.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([testUser]) + .rpc(); + } catch (e) { + const { error } = AnchorError.parse(e.logs); + expect(error.errorCode.code).to.equal("UnAuthorizedAccess"); + } }); }); });