├── tsfmt.json ├── .yarnrc.yml ├── usedispatch_client ├── tsfmt.json ├── .yarnrc.yml ├── tslint.json ├── .prettierrc ├── src │ ├── types.ts │ ├── index.ts │ ├── solanart.ts │ ├── wallets.ts │ ├── constants.ts │ ├── connection.ts │ ├── api.ts │ ├── utils.ts │ ├── forum.ts │ └── mailbox.ts ├── .parcelrc ├── .gitignore ├── jestconfig.json ├── README.md ├── tsconfig.json ├── tests │ ├── react.test.ts │ ├── index.test.ts │ └── helpers.test.ts └── package.json ├── scripts ├── README.md └── userTest.ts ├── tslint.json ├── programs ├── postbox │ ├── Xargo.toml │ ├── src │ │ ├── vote_entry.rs │ │ ├── treasury.rs │ │ ├── nft_metadata.rs │ │ ├── settings.rs │ │ ├── errors.rs │ │ ├── post_restrictions.rs │ │ └── lib.rs │ └── Cargo.toml └── messaging │ ├── Xargo.toml │ ├── src │ ├── treasury.rs │ └── lib.rs │ └── Cargo.toml ├── .prettierrc ├── Cargo.toml ├── .yarn └── sdks │ ├── integrations.yml │ └── typescript │ ├── package.json │ ├── bin │ ├── tsc │ └── tsserver │ └── lib │ ├── tsc.js │ ├── typescript.js │ ├── tsserver.js │ └── tsserverlibrary.js ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md ├── .github └── workflows │ └── rust.yml ├── Anchor.toml └── tests ├── token-gating.ts ├── topic-gating.ts ├── messaging.ts └── postbox.ts /tsfmt.json: -------------------------------------------------------------------------------- 1 | { "indentSize": 2, "tabSize": 2 } 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.2.3.cjs 2 | -------------------------------------------------------------------------------- /usedispatch_client/tsfmt.json: -------------------------------------------------------------------------------- 1 | { "indentSize": 2, "tabSize": 2 } 2 | -------------------------------------------------------------------------------- /usedispatch_client/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.2.3.cjs 2 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | Run `npx ts-node userTest.ts` to run transactions; 4 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /programs/postbox/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] 3 | -------------------------------------------------------------------------------- /programs/messaging/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /usedispatch_client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "programs/*" 4 | ] 5 | 6 | [profile.release] 7 | overflow-checks = true 8 | -------------------------------------------------------------------------------- /usedispatch_client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /usedispatch_client/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Error { 2 | error: true; 3 | message: string; 4 | } 5 | 6 | export type Result = T | Error; 7 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "4.8.3-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /usedispatch_client/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /usedispatch_client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .parcel-cache 4 | dist 5 | .vim 6 | .history 7 | 8 | # Yarn 9 | .pnp.* 10 | .yarn/* 11 | !.yarn/patches 12 | !.yarn/plugins 13 | !.yarn/releases 14 | !.yarn/versions 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .anchor 3 | .DS_Store 4 | target 5 | **/*.rs.bk 6 | node_modules 7 | .vscode 8 | .env 9 | .env.local 10 | 11 | # Yarn 12 | .pnp.* 13 | .yarn/* 14 | !.yarn/patches 15 | !.yarn/plugins 16 | !.yarn/releases 17 | !.yarn/sdks 18 | !.yarn/versions 19 | -------------------------------------------------------------------------------- /programs/postbox/src/vote_entry.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | 3 | #[derive( 4 | AnchorSerialize, 5 | AnchorDeserialize, 6 | Clone, 7 | PartialEq, 8 | Eq 9 | )] 10 | pub struct VoteEntry { 11 | pub post_id: u32, 12 | pub up_vote: bool, 13 | } 14 | -------------------------------------------------------------------------------- /usedispatch_client/jestconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.(t|j)sx?$": "ts-jest" 4 | }, 5 | "testRegex": "(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 6 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], 7 | "testTimeout": 1000000, 8 | "detectOpenHandles": true 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["mocha", "chai"], 4 | "typeRoots": ["./node_modules/@types"], 5 | "lib": ["es2015", "dom"], 6 | "module": "commonjs", 7 | "target": "es6", 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "resolveJsonModule": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /programs/messaging/src/treasury.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | 3 | #[cfg(feature = "mainnet")] 4 | pub const TREASURY_ADDRESS: Pubkey = solana_program::pubkey!("5MNBoBJDHHG4pB6qtWgYPzGEncoYTLAaANovvoaxu28p"); 5 | #[cfg(not(feature = "mainnet"))] 6 | pub const TREASURY_ADDRESS: Pubkey = solana_program::pubkey!("G2GGDc89qpuk21WgRUVPDY517uc6qR5yT4KX7AakyVR1"); 7 | -------------------------------------------------------------------------------- /usedispatch_client/README.md: -------------------------------------------------------------------------------- 1 | # usedispatch/client 2 | 3 | [Site](https://usedispatch.net) | 4 | [Docs](https://github.com/usedispatch/docs/blob/main/README.md) 5 | 6 | 7 | ## Installation 8 | 9 | Using npm: 10 | ```shell 11 | $ npm install @usedispatch/client 12 | ``` 13 | 14 | # Quickstart 15 | 16 | See the latest documentation [here](https://github.com/usedispatch/docs/blob/main/README.md). 17 | -------------------------------------------------------------------------------- /usedispatch_client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["es2015"], 6 | "declaration": true, 7 | "outDir": "./lib", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "noImplicitAny": true, 13 | "skipLibCheck": true 14 | }, 15 | "include": ["src"], 16 | "exclude": ["node_modules", "**/__tests__/*"] 17 | } 18 | -------------------------------------------------------------------------------- /programs/messaging/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "messaging" 3 | version = "0.2.1" 4 | description = "Dispatch Protocol core messaging program. Created with Anchor." 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "lib"] 9 | name = "messaging" 10 | 11 | [features] 12 | no-entrypoint = [] 13 | no-idl = [] 14 | cpi = ["no-entrypoint"] 15 | default = [] 16 | mainnet = [] 17 | 18 | [dependencies] 19 | anchor-lang = {version = "0.24.2", features = ["init-if-needed"]} 20 | anchor-spl = "0.24.2" 21 | solana-program = "1.8.6" 22 | -------------------------------------------------------------------------------- /programs/postbox/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "postbox" 3 | version = "0.1.0" 4 | description = "Created with Anchor" 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "lib"] 9 | name = "postbox" 10 | 11 | [features] 12 | no-entrypoint = [] 13 | no-idl = [] 14 | no-log-ix-name = [] 15 | cpi = ["no-entrypoint"] 16 | default = [] 17 | mainnet = [] 18 | 19 | [dependencies] 20 | anchor-lang = "0.24.2" 21 | anchor-spl = "0.24.2" 22 | solana-program = "1.8.6" 23 | mpl-token-metadata = { version = "1.2.10", features = ["no-entrypoint"] } 24 | -------------------------------------------------------------------------------- /usedispatch_client/src/index.ts: -------------------------------------------------------------------------------- 1 | export { DispatchConnection } from './connection'; 2 | export { clusterAddresses, defaultCluster, seeds } from './constants'; 3 | export { Forum, ForumInfo, ForumPost, IForum } from './forum'; 4 | export { MailboxAccount, MessageAccount, MailboxOpts, Mailbox } from './mailbox'; 5 | export { KeyPairWallet, WalletInterface } from './wallets'; 6 | export { Postbox, SettingsType, PostRestriction, VoteType, ChainVoteEntry } from './postbox'; 7 | export * from './utils'; 8 | export { getForumIdFromSolanartId, addSolanartMap } from './api'; 9 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/typescript.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/typescript.js your application uses 20 | module.exports = absRequire(`typescript/lib/typescript.js`); 21 | -------------------------------------------------------------------------------- /programs/postbox/src/treasury.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | 3 | #[cfg(feature = "mainnet")] 4 | pub const TREASURY_ADDRESS: Pubkey = solana_program::pubkey!("5MNBoBJDHHG4pB6qtWgYPzGEncoYTLAaANovvoaxu28p"); 5 | #[cfg(not(feature = "mainnet"))] 6 | pub const TREASURY_ADDRESS: Pubkey = solana_program::pubkey!("G2GGDc89qpuk21WgRUVPDY517uc6qR5yT4KX7AakyVR1"); 7 | 8 | pub fn transfer_lamports<'info>(from: &dyn ToAccountInfo<'info>, to: &dyn ToAccountInfo<'info>, lamports: u64) -> Result<()> { 9 | let from_info = from.to_account_info(); 10 | let to_info = to.to_account_info(); 11 | solana_program::program::invoke( 12 | &solana_program::system_instruction::transfer(from_info.key, to_info.key, lamports), 13 | &[from_info, to_info], 14 | )?; 15 | Ok(()) 16 | } 17 | -------------------------------------------------------------------------------- /usedispatch_client/src/solanart.ts: -------------------------------------------------------------------------------- 1 | import { ParsedMessageData, MessageData } from './mailbox'; 2 | 3 | export type SolanartMessage = { 4 | message: { 5 | event: string; 6 | subject: string; 7 | message: string; 8 | timestamp: string; 9 | }; 10 | id: number; 11 | senderName: string; 12 | ns: 'solanart'; 13 | }; 14 | 15 | export const convertSolanartToDispatchMessage = (messageData: ParsedMessageData): MessageData => { 16 | if (messageData.ns !== 'solanart') { 17 | throw new Error('Cannot parse message as a solanart native message if ns not solanart'); 18 | } 19 | const solanartMessage = messageData as any as SolanartMessage; 20 | return { 21 | subj: solanartMessage.message.subject, 22 | body: solanartMessage.message.message, 23 | ts: new Date(1000 * +solanartMessage.message.timestamp), 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /programs/postbox/src/nft_metadata.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | use std::ops::Deref; 3 | use mpl_token_metadata; 4 | 5 | #[derive(Clone)] 6 | pub struct Metadata(mpl_token_metadata::state::Metadata); 7 | 8 | // The "try_deserialize" function delegates to 9 | // "try_deserialize_unchecked" by default which is what we want here 10 | // because non-anchor accounts don't have a discriminator to check 11 | impl anchor_lang::AccountDeserialize for Metadata { 12 | fn try_deserialize_unchecked(buf: &mut &[u8]) -> Result { 13 | mpl_token_metadata::deser::meta_deser(buf).map(Metadata).map_err(|e| e.into()) 14 | } 15 | } 16 | 17 | // AccountSerialize defaults to a no-op which is what we want here 18 | // because it's a foreign program, so our program does not 19 | // have permission to write to the foreign program's accounts anyway 20 | impl anchor_lang::AccountSerialize for Metadata {} 21 | 22 | impl anchor_lang::Owner for Metadata { 23 | fn owner() -> Pubkey { 24 | mpl_token_metadata::ID 25 | } 26 | } 27 | 28 | // Implement the "std::ops::Deref" trait for better user experience 29 | impl Deref for Metadata { 30 | type Target = mpl_token_metadata::state::Metadata; 31 | 32 | fn deref(&self) -> &Self::Target { 33 | &self.0 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /usedispatch_client/src/wallets.ts: -------------------------------------------------------------------------------- 1 | import * as web3 from '@solana/web3.js'; 2 | import { WalletAdapterProps } from '@solana/wallet-adapter-base'; 3 | 4 | export interface AnchorExpectedWalletInterface { 5 | signTransaction(tx: web3.Transaction): Promise; 6 | signAllTransactions(txs: web3.Transaction[]): Promise; 7 | get publicKey(): web3.PublicKey; 8 | } 9 | 10 | // We copy this code here so as not to depend on the react wallet-adapter 11 | export interface WalletInterface { 12 | publicKey: web3.PublicKey | null; 13 | sendTransaction?: WalletAdapterProps['sendTransaction']; 14 | signTransaction?: AnchorExpectedWalletInterface['signTransaction']; 15 | signAllTransactions?: AnchorExpectedWalletInterface['signAllTransactions']; 16 | } 17 | 18 | export interface AnchorNodeWalletInterface extends WalletInterface { 19 | payer: web3.Signer; 20 | } 21 | 22 | export class KeyPairWallet { 23 | constructor(readonly payer: web3.Keypair) {} 24 | 25 | async signTransaction(tx: web3.Transaction): Promise { 26 | tx.partialSign(this.payer); 27 | return tx; 28 | } 29 | 30 | async signAllTransactions(txs: web3.Transaction[]): Promise { 31 | return txs.map((tx) => { 32 | tx.partialSign(this.payer); 33 | return tx; 34 | }); 35 | } 36 | 37 | get publicKey(): web3.PublicKey { 38 | return this.payer.publicKey; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /programs/postbox/src/settings.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | use crate::post_restrictions::PostRestrictionRule; 3 | 4 | #[derive( 5 | AnchorSerialize, 6 | AnchorDeserialize, 7 | Clone, 8 | PartialEq, 9 | Eq 10 | )] 11 | pub enum SettingsType { 12 | Description, 13 | OwnerInfo, 14 | PostRestriction, 15 | Null, 16 | Images, 17 | } 18 | 19 | #[derive( 20 | AnchorSerialize, 21 | AnchorDeserialize, 22 | Clone, 23 | PartialEq, 24 | Eq 25 | )] 26 | pub enum SettingsData { 27 | Description { title: String, desc: String }, 28 | OwnerInfo { owners: Vec }, 29 | PostRestriction { post_restriction: PostRestrictionRule }, 30 | Null, 31 | Images { json: String }, 32 | } 33 | 34 | impl SettingsData { 35 | pub fn get_size(&self) -> usize { 36 | return match self.try_to_vec() { 37 | Ok(v) => v.len(), 38 | Err(_) => 0, 39 | }; 40 | } 41 | 42 | pub fn get_type(&self) -> SettingsType { 43 | return match self { 44 | SettingsData::Description { title: _, desc: _ } => SettingsType::Description, 45 | SettingsData::OwnerInfo { owners: _ } => SettingsType::OwnerInfo, 46 | SettingsData::PostRestriction { post_restriction: _ } => SettingsType::PostRestriction, 47 | SettingsData::Null => SettingsType::Null, 48 | SettingsData::Images { json: _ } => SettingsType::Images, 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@project-serum/anchor": "^0.24.2" 4 | }, 5 | "devDependencies": { 6 | "@metaplex-foundation/mpl-token-metadata": "^2.2.2", 7 | "@solana/spl-token": "^0.2.0", 8 | "@solana/wallet-adapter-base": "^0.9.18", 9 | "@solana/web3.js": "^1.44.0", 10 | "@supabase/supabase-js": "^1.35.7", 11 | "@testing-library/react": "^13.4.0", 12 | "@types/chai": "^4.3.0", 13 | "@types/crypto-js": "^4.1.1", 14 | "@types/jest": "^29.2.0", 15 | "@types/lodash": "^4.14.186", 16 | "@types/mocha": "^9.0.0", 17 | "@types/node": "^18.11.0", 18 | "@types/react": "^18.0.24", 19 | "bs58": "^5.0.0", 20 | "chai": "^4.3.4", 21 | "crypto-js": "^4.1.1", 22 | "dotenv": "^16.0.1", 23 | "jest": "^29.2.2", 24 | "lodash": "^4.17.21", 25 | "mocha": "^9.0.3", 26 | "prettier": "^2.7.1", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "superstruct": "^0.16.0", 30 | "ts-mocha": "^9.0.2", 31 | "tslint": "^6.1.3", 32 | "tslint-config-prettier": "^1.18.0", 33 | "typescript": "^4.3.5" 34 | }, 35 | "license": "GPL-3.0-only", 36 | "version": "0.2.1", 37 | "packageManager": "yarn@3.2.3", 38 | "scripts": { 39 | "format": "prettier --write \"tests/*.ts\" \"scripts/*.ts\"", 40 | "lint": "tslint -p tsconfig.json" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build and Test Solana Programs and usedispatch library](https://github.com/0xengage/msg/actions/workflows/rust.yml/badge.svg)](https://github.com/0xengage/msg/actions/workflows/rust.yml) 2 | 3 | ### Setup 4 | 5 | #### One-time 6 | 7 | 1. `yarn` - Install JS packages 8 | 1. `anchor build && anchor test` 9 | 1. `cd usedispatch_client/ && npm install` 10 | 1. `npm run test` 11 | 12 | #### After every change 13 | 14 | 1. `anchor build && anchor test` 15 | 1. `cd usedispatch_client/ && npm run test && cd ..` 16 | 17 | ## Using the library 18 | 19 | Here is some example code on how you might interact with the library. For more details, see the [[postbox docs](https://docs.dispatch.forum/docs/developer/postbox)]. 20 | 21 | ```typescript 22 | import { Postbox, DispatchConnection } from '@usedispatch/msg'; 23 | 24 | const targetKey = new web.PublicKey(DEGEN_APE_COLLECTION_KEY); 25 | const dispatchConn = new DispatchConnection(web3Conn, wallet); 26 | const postbox = new Postbox({key: targetKey}); 27 | 28 | // Interact with the postbox 29 | 30 | // Get all of the topics 31 | const topics = await postbox.fetchPosts(); 32 | const firstTopic = topics[0]; 33 | // Get the posts in that topic 34 | const postsInTopic = await postbox.fetchReplies(firstTopic); 35 | // Check that the wallet we used is allowed to write to this topic 36 | if (postbox.canPost(firstTopic)) { 37 | // Add a new post 38 | await postbox.createPost({ 39 | subj: 'This topic is amazing', 40 | body: 'Thank you so much for creating it', 41 | }, firstTopic); 42 | // Vote up on the first post in the topic 43 | await postbox.vote(postsInTopic[0], true); 44 | } 45 | ``` -------------------------------------------------------------------------------- /usedispatch_client/tests/react.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import * as web3 from '@solana/web3.js'; 5 | import React from 'react'; 6 | import * as tlr from '@testing-library/react'; 7 | import { Mailbox, KeyPairWallet } from '../src/'; 8 | import '@testing-library/jest-dom'; 9 | 10 | interface IState { 11 | receiverMailbox?: Mailbox; 12 | } 13 | 14 | const keypair: web3.Keypair = web3.Keypair.generate(); 15 | 16 | class WalletComponent extends React.Component<{}, IState> { 17 | constructor(props: {}) { 18 | super(props); 19 | const conn = new web3.Connection(web3.clusterApiUrl('devnet')); 20 | const receiver = keypair; 21 | console.log('receiver', receiver.publicKey.toBase58()); 22 | const receiverWallet = new KeyPairWallet(receiver); 23 | const receiverMailbox = new Mailbox(conn, receiverWallet); 24 | this.state = { 25 | receiverMailbox, 26 | }; 27 | } 28 | render() { 29 | const mailboxAddress = this.state?.receiverMailbox?.mailboxOwner.toBase58() ?? ''; 30 | return React.createElement('div', null, `Wallet owner: ${mailboxAddress}`); 31 | } 32 | } 33 | 34 | describe('Test for creating Mailbox in react.', () => { 35 | describe('reactTest', () => { 36 | test('Create mailbox in react', async () => { 37 | const element = new WalletComponent({}); 38 | const component = tlr.render(element.render()); 39 | tlr.screen.debug(); 40 | await tlr.waitFor(() => tlr.screen.getByText(/Wallet owner:/)); 41 | 42 | expect(tlr.screen.getByText(`Wallet owner: ${keypair.publicKey.toBase58()}`)).toBeInTheDocument(); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /usedispatch_client/src/constants.ts: -------------------------------------------------------------------------------- 1 | import * as web3 from '@solana/web3.js'; 2 | 3 | export type DispatchAddresses = { 4 | programAddress: web3.PublicKey; 5 | treasuryAddress: web3.PublicKey; 6 | postboxAddress: web3.PublicKey; 7 | }; 8 | 9 | export const seeds = { 10 | protocolSeed: Buffer.from('dispatch'), 11 | mailboxSeed: Buffer.from('mailbox'), 12 | messageSeed: Buffer.from('message'), 13 | postboxSeed: Buffer.from('postbox'), 14 | postSeed: Buffer.from('post'), 15 | moderatorSeed: Buffer.from('moderator'), 16 | voteTrackerSeed: Buffer.from('votes'), 17 | }; 18 | 19 | export const eventName = 'DispatchMessage'; 20 | 21 | export const clusterAddresses = new Map(); 22 | 23 | clusterAddresses.set('devnet', { 24 | programAddress: new web3.PublicKey('BHJ4tRcogS88tUhYotPfYWDjR4q7MGdizdiguY3N54rb'), 25 | treasuryAddress: new web3.PublicKey('G2GGDc89qpuk21WgRUVPDY517uc6qR5yT4KX7AakyVR1'), 26 | postboxAddress: new web3.PublicKey('Fs5wSa7GYtTqivXGqHyx673v5oPuD5Cb7ij9utsFKdLb'), 27 | }); 28 | 29 | clusterAddresses.set('mainnet-beta', { 30 | programAddress: new web3.PublicKey('BHJ4tRcogS88tUhYotPfYWDjR4q7MGdizdiguY3N54rb'), 31 | treasuryAddress: new web3.PublicKey('5MNBoBJDHHG4pB6qtWgYPzGEncoYTLAaANovvoaxu28p'), 32 | postboxAddress: new web3.PublicKey('DHepkufWDLJ9DCD37nbEDbPSFKjGiziQ6Lbgo1zgGX7S'), 33 | }); 34 | 35 | export const defaultCluster: web3.Cluster = 'devnet'; 36 | 37 | export const TXN_COMMITMENT = 'processed'; 38 | 39 | /** 40 | * How many times the dispatch connection should retry sending a 41 | * connection if it fails 42 | */ 43 | export const SOLANA_CONNECTION_MAX_RETRIES = 6; 44 | 45 | export const FORUM_IMAGE_URL = 46 | 'https://aiqrzujttjxgjhumjcky.supabase.co/storage/v1/object/public/forum-images/dispatch_header.png'; 47 | -------------------------------------------------------------------------------- /programs/postbox/src/errors.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | 3 | #[error_code] 4 | pub enum PostboxErrorCode { 5 | // Postbox initialize errors 6 | #[msg("If no target string, target account must be the signer")] 7 | NotPersonalPostbox, 8 | #[msg("The description provided is not a description setting")] 9 | BadDescriptionSetting, 10 | 11 | // Create post errors 12 | #[msg("The provided post ID is too large an increase")] 13 | PostIdTooLarge = 100, 14 | #[msg("The reply-to account is not a Post account")] 15 | ReplyToNotPost, 16 | #[msg("Replies cannot have a further reply restriction")] 17 | ReplyCannotRestrictReplies, 18 | #[msg("Invalid setting type for post")] 19 | PostInvalidSettingsType, 20 | 21 | // Post restriction errors 22 | #[msg("The provided token account is not a token account")] 23 | NotTokenAccount = 200, 24 | #[msg("Missing the token required by the restriction")] 25 | MissingTokenRestriction, 26 | #[msg("Account provided is not expected metadata key")] 27 | InvalidMetadataKey, 28 | #[msg("The provided account is not a metadata account")] 29 | MetadataAccountInvalid, 30 | #[msg("No collection set on the metadata")] 31 | NoCollectionOnMetadata, 32 | #[msg("Missing an NFT from the collection required by the restriction")] 33 | MissingCollectionNftRestriction, 34 | #[msg("Cannot parse a setting")] 35 | MalformedSetting, 36 | #[msg("Extra account offsets invalid for this restriction type")] 37 | InvalidRestrictionExtraAccounts, 38 | #[msg("Must supply offsets when a post restriction applies")] 39 | MissingRequiredOffsets, 40 | #[msg("We hit the test error")] 41 | TestError, 42 | #[msg("Already voted on this post")] 43 | AlreadyVoted, 44 | #[msg("Missing a required credential for post restriction")] 45 | MissingCredentials, 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Test full codebase (Solana Program and Typescript library) 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: install rust toolchain 20 | uses: actions-rs/toolchain@v1 21 | with: 22 | toolchain: stable 23 | - uses: Swatinem/rust-cache@v1 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | - name: install solana 27 | run: sh -c "$(curl -sSfL https://release.solana.com/v1.9.9/install)" 28 | 29 | - name: add solana to path 30 | run: echo "$HOME/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH 31 | 32 | - name: print solana version 33 | run: solana --version 34 | - name: gen wallet 35 | run: solana-keygen new 36 | - name: view config detail 37 | run: solana config get 38 | 39 | - name: Install Anchor 40 | run: npm install -g @project-serum/anchor-cli@0.24.2 41 | - name: Test cargo bpf 42 | run: cargo build-bpf 43 | - name: install yarn 44 | run: npm install -g yarn 45 | - run: yarn install 46 | - name: Run Anchor test 47 | env: 48 | USER_KEY: ${{ secrets.USER_KEY }} 49 | OWNER_KEY: ${{ secrets.OWNER_KEY }} 50 | UNAUTHORIZED_USER_KEY: ${{ secrets.UNAUTHORIZED_USER_KEY }} 51 | USER_WITH_ASSOCIATED_ACCOUNT_WITH_ZERO_BALANCE_KEY: ${{ secrets.USER_WITH_ASSOCIATED_ACCOUNT_WITH_ZERO_BALANCE_KEY }} 52 | run: anchor test 53 | - name: Test Dispatch client 54 | working-directory: ./usedispatch_client 55 | run: | 56 | yarn install 57 | yarn build 58 | -------------------------------------------------------------------------------- /Anchor.toml: -------------------------------------------------------------------------------- 1 | [programs.localnet] 2 | messaging = "BHJ4tRcogS88tUhYotPfYWDjR4q7MGdizdiguY3N54rb" 3 | postbox = "Fs5wSa7GYtTqivXGqHyx673v5oPuD5Cb7ij9utsFKdLb" 4 | 5 | [programs.devnet] 6 | messaging = "BHJ4tRcogS88tUhYotPfYWDjR4q7MGdizdiguY3N54rb" 7 | postbox = "Fs5wSa7GYtTqivXGqHyx673v5oPuD5Cb7ij9utsFKdLb" 8 | 9 | [programs.mainnet] 10 | messaging = "BHJ4tRcogS88tUhYotPfYWDjR4q7MGdizdiguY3N54rb" 11 | postbox = "DHepkufWDLJ9DCD37nbEDbPSFKjGiziQ6Lbgo1zgGX7S" 12 | 13 | [registry] 14 | url = "https://anchor.projectserum.com" 15 | 16 | [provider] 17 | cluster = "localnet" 18 | wallet = "~/.config/solana/id.json" 19 | 20 | [scripts] 21 | test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" 22 | 23 | [features] 24 | seeds = true 25 | 26 | [test] 27 | startup_wait = 10000 28 | 29 | [test.validator] 30 | url = "https://api.devnet.solana.com" 31 | 32 | # Collection mint key 33 | [[test.validator.clone]] 34 | address = "GcMPukzjZWfY4y4KVM3HNdqtZTf5WyTWPvL4YXznoS9c" 35 | 36 | # Collection metadata 37 | [[test.validator.clone]] 38 | address = "127CfcfCk5H8zSNDvSLJjKLVxwb6YP6hPK4TGRmVMNtE" 39 | 40 | # authorized user key 41 | [[test.validator.clone]] 42 | address = "CKQZCHQTJJDXzfkcodYZ4i6adjjL5cwuiqnFQgrgPh67" 43 | 44 | # associated token account for authorized user 45 | [[test.validator.clone]] 46 | address = "9SEWPdMDNWuvDhsjvzUGzLeAjLpFgthrnBjcCwmvsPA6" 47 | 48 | # Owner key 49 | [[test.validator.clone]] 50 | address = "E5rGQH5XXds6ZJLWnx9mjtoc2JKDF8rchK5UeDCgkbuV" 51 | 52 | # unauthorized user key 53 | [[test.validator.clone]] 54 | address = "A5E92mr5PSCHyeSRB1JwPE4tYCjMCGkY3aEHr3TYZ7xg" 55 | 56 | # zero-balance user key 57 | [[test.validator.clone]] 58 | address = "GFUfnHVCpDLgoVfuvUEYnqwogn5AfPkrW57EzK2TKVFC" 59 | 60 | # associated token account for zero-balance user 61 | [[test.validator.clone]] 62 | address = "ERrPQisQyjdNTMXej1hfSrV7c3eWMARykgS5KRR5MmCu" 63 | 64 | # SOLID color #6 65 | [[test.validator.clone]] 66 | address = "3m1CQGFk31VtcBU6gEQqcqvSeH265bGX52URHAy7aMwZ" 67 | 68 | # SOLID color #6 metadata 69 | [[test.validator.clone]] 70 | address = "H2wPow8g9KCgXPeUcM4djJECK3ji4NJeiQQ2HgWHA3Sf" 71 | 72 | # SOLID color #1 73 | [[test.validator.clone]] 74 | address = "AGGEiRQSRdqSRvWuxotuNinDoayQH8cVRhNcSsFJQDVp" 75 | 76 | # SOLID color #1 metadata 77 | [[test.validator.clone]] 78 | address = "3sQSbdWqK8hPMzoA89S8pRuFSkdeU7tZHvThAyKgW4Vg" 79 | -------------------------------------------------------------------------------- /usedispatch_client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@usedispatch/client", 3 | "version": "0.15.7", 4 | "description": "Client for Dispatch Protocol", 5 | "scripts": { 6 | "test": "jest --config jestconfig.json", 7 | "clean": "rm -rf dist", 8 | "build": "yarn clean && parcel build", 9 | "format": "prettier --write \"src/*.ts\" \"tests/*.ts\"", 10 | "idl": "mkdir -p lib/target/idl && mkdir -p lib/target/types && cp ../target/idl/* ./lib/target/idl/ && cp ../target/types/* ./lib/target/types/", 11 | "lint": "tslint -p tsconfig.json", 12 | "prepare": "yarn build && echo 'Please run the tests manually before publish'", 13 | "prepublishOnly": "yarn lint", 14 | "preversion": "yarn lint", 15 | "version": "yarn format && git add -A src", 16 | "postversion": "git push && git push --tags" 17 | }, 18 | "source": "src/index.ts", 19 | "main": "dist/index.js", 20 | "types": "dist/index.d.ts", 21 | "targets": { 22 | "main": { 23 | "optimize": true 24 | } 25 | }, 26 | "keywords": [], 27 | "files": [ 28 | "dist/**/*" 29 | ], 30 | "author": "Dispatch Protocol", 31 | "homepage": "https://usedispatch.net/", 32 | "license": "MIT or Apache 2.0", 33 | "devDependencies": { 34 | "@parcel/config-default": "^2.7.0", 35 | "@parcel/core": "^2.7.0", 36 | "@parcel/packager-ts": "^2.7.0", 37 | "@parcel/transformer-typescript-tsc": "^2.7.0", 38 | "@parcel/transformer-typescript-types": "^2.7.0", 39 | "@solana/wallet-adapter-base": "^0.9.18", 40 | "@testing-library/jest-dom": "^5.16.2", 41 | "@testing-library/react": "^13.4.0", 42 | "@types/bs58": "^4.0.1", 43 | "@types/crypto-js": "^4.1.1", 44 | "@types/jest": "^27.0.3", 45 | "@types/lodash": "^4.14.185", 46 | "@types/node": "^16.11.12", 47 | "@types/react": "^18.0.20", 48 | "assert": "^2.0.0", 49 | "bs58": "^4.0.1", 50 | "jest": "^29.2.2", 51 | "parcel": "^2.7.0", 52 | "prettier": "^2.5.1", 53 | "process": "^0.11.10", 54 | "react": "^18.2.0", 55 | "react-dom": "^18.2.0", 56 | "rollup": "^2.79.0", 57 | "superstruct": "^0.16.0", 58 | "ts-jest": "^29.0.3", 59 | "tslint": "^6.1.3", 60 | "tslint-config-prettier": "^1.18.0", 61 | "typescript": "^4.7.2" 62 | }, 63 | "dependencies": { 64 | "@metaplex-foundation/mpl-token-metadata": "^2.2.2", 65 | "@project-serum/anchor": "^0.24.2", 66 | "@solana/spl-token": "^0.2.0", 67 | "@solana/web3.js": "^1.50.1", 68 | "@supabase/supabase-js": "^1.35.7", 69 | "crypto-js": "^4.1.1", 70 | "lodash": "^4.17.21" 71 | }, 72 | "resolutions": { 73 | "typescript": "~4.7" 74 | }, 75 | "packageManager": "yarn@3.2.3" 76 | } 77 | -------------------------------------------------------------------------------- /scripts/userTest.ts: -------------------------------------------------------------------------------- 1 | import * as anchor from '@project-serum/anchor'; 2 | 3 | import { DispatchConnection, Forum, ForumPost } from '../usedispatch_client/src'; 4 | 5 | const TRANSACTION_TIMEOUT = 500; 6 | const COLLECTION_ID = 'Fs5wSa7GYtTqivXGqHyx673v5oPuD5Cb7ij9utsFKdLb'; // dispatch dev forum 7 | const TOPIC_DATA = { 8 | subj: 'scripted topic', 9 | body: 'this is a scripted topic', 10 | }; 11 | 12 | async function main() { 13 | console.log('Starting using collection id: ', COLLECTION_ID); 14 | const connection = new anchor.web3.Connection('http://api.devnet.solana.com', 'confirmed'); 15 | const wallet = new anchor.Wallet(anchor.web3.Keypair.generate()); 16 | const dispatchConnection = new DispatchConnection(connection, wallet); 17 | 18 | const collectionKey = new anchor.web3.PublicKey(COLLECTION_ID); 19 | const dispatchForum = new Forum(dispatchConnection, collectionKey); 20 | 21 | const TOPIC_POST = { 22 | parent: { 23 | dispatch: dispatchConnection, 24 | target: {}, 25 | }, 26 | address: new anchor.web3.PublicKey('GixW8PFnhLxs9yJEbcfZzZrk66HwXAtcPFHx6JapdaQ5'), 27 | postId: 86, 28 | poster: new anchor.web3.PublicKey('iDPqXWr1KeDNUHQFmjuckjNgyfwtRhe2bcDvWBao5XY'), 29 | data: { 30 | subj: 'scripted topic', 31 | body: 'this is a scripted topic', 32 | ts: new Date('2022-08-01T20:34:32.000Z'), 33 | meta: { topic: true }, 34 | }, 35 | isTopic: true, 36 | } as ForumPost; 37 | 38 | await connection.confirmTransaction( 39 | await connection.requestAirdrop(wallet.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL), 40 | ); 41 | console.log('airdrop complete'); 42 | // create topic 43 | // await dispatchForum.createTopic(TOPIC_DATA); 44 | // console.log("topic created") 45 | // const topics = await dispatchForum.getTopicsForForum(); 46 | // const topicPost = topics[0] 47 | // console.log(topicPost.parent, topicPost.address.toBase58(), topicPost.poster.toBase58()) 48 | // console.log("topic post created at ID: ", topicPost.postId, topicPost); 49 | 50 | console.log('Start Posts'); 51 | for (let i = 0; i < 100; i++) { 52 | const postData = { body: `p${i}` }; 53 | const postTxn = await dispatchForum.createForumPost(postData, TOPIC_POST); 54 | console.log('created post', postData, postTxn); 55 | const posts = await dispatchForum.getTopicMessages(TOPIC_POST); 56 | const lastPost = posts[posts.length - 1]; 57 | await new Promise((f) => setTimeout(f, TRANSACTION_TIMEOUT)); 58 | const replyData = { body: `r1` }; 59 | const replyTxn = await dispatchForum.replyToForumPost(lastPost, replyData); 60 | console.log('created reply', replyData, replyTxn); 61 | await new Promise((f) => setTimeout(f, TRANSACTION_TIMEOUT)); 62 | } 63 | } 64 | 65 | main(); 66 | -------------------------------------------------------------------------------- /usedispatch_client/src/connection.ts: -------------------------------------------------------------------------------- 1 | import * as web3 from '@solana/web3.js'; 2 | import * as anchor from '@project-serum/anchor'; 3 | import { Messaging } from '../../target/types/messaging'; 4 | import messagingProgramIdl from '../../target/idl/messaging.json'; 5 | import { Postbox } from '../../target/types/postbox'; 6 | import postboxProgramIdl from '../../target/idl/postbox.json'; 7 | import { 8 | clusterAddresses, 9 | defaultCluster, 10 | DispatchAddresses, 11 | TXN_COMMITMENT, 12 | SOLANA_CONNECTION_MAX_RETRIES, 13 | } from './constants'; 14 | import { WalletInterface, AnchorExpectedWalletInterface, AnchorNodeWalletInterface } from './wallets'; 15 | 16 | export type DispatchConnectionOpts = { 17 | skipAnchorProvider?: boolean; 18 | cluster?: web3.Cluster; 19 | }; 20 | 21 | export class DispatchConnection { 22 | public addresses: DispatchAddresses; 23 | public messagingProgram: anchor.Program; 24 | public postboxProgram: anchor.Program; 25 | public cluster: web3.Cluster; 26 | 27 | constructor(public conn: web3.Connection, public wallet: WalletInterface, opts?: DispatchConnectionOpts) { 28 | if (!wallet.publicKey) { 29 | throw new Error('Provided wallet must have a public key defined'); 30 | } 31 | this.addresses = clusterAddresses.get(opts?.cluster ?? defaultCluster)!; 32 | this.cluster = opts?.cluster ?? defaultCluster; 33 | // Initialize anchor 34 | if (!opts?.skipAnchorProvider) { 35 | if (this.wallet.signTransaction && this.wallet.signAllTransactions) { 36 | const anchorWallet = this.wallet as AnchorExpectedWalletInterface; 37 | anchor.setProvider(new anchor.AnchorProvider(conn, anchorWallet, {})); 38 | } else { 39 | throw new Error('The provided wallet is unable to sign transactions'); 40 | } 41 | } 42 | this.messagingProgram = new anchor.Program(messagingProgramIdl as any, this.addresses.programAddress); 43 | this.postboxProgram = new anchor.Program(postboxProgramIdl as any, this.addresses.postboxAddress); 44 | } 45 | 46 | public async sendTransaction(tx: web3.Transaction, commitment: web3.Commitment = TXN_COMMITMENT) { 47 | let sig: string; 48 | if ('sendTransaction' in this.wallet && this.wallet.sendTransaction) { 49 | sig = await this.wallet.sendTransaction(tx, this.conn, { maxRetries: SOLANA_CONNECTION_MAX_RETRIES }); 50 | } else if ('payer' in this.wallet) { 51 | const wallet = this.wallet as AnchorNodeWalletInterface; 52 | const signer = wallet.payer; 53 | sig = await this.conn.sendTransaction(tx, [signer], { maxRetries: SOLANA_CONNECTION_MAX_RETRIES }); 54 | } else { 55 | throw new Error('`wallet` has neither `sendTransaction` nor `payer` so cannot send transaction'); 56 | } 57 | await this.conn.confirmTransaction(sig, commitment); 58 | return sig; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /usedispatch_client/src/api.ts: -------------------------------------------------------------------------------- 1 | const SUPABASE_URL = 'https://aiqrzujttjxgjhumjcky.supabase.co'; 2 | const SUPABASE_ANON_KEY = 3 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFpcXJ6dWp0dGp4Z2podW1qY2t5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NjE0NzA5NjgsImV4cCI6MTk3NzA0Njk2OH0.qyDrAwwq1pyys4t12Klp7YWHCV05YRMj29Du2xRLKe8'; 4 | import { createClient } from '@supabase/supabase-js'; 5 | import * as web3 from '@solana/web3.js'; 6 | import { ForumInfo } from './forum'; 7 | import { FORUM_IMAGE_URL } from './constants'; 8 | 9 | const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); 10 | 11 | const getNormalizedCluster = (cluster: web3.Cluster) => { 12 | return cluster === 'mainnet-beta' ? 'mainnet' : cluster; 13 | }; 14 | 15 | export const getMaxChildId = async (cluster: web3.Cluster, forumID: web3.PublicKey): Promise => { 16 | const { data, error } = await supabase 17 | .from(`ro_view_postbox_post_id_${getNormalizedCluster(cluster)}`) 18 | .select('*') 19 | .eq('forum_id', forumID.toBase58()) 20 | .limit(1) 21 | .single(); 22 | return data.max_child_id; 23 | }; 24 | 25 | export const updateAndGetNewChildId = async (cluster: web3.Cluster, forumID: web3.PublicKey): Promise => { 26 | const { data, error } = await supabase 27 | .rpc(`increment_max_child_${getNormalizedCluster(cluster)}`, { 28 | forum_id_key: forumID.toBase58(), 29 | }) 30 | .single(); 31 | return data as number; 32 | }; 33 | 34 | export const addNewPostbox = async (cluster: web3.Cluster, forumID: web3.PublicKey): Promise => { 35 | await supabase.rpc(`add_postbox_${getNormalizedCluster(cluster)}`, { forum_id_key: forumID.toBase58() }); 36 | }; 37 | 38 | export const createNewForum = async (cluster: web3.Cluster, forumInfo: ForumInfo): Promise => { 39 | const info = { 40 | forum_id_key: forumInfo.collectionId.toBase58(), 41 | vanity_url_key: forumInfo.collectionId.toBase58(), 42 | forum_name_key: forumInfo.title, 43 | forum_desc_key: forumInfo.description, 44 | forum_image_url_key: FORUM_IMAGE_URL, 45 | show_forum_key: true, 46 | featured_forum_key: false, 47 | }; 48 | await supabase.rpc(`create_new_forum_${getNormalizedCluster(cluster)}`, info); 49 | }; 50 | 51 | export const addSolanartMap = async ( 52 | cluster: web3.Cluster, 53 | solanartID: string, 54 | forumID: web3.PublicKey, 55 | ): Promise => { 56 | await supabase.rpc(`create_solanart_${getNormalizedCluster(cluster)}_map`, { 57 | solanart_id_key: solanartID, 58 | forum_id_key: forumID.toBase58(), 59 | }); 60 | }; 61 | 62 | export const getForumIdFromSolanartId = async (cluster: web3.Cluster, solanartID: string): Promise => { 63 | const { data, error } = await supabase 64 | .from(`ro_view_solanart_${getNormalizedCluster(cluster)}`) 65 | .select('*') 66 | .eq('solanart_id', solanartID) 67 | .limit(1) 68 | .single(); 69 | 70 | if (data !== undefined && data !== null) { 71 | return data.forum_id; 72 | } else if (error !== undefined && error !== null) { 73 | return error.message; 74 | } 75 | return 'Unkown error'; 76 | }; 77 | -------------------------------------------------------------------------------- /usedispatch_client/tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as os from 'os'; 3 | import * as path from 'path'; 4 | import * as web3 from '@solana/web3.js'; 5 | import * as bs58 from 'bs58'; 6 | import { Mailbox, KeyPairWallet } from '../src/'; 7 | 8 | const getPayer = (): web3.Keypair => { 9 | if (process.env.WALLET_SECRET_KEY) { 10 | const walletSecretKey = process.env.WALLET_SECRET_KEY; 11 | return web3.Keypair.fromSecretKey(bs58.decode(walletSecretKey)); 12 | } 13 | const walletFile = process.env.WALLET_FILE || path.join(os.homedir(), '.config', 'solana', 'id.json'); 14 | const secretKeyString = fs.readFileSync(walletFile, 'utf-8'); 15 | const secretKey = Uint8Array.from(JSON.parse(secretKeyString)); 16 | return web3.Keypair.fromSecretKey(secretKey); 17 | }; 18 | 19 | const conn = new web3.Connection(web3.clusterApiUrl('devnet')); 20 | const payer = getPayer(); 21 | const receiver = web3.Keypair.generate(); 22 | const payerWallet = new KeyPairWallet(payer); 23 | const receiverWallet = new KeyPairWallet(receiver); 24 | const senderMailbox = new Mailbox(conn, payerWallet); 25 | const receiverMailbox = new Mailbox(conn, receiverWallet); 26 | 27 | const OTSender = web3.Keypair.generate(); 28 | const OTReceiver = web3.Keypair.generate(); 29 | const OTSenderWallet = new KeyPairWallet(OTSender); 30 | const OTReceiverWallet = new KeyPairWallet(OTReceiver); 31 | const OTSenderMailbox = new Mailbox(conn, OTSenderWallet, { sendObfuscated: true }); 32 | const OTReceiverMailbox = new Mailbox(conn, OTReceiverWallet, { sendObfuscated: true }); 33 | const OTReceiverMailboxAsSender = new Mailbox(conn, OTSenderWallet, { 34 | sendObfuscated: true, 35 | mailboxOwner: OTReceiver.publicKey, 36 | }); 37 | 38 | describe('Test for initial Mailbox setup.', () => { 39 | describe('mailboxTest', () => { 40 | test('Obfuscated mailbox send and receive', async () => { 41 | await conn.confirmTransaction(await conn.requestAirdrop(OTReceiver.publicKey, 2 * web3.LAMPORTS_PER_SOL)); 42 | await conn.confirmTransaction(await conn.requestAirdrop(OTSender.publicKey, 2 * web3.LAMPORTS_PER_SOL)); 43 | 44 | console.log('Send obf message 1 from Sender to Receiver'); 45 | const txSig0 = await OTSenderMailbox.send('obftext0', OTReceiver.publicKey); 46 | await conn.confirmTransaction(txSig0, 'finalized'); 47 | 48 | const messages = await OTReceiverMailboxAsSender.fetch(); 49 | expect(messages.length).toEqual(1); 50 | expect(messages[0].data).toEqual('obftext0'); 51 | }); 52 | 53 | test('Mailbox Send, receive, pop test', async () => { 54 | await conn.confirmTransaction(await conn.requestAirdrop(receiver.publicKey, 2 * web3.LAMPORTS_PER_SOL)); 55 | 56 | console.log('Send message 1'); 57 | const txSig0 = await senderMailbox.send('text0', receiver.publicKey); 58 | await conn.confirmTransaction(txSig0, 'finalized'); 59 | 60 | console.log('Send message 2'); 61 | const txSig1 = await senderMailbox.send('text1', receiver.publicKey); 62 | await conn.confirmTransaction(txSig1, 'finalized'); 63 | 64 | const messages = await receiverMailbox.fetch(); 65 | expect(messages.length).toEqual(2); 66 | 67 | expect(messages[0].sender.equals(payer.publicKey)); 68 | expect(messages[0].data).toEqual('text0'); 69 | 70 | expect(messages[1].sender).toEqual(payer.publicKey); 71 | expect(messages[1].data).toEqual('text1'); 72 | 73 | console.log('Pop 1 message from mailbox'); 74 | const txSig2 = await receiverMailbox.pop(); 75 | await conn.confirmTransaction(txSig2, 'finalized'); 76 | 77 | const messages2 = await receiverMailbox.fetch(); 78 | expect(messages2.length).toEqual(1); 79 | 80 | expect(messages2[0].sender).toEqual(payer.publicKey); 81 | expect(messages2[0].data).toEqual('text1'); 82 | 83 | console.log('Pop 1 message from mailbox'); 84 | const txSig3 = await receiverMailbox.pop(); 85 | await conn.confirmTransaction(txSig3, 'finalized'); 86 | 87 | const messages3 = await receiverMailbox.fetch(); 88 | expect(messages3.length).toEqual(0); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /usedispatch_client/tests/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import * as web3 from '@solana/web3.js'; 2 | import { Key, Metadata } from '@metaplex-foundation/mpl-token-metadata'; 3 | import { Error } from '../src/types'; 4 | import { getMintsForOwner, getMetadataForOwner, getMetadataForMint } from '../src/utils'; 5 | 6 | describe('Test helper functions', () => { 7 | let conn: web3.Connection; 8 | 9 | beforeAll(() => { 10 | conn = new web3.Connection(web3.clusterApiUrl('mainnet-beta')); 11 | }); 12 | 13 | test('Get V1 metadata for user', async () => { 14 | // A trash panda holder I found. TODO replace this with a 15 | // test wallet under our control 16 | const publicKey = new web3.PublicKey('7ycUFfnspMwnjp2DfSjAvZgf7g7T6nugrGv2kpzogrNC'); 17 | 18 | const mints = await getMintsForOwner(conn, publicKey); 19 | 20 | // Make sure our mints are correct 21 | expect(mints).toEqual( 22 | expect.arrayContaining([ 23 | new web3.PublicKey('9pSeEsGdnHCdUF9328Xdn88nMmzWUSLAVEC5dWgPvM3Q'), 24 | new web3.PublicKey('DTPMARh15YSqggNbMLECj8RxVoxfhtobyyCLiwEeVwZu'), 25 | ]), 26 | ); 27 | 28 | // Now, get the Metadata for the user... 29 | // And assert that it owns an item in the trash panda 30 | // collection 31 | const metadata = await getMetadataForOwner(conn, publicKey); 32 | // Assert that the raccon mint can be found 33 | const raccoonOrUndefined = metadata.find( 34 | ({ mint }) => mint.toBase58() === '9pSeEsGdnHCdUF9328Xdn88nMmzWUSLAVEC5dWgPvM3Q', 35 | ); 36 | // Raccoon should be defined 37 | expect(raccoonOrUndefined).not.toBeUndefined(); 38 | // Now assert we have it 39 | const raccoon = raccoonOrUndefined!; 40 | // Test the raccoon's properties 41 | expect(raccoon.key).toEqual(Key.MetadataV1); 42 | expect(raccoon.tokenStandard).toBeNull(); 43 | expect(raccoon.collection).not.toBeNull(); 44 | const collection = raccoon.collection!; 45 | expect(collection.verified).toBe(true); 46 | expect(collection.key).toEqual(new web3.PublicKey('GoLMLLR6iSUrA6KsCrFh7f45Uq5EHFQ3p8RmzPoUH9mb')); 47 | }); 48 | 49 | test('Test cardinal.so metadata', async () => { 50 | const cardinalTokenMint = new web3.PublicKey('3MZRqiVc8AxsFwsnySkwmeT1RWxz8sUDHBSzgeZB7bRc'); 51 | 52 | const metadataOrError = await getMetadataForMint(conn, cardinalTokenMint); 53 | expect(metadataOrError).not.toHaveProperty('error'); 54 | 55 | const metadata = metadataOrError as Metadata; 56 | expect(metadata.key).toBe(Key.MetadataV1); 57 | expect(metadata.tokenStandard).toBeNull(); 58 | expect(metadata.collection).toBeNull(); 59 | expect(metadata.mint).toEqual(cardinalTokenMint); 60 | }); 61 | 62 | test('Get account without any metadata', async () => { 63 | const confusingMint = new web3.PublicKey('HxNTUR6YEixfzJbxCR1xjDH6sTGLrAACnKEhHEoWdrf'); 64 | const metadataOrError = await getMetadataForMint(conn, confusingMint); 65 | expect(metadataOrError).toHaveProperty('error'); 66 | 67 | const error = metadataOrError as Error; 68 | expect(error.error).toBe(true); 69 | }); 70 | 71 | test('Fetch NFTs from Dispatch wallet', async () => { 72 | const dispatchKey = new web3.PublicKey('EuoVktg82q5oxEA6LvLXF4Xi9rKT1ZrjYqwcd9JA7X1B'); 73 | const metadataList = await getMetadataForOwner(conn, dispatchKey); 74 | 75 | // Should have at least one metadata 76 | expect(metadataList.length).toBeGreaterThan(0); 77 | 78 | const nftOrUndefined = metadataList.find( 79 | ({ mint }) => mint.toBase58() === '3MZRqiVc8AxsFwsnySkwmeT1RWxz8sUDHBSzgeZB7bRc', 80 | ); 81 | 82 | expect(nftOrUndefined).not.toBeUndefined(); 83 | 84 | const nft = nftOrUndefined!; 85 | expect(nft.mint).toEqual(new web3.PublicKey('3MZRqiVc8AxsFwsnySkwmeT1RWxz8sUDHBSzgeZB7bRc')); 86 | expect(nft.collection).toBeNull(); 87 | expect(nft.tokenStandard).toBeNull(); 88 | expect(nft.collection).toBeNull(); 89 | expect(nft.data.symbol.replace(/\0/g, '')).toEqual('NAME'); 90 | expect(nft.data.name.replace(/\0/g, '')).toEqual('usedispatch.twitter'); 91 | }); 92 | 93 | afterAll(() => { 94 | // Wait for six seconds, for the connection to close down 95 | // TODO remove this waiting when https://github.com/solana-labs/solana/issues/25069 is resolved 96 | Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 6 * 1000); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /usedispatch_client/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Connection, PublicKey, AccountInfo } from '@solana/web3.js'; 2 | import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; 3 | import chunk from 'lodash/chunk'; 4 | import { Metadata } from '@metaplex-foundation/mpl-token-metadata/dist/src/generated/accounts/Metadata'; 5 | import { Result } from '../src/types'; 6 | 7 | const PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); 8 | 9 | export async function deriveMetadataAccount(mint: PublicKey) { 10 | // This key derivation is based on the fields describe here: 11 | // https://github.com/metaplex-foundation/metaplex-program-library/blob/0d63c8b3c6ac077dba63519c78a8da7a58b285a1/token-metadata/js/src/generated/instructions/MintNewEditionFromMasterEditionViaToken.ts#L46 12 | const [key] = await PublicKey.findProgramAddress( 13 | [Buffer.from('metadata'), PROGRAM_ID.toBuffer(), mint.toBuffer()], 14 | PROGRAM_ID, 15 | ); 16 | 17 | return key; 18 | } 19 | 20 | /** 21 | * This function will return a list of a mints for which the user 22 | * has a balance of one or more. Note that this will include both 23 | * fungible and non-fungible tokens, as long as their balance is 24 | * greater than zero 25 | */ 26 | export async function getMintsForOwner(connection: Connection, publicKey: PublicKey): Promise { 27 | const { value } = await connection.getParsedTokenAccountsByOwner(publicKey, { programId: TOKEN_PROGRAM_ID }); 28 | 29 | const parsedObjects = value.map(({ account }) => account.data.parsed); 30 | const mints = parsedObjects 31 | .filter( 32 | (obj) => 33 | 'info' in obj && 34 | // Confirm the object has a mint, which is a string 35 | 'mint' in obj.info && 36 | typeof obj.info.mint === 'string' && 37 | // Confirm the object has a token amount, which is greater than zero 38 | 'tokenAmount' in obj.info && 39 | 'amount' in obj.info.tokenAmount && 40 | typeof obj.info.tokenAmount.amount === 'string' && 41 | Number(obj.info.tokenAmount.amount) > 0, 42 | ) 43 | .map((obj) => new PublicKey(obj.info.mint)); 44 | 45 | return mints; 46 | } 47 | 48 | export async function getMetadataForMints(connection: Connection, mints: PublicKey[]): Promise { 49 | // Derive addresses for all metadata accounts 50 | const derivedAddresses = await Promise.all(mints.map(async (mint) => deriveMetadataAccount(mint))); 51 | // Fetch all accounts, paginated 52 | const accountInfoOrNullList = await getAccountsInfoPaginated(connection, derivedAddresses); 53 | // Filter out nulls 54 | const accountInfoList = accountInfoOrNullList.filter((acct) => acct !== null) as AccountInfo[]; 55 | return accountInfoList.map((accountInfo) => Metadata.fromAccountInfo(accountInfo)).map(([metadata]) => metadata); 56 | } 57 | 58 | /** 59 | * Like connection.getMultipleAccountsInfo, but paginated over 60 | * groups of (default) 100 to prevent endpoint errors 61 | */ 62 | export async function getAccountsInfoPaginated( 63 | connection: Connection, 64 | pkeys: PublicKey[], 65 | chunkSize = 100, 66 | ): Promise<(AccountInfo | null)[]> { 67 | // Divide the list of publicKeys into groups of size `chunkSize` 68 | const chunks = chunk(pkeys, chunkSize); 69 | // Fetch each group of publicKeys in its own 70 | // getMultipleAccountsInfo() call 71 | const chunkFetchPromises = chunks.map((c) => { 72 | return connection.getMultipleAccountsInfo(c); 73 | }); 74 | 75 | // Await all these promises to get a list of lists of 76 | // AccountInfo's 77 | const fetchedChunks = await Promise.all(chunkFetchPromises); 78 | 79 | // Flatten this group to get all accountInfo in one array 80 | const result = fetchedChunks.flat(); 81 | 82 | return result; 83 | } 84 | 85 | /** 86 | * This function fails with an Error if there is no Metadata 87 | * associated with the mint 88 | */ 89 | export async function getMetadataForMint(connection: Connection, mint: PublicKey): Promise> { 90 | const metadataList = await getMetadataForMints(connection, [mint]); 91 | 92 | const result = metadataList[0]; 93 | if (result) { 94 | return result; 95 | } else { 96 | return { 97 | error: true, 98 | message: `Derived account for mint ${mint.toBase58()} not found`, 99 | }; 100 | } 101 | } 102 | 103 | /** 104 | * This function returns all the `Metadata` objects associated 105 | * with a particular PublicKey. Note that this includes both 106 | * fungible and non-fungible tokens 107 | */ 108 | export async function getMetadataForOwner(connection: Connection, publicKey: PublicKey): Promise { 109 | const mints = await getMintsForOwner(connection, publicKey); 110 | 111 | const metadataList = await getMetadataForMints(connection, mints); 112 | 113 | const successes = metadataList.filter((metadata) => !('error' in metadata)); 114 | 115 | return successes as Metadata[]; 116 | } 117 | -------------------------------------------------------------------------------- /programs/postbox/src/post_restrictions.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | use mpl_token_metadata; 3 | use crate::errors::PostboxErrorCode; 4 | 5 | #[derive( 6 | AnchorSerialize, 7 | AnchorDeserialize, 8 | Clone, 9 | PartialEq, 10 | Eq 11 | )] 12 | pub struct QuantifiedMint { 13 | mint: Pubkey, 14 | amount: u64, 15 | } 16 | 17 | #[derive( 18 | AnchorSerialize, 19 | AnchorDeserialize, 20 | Clone, 21 | PartialEq, 22 | Eq 23 | )] 24 | pub enum PostRestrictionRule { 25 | TokenOwnership { mint: Pubkey, amount: u64 }, 26 | NftOwnership { collection_id: Pubkey }, 27 | Null, 28 | NftListAnyOwnership { collection_ids: Vec }, 29 | TokenOrNftAnyOwnership { mints: Vec, collection_ids: Vec }, 30 | } 31 | 32 | #[derive( 33 | AnchorSerialize, 34 | AnchorDeserialize, 35 | Clone, 36 | PartialEq, 37 | Eq 38 | )] 39 | pub enum AdditionalAccountIndices { 40 | TokenOwnership { token_idx: u8 }, 41 | NftOwnership { token_idx: u8, meta_idx: u8, collection_idx: u8 }, 42 | Null, 43 | } 44 | 45 | impl PostRestrictionRule { 46 | pub fn get_size(&self) -> usize { 47 | return match self.try_to_vec() { 48 | Ok(v) => v.len(), 49 | Err(_) => 0, 50 | }; 51 | } 52 | 53 | fn validate_nft_ownership(&self, 54 | poster: &Pubkey, 55 | extra_accounts: &[AccountInfo], 56 | account_indices_vec: &Vec, 57 | collection_id: &Pubkey, 58 | ) -> Result<()> { 59 | let mut checked: bool = false; 60 | for account_indices in account_indices_vec { 61 | if let AdditionalAccountIndices::NftOwnership { token_idx, meta_idx, collection_idx } = account_indices { 62 | let membership_token = &extra_accounts[usize::from(*token_idx)]; 63 | let membership_mint_meta = &extra_accounts[usize::from(*meta_idx)]; 64 | let membership_collection = &extra_accounts[usize::from(*collection_idx)]; 65 | let token = Account::::try_from(membership_token).map_err( 66 | |_| Error::from(PostboxErrorCode::InvalidRestrictionExtraAccounts).with_source(source!()) 67 | )?; 68 | let expected_meta_key = mpl_token_metadata::pda::find_metadata_account(& token.mint).0; 69 | require!(membership_mint_meta.key() == expected_meta_key, PostboxErrorCode::InvalidMetadataKey); 70 | let mint_meta = Account::::try_from(membership_mint_meta).map_err( 71 | |_| Error::from(PostboxErrorCode::InvalidRestrictionExtraAccounts).with_source(source!()) 72 | )?; 73 | require!(mint_meta.collection.is_some(), PostboxErrorCode::NoCollectionOnMetadata); 74 | let collection = mint_meta.collection.as_ref().unwrap(); 75 | let has_collection_nft = token.owner == *poster 76 | && token.amount == 1 77 | && collection.verified 78 | && collection.key == *collection_id 79 | && collection.key == membership_collection.key() 80 | && *membership_collection.owner == anchor_spl::token::ID; 81 | require!(has_collection_nft, PostboxErrorCode::MissingCollectionNftRestriction); 82 | checked = true; 83 | } 84 | } 85 | require!(checked, PostboxErrorCode::MissingRequiredOffsets); 86 | Ok(()) 87 | } 88 | 89 | fn validate_token_ownership(&self, 90 | poster: &Pubkey, 91 | extra_accounts: &[AccountInfo], 92 | account_indices_vec: &Vec, 93 | mint: &Pubkey, 94 | amount: &u64, 95 | ) -> Result<()> { 96 | let mut checked: bool = false; 97 | for account_indices in account_indices_vec { 98 | if let AdditionalAccountIndices::TokenOwnership { token_idx } = account_indices { 99 | let membership_token = &extra_accounts[usize::from(*token_idx)]; 100 | let token = Account::::try_from(membership_token).map_err( 101 | |_| Error::from(PostboxErrorCode::InvalidRestrictionExtraAccounts).with_source(source!()) 102 | )?; 103 | let has_token = token.owner == *poster && token.mint == *mint && token.amount >= *amount; 104 | require!(has_token, PostboxErrorCode::MissingTokenRestriction); 105 | checked = true; 106 | } 107 | } 108 | require!(checked, PostboxErrorCode::MissingRequiredOffsets); 109 | Ok(()) 110 | } 111 | 112 | pub fn validate_reply_allowed(&self, 113 | poster: &Pubkey, 114 | extra_accounts: &[AccountInfo], 115 | account_indices_vec: &Vec, 116 | ) -> Result<()> { 117 | match self { 118 | PostRestrictionRule::TokenOwnership { mint, amount } => { 119 | self.validate_token_ownership(poster, extra_accounts, account_indices_vec, &mint, &amount)?; 120 | }, 121 | 122 | PostRestrictionRule::NftOwnership { collection_id } => { 123 | self.validate_nft_ownership(poster, extra_accounts, account_indices_vec, &collection_id)?; 124 | }, 125 | 126 | PostRestrictionRule::Null => {}, 127 | 128 | PostRestrictionRule::NftListAnyOwnership { collection_ids } => { 129 | let valid = collection_ids.iter().map(|collection_id| self.validate_nft_ownership( 130 | poster, extra_accounts, account_indices_vec, &collection_id 131 | )).any(|r| r.is_ok()); 132 | require!(valid, PostboxErrorCode::MissingCollectionNftRestriction); 133 | }, 134 | 135 | PostRestrictionRule::TokenOrNftAnyOwnership { mints, collection_ids } => { 136 | let token_valid = mints.iter().map(|qmint| self.validate_token_ownership( 137 | poster, extra_accounts, account_indices_vec, &qmint.mint, &qmint.amount 138 | )).any(|r| r.is_ok()); 139 | let nft_valid = collection_ids.iter().map(|collection_id| self.validate_nft_ownership( 140 | poster, extra_accounts, account_indices_vec, &collection_id 141 | )).any(|r| r.is_ok()); 142 | require!(token_valid || nft_valid, PostboxErrorCode::MissingCredentials); 143 | }, 144 | } 145 | 146 | Ok(()) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/token-gating.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Keypair, PublicKey } from '@solana/web3.js'; 2 | import { strict as assert } from 'assert'; 3 | import { decode } from 'bs58'; 4 | import { config } from 'dotenv'; 5 | import { DispatchConnection, Forum, KeyPairWallet } from '../usedispatch_client/src'; 6 | import * as anchor from '@project-serum/anchor'; 7 | 8 | describe('Token gating', () => { 9 | let conn: Connection; 10 | // Owner of the forum 11 | let ownerKeypair: Keypair; 12 | // User of the forum 13 | let userKeypair: Keypair; 14 | // Unauthorized user 15 | let unauthorizedUserKeypair: Keypair; 16 | // A user who has an associated account for the token but does 17 | // not own it 18 | let zeroBalanceUserKeypair: Keypair; 19 | 20 | // Wallets 21 | let owner: KeyPairWallet; 22 | let user: KeyPairWallet; 23 | let unauthorizedUser: KeyPairWallet; 24 | let zeroBalanceUser: KeyPairWallet; 25 | 26 | // Identifier for created forum 27 | let collectionId: PublicKey; 28 | // The forum objects for the different parties 29 | let forumAsOwner: Forum; 30 | let forumAsUser: Forum; 31 | let forumAsUnauthorizedUser: Forum; 32 | let forumAsZeroBalanceUser: Forum; 33 | 34 | before(async () => { 35 | anchor.setProvider(anchor.AnchorProvider.env()); 36 | conn = anchor.getProvider().connection; 37 | // Load environment variables from .env, if it exists 38 | config(); 39 | 40 | if ( 41 | !process.env.OWNER_KEY || 42 | !process.env.USER_KEY || 43 | !process.env.UNAUTHORIZED_USER_KEY || 44 | !process.env.USER_WITH_ASSOCIATED_ACCOUNT_WITH_ZERO_BALANCE_KEY 45 | ) { 46 | assert.fail( 47 | 'Secret keys not found. If running locally, fetch secret keys from https://www.notion.so/usedispatch/Secret-Keys-for-Testing-c468d260f9514c16aa0e227b6b693421 and write them to a file called .env in the project root', 48 | ); 49 | } 50 | 51 | // Initialize the two parties 52 | ownerKeypair = Keypair.fromSecretKey(decode(process.env.OWNER_KEY)); 53 | userKeypair = Keypair.fromSecretKey(decode(process.env.USER_KEY)); 54 | unauthorizedUserKeypair = Keypair.fromSecretKey(decode(process.env.UNAUTHORIZED_USER_KEY)); 55 | zeroBalanceUserKeypair = Keypair.fromSecretKey( 56 | decode(process.env.USER_WITH_ASSOCIATED_ACCOUNT_WITH_ZERO_BALANCE_KEY!), 57 | ); 58 | 59 | // Initiate wallets for the keypairs 60 | owner = new KeyPairWallet(ownerKeypair); 61 | user = new KeyPairWallet(userKeypair); 62 | unauthorizedUser = new KeyPairWallet(unauthorizedUserKeypair); 63 | zeroBalanceUser = new KeyPairWallet(zeroBalanceUserKeypair); 64 | 65 | // Define a random collectionId here 66 | // Normally this would be the PublicKey of the collection 67 | // mint, but we randomize so it doesn't collide with other 68 | // forums with the same collectionId for testing purposes 69 | collectionId = Keypair.generate().publicKey; 70 | 71 | // Initialize forum for both Owner and User 72 | forumAsOwner = new Forum(new DispatchConnection(conn, owner), collectionId); 73 | forumAsUser = new Forum(new DispatchConnection(conn, user), collectionId); 74 | forumAsUnauthorizedUser = new Forum(new DispatchConnection(conn, unauthorizedUser), collectionId); 75 | forumAsZeroBalanceUser = new Forum(new DispatchConnection(conn, zeroBalanceUser), collectionId); 76 | 77 | const txs = await forumAsOwner.createForum({ 78 | // In the real world, this would be the collection mint ID. 79 | // But since we need to run this multiple times, it has to 80 | // be randomized each time 81 | collectionId, 82 | owners: [owner.publicKey], 83 | moderators: [owner.publicKey], 84 | title: 'Test Forum', 85 | description: 'A forum for the test suite', 86 | }); 87 | await Promise.all(txs.map((t) => conn.confirmTransaction(t))); 88 | 89 | const desc = await forumAsOwner.getDescription(); 90 | assert.notEqual(desc, undefined); 91 | assert.equal(desc.title, 'Test Forum'); 92 | assert.equal(desc.desc, 'A forum for the test suite'); 93 | }); 94 | 95 | it('Validates permissions for the entire forum', async () => { 96 | await forumAsOwner.setForumPostRestriction( 97 | { 98 | nftOwnership: { 99 | collectionId: new PublicKey('GcMPukzjZWfY4y4KVM3HNdqtZTf5WyTWPvL4YXznoS9c'), 100 | }, 101 | }, 102 | 'max', 103 | ); 104 | 105 | const restriction = await forumAsOwner.getForumPostRestriction(); 106 | assert.notEqual(restriction, null); 107 | 108 | const authorizedUserCanCreateTopic = await forumAsUser.canCreateTopic(); 109 | const unauthorizedUserCanCreateTopic = await forumAsUnauthorizedUser.canCreateTopic(); 110 | const zeroBalanceUserCanCreateTopic = await forumAsZeroBalanceUser.canCreateTopic(); 111 | 112 | assert.equal(authorizedUserCanCreateTopic, true); 113 | assert.equal(unauthorizedUserCanCreateTopic, false); 114 | assert.equal(zeroBalanceUserCanCreateTopic, false); 115 | }); 116 | 117 | it('Attempts to create a topic', async () => { 118 | const tx0 = await forumAsUser.createTopic({ 119 | subj: 'Subject title', 120 | body: 'body', 121 | }); 122 | await conn.confirmTransaction(tx0); 123 | 124 | try { 125 | const tx = await forumAsUnauthorizedUser.createTopic({ 126 | body: 'body', 127 | subj: 'subj', 128 | }); 129 | await conn.confirmTransaction(tx); 130 | assert.fail(); 131 | } catch (e) { 132 | const expectedError = 'custom program error: 0x1840'; 133 | assert.ok(e instanceof Error); 134 | assert.ok(e.message.includes(expectedError)); 135 | } 136 | 137 | try { 138 | const tx = await forumAsZeroBalanceUser.createTopic({ 139 | body: 'body', 140 | subj: 'subj', 141 | }); 142 | await conn.confirmTransaction(tx); 143 | assert.fail(); 144 | } catch (e) { 145 | const expectedError = 'custom program error: 0x1840'; 146 | assert.ok(e instanceof Error); 147 | assert.ok(e.message.includes(expectedError)); 148 | } 149 | }); 150 | 151 | it('Topics without explicit permissions inherit permissions from forum', async () => { 152 | // forum permissions should be set before this happens 153 | await forumAsUser.createTopic({ 154 | subj: 'Topic without permissions', 155 | body: 'body', 156 | }); 157 | 158 | const topics = await forumAsUser.getTopicsForForum(); 159 | const topic = topics.find((topicI) => topicI.data.subj === 'Topic without permissions'); 160 | 161 | assert.notEqual(topic, null); 162 | 163 | assert.equal(await forumAsUser.canPost(topic), true); 164 | assert.equal(await forumAsUnauthorizedUser.canPost(topic), false); 165 | 166 | await forumAsUser.replyToForumPost(topic, { subj: 'reply', body: 'unauthorized reply' }); 167 | try { 168 | await forumAsUnauthorizedUser.replyToForumPost(topic, { subj: 'unauthorized reply', body: 'unauthorized reply' }); 169 | assert.fail(); 170 | } catch (e) { 171 | const expectedError = 'custom program error: 0x1840'; 172 | assert.ok(e instanceof Error); 173 | assert.ok(e.message.includes(expectedError)); 174 | } 175 | }); 176 | 177 | it('Validates NFT list (any) permissions', async () => { 178 | await forumAsOwner.setForumPostRestriction( 179 | { 180 | nftListAnyOwnership: { 181 | collectionIds: [PublicKey.default, new PublicKey('GcMPukzjZWfY4y4KVM3HNdqtZTf5WyTWPvL4YXznoS9c')], 182 | }, 183 | }, 184 | 'max', 185 | ); 186 | 187 | const restriction = await forumAsOwner.getForumPostRestriction(); 188 | assert.notEqual(restriction, null); 189 | 190 | await forumAsUser.createTopic({ 191 | subj: 'Topic without permissions', 192 | body: 'body', 193 | }); 194 | const topics = await forumAsUser.getTopicsForForum(); 195 | const topic = topics.find((topicI) => topicI.data.subj === 'Topic without permissions'); 196 | assert.notEqual(topic, null); 197 | 198 | const authorizedUserCanCreateTopic = await forumAsUser.canCreateTopic(); 199 | const unauthorizedUserCanCreateTopic = await forumAsUnauthorizedUser.canCreateTopic(); 200 | const zeroBalanceUserCanCreateTopic = await forumAsZeroBalanceUser.canCreateTopic(); 201 | 202 | assert.equal(authorizedUserCanCreateTopic, true); 203 | assert.equal(unauthorizedUserCanCreateTopic, false); 204 | assert.equal(zeroBalanceUserCanCreateTopic, false); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /tests/topic-gating.ts: -------------------------------------------------------------------------------- 1 | import { Connection, Keypair, PublicKey } from '@solana/web3.js'; 2 | import { strict as assert } from 'assert'; 3 | import { decode } from 'bs58'; 4 | import { config } from 'dotenv'; 5 | import { DispatchConnection, Forum, KeyPairWallet, ForumPost } from '../usedispatch_client/src'; 6 | import * as anchor from '@project-serum/anchor'; 7 | 8 | describe('Topic gating', () => { 9 | let conn: Connection; 10 | // Owner of the forum 11 | let ownerKeypair: Keypair; 12 | // User of the forum 13 | let userKeypair: Keypair; 14 | // Unauthorized user 15 | let unauthorizedUserKeypair: Keypair; 16 | // User with zero token balance 17 | let zeroBalanceUserKeypair: Keypair; 18 | 19 | // Wallets 20 | let owner: KeyPairWallet; 21 | let user: KeyPairWallet; 22 | let unauthorizedUser: KeyPairWallet; 23 | let zeroBalanceUser: KeyPairWallet; 24 | 25 | // Identifier for created forum 26 | let collectionId: PublicKey; 27 | // The forum objects for the different parties 28 | let forumAsOwner: Forum; 29 | let forumAsUser: Forum; 30 | let forumAsUnauthorizedUser: Forum; 31 | let forumAsZeroBalanceUser: Forum; 32 | 33 | // A topic for testing 34 | let topic: ForumPost; 35 | 36 | before(async () => { 37 | anchor.setProvider(anchor.AnchorProvider.env()); 38 | conn = anchor.getProvider().connection; 39 | // Load environment variables from .env, if it exists 40 | config(); 41 | 42 | if ( 43 | !process.env.OWNER_KEY || 44 | !process.env.USER_KEY || 45 | !process.env.UNAUTHORIZED_USER_KEY || 46 | !process.env.USER_WITH_ASSOCIATED_ACCOUNT_WITH_ZERO_BALANCE_KEY 47 | ) { 48 | assert.fail( 49 | 'Secret keys not found. If running locally, fetch secret keys from https://www.notion.so/usedispatch/Secret-Keys-for-Testing-c468d260f9514c16aa0e227b6b693421 and write them to a file called .env in the project root', 50 | ); 51 | } 52 | 53 | // Initialize the two parties 54 | ownerKeypair = Keypair.fromSecretKey(decode(process.env.OWNER_KEY)); 55 | userKeypair = Keypair.fromSecretKey(decode(process.env.USER_KEY)); 56 | unauthorizedUserKeypair = Keypair.fromSecretKey(decode(process.env.UNAUTHORIZED_USER_KEY)); 57 | zeroBalanceUserKeypair = Keypair.fromSecretKey( 58 | decode(process.env.USER_WITH_ASSOCIATED_ACCOUNT_WITH_ZERO_BALANCE_KEY), 59 | ); 60 | 61 | // Initiate wallets for the keypairs 62 | owner = new KeyPairWallet(ownerKeypair); 63 | user = new KeyPairWallet(userKeypair); 64 | unauthorizedUser = new KeyPairWallet(unauthorizedUserKeypair); 65 | zeroBalanceUser = new KeyPairWallet(zeroBalanceUserKeypair); 66 | 67 | // Define a random collectionId here 68 | // Normally this would be the PublicKey of the collection 69 | // mint, but we randomize so it doesn't collide with other 70 | // forums with the same collectionId for testing purposes 71 | collectionId = Keypair.generate().publicKey; 72 | 73 | // Initialize forum for both Owner and User 74 | forumAsOwner = new Forum(new DispatchConnection(conn, owner), collectionId); 75 | forumAsUser = new Forum(new DispatchConnection(conn, user), collectionId); 76 | forumAsUnauthorizedUser = new Forum(new DispatchConnection(conn, unauthorizedUser), collectionId); 77 | forumAsZeroBalanceUser = new Forum(new DispatchConnection(conn, zeroBalanceUser), collectionId); 78 | 79 | const txs = await forumAsOwner.createForum({ 80 | // In the real world, this would be the collection mint ID. 81 | // But since we need to run this multiple times, it has to 82 | // be randomized each time 83 | collectionId, 84 | owners: [owner.publicKey], 85 | moderators: [owner.publicKey], 86 | title: 'Test Forum', 87 | description: 'A forum for the test suite', 88 | }); 89 | await Promise.all(txs.map((t) => conn.confirmTransaction(t))); 90 | 91 | const desc = await forumAsOwner.getDescription(); 92 | assert.notEqual(desc, undefined); 93 | assert.equal(desc.title, 'Test Forum'); 94 | assert.equal(desc.desc, 'A forum for the test suite'); 95 | 96 | await forumAsUser.createTopic( 97 | { 98 | subj: 'restricted subject', 99 | body: 'restricted body', 100 | }, 101 | { 102 | nftOwnership: { 103 | collectionId: new PublicKey('GcMPukzjZWfY4y4KVM3HNdqtZTf5WyTWPvL4YXznoS9c'), 104 | }, 105 | }, 106 | ); 107 | 108 | const topics = await forumAsUser.getTopicsForForum(); 109 | assert.equal(topics.length, 1); 110 | topic = topics[0]; 111 | }); 112 | 113 | it('Enforces topic gating', async () => { 114 | assert.equal(await forumAsUser.canPost(topic), true); 115 | assert.equal(await forumAsUnauthorizedUser.canPost(topic), false); 116 | 117 | await forumAsUser.createForumPost( 118 | { 119 | subj: 'reply', 120 | body: 'authorized reply to topic', 121 | }, 122 | topic, 123 | ); 124 | 125 | try { 126 | await forumAsUnauthorizedUser.createForumPost( 127 | { 128 | subj: 'reply', 129 | body: 'unauthorized reply to topic', 130 | }, 131 | topic, 132 | ); 133 | assert.fail(); 134 | } catch (e) { 135 | const expectedError = 'custom program error: 0x1840'; 136 | assert.ok(e instanceof Error); 137 | assert.ok(e.message.includes(expectedError)); 138 | } 139 | 140 | try { 141 | await forumAsZeroBalanceUser.createForumPost( 142 | { 143 | subj: 'reply', 144 | body: 'unauthorized reply to topic', 145 | }, 146 | topic, 147 | ); 148 | assert.fail(); 149 | } catch (e) { 150 | const expectedError = 'custom program error: 0x1840'; 151 | assert.ok(e instanceof Error); 152 | assert.ok(e.message.includes(expectedError)); 153 | } 154 | }); 155 | 156 | it('Gates voting', async () => { 157 | const userCanVote = await forumAsUser.canVote(topic); 158 | const unauthorizedUserCanVote = await forumAsUnauthorizedUser.canVote(topic); 159 | const zeroBalanceUserCanVote = await forumAsZeroBalanceUser.canVote(topic); 160 | assert.equal(userCanVote, true); 161 | assert.equal(unauthorizedUserCanVote, false); 162 | assert.equal(zeroBalanceUserCanVote, false); 163 | 164 | // Should upvote without issue 165 | await forumAsUser.voteUpForumPost(topic); 166 | 167 | try { 168 | await forumAsUnauthorizedUser.voteUpForumPost(topic); 169 | assert.fail(); 170 | } catch (e) { 171 | const expectedError = 'custom program error: 0x1840'; 172 | assert.ok(e instanceof Error); 173 | assert.ok(e.message.includes(expectedError)); 174 | } 175 | 176 | try { 177 | await forumAsZeroBalanceUser.voteUpForumPost(topic); 178 | assert.fail(); 179 | } catch (e) { 180 | const expectedError = 'custom program error: 0x1840'; 181 | assert.ok(e instanceof Error); 182 | assert.ok(e.message.includes(expectedError)); 183 | } 184 | }); 185 | 186 | it('Uses or restrictions', async () => { 187 | assert.equal(await forumAsUser.canPost(topic), true); 188 | assert.equal(await forumAsUnauthorizedUser.canPost(topic), false); 189 | await forumAsUser.createTopic( 190 | { 191 | subj: 'restricted subject', 192 | body: 'restricted body', 193 | }, 194 | { 195 | tokenOrNftAnyOwnership: { 196 | mints: [{ mint: await forumAsUser.getModeratorMint(), amount: 2 }], 197 | collectionIds: [new PublicKey('GcMPukzjZWfY4y4KVM3HNdqtZTf5WyTWPvL4YXznoS9c')], 198 | }, 199 | }, 200 | ); 201 | 202 | const topics = await forumAsUser.getTopicsForForum(); 203 | assert.equal(topics.length, 2); 204 | topic = topics[1]; 205 | 206 | await forumAsUser.createForumPost( 207 | { 208 | subj: 'reply', 209 | body: 'authorized reply to topic', 210 | }, 211 | topic, 212 | ); 213 | 214 | try { 215 | await forumAsUnauthorizedUser.createForumPost( 216 | { 217 | subj: 'reply', 218 | body: 'unauthorized reply to topic', 219 | }, 220 | topic, 221 | ); 222 | assert.fail(); 223 | } catch (e) { 224 | const expectedError = 'custom program error: 0x1840'; 225 | assert.ok(e instanceof Error); 226 | assert.ok(e.message.includes(expectedError)); 227 | } 228 | 229 | await forumAsOwner.setForumPostRestriction({ 230 | tokenOrNftAnyOwnership: { 231 | mints: [{ mint: await forumAsUser.getModeratorMint(), amount: 2 }], 232 | collectionIds: [new PublicKey('GcMPukzjZWfY4y4KVM3HNdqtZTf5WyTWPvL4YXznoS9c')], 233 | }, 234 | }); 235 | const restriction = await forumAsOwner.getForumPostRestriction(); 236 | assert.equal(restriction.tokenOrNftAnyOwnership.mints[0].amount, 2); 237 | }); 238 | 239 | it("Allows moderator to edit post restrictions on another user's post", async () => { 240 | assert.equal(topic.settings.length, 1); 241 | await forumAsOwner.setPostSpecificRestriction(topic, { null: {} }); 242 | const topicAgain = (await forumAsUser.getTopicsForForum())[1]; 243 | assert.equal(topicAgain.settings.length, 1); 244 | assert.ok(topicAgain.settings[0].postRestriction.postRestriction.null); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserver.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } 113 | } 114 | 115 | return str; 116 | } 117 | 118 | function fromEditorPath(str) { 119 | switch (hostInfo) { 120 | case `coc-nvim`: { 121 | str = str.replace(/\.zip::/, `.zip/`); 122 | // The path for coc-nvim is in format of //zipfile://.yarn/... 123 | // So in order to convert it back, we use .* to match all the thing 124 | // before `zipfile:` 125 | return process.platform === `win32` 126 | ? str.replace(/^.*zipfile:\//, ``) 127 | : str.replace(/^.*zipfile:/, ``); 128 | } break; 129 | 130 | case `neovim`: { 131 | str = str.replace(/\.zip::/, `.zip/`); 132 | // The path for neovim is in format of zipfile:////.yarn/... 133 | return str.replace(/^zipfile:\/\//, ``); 134 | } break; 135 | 136 | case `vscode`: 137 | default: { 138 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 139 | } break; 140 | } 141 | } 142 | 143 | // Force enable 'allowLocalPluginLoads' 144 | // TypeScript tries to resolve plugins using a path relative to itself 145 | // which doesn't work when using the global cache 146 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 147 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 148 | // TypeScript already does local loads and if this code is running the user trusts the workspace 149 | // https://github.com/microsoft/vscode/issues/45856 150 | const ConfiguredProject = tsserver.server.ConfiguredProject; 151 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 152 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 153 | this.projectService.allowLocalPluginLoads = true; 154 | return originalEnablePluginsWithOptions.apply(this, arguments); 155 | }; 156 | 157 | // And here is the point where we hijack the VSCode <-> TS communications 158 | // by adding ourselves in the middle. We locate everything that looks 159 | // like an absolute path of ours and normalize it. 160 | 161 | const Session = tsserver.server.Session; 162 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 163 | let hostInfo = `unknown`; 164 | 165 | Object.assign(Session.prototype, { 166 | onMessage(/** @type {string | object} */ message) { 167 | const isStringMessage = typeof message === 'string'; 168 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 169 | 170 | if ( 171 | parsedMessage != null && 172 | typeof parsedMessage === `object` && 173 | parsedMessage.arguments && 174 | typeof parsedMessage.arguments.hostInfo === `string` 175 | ) { 176 | hostInfo = parsedMessage.arguments.hostInfo; 177 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 178 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 179 | // The RegExp from https://semver.org/ but without the caret at the start 180 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 181 | ) ?? []).map(Number) 182 | 183 | if (major === 1) { 184 | if (minor < 61) { 185 | hostInfo += ` <1.61`; 186 | } else if (minor < 66) { 187 | hostInfo += ` <1.66`; 188 | } else if (minor < 68) { 189 | hostInfo += ` <1.68`; 190 | } 191 | } 192 | } 193 | } 194 | 195 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 196 | return typeof value === 'string' ? fromEditorPath(value) : value; 197 | }); 198 | 199 | return originalOnMessage.call( 200 | this, 201 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 202 | ); 203 | }, 204 | 205 | send(/** @type {any} */ msg) { 206 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 207 | return typeof value === `string` ? toEditorPath(value) : value; 208 | }))); 209 | } 210 | }); 211 | 212 | return tsserver; 213 | }; 214 | 215 | if (existsSync(absPnpApiPath)) { 216 | if (!process.versions.pnp) { 217 | // Setup the environment to be able to require typescript/lib/tsserver.js 218 | require(absPnpApiPath).setup(); 219 | } 220 | } 221 | 222 | // Defer to the real typescript/lib/tsserver.js your application uses 223 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`)); 224 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserverlibrary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } 113 | } 114 | 115 | return str; 116 | } 117 | 118 | function fromEditorPath(str) { 119 | switch (hostInfo) { 120 | case `coc-nvim`: { 121 | str = str.replace(/\.zip::/, `.zip/`); 122 | // The path for coc-nvim is in format of //zipfile://.yarn/... 123 | // So in order to convert it back, we use .* to match all the thing 124 | // before `zipfile:` 125 | return process.platform === `win32` 126 | ? str.replace(/^.*zipfile:\//, ``) 127 | : str.replace(/^.*zipfile:/, ``); 128 | } break; 129 | 130 | case `neovim`: { 131 | str = str.replace(/\.zip::/, `.zip/`); 132 | // The path for neovim is in format of zipfile:////.yarn/... 133 | return str.replace(/^zipfile:\/\//, ``); 134 | } break; 135 | 136 | case `vscode`: 137 | default: { 138 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 139 | } break; 140 | } 141 | } 142 | 143 | // Force enable 'allowLocalPluginLoads' 144 | // TypeScript tries to resolve plugins using a path relative to itself 145 | // which doesn't work when using the global cache 146 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 147 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 148 | // TypeScript already does local loads and if this code is running the user trusts the workspace 149 | // https://github.com/microsoft/vscode/issues/45856 150 | const ConfiguredProject = tsserver.server.ConfiguredProject; 151 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 152 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 153 | this.projectService.allowLocalPluginLoads = true; 154 | return originalEnablePluginsWithOptions.apply(this, arguments); 155 | }; 156 | 157 | // And here is the point where we hijack the VSCode <-> TS communications 158 | // by adding ourselves in the middle. We locate everything that looks 159 | // like an absolute path of ours and normalize it. 160 | 161 | const Session = tsserver.server.Session; 162 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 163 | let hostInfo = `unknown`; 164 | 165 | Object.assign(Session.prototype, { 166 | onMessage(/** @type {string | object} */ message) { 167 | const isStringMessage = typeof message === 'string'; 168 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 169 | 170 | if ( 171 | parsedMessage != null && 172 | typeof parsedMessage === `object` && 173 | parsedMessage.arguments && 174 | typeof parsedMessage.arguments.hostInfo === `string` 175 | ) { 176 | hostInfo = parsedMessage.arguments.hostInfo; 177 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 178 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 179 | // The RegExp from https://semver.org/ but without the caret at the start 180 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 181 | ) ?? []).map(Number) 182 | 183 | if (major === 1) { 184 | if (minor < 61) { 185 | hostInfo += ` <1.61`; 186 | } else if (minor < 66) { 187 | hostInfo += ` <1.66`; 188 | } else if (minor < 68) { 189 | hostInfo += ` <1.68`; 190 | } 191 | } 192 | } 193 | } 194 | 195 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 196 | return typeof value === 'string' ? fromEditorPath(value) : value; 197 | }); 198 | 199 | return originalOnMessage.call( 200 | this, 201 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 202 | ); 203 | }, 204 | 205 | send(/** @type {any} */ msg) { 206 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 207 | return typeof value === `string` ? toEditorPath(value) : value; 208 | }))); 209 | } 210 | }); 211 | 212 | return tsserver; 213 | }; 214 | 215 | if (existsSync(absPnpApiPath)) { 216 | if (!process.versions.pnp) { 217 | // Setup the environment to be able to require typescript/lib/tsserverlibrary.js 218 | require(absPnpApiPath).setup(); 219 | } 220 | } 221 | 222 | // Defer to the real typescript/lib/tsserverlibrary.js your application uses 223 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`)); 224 | -------------------------------------------------------------------------------- /usedispatch_client/src/forum.ts: -------------------------------------------------------------------------------- 1 | import * as web3 from '@solana/web3.js'; 2 | import { createNewForum } from './api'; 3 | import { DispatchConnection } from './connection'; 4 | import { TXN_COMMITMENT } from './constants'; 5 | import * as postbox from './postbox'; 6 | 7 | export type ForumInfo = { 8 | collectionId: web3.PublicKey; 9 | owners: web3.PublicKey[]; 10 | moderators: web3.PublicKey[]; 11 | title: string; 12 | description: string; 13 | postRestriction?: postbox.PostRestriction; 14 | }; 15 | 16 | export type ForumPost = postbox.Post & { 17 | forum: Forum; 18 | isTopic: boolean; 19 | }; 20 | 21 | export interface IForum { 22 | // Does the forum exist on chain? 23 | exists(): Promise; 24 | 25 | // Create a postbox for a given collection ID. This might require multiple signatures 26 | createForum(forum: ForumInfo): Promise; 27 | 28 | // Create a postbox for a given collection ID. This might require multiple signatures 29 | createForumIx(forum: ForumInfo): Promise; 30 | 31 | // Get topics for a forum 32 | // topics are the same as a post but with topic=true set 33 | getTopicsForForum(forum: Forum): Promise; 34 | 35 | // Get all posts for a forum 36 | getPostsForForum(forum: Forum): Promise; 37 | 38 | // For a given topic, the messages 39 | getTopicMessages(topic: ForumPost): Promise; 40 | 41 | // Create a new topic, optionally overriding the post restrictions on the whole forum 42 | createTopic( 43 | forumPost: postbox.InputPostData, 44 | postRestriction?: postbox.PostRestriction, 45 | ): Promise; 46 | 47 | // Create a post 48 | createForumPost(forumPost: postbox.InputPostData, topic: ForumPost): Promise; 49 | 50 | // Delete a post 51 | deleteForumPost(forumPost: ForumPost): Promise; 52 | 53 | // This is the same as createPost, but additionally, 54 | // post.parent = postId 55 | replyToForumPost(replyToPost: ForumPost, post: postbox.InputPostData): Promise; 56 | 57 | // For a given topic, the messages 58 | getReplies(topic: ForumPost): Promise; 59 | 60 | // Vote a post up 61 | voteUpForumPost(post: ForumPost): Promise; 62 | 63 | // Vote a post down 64 | voteDownForumPost(post: ForumPost): Promise; 65 | 66 | // Get the vote for a post 67 | getVote(post: ForumPost): Promise; 68 | 69 | // Get all votes for a user in a forum 70 | getVotes(): Promise; 71 | 72 | // Get a list of the owners of this forum 73 | getOwners(): Promise; 74 | 75 | // Get the description of the forum: title and blurb 76 | getDescription(): Promise; 77 | 78 | // Update the description of the forum 79 | setDescription(desc: postbox.Description): Promise; 80 | 81 | // Get any currently set global post restriction on the forum 82 | getForumPostRestriction(): Promise; 83 | 84 | // Update the pots restrictions on the forum 85 | setForumPostRestriction(restriction: postbox.PostRestriction): Promise; 86 | 87 | // Delegate the given account as a moderator by giving them a moderator token 88 | addModerator(newMod: web3.PublicKey): Promise; 89 | 90 | // Delegate the given account as a moderator by giving them a moderator token 91 | addModeratorIx(newMod: web3.PublicKey): Promise; 92 | 93 | getModeratorMint(): Promise; 94 | 95 | // Get a list of moderators 96 | getModerators(): Promise; 97 | } 98 | 99 | /** 100 | * - A forum object is initialized around one postbox 101 | * - We should cache messages within forums not postboxes 102 | */ 103 | export class Forum implements IForum { 104 | protected _postbox: postbox.Postbox; 105 | 106 | constructor(public dispatchConn: DispatchConnection, public collectionId: web3.PublicKey) { 107 | // Create a third party postbox for this forum 108 | this._postbox = new postbox.Postbox(dispatchConn, { 109 | key: collectionId, 110 | str: 'Public', 111 | }); 112 | } 113 | 114 | async exists(): Promise { 115 | const info = await this._postbox.dispatch.conn.getAccountInfo(await this._postbox.getAddress()); 116 | return info !== null; 117 | } 118 | 119 | async createForum(info: ForumInfo): Promise { 120 | const forumIx = await this.createForumIx(info); 121 | const tx = await this.dispatchConn.sendTransaction(forumIx); 122 | await createNewForum(this.dispatchConn.cluster, info); 123 | await this._postbox.dispatch.conn.confirmTransaction(tx); 124 | return [tx]; 125 | } 126 | 127 | async createForumIx(info: ForumInfo): Promise { 128 | if (!this.collectionId.equals(info.collectionId)) { 129 | throw new Error('Collection ID must match'); 130 | } 131 | const desc = { 132 | title: info.title, 133 | desc: info.description, 134 | }; 135 | const ixs = new web3.Transaction(); 136 | ixs.add(await this._postbox.createInitializeIx(info.owners, desc)); 137 | 138 | if (info.postRestriction !== undefined) { 139 | const addRestriction = await this._postbox.setPostboxPostRestrictionIx(info.postRestriction); 140 | ixs.add(addRestriction); 141 | } 142 | await Promise.all( 143 | info.moderators.map(async (m) => { 144 | ixs.add(await this.addModeratorIx(m)); 145 | }), 146 | ); 147 | return ixs; 148 | } 149 | 150 | async getTopicsForForum(): Promise { 151 | const topLevelPosts = await this._postbox.fetchPosts(); 152 | const topics = topLevelPosts.filter((p) => p.data.meta?.topic === true); 153 | return topics.map(this.convertPostboxToForum).sort((a, b) => { 154 | // Newest topic first 155 | return -(a.data.ts.getTime() - b.data.ts.getTime()); 156 | }); 157 | } 158 | 159 | async getPostsForForum(): Promise { 160 | const posts = await this._postbox.fetchAllPosts(); 161 | return posts.map(this.convertPostboxToForum).sort((a, b) => { 162 | // Newest topic first 163 | return -(a.data.ts.getTime() - b.data.ts.getTime()); 164 | }); 165 | } 166 | 167 | async getTopicMessages(topic: ForumPost): Promise { 168 | const messages = await this._postbox.fetchReplies(topic); 169 | return messages.map(this.convertPostboxToForum).sort((a, b) => { 170 | // Oldest message first 171 | return a.data.ts.getTime() - b.data.ts.getTime(); 172 | }); 173 | } 174 | 175 | async createTopic( 176 | forumPost: postbox.InputPostData, 177 | postRestriction?: postbox.PostRestriction, 178 | ): Promise { 179 | if (!forumPost.meta) { 180 | forumPost.meta = {}; 181 | } 182 | forumPost.meta.topic = true; 183 | return this._postbox.createPost(forumPost, undefined, postRestriction); 184 | } 185 | 186 | async createForumPost(forumPost: postbox.InputPostData, topic: ForumPost): Promise { 187 | if (!topic.isTopic) throw new Error('`topic` must have isTopic true'); 188 | return this._postbox.createPost(forumPost, topic); 189 | } 190 | 191 | async editForumPost(forumPost: ForumPost, newPostData: postbox.InputPostData): Promise { 192 | if (forumPost.isTopic) { 193 | if (!newPostData.meta) { 194 | newPostData.meta = {}; 195 | } 196 | newPostData.meta.topic = true; 197 | } 198 | const pPost = this.convertForumToInteractable(forumPost); 199 | return this._postbox.editPost(pPost, newPostData); 200 | } 201 | 202 | async deleteForumPost(forumPost: ForumPost, asModerator?: boolean): Promise { 203 | const pPost = this.convertForumToInteractable(forumPost); 204 | if (asModerator) { 205 | return this._postbox.deletePostAsModerator(pPost); 206 | } 207 | return this._postbox.deletePost(pPost); 208 | } 209 | 210 | async replyToForumPost(replyToPost: ForumPost, post: postbox.InputPostData): Promise { 211 | return this._postbox.replyToPost(post, replyToPost); 212 | } 213 | 214 | async getReplies(post: ForumPost): Promise { 215 | const messages = await this._postbox.fetchReplies(post); 216 | return messages.map(this.convertPostboxToForum).sort((a, b) => { 217 | // Oldest message first 218 | return a.data.ts.getTime() - b.data.ts.getTime(); 219 | }); 220 | } 221 | 222 | async voteUpForumPost(post: ForumPost): Promise { 223 | return this._postbox.vote(post, true); 224 | } 225 | 226 | async voteDownForumPost(post: ForumPost): Promise { 227 | return this._postbox.vote(post, false); 228 | } 229 | 230 | async getVote(post: ForumPost): Promise { 231 | return this._postbox.getVote(post); 232 | } 233 | 234 | async getVotes(): Promise { 235 | return this._postbox.getVotes(); 236 | } 237 | 238 | async setOwners(newOwners: web3.PublicKey[]): Promise { 239 | const updatedOwners = newOwners; 240 | if (!newOwners.find((elem) => elem.equals(this.dispatchConn.wallet.publicKey!))) { 241 | updatedOwners.push(this.dispatchConn.wallet.publicKey!); 242 | } 243 | return this._postbox.setOwners(updatedOwners); 244 | } 245 | 246 | async getOwners(): Promise { 247 | return this._postbox.getOwners(); 248 | } 249 | 250 | async getDescription(): Promise { 251 | return this._postbox.getDescription(); 252 | } 253 | 254 | async setDescription(desc: postbox.Description): Promise { 255 | return this._postbox.setDescription(desc); 256 | } 257 | 258 | async getImageUrls(): Promise { 259 | return this._postbox.getImages(); 260 | } 261 | 262 | async setImageUrls(images: postbox.Images): Promise { 263 | return this._postbox.setImages(images); 264 | } 265 | 266 | async getForumPostRestriction(): Promise { 267 | return this._postbox.getPostboxPostRestriction(); 268 | } 269 | 270 | async setForumPostRestriction( 271 | restriction: postbox.PostRestriction, 272 | commitment: web3.Commitment = TXN_COMMITMENT, 273 | ): Promise { 274 | return this._postbox.setPostboxPostRestriction(restriction, commitment); 275 | } 276 | 277 | async setPostSpecificRestriction( 278 | post: postbox.InteractablePost, 279 | restriction: postbox.PostRestriction, 280 | ): Promise { 281 | return this._postbox.setPostSpecificRestriction(post, restriction); 282 | } 283 | 284 | async setForumPostRestrictionIx(restriction: postbox.PostRestriction): Promise { 285 | return this._postbox.setPostboxPostRestrictionIx(restriction); 286 | } 287 | 288 | async deleteForumPostRestriction(commitment: web3.Commitment = TXN_COMMITMENT): Promise { 289 | return this._postbox.setPostboxPostRestriction({ null: {} }, commitment); 290 | } 291 | 292 | async addModerator(newMod: web3.PublicKey): Promise { 293 | return this._postbox.addModerator(newMod); 294 | } 295 | 296 | async addModeratorIx(newMod: web3.PublicKey): Promise { 297 | return this._postbox.createAddModeratorIx(newMod); 298 | } 299 | 300 | async getModeratorMint(): Promise { 301 | return this._postbox.getModeratorMint(); 302 | } 303 | 304 | async getModerators(): Promise { 305 | return this._postbox.getModerators(); 306 | } 307 | 308 | // Role functions 309 | 310 | async isOwner(): Promise { 311 | return this._postbox.isOwner(); 312 | } 313 | 314 | async isModerator(): Promise { 315 | return this._postbox.isModerator(); 316 | } 317 | 318 | async canCreateTopic(): Promise { 319 | return this._postbox.canPost(); 320 | } 321 | 322 | async canPost(topic: ForumPost): Promise { 323 | return this._postbox.canPost(topic); 324 | } 325 | 326 | async canVote(post: ForumPost): Promise { 327 | return this._postbox.canPost(post); 328 | } 329 | 330 | // Helper functions 331 | 332 | protected convertPostboxToForum(p: postbox.Post): ForumPost { 333 | return { 334 | ...p, 335 | forum: this, 336 | isTopic: (p.data.meta?.topic ?? false) === true, 337 | }; 338 | } 339 | 340 | protected convertForumToInteractable(p: ForumPost): postbox.InteractablePost { 341 | return { 342 | postId: p.postId, 343 | address: p.address, 344 | poster: p.poster, 345 | settings: p.settings, 346 | }; 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /programs/messaging/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | use anchor_lang::solana_program; 3 | use anchor_spl::{token, associated_token}; 4 | mod treasury; 5 | 6 | #[cfg(feature = "mainnet")] 7 | declare_id!("BHJ4tRcogS88tUhYotPfYWDjR4q7MGdizdiguY3N54rb"); 8 | #[cfg(not(feature = "mainnet"))] 9 | declare_id!("BHJ4tRcogS88tUhYotPfYWDjR4q7MGdizdiguY3N54rb"); 10 | 11 | #[constant] 12 | const MESSAGE_FEE_LAMPORTS: u64 = 50000; 13 | const PROTOCOL_SEED: & str = "dispatch"; 14 | const MAILBOX_SEED: & str = "mailbox"; 15 | const MESSAGE_SEED: & str = "message"; 16 | 17 | fn inner_send_message<'info>(mailbox: &mut Mailbox, message: &mut Message, data: String, sender: Pubkey, 18 | payer: AccountInfo<'info>, receiver: Pubkey, fee_receiver: AccountInfo<'info>) -> Result<()> { 19 | mailbox.message_count += 1; 20 | message.sender = sender; 21 | message.payer = payer.key(); 22 | message.data = data; 23 | let ix = solana_program::system_instruction::transfer(&payer.key(), &fee_receiver.key(), MESSAGE_FEE_LAMPORTS); 24 | solana_program::program::invoke(&ix, &[payer, fee_receiver])?; 25 | emit!(DispatchMessage { 26 | sender_pubkey: message.sender, 27 | receiver_pubkey: receiver, 28 | message_index: mailbox.message_count - 1, 29 | message: message.data.clone(), 30 | }); 31 | Ok(()) 32 | } 33 | 34 | #[program] 35 | pub mod messaging { 36 | use super::*; 37 | /// Send a message to the receiver. Note that anyone can create a mailbox for the receiver 38 | /// and send messages. 39 | pub fn send_message(ctx: Context, data: String) -> Result<()> { 40 | inner_send_message( 41 | &mut ctx.accounts.mailbox, 42 | &mut ctx.accounts.message, 43 | data, 44 | ctx.accounts.sender.key(), 45 | ctx.accounts.payer.to_account_info(), 46 | ctx.accounts.receiver.key(), 47 | ctx.accounts.fee_receiver.to_account_info(), 48 | )?; 49 | Ok(()) 50 | } 51 | 52 | /// Delete an arbitrary message account and send rent to the original payer. Only the 53 | /// sender, payer, or receiver is allowed to call this function. If the account being 54 | /// deleted is the first remaining message, increment the read message count pointer. 55 | pub fn delete_message(ctx: Context, message_index: u32) -> Result<()> { 56 | let mailbox = &mut ctx.accounts.mailbox; 57 | if message_index == mailbox.read_message_count && mailbox.read_message_count < mailbox.message_count { 58 | mailbox.read_message_count += 1; 59 | } 60 | 61 | Ok(()) 62 | } 63 | 64 | /// Allow the receiver to update the count of read messages in case others have deleted 65 | /// and a gap has formed. 66 | pub fn update_read_messages(ctx: Context, read_messages: u32) -> Result<()> { 67 | let mailbox = &mut ctx.accounts.mailbox; 68 | mailbox.read_message_count = read_messages; 69 | 70 | if mailbox.read_message_count > mailbox.message_count { 71 | return Err(Error::from(ProgramError::InvalidArgument).with_source(source!())); 72 | } 73 | 74 | Ok(()) 75 | } 76 | 77 | /// Send a message while creating an attachment 78 | pub fn send_message_with_incentive(ctx: Context, data: String, incentive_amount: u64) -> Result<()> { 79 | let message = &mut ctx.accounts.message; 80 | inner_send_message( 81 | &mut ctx.accounts.mailbox, 82 | message, 83 | data, 84 | ctx.accounts.sender.key(), 85 | ctx.accounts.payer.to_account_info(), 86 | ctx.accounts.receiver.key(), 87 | ctx.accounts.fee_receiver.to_account_info(), 88 | )?; 89 | message.incentive_mint = ctx.accounts.incentive_mint.key(); 90 | 91 | let transfer_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), token::Transfer { 92 | authority: ctx.accounts.payer.to_account_info(), 93 | from: ctx.accounts.payer_token_account.to_account_info(), 94 | to: ctx.accounts.incentive_token_account.to_account_info(), 95 | }); 96 | token::transfer(transfer_ctx, incentive_amount)?; 97 | 98 | Ok(()) 99 | } 100 | 101 | /// Allow the receiver to claim the incentive payment 102 | pub fn claim_incentive(ctx: Context, message_index: u32) -> Result<()> { 103 | let incentive_amount = ctx.accounts.incentive_token_account.amount; 104 | let mailbox_address = ctx.accounts.mailbox.key(); 105 | 106 | let signer_seeds: &[&[&[u8]]] = &[&[ 107 | PROTOCOL_SEED.as_bytes(), 108 | MESSAGE_SEED.as_bytes(), 109 | mailbox_address.as_ref(), 110 | &message_index.to_le_bytes(), 111 | &[*ctx.bumps.get("message").unwrap()], 112 | ]]; 113 | 114 | let transfer_ctx = CpiContext::new_with_signer(ctx.accounts.token_program.to_account_info(), token::Transfer { 115 | authority: ctx.accounts.message.to_account_info(), 116 | from: ctx.accounts.incentive_token_account.to_account_info(), 117 | to: ctx.accounts.receiver_token_account.to_account_info(), 118 | }, signer_seeds); 119 | token::transfer(transfer_ctx, incentive_amount)?; 120 | 121 | let close_ctx = CpiContext::new_with_signer(ctx.accounts.token_program.to_account_info(), token::CloseAccount { 122 | authority: ctx.accounts.message.to_account_info(), 123 | account: ctx.accounts.incentive_token_account.to_account_info(), 124 | destination: ctx.accounts.rent_destination.to_account_info(), 125 | }, signer_seeds); 126 | token::close_account(close_ctx)?; 127 | 128 | ctx.accounts.message.incentive_mint = Pubkey::default(); 129 | 130 | emit!(IncentiveClaimed { 131 | sender_pubkey: ctx.accounts.message.sender, 132 | receiver_pubkey: ctx.accounts.receiver.key(), 133 | message_index: message_index, 134 | mint: ctx.accounts.incentive_token_account.mint, 135 | amount: incentive_amount, 136 | }); 137 | 138 | Ok(()) 139 | } 140 | } 141 | 142 | #[derive(Accounts)] 143 | #[instruction(data: String)] 144 | pub struct SendMessage<'info> { 145 | #[account(init_if_needed, 146 | payer = payer, 147 | space = 8 + 4 + 4, 148 | seeds = [PROTOCOL_SEED.as_bytes(), MAILBOX_SEED.as_bytes(), receiver.key().as_ref()], 149 | bump, 150 | )] 151 | pub mailbox: Box>, 152 | /// CHECK: we do not access the data in the receiver 153 | pub receiver: UncheckedAccount<'info>, 154 | 155 | #[account(init, 156 | payer = payer, 157 | space = 158 | 8 // account discriminator 159 | + 32 // sender pubkey 160 | + 32 // payer pubkey 161 | + 4 + data.as_bytes().len() // payload string 162 | + 32, // incentive pubkey 163 | seeds = [PROTOCOL_SEED.as_bytes(), MESSAGE_SEED.as_bytes(), mailbox.key().as_ref(), &mailbox.message_count.to_le_bytes()], 164 | bump, 165 | )] 166 | pub message: Box>, 167 | 168 | #[account(mut)] 169 | pub payer: Signer<'info>, 170 | pub sender: Signer<'info>, 171 | /// CHECK: we do not access the data in the fee_receiver other than to transfer lamports to it 172 | #[account(mut, 173 | address = treasury::TREASURY_ADDRESS, 174 | )] 175 | pub fee_receiver: UncheckedAccount<'info>, 176 | 177 | pub system_program: Program<'info, System>, 178 | } 179 | 180 | #[derive(Accounts)] 181 | #[instruction(data: String)] 182 | pub struct SendMessageWithIncentive<'info> { 183 | #[account(init_if_needed, 184 | payer = payer, 185 | space = 8 + 4 + 4, 186 | seeds = [PROTOCOL_SEED.as_bytes(), MAILBOX_SEED.as_bytes(), receiver.key().as_ref()], 187 | bump, 188 | )] 189 | pub mailbox: Box>, 190 | /// CHECK: we do not access the data in the receiver 191 | pub receiver: UncheckedAccount<'info>, 192 | 193 | #[account(init, 194 | payer = payer, 195 | space = 196 | 8 // account discriminator 197 | + 32 // sender pubkey 198 | + 32 // payer pubkey 199 | + 4 + data.as_bytes().len() // payload string 200 | + 32, // incentive pubkey 201 | seeds = [PROTOCOL_SEED.as_bytes(), MESSAGE_SEED.as_bytes(), mailbox.key().as_ref(), &mailbox.message_count.to_le_bytes()], 202 | bump, 203 | )] 204 | pub message: Box>, 205 | 206 | #[account(mut)] 207 | pub payer: Signer<'info>, 208 | pub sender: Signer<'info>, 209 | /// CHECK: we do not access the data in the fee_receiver other than to transfer lamports to it 210 | #[account(mut, 211 | address = treasury::TREASURY_ADDRESS, 212 | )] 213 | pub fee_receiver: UncheckedAccount<'info>, 214 | 215 | pub system_program: Program<'info, System>, 216 | 217 | pub incentive_mint: Box>, 218 | #[account(mut, associated_token::mint=incentive_mint, associated_token::authority=payer)] 219 | pub payer_token_account: Box>, 220 | #[account(init, payer=payer, associated_token::mint=incentive_mint, associated_token::authority=message)] 221 | pub incentive_token_account: Box>, 222 | 223 | pub token_program: Program<'info, token::Token>, 224 | pub associated_token_program: Program<'info, associated_token::AssociatedToken>, 225 | pub rent: Sysvar<'info, Rent>, 226 | } 227 | 228 | #[derive(Accounts)] 229 | #[instruction(message_index: u32)] 230 | pub struct DeleteMessage<'info> { 231 | #[account(mut, 232 | seeds = [PROTOCOL_SEED.as_bytes(), MAILBOX_SEED.as_bytes(), receiver.key().as_ref()], 233 | bump, 234 | )] 235 | pub mailbox: Box>, 236 | /// CHECK: we only include receiver for their public key and do not access the account 237 | /// and verify it based on the PDA of the mailbox 238 | pub receiver: UncheckedAccount<'info>, 239 | 240 | #[account(mut, 241 | constraint = (authorized_deleter.key() == receiver.key() || authorized_deleter.key() == message.sender || authorized_deleter.key() == message.payer) 242 | )] 243 | pub authorized_deleter: Signer<'info>, 244 | 245 | #[account(mut, 246 | close = rent_destination, 247 | seeds = [PROTOCOL_SEED.as_bytes(), MESSAGE_SEED.as_bytes(), mailbox.key().as_ref(), &message_index.to_le_bytes()], 248 | bump, 249 | )] 250 | pub message: Box>, 251 | 252 | /// CHECK: we do not access the data in the rent_destination other than to transfer lamports to it 253 | #[account(mut, 254 | address = message.payer, 255 | )] 256 | pub rent_destination: UncheckedAccount<'info>, 257 | 258 | pub system_program: Program<'info, System>, 259 | } 260 | 261 | #[derive(Accounts)] 262 | pub struct UpdateReadMessages<'info> { 263 | #[account(mut, 264 | seeds = [PROTOCOL_SEED.as_bytes(), MAILBOX_SEED.as_bytes(), receiver.key().as_ref()], 265 | bump, 266 | )] 267 | pub mailbox: Box>, 268 | pub receiver: Signer<'info>, 269 | } 270 | 271 | #[derive(Accounts)] 272 | #[instruction(message_index: u32)] 273 | pub struct ClaimIncentive<'info> { 274 | #[account(mut, 275 | seeds = [PROTOCOL_SEED.as_bytes(), MAILBOX_SEED.as_bytes(), receiver.key().as_ref()], 276 | bump, 277 | )] 278 | pub mailbox: Box>, 279 | #[account(mut)] 280 | pub receiver: Signer<'info>, 281 | 282 | #[account(mut, 283 | seeds = [PROTOCOL_SEED.as_bytes(), MESSAGE_SEED.as_bytes(), mailbox.key().as_ref(), &message_index.to_le_bytes()], 284 | bump, 285 | )] 286 | pub message: Box>, 287 | 288 | /// CHECK: we do not access the data in the rent_destination other than to transfer lamports to it 289 | #[account(mut, 290 | address = message.payer, 291 | )] 292 | pub rent_destination: UncheckedAccount<'info>, 293 | 294 | #[account(address=message.incentive_mint)] 295 | pub incentive_mint: Box>, 296 | #[account(mut, associated_token::mint=incentive_mint, associated_token::authority=message)] 297 | pub incentive_token_account: Box>, 298 | #[account(init_if_needed, payer=receiver, associated_token::mint=incentive_mint, associated_token::authority=receiver)] 299 | pub receiver_token_account: Box>, 300 | 301 | pub system_program: Program<'info, System>, 302 | pub token_program: Program<'info, token::Token>, 303 | pub associated_token_program: Program<'info, associated_token::AssociatedToken>, 304 | pub rent: Sysvar<'info, Rent>, 305 | } 306 | 307 | #[account] 308 | #[derive(Default)] 309 | pub struct Mailbox { 310 | pub read_message_count: u32, 311 | pub message_count: u32, 312 | } 313 | 314 | #[account] 315 | #[derive(Default)] 316 | pub struct Message { 317 | pub sender: Pubkey, 318 | pub payer: Pubkey, 319 | pub data: String, 320 | pub incentive_mint: Pubkey, 321 | } 322 | 323 | #[event] 324 | pub struct DispatchMessage { 325 | pub sender_pubkey: Pubkey, 326 | pub receiver_pubkey: Pubkey, 327 | pub message_index: u32, 328 | pub message: String, 329 | } 330 | 331 | #[event] 332 | pub struct IncentiveClaimed { 333 | pub sender_pubkey: Pubkey, 334 | pub receiver_pubkey: Pubkey, 335 | pub message_index: u32, 336 | pub mint: Pubkey, 337 | pub amount: u64, 338 | } 339 | -------------------------------------------------------------------------------- /usedispatch_client/src/mailbox.ts: -------------------------------------------------------------------------------- 1 | import * as splToken from '@solana/spl-token'; 2 | import * as web3 from '@solana/web3.js'; 3 | import * as anchor from '@project-serum/anchor'; 4 | import * as CryptoJS from 'crypto-js'; 5 | import { seeds, eventName } from './constants'; 6 | import { WalletInterface } from './wallets'; 7 | import { convertSolanartToDispatchMessage } from './solanart'; 8 | import { DispatchConnection, DispatchConnectionOpts } from './connection'; 9 | 10 | export type MailboxAccount = { 11 | messageCount: number; 12 | readMessageCount: number; 13 | }; 14 | 15 | export type ParsedMessageData = { 16 | subj?: string; 17 | body?: string; 18 | /// ts is in seconds 19 | ts?: number; 20 | meta?: object; 21 | ns?: string; 22 | }; 23 | 24 | export type MessageData = { 25 | subj?: string; 26 | body: string; 27 | ts?: Date; 28 | meta?: object; 29 | }; 30 | 31 | export type MessageAccount = { 32 | sender: web3.PublicKey; 33 | receiver: web3.PublicKey; 34 | data: MessageData; 35 | messageId: number; 36 | incentiveMint?: web3.PublicKey; 37 | }; 38 | 39 | /** @deprecated Use MessageAccount instead */ 40 | export type DeprecatedMessageAccount = { 41 | sender: web3.PublicKey; 42 | receiver: web3.PublicKey; 43 | data: string; 44 | messageId: number; 45 | }; 46 | 47 | export type SentMessageAccount = { 48 | receiver: web3.PublicKey; 49 | messageId: number; 50 | }; 51 | 52 | export type MailboxOpts = DispatchConnectionOpts & { 53 | mailboxOwner?: web3.PublicKey; 54 | payer?: web3.PublicKey; 55 | sendObfuscated?: boolean; 56 | }; 57 | 58 | export type IncentiveArgs = { 59 | mint: web3.PublicKey; 60 | amount: number; 61 | payerAccount: web3.PublicKey; 62 | }; 63 | 64 | export type SendOpts = { 65 | incentive?: IncentiveArgs; 66 | }; 67 | 68 | export class Mailbox extends DispatchConnection { 69 | public mailboxOwner: web3.PublicKey; 70 | public payer?: web3.PublicKey; 71 | public obfuscate: boolean; 72 | 73 | constructor(public conn: web3.Connection, public wallet: WalletInterface, opts?: MailboxOpts) { 74 | super(conn, wallet, opts); 75 | this.mailboxOwner = opts?.mailboxOwner ?? wallet.publicKey!; 76 | this.payer = opts?.payer; 77 | this.obfuscate = opts?.sendObfuscated ?? false; 78 | } 79 | 80 | /* 81 | Porcelain commands 82 | */ 83 | /** @deprecated use sendMessage instead */ 84 | async send(data: string, receiverAddress: web3.PublicKey, opts?: SendOpts): Promise { 85 | this.validateWallet(); 86 | const tx = await this.makeSendTx(data, receiverAddress, opts); 87 | return this.sendTransaction(tx); 88 | } 89 | 90 | async sendMessage( 91 | subj: string, 92 | body: string, 93 | receiverAddress: web3.PublicKey, 94 | opts?: SendOpts, 95 | meta?: object, 96 | ): Promise { 97 | this.validateWallet(); 98 | const tx = await this.makeSendTx(this.getMessageString(subj, body, meta), receiverAddress, opts); 99 | return this.sendTransaction(tx); 100 | } 101 | 102 | /** @deprecated use delete instead */ 103 | async pop(): Promise { 104 | this.validateWallet(); 105 | const tx = await this.makePopTx(); 106 | return this.sendTransaction(tx); 107 | } 108 | 109 | /** @deprecated use delete instead */ 110 | async delete(messageId: number, receiverAddress?: web3.PublicKey): Promise { 111 | this.validateWallet(); 112 | const tx = await this.makeDeleteTx(messageId, receiverAddress); 113 | return this.sendTransaction(tx); 114 | } 115 | 116 | async deleteMessage(message: MessageAccount): Promise { 117 | this.validateWallet(); 118 | const tx = await this.makeDeleteTx(message.messageId, message.receiver); 119 | return this.sendTransaction(tx); 120 | } 121 | 122 | async claimIncentive(message: MessageAccount): Promise { 123 | this.validateWallet(); 124 | if (!message.receiver.equals(this.mailboxOwner)) throw new Error('Receiver does not match mailboxOwner'); 125 | const tx = await this.makeClaimIncentiveTx(message.messageId); 126 | return this.sendTransaction(tx); 127 | } 128 | 129 | /** @deprecated Upgrade to fetchMessages */ 130 | async fetch(): Promise { 131 | const mailbox = await this.fetchMailbox(); 132 | if (!mailbox) { 133 | return []; 134 | } 135 | const numMessages = mailbox.messageCount - mailbox.readMessageCount; 136 | if (0 === numMessages) { 137 | return []; 138 | } 139 | const messageIds = Array(numMessages) 140 | .fill(0) 141 | .map((_element, index) => index + mailbox.readMessageCount); 142 | const addresses = await Promise.all(messageIds.map((id) => this.getMessageAddress(id))); 143 | const messages = await this.messagingProgram.account.message.fetchMultiple(addresses); 144 | const normalize = (messageAccount: any | null, index: number) => { 145 | return this.normalizeMessageAccountDeprecated(messageAccount, index + mailbox.readMessageCount); 146 | }; 147 | return messages.map(normalize).filter((m): m is DeprecatedMessageAccount => m !== null); 148 | } 149 | 150 | async fetchMessages(): Promise { 151 | const mailbox = await this.fetchMailbox(); 152 | if (!mailbox) { 153 | return []; 154 | } 155 | const numMessages = mailbox.messageCount - mailbox.readMessageCount; 156 | if (0 === numMessages) { 157 | return []; 158 | } 159 | const messageIds = Array(numMessages) 160 | .fill(0) 161 | .map((_element, index) => index + mailbox.readMessageCount); 162 | const addresses = await Promise.all(messageIds.map((id) => this.getMessageAddress(id))); 163 | const messages = await this.messagingProgram.account.message.fetchMultiple(addresses); 164 | const normalize = (messageAccount: any | null, index: number) => { 165 | return this.normalizeMessageAccount(messageAccount, index + mailbox.readMessageCount); 166 | }; 167 | return messages.map(normalize).filter((m): m is MessageAccount => m !== null); 168 | } 169 | 170 | async fetchMessageById(messageId: number): Promise { 171 | const messageAddress = await this.getMessageAddress(messageId); 172 | const messageAccount = await this.messagingProgram.account.message.fetch(messageAddress); 173 | return this.normalizeMessageAccount(messageAccount, messageId)!; 174 | } 175 | 176 | async fetchSentMessagesTo(receiverAddress: web3.PublicKey): Promise { 177 | const receiverMailbox = new Mailbox(this.conn, this.wallet, { mailboxOwner: receiverAddress }); 178 | const sentToReceiver = (await receiverMailbox.fetchMessages()).filter((m) => { 179 | return m.sender.equals(this.mailboxOwner); 180 | }); 181 | return sentToReceiver; 182 | } 183 | 184 | async fetchIncentiveTokenAccount(messageAccount: MessageAccount) { 185 | if (!messageAccount.incentiveMint) throw new Error('messageAccount does not have incentive attached'); 186 | const messageAddress = await this.getMessageAddress(messageAccount.messageId, messageAccount.receiver); 187 | const ata = await splToken.getAssociatedTokenAddress(messageAccount.incentiveMint, messageAddress, true); 188 | const splAccount = await splToken.getAccount(this.conn, ata); 189 | return splAccount; 190 | } 191 | 192 | async count() { 193 | return (await this.fetchMessages()).length; 194 | } 195 | 196 | async countEx() { 197 | const mailbox = await this.fetchMailbox(); 198 | if (!mailbox) { 199 | return { 200 | messageCount: 0, 201 | readMessageCount: 0, 202 | }; 203 | } 204 | 205 | return { 206 | messageCount: mailbox.messageCount, 207 | readMessageCount: mailbox.readMessageCount, 208 | }; 209 | } 210 | 211 | /* 212 | Transaction generation commands 213 | */ 214 | async makeSendTx(data: string, receiverAddress: web3.PublicKey, opts?: SendOpts): Promise { 215 | const toMailboxAddress = await this.getMailboxAddress(receiverAddress); 216 | let messageIndex = 0; 217 | 218 | const toMailbox = await this.fetchMailbox(toMailboxAddress); 219 | if (toMailbox) { 220 | messageIndex = toMailbox.messageCount; 221 | } 222 | 223 | const messageAddress = await this.getMessageAddress(messageIndex, receiverAddress); 224 | 225 | const message = this.obfuscate ? this.obfuscateMessage(data, receiverAddress) : data; 226 | 227 | const accounts = { 228 | mailbox: toMailboxAddress, 229 | receiver: receiverAddress, 230 | message: messageAddress, 231 | payer: this.payer ?? this.mailboxOwner, 232 | sender: this.mailboxOwner, 233 | feeReceiver: this.addresses.treasuryAddress, 234 | systemProgram: web3.SystemProgram.programId, 235 | }; 236 | 237 | let tx: web3.Transaction; 238 | if (opts?.incentive) { 239 | const ata = await splToken.getAssociatedTokenAddress(opts.incentive.mint, messageAddress, true); 240 | const incentiveAccounts = { 241 | ...accounts, 242 | incentiveMint: opts.incentive.mint, 243 | payerTokenAccount: opts.incentive.payerAccount, 244 | incentiveTokenAccount: ata, 245 | tokenProgram: splToken.TOKEN_PROGRAM_ID, 246 | associatedTokenProgram: splToken.ASSOCIATED_TOKEN_PROGRAM_ID, 247 | rent: web3.SYSVAR_RENT_PUBKEY, 248 | }; 249 | tx = this.messagingProgram.transaction.sendMessageWithIncentive(message, new anchor.BN(opts.incentive.amount), { 250 | accounts: incentiveAccounts, 251 | }); 252 | } else { 253 | tx = this.messagingProgram.transaction.sendMessage(message, { accounts }); 254 | } 255 | 256 | return this.setTransactionPayer(tx); 257 | } 258 | 259 | /** @deprecated use makeDeleteTx instead */ 260 | async makePopTx(): Promise { 261 | const mailboxAddress = await this.getMailboxAddress(); 262 | const mailbox = await this.fetchMailbox(); 263 | if (!mailbox) { 264 | throw new Error(`Mailbox ${mailboxAddress.toBase58()} not found`); 265 | } 266 | return this.makeDeleteTx(mailbox.readMessageCount, this.mailboxOwner); 267 | } 268 | 269 | /// Returns null if message account doesn't exist, the transaction otherwise 270 | async makeDeleteTx(messageId: number, receiverAddress?: web3.PublicKey): Promise { 271 | const messageAddress = await this.getMessageAddress(messageId, receiverAddress ?? this.mailboxOwner); 272 | const messageAccount = await this.messagingProgram.account.message.fetch(messageAddress); 273 | const tx = await this.messagingProgram.methods 274 | .deleteMessage(messageId) 275 | .accounts({ 276 | receiver: receiverAddress ?? this.mailboxOwner, 277 | authorizedDeleter: this.mailboxOwner, 278 | rentDestination: messageAccount.payer, 279 | }) 280 | .transaction(); 281 | return this.setTransactionPayer(tx); 282 | } 283 | 284 | /// Returns null if message account doesn't exist, the transaction otherwise 285 | async makeClaimIncentiveTx(messageId: number, receiverAddress?: web3.PublicKey): Promise { 286 | const receiver = receiverAddress ?? this.mailboxOwner; 287 | const messageAddress = await this.getMessageAddress(messageId, receiver); 288 | const messageAccount = await this.messagingProgram.account.message.fetch(messageAddress); 289 | const mint = messageAccount.incentiveMint; 290 | const ata = await splToken.getAssociatedTokenAddress(mint, messageAddress, true); 291 | const receiverAta = await splToken.getAssociatedTokenAddress(mint, receiver, true); 292 | const tx = await this.messagingProgram.methods 293 | .claimIncentive(messageId) 294 | .accounts({ 295 | receiver, 296 | rentDestination: messageAccount.payer, 297 | incentiveMint: mint, 298 | incentiveTokenAccount: ata, 299 | receiverTokenAccount: receiverAta, 300 | }) 301 | .transaction(); 302 | return this.setTransactionPayer(tx); 303 | } 304 | 305 | /* 306 | Subscriptions 307 | */ 308 | 309 | // Reminder this is every single message on the protocol, which we filter here 310 | addMessageListener(callback: (message: MessageAccount) => void): number { 311 | return this.messagingProgram.addEventListener(eventName, (event: any, _slot: number) => { 312 | if (event.receiverPubkey.equals(this.mailboxOwner)) { 313 | callback({ 314 | sender: event.senderPubkey, 315 | receiver: event.receiverPubkey, 316 | data: this.unpackMessageData(event.message, event.senderPubkey, event.receiverPubkey), 317 | messageId: event.messageIndex, 318 | }); 319 | } 320 | }); 321 | } 322 | 323 | addSentMessageListener(callback: (message: SentMessageAccount) => void): number { 324 | return this.messagingProgram.addEventListener(eventName, (event: any, _slot: number) => { 325 | if (event.senderPubkey.equals(this.mailboxOwner)) { 326 | callback({ 327 | receiver: event.receiverPubkey, 328 | messageId: event.messageIndex, 329 | }); 330 | } 331 | }); 332 | } 333 | 334 | removeMessageListener(subscriptionId: number) { 335 | this.messagingProgram.removeEventListener(subscriptionId); 336 | } 337 | 338 | /* 339 | Utility functions 340 | */ 341 | 342 | async getMailboxAddress(mailboxOwner?: web3.PublicKey) { 343 | const ownerAddress = mailboxOwner ?? this.mailboxOwner; 344 | const [mailboxAddress] = await web3.PublicKey.findProgramAddress( 345 | [seeds.protocolSeed, seeds.mailboxSeed, ownerAddress.toBuffer()], 346 | this.messagingProgram.programId, 347 | ); 348 | 349 | return mailboxAddress; 350 | } 351 | 352 | async getMessageAddress(index: number, receiverAddress?: web3.PublicKey) { 353 | const receiver = receiverAddress ?? this.mailboxOwner; 354 | const mailboxAddress = await this.getMailboxAddress(receiver); 355 | const msgCountBuf = Buffer.allocUnsafe(4); 356 | msgCountBuf.writeInt32LE(index); 357 | const [messageAddress] = await web3.PublicKey.findProgramAddress( 358 | [seeds.protocolSeed, seeds.messageSeed, mailboxAddress.toBuffer(), msgCountBuf], 359 | this.messagingProgram.programId, 360 | ); 361 | 362 | return messageAddress; 363 | } 364 | 365 | private async fetchMailbox(mailboxAddress?: web3.PublicKey) { 366 | const address = mailboxAddress ?? (await this.getMailboxAddress()); 367 | const mailboxAccount = await this.messagingProgram.account.mailbox.fetchNullable(address); 368 | return mailboxAccount; 369 | } 370 | 371 | private validateWallet() { 372 | if (!this.wallet.publicKey!.equals(this.mailboxOwner)) { 373 | throw new Error('`mailboxOwner` must equal `wallet.publicKey` to send transaction'); 374 | } 375 | if (this.payer && !this.payer.equals(this.mailboxOwner)) { 376 | throw new Error('`mailboxOwner` must equal `payer` to send transaction'); 377 | } 378 | } 379 | 380 | private setTransactionPayer(tx: web3.Transaction): web3.Transaction { 381 | if (this.payer) { 382 | tx.feePayer = this.wallet.publicKey!; 383 | } 384 | return tx; 385 | } 386 | 387 | getMessageString(subj: string, body: string, meta?: object): string { 388 | const ts = new Date().getTime() / 1000; 389 | const enhancedMessage = { subj, body, ts, meta }; 390 | return JSON.stringify(enhancedMessage); 391 | } 392 | 393 | // Obfuscation 394 | private _obfuscationPrefix = '__o__'; 395 | 396 | private getObfuscationKey(publicKey: web3.PublicKey) { 397 | return `PK_${publicKey.toBase58()}`; 398 | } 399 | 400 | private obfuscateMessage(message: string, receiverAddress: web3.PublicKey) { 401 | const key = this.getObfuscationKey(receiverAddress); 402 | const obfuscated = CryptoJS.AES.encrypt(message, key).toString(); 403 | return `${this._obfuscationPrefix}${obfuscated}`; 404 | } 405 | 406 | private unObfuscateMessage(message: string, sender: web3.PublicKey, receiver: web3.PublicKey) { 407 | // Bugfix: obfuscate-fix 408 | // Check if this message starts with a prefix and that the current wallet was a party to 409 | // the message. 410 | // The right obfuscation key is that of the MailBoxOwner not the current wallet 411 | // in the two cases a mailbox is initialized, 412 | // When a mailbox is initialized by a user to get their sent messages 413 | // When a mailbox is initialized by a user to get their received messages 414 | 415 | if ( 416 | message.startsWith(this._obfuscationPrefix) && 417 | (this.wallet.publicKey?.equals(sender) || this.wallet.publicKey?.equals(receiver)) 418 | ) { 419 | const innerMessage = message.substring(this._obfuscationPrefix.length); 420 | const obfuscationKey = this.mailboxOwner; 421 | const key = this.getObfuscationKey(obfuscationKey); 422 | return CryptoJS.AES.decrypt(innerMessage, key).toString(CryptoJS.enc.Utf8); 423 | } 424 | return message; 425 | } 426 | 427 | private unpackMessageData(message: string, sender: web3.PublicKey, receiver: web3.PublicKey): MessageData { 428 | const data = this.unObfuscateMessage(message, sender, receiver); 429 | try { 430 | if (data.startsWith('{')) { 431 | const parsedData = JSON.parse(data) as ParsedMessageData; 432 | if (parsedData.ns === 'solanart') { 433 | return convertSolanartToDispatchMessage(parsedData); 434 | } 435 | return { 436 | subj: parsedData.subj, 437 | body: parsedData.body ?? '', 438 | ts: parsedData.ts ? new Date(1000 * +parsedData.ts) : undefined, 439 | meta: parsedData.meta, 440 | }; 441 | } 442 | } catch (e) { 443 | // do nothing and just return the default 444 | } 445 | return { body: data }; 446 | } 447 | 448 | /** @deprecated Upgrade to fetchMessages / normalizeMessageAccount */ 449 | private normalizeMessageAccountDeprecated(messageAccount: any, messageId: number): DeprecatedMessageAccount | null { 450 | if (messageAccount === null) return null; 451 | return { 452 | sender: messageAccount.sender, 453 | receiver: this.mailboxOwner, 454 | payer: messageAccount.payer, 455 | data: this.unObfuscateMessage(messageAccount.data, messageAccount.sender, this.mailboxOwner), 456 | messageId, 457 | } as DeprecatedMessageAccount; 458 | } 459 | 460 | private normalizeMessageAccount(messageAccount: any, messageId: number): MessageAccount | null { 461 | if (messageAccount === null) return null; 462 | return { 463 | sender: messageAccount.sender, 464 | receiver: this.mailboxOwner, 465 | payer: messageAccount.payer, 466 | data: this.unpackMessageData(messageAccount.data, messageAccount.sender, this.mailboxOwner), 467 | messageId, 468 | incentiveMint: web3.PublicKey.default.equals(messageAccount.incentiveMint) 469 | ? undefined 470 | : messageAccount.incentiveMint, 471 | } as MessageAccount; 472 | } 473 | } 474 | -------------------------------------------------------------------------------- /tests/messaging.ts: -------------------------------------------------------------------------------- 1 | import * as splToken from '@solana/spl-token'; 2 | import * as anchor from '@project-serum/anchor'; 3 | import { strict as assert } from 'assert'; 4 | import { Program } from '@project-serum/anchor'; 5 | import { Messaging } from '../target/types/messaging'; 6 | 7 | import { Mailbox, clusterAddresses, seeds } from '../usedispatch_client/src'; 8 | 9 | describe('messaging', () => { 10 | // Configure the client to use the local cluster. 11 | anchor.setProvider(anchor.AnchorProvider.env()); 12 | 13 | const program = anchor.workspace.Messaging as Program; 14 | const conn = anchor.getProvider().connection; 15 | const TREASURY = clusterAddresses.get('devnet').treasuryAddress; 16 | 17 | it('Basic test', async () => { 18 | const receiver = anchor.web3.Keypair.generate(); 19 | const sender = anchor.web3.Keypair.generate(); 20 | 21 | const payer = anchor.web3.Keypair.generate(); 22 | await conn.confirmTransaction(await conn.requestAirdrop(payer.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 23 | await conn.confirmTransaction(await conn.requestAirdrop(TREASURY, 1 * anchor.web3.LAMPORTS_PER_SOL)); 24 | 25 | // send a couple of messages 26 | const [mailbox] = await anchor.web3.PublicKey.findProgramAddress( 27 | [seeds.protocolSeed, seeds.mailboxSeed, receiver.publicKey.toBuffer()], 28 | program.programId, 29 | ); 30 | 31 | // Send first message 32 | const msgCountBuf0 = Buffer.allocUnsafe(4); 33 | msgCountBuf0.writeInt32LE(0); 34 | const [message0] = await anchor.web3.PublicKey.findProgramAddress( 35 | [seeds.protocolSeed, seeds.messageSeed, mailbox.toBuffer(), msgCountBuf0], 36 | program.programId, 37 | ); 38 | 39 | const tx0 = await program.rpc.sendMessage('text0', { 40 | accounts: { 41 | mailbox, 42 | receiver: receiver.publicKey, 43 | message: message0, 44 | payer: payer.publicKey, 45 | sender: sender.publicKey, 46 | feeReceiver: TREASURY, 47 | systemProgram: anchor.web3.SystemProgram.programId, 48 | }, 49 | signers: [payer, sender], 50 | }); 51 | await conn.confirmTransaction(tx0); 52 | 53 | // Send second message 54 | const msgCountBuf1 = Buffer.allocUnsafe(4); 55 | msgCountBuf1.writeInt32LE(1); 56 | const [message1] = await anchor.web3.PublicKey.findProgramAddress( 57 | [seeds.protocolSeed, seeds.messageSeed, mailbox.toBuffer(), msgCountBuf1], 58 | program.programId, 59 | ); 60 | 61 | const tx1 = await program.rpc.sendMessage('text1', { 62 | accounts: { 63 | mailbox, 64 | receiver: receiver.publicKey, 65 | message: message1, 66 | payer: payer.publicKey, 67 | sender: sender.publicKey, 68 | feeReceiver: TREASURY, 69 | systemProgram: anchor.web3.SystemProgram.programId, 70 | }, 71 | signers: [payer, sender], 72 | }); 73 | await conn.confirmTransaction(tx1); 74 | 75 | // assert mailbox and messages look good 76 | let mailboxAccount = await program.account.mailbox.fetch(mailbox); 77 | assert.ok(mailboxAccount.messageCount === 2); 78 | assert.ok(mailboxAccount.readMessageCount === 0); 79 | 80 | const messageAccount0 = await program.account.message.fetch(message0); 81 | 82 | assert.ok(messageAccount0.sender.equals(sender.publicKey)); 83 | assert.ok(messageAccount0.data === 'text0'); 84 | 85 | const messageAccount1 = await program.account.message.fetch(message1); 86 | assert.ok(messageAccount1.sender.equals(sender.publicKey)); 87 | assert.ok(messageAccount1.data === 'text1'); 88 | 89 | // delete messages 90 | const tx2 = await program.rpc.deleteMessage(0, { 91 | accounts: { 92 | mailbox, 93 | receiver: receiver.publicKey, 94 | authorizedDeleter: receiver.publicKey, 95 | message: message0, 96 | rentDestination: payer.publicKey, 97 | systemProgram: anchor.web3.SystemProgram.programId, 98 | }, 99 | signers: [receiver], 100 | }); 101 | await conn.confirmTransaction(tx2); 102 | 103 | const tx3 = await program.rpc.deleteMessage(1, { 104 | accounts: { 105 | mailbox, 106 | receiver: receiver.publicKey, 107 | authorizedDeleter: receiver.publicKey, 108 | message: message1, 109 | rentDestination: payer.publicKey, 110 | systemProgram: anchor.web3.SystemProgram.programId, 111 | }, 112 | signers: [receiver], 113 | }); 114 | await conn.confirmTransaction(tx3); 115 | 116 | // assert mailbox looks good and rent was returned 117 | mailboxAccount = await program.account.mailbox.fetch(mailbox); 118 | assert.ok(mailboxAccount.messageCount === 2); 119 | assert.ok(mailboxAccount.readMessageCount === 2); 120 | 121 | const payerBalance = await conn.getBalance(payer.publicKey); 122 | assert.ok(payerBalance >= 199899760); 123 | }); 124 | 125 | it('Client library porcelain commands test', async () => { 126 | // Set up accounts 127 | const receiver = new anchor.Wallet(anchor.web3.Keypair.generate()); 128 | const sender = new anchor.Wallet(anchor.web3.Keypair.generate()); 129 | await conn.confirmTransaction(await conn.requestAirdrop(sender.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 130 | await conn.confirmTransaction(await conn.requestAirdrop(receiver.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 131 | 132 | // Mailbox usage 133 | const senderMailbox = new Mailbox(conn, sender); 134 | const receiverMailbox = new Mailbox(conn, receiver); 135 | 136 | assert.ok((await receiverMailbox.fetchMessages()).length === 0); 137 | assert.ok((await receiverMailbox.count()) === 0); 138 | 139 | const emptyCountEx = await receiverMailbox.countEx(); 140 | assert.ok(emptyCountEx.messageCount === 0); 141 | assert.ok(emptyCountEx.readMessageCount === 0); 142 | 143 | const treasuryBalance = await conn.getBalance(TREASURY); 144 | 145 | await senderMailbox.send('text0', receiver.publicKey); 146 | await senderMailbox.send('text1', receiver.publicKey); 147 | 148 | const endTreasuryBalance = await conn.getBalance(TREASURY); 149 | assert.equal(endTreasuryBalance, treasuryBalance + 2 * 50_000); 150 | 151 | assert.ok((await receiverMailbox.count()) === 2); 152 | 153 | const fullCountEx1 = await receiverMailbox.countEx(); 154 | assert.ok(fullCountEx1.messageCount === 2); 155 | assert.ok(fullCountEx1.readMessageCount === 0); 156 | 157 | const firstMessage = await receiverMailbox.fetchMessageById(1); 158 | assert.ok(firstMessage.messageId === 1); 159 | assert.ok(firstMessage.data.body === 'text1'); 160 | assert.ok(firstMessage.incentiveMint === undefined); 161 | 162 | let messages = await receiverMailbox.fetchMessages(); 163 | assert.ok(messages.length === 2); 164 | 165 | assert.ok(messages[0].sender.equals(sender.publicKey)); 166 | assert.ok(messages[0].data.body === 'text0'); 167 | 168 | assert.ok(messages[1].sender.equals(sender.publicKey)); 169 | assert.ok(messages[1].data.body === 'text1'); 170 | 171 | await receiverMailbox.pop(); 172 | assert.ok((await receiverMailbox.count()) === 1); 173 | 174 | messages = await receiverMailbox.fetchMessages(); 175 | assert.ok(messages.length === 1); 176 | 177 | assert.ok(messages[0].sender.equals(sender.publicKey)); 178 | assert.ok(messages[0].data.body === 'text1'); 179 | 180 | await receiverMailbox.pop(); 181 | assert.ok((await receiverMailbox.count()) === 0); 182 | 183 | const fullCountEx2 = await receiverMailbox.countEx(); 184 | assert.ok(fullCountEx2.messageCount === 2); 185 | assert.ok(fullCountEx2.readMessageCount === 2); 186 | 187 | messages = await receiverMailbox.fetchMessages(); 188 | assert.ok(messages.length === 0); 189 | }); 190 | 191 | it('Client library tx commands test', async () => { 192 | // Set up accounts 193 | const receiver = anchor.web3.Keypair.generate(); 194 | const payer = anchor.web3.Keypair.generate(); 195 | await conn.confirmTransaction(await conn.requestAirdrop(payer.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 196 | 197 | // Mailbox usage 198 | const senderMailbox = new Mailbox(conn, new anchor.Wallet(payer)); 199 | const receiverMailbox = new Mailbox(conn, new anchor.Wallet(receiver), { payer: payer.publicKey }); 200 | 201 | // Send a message 202 | const sendTx = await senderMailbox.makeSendTx('test1', receiver.publicKey); 203 | 204 | sendTx.feePayer = payer.publicKey; 205 | const sendSig = await conn.sendTransaction(sendTx, [payer]); 206 | await conn.confirmTransaction(sendSig, 'recent'); 207 | 208 | // Fetch messages 209 | let messages = await receiverMailbox.fetchMessages(); 210 | assert.ok(messages.length === 1); 211 | 212 | assert.ok(messages[0].sender.equals(payer.publicKey)); 213 | assert.ok(messages[0].data.body === 'test1'); 214 | 215 | // Free message account and send rent to receiver 216 | const popTx = await receiverMailbox.makePopTx(); 217 | 218 | popTx.feePayer = payer.publicKey; 219 | const popSig = await conn.sendTransaction(popTx, [payer, receiver]); 220 | await conn.confirmTransaction(popSig, 'recent'); 221 | 222 | // Fetch messages 223 | messages = await receiverMailbox.fetchMessages(); 224 | assert.ok(messages.length === 0); 225 | }); 226 | 227 | it('Returns rent to original payer', async () => { 228 | const receiver = anchor.web3.Keypair.generate(); 229 | 230 | const payer = anchor.web3.Keypair.generate(); 231 | await conn.confirmTransaction(await conn.requestAirdrop(payer.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 232 | 233 | // Get mailbox address 234 | const [mailbox] = await anchor.web3.PublicKey.findProgramAddress( 235 | [seeds.protocolSeed, seeds.mailboxSeed, receiver.publicKey.toBuffer()], 236 | program.programId, 237 | ); 238 | 239 | // Send first message 240 | const msgCountBuf0 = Buffer.allocUnsafe(4); 241 | msgCountBuf0.writeInt32LE(0); 242 | const [message0] = await anchor.web3.PublicKey.findProgramAddress( 243 | [seeds.protocolSeed, seeds.messageSeed, mailbox.toBuffer(), msgCountBuf0], 244 | program.programId, 245 | ); 246 | 247 | const tx0 = await program.rpc.sendMessage('text0', { 248 | accounts: { 249 | mailbox, 250 | receiver: receiver.publicKey, 251 | message: message0, 252 | payer: payer.publicKey, 253 | sender: payer.publicKey, 254 | feeReceiver: TREASURY, 255 | systemProgram: anchor.web3.SystemProgram.programId, 256 | }, 257 | signers: [payer], 258 | }); 259 | await conn.confirmTransaction(tx0); 260 | 261 | // close messages 262 | const oldConsoleLog = console.log; 263 | const oldConsoleError = console.error; 264 | console.log = () => {}; 265 | console.error = () => {}; 266 | try { 267 | const tx1 = await program.rpc.deleteMessage(0, { 268 | accounts: { 269 | mailbox, 270 | receiver: receiver.publicKey, 271 | authorizedDeleter: receiver.publicKey, 272 | message: message0, 273 | rentDestination: receiver.publicKey, // Intentionally wrong 274 | systemProgram: anchor.web3.SystemProgram.programId, 275 | }, 276 | signers: [receiver], 277 | }); 278 | } catch (e) { 279 | assert.ok( 280 | String(e).startsWith('AnchorError caused by account: rent_destination. Error Code: ConstraintAddress.'), 281 | ); 282 | } 283 | console.log = oldConsoleLog; 284 | console.error = oldConsoleError; 285 | }); 286 | 287 | it('Emits an event when sending', async () => { 288 | const receiver = anchor.web3.Keypair.generate(); 289 | 290 | const payer = anchor.web3.Keypair.generate(); 291 | await conn.confirmTransaction(await conn.requestAirdrop(payer.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 292 | 293 | let eventEmitted = false; 294 | const eventListener = program.addEventListener('DispatchMessage', async (event, slot) => { 295 | await program.removeEventListener(eventListener); 296 | assert.ok(receiver.publicKey.equals(event.receiverPubkey)); 297 | assert.ok(payer.publicKey.equals(event.senderPubkey)); 298 | assert.ok(event.messageIndex === 0); 299 | assert.ok(event.message === 'text0'); 300 | eventEmitted = true; 301 | }); 302 | 303 | // Get mailbox address 304 | const [mailbox] = await anchor.web3.PublicKey.findProgramAddress( 305 | [seeds.protocolSeed, seeds.mailboxSeed, receiver.publicKey.toBuffer()], 306 | program.programId, 307 | ); 308 | 309 | // Send first message 310 | const msgCountBuf0 = Buffer.allocUnsafe(4); 311 | msgCountBuf0.writeInt32LE(0); 312 | const [message0] = await anchor.web3.PublicKey.findProgramAddress( 313 | [seeds.protocolSeed, seeds.messageSeed, mailbox.toBuffer(), msgCountBuf0], 314 | program.programId, 315 | ); 316 | 317 | const tx0 = await program.rpc.sendMessage('text0', { 318 | accounts: { 319 | mailbox, 320 | receiver: receiver.publicKey, 321 | message: message0, 322 | payer: payer.publicKey, 323 | sender: payer.publicKey, 324 | feeReceiver: TREASURY, 325 | systemProgram: anchor.web3.SystemProgram.programId, 326 | }, 327 | signers: [payer], 328 | }); 329 | await conn.confirmTransaction(tx0); 330 | 331 | assert.ok(eventEmitted); 332 | }); 333 | 334 | it('Emits events from the client SDK', async () => { 335 | const receiverWallet = new anchor.Wallet(anchor.web3.Keypair.generate()); 336 | const senderWallet = new anchor.Wallet(anchor.web3.Keypair.generate()); 337 | await conn.confirmTransaction(await conn.requestAirdrop(senderWallet.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 338 | 339 | const senderMailbox = new Mailbox(conn, senderWallet); 340 | const receiverMailbox = new Mailbox(conn, receiverWallet); 341 | 342 | const payload = 'Test Message'; 343 | 344 | let eventEmitted = false; 345 | const subscriptionId = receiverMailbox.addMessageListener((message) => { 346 | receiverMailbox.removeMessageListener(subscriptionId); 347 | assert.ok(senderWallet.publicKey.equals(message.sender)); 348 | assert.ok(payload === message.data.body); 349 | eventEmitted = true; 350 | }); 351 | 352 | const tx = await senderMailbox.send(payload, receiverWallet.publicKey); 353 | await conn.confirmTransaction(tx); 354 | 355 | assert.ok(eventEmitted); 356 | }); 357 | 358 | it('Emits send events from the client SDK', async () => { 359 | const receiverWallet = new anchor.Wallet(anchor.web3.Keypair.generate()); 360 | const senderWallet = new anchor.Wallet(anchor.web3.Keypair.generate()); 361 | await conn.confirmTransaction(await conn.requestAirdrop(senderWallet.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 362 | 363 | const senderMailbox = new Mailbox(conn, senderWallet); 364 | 365 | const payload = 'Test Message'; 366 | 367 | let eventEmitted = false; 368 | const subscriptionId = senderMailbox.addSentMessageListener((message) => { 369 | senderMailbox.removeMessageListener(subscriptionId); 370 | assert.ok(receiverWallet.publicKey.equals(message.receiver)); 371 | assert.ok(0 === message.messageId); 372 | eventEmitted = true; 373 | }); 374 | 375 | const tx = await senderMailbox.send(payload, receiverWallet.publicKey); 376 | await conn.confirmTransaction(tx); 377 | 378 | assert.ok(eventEmitted); 379 | }); 380 | 381 | it('Obfuscates in the client library', async () => { 382 | // Set up accounts 383 | const receiver = new anchor.Wallet(anchor.web3.Keypair.generate()); 384 | const sender = new anchor.Wallet(anchor.web3.Keypair.generate()); 385 | await conn.confirmTransaction(await conn.requestAirdrop(sender.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 386 | 387 | const senderMailbox = new Mailbox(conn, sender, { sendObfuscated: true }); 388 | const testMessage = 'text0'; 389 | await senderMailbox.send(testMessage, receiver.publicKey); 390 | 391 | const receiverMailbox = new Mailbox(conn, receiver); 392 | 393 | const messageAddress = await receiverMailbox.getMessageAddress(0); 394 | const messageAccount = await receiverMailbox.messagingProgram.account.message.fetch(messageAddress); 395 | assert.ok(messageAccount.data !== testMessage); 396 | 397 | const resultingMessage = await receiverMailbox.fetchMessageById(0); 398 | assert.ok(resultingMessage.data.body === testMessage); 399 | }); 400 | 401 | it('Handles deletes', async () => { 402 | // Set up accounts 403 | const receiver = new anchor.Wallet(anchor.web3.Keypair.generate()); 404 | const sender = new anchor.Wallet(anchor.web3.Keypair.generate()); 405 | await conn.confirmTransaction(await conn.requestAirdrop(receiver.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 406 | await conn.confirmTransaction(await conn.requestAirdrop(sender.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 407 | 408 | const receiverMailbox = new Mailbox(conn, receiver); 409 | const senderMailbox = new Mailbox(conn, sender); 410 | 411 | const testMessages = ['text0', 'text1', 'text2', 'text3', 'text4', 'text5']; 412 | for (const testMessage of testMessages) { 413 | await senderMailbox.send(testMessage, receiver.publicKey); 414 | } 415 | 416 | await conn.confirmTransaction(await receiverMailbox.delete(2)); 417 | await conn.confirmTransaction(await senderMailbox.delete(0, receiver.publicKey)); 418 | await conn.confirmTransaction(await receiverMailbox.delete(1)); 419 | await conn.confirmTransaction(await senderMailbox.delete(4, receiver.publicKey)); 420 | 421 | const messages = await receiverMailbox.fetchMessages(); 422 | const messageTexts = messages.map((m) => m.data.body); 423 | assert.deepEqual(messageTexts, ['text3', 'text5']); 424 | 425 | const { messageCount, readMessageCount } = await receiverMailbox.countEx(); 426 | assert.equal(messageCount, 6); 427 | assert.equal(readMessageCount, 2); 428 | }); 429 | 430 | it('Sends a message with incentive and accepts it', async () => { 431 | const receiver = new anchor.Wallet(anchor.web3.Keypair.generate()); 432 | const sender = new anchor.Wallet(anchor.web3.Keypair.generate()); 433 | await conn.confirmTransaction(await conn.requestAirdrop(receiver.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 434 | await conn.confirmTransaction(await conn.requestAirdrop(sender.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 435 | 436 | const mint = await splToken.createMint(conn, sender.payer, sender.publicKey, null, 10); 437 | const ata = await splToken.createAssociatedTokenAccount(conn, sender.payer, mint, sender.publicKey); 438 | const tx1 = await splToken.mintTo(conn, sender.payer, mint, ata, sender.payer, 1_000_000_000); 439 | await conn.confirmTransaction(tx1); 440 | 441 | const receiverMailbox = new Mailbox(conn, receiver); 442 | const senderMailbox = new Mailbox(conn, sender); 443 | 444 | const incentiveAmount = 500_000; 445 | const sendOpts = { 446 | incentive: { 447 | mint, 448 | amount: incentiveAmount, 449 | payerAccount: ata, 450 | }, 451 | }; 452 | await senderMailbox.send('message with incentive', receiver.publicKey, sendOpts); 453 | const messageAccount = await receiverMailbox.fetchMessageById(0); 454 | assert.ok(messageAccount.incentiveMint.equals(mint)); 455 | assert.equal((await receiverMailbox.fetchIncentiveTokenAccount(messageAccount)).amount, BigInt(incentiveAmount)); 456 | 457 | let eventEmitted = false; 458 | const subscriptionId = program.addEventListener('IncentiveClaimed', (event: any, _slot: number) => { 459 | program.removeEventListener(subscriptionId); 460 | eventEmitted = true; 461 | assert.ok(event.senderPubkey.equals(sender.publicKey)); 462 | assert.ok(event.receiverPubkey.equals(receiver.publicKey)); 463 | assert.ok(event.mint.equals(mint)); 464 | assert.equal(event.messageIndex, 0); 465 | assert.equal(event.amount.toNumber(), incentiveAmount); 466 | }); 467 | 468 | await conn.confirmTransaction(await receiverMailbox.claimIncentive(messageAccount)); 469 | 470 | assert.ok(eventEmitted); 471 | 472 | const receiverAtaAddr = await splToken.getAssociatedTokenAddress(mint, receiver.publicKey); 473 | const receiverAta = await splToken.getAccount(conn, receiverAtaAddr); 474 | assert.equal(receiverAta.amount, BigInt(incentiveAmount)); 475 | 476 | const messageAccountAfter = await receiverMailbox.fetchMessageById(0); 477 | assert.equal(messageAccountAfter.incentiveMint, undefined); 478 | }); 479 | 480 | it('Sends messages and reads them and deletes them', async () => { 481 | const receiver = new anchor.Wallet(anchor.web3.Keypair.generate()); 482 | const sender = new anchor.Wallet(anchor.web3.Keypair.generate()); 483 | await conn.confirmTransaction(await conn.requestAirdrop(sender.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 484 | 485 | const senderMailbox = new Mailbox(conn, sender); 486 | 487 | const messagesToSend = ['msg0', 'msg1', 'msg2', 'msg3']; 488 | for (const msg of messagesToSend) { 489 | await senderMailbox.send(msg, receiver.publicKey); 490 | } 491 | 492 | const sentMessages = await senderMailbox.fetchSentMessagesTo(receiver.publicKey); 493 | assert.equal(sentMessages.length, messagesToSend.length); 494 | 495 | await conn.confirmTransaction(await senderMailbox.deleteMessage(sentMessages[2])); 496 | const sentMessages2 = await senderMailbox.fetchSentMessagesTo(receiver.publicKey); 497 | assert.equal(sentMessages2.length, messagesToSend.length - 1); 498 | const sentMessages2Text = sentMessages2.map((m) => m.data.body); 499 | assert.deepEqual(sentMessages2Text, ['msg0', 'msg1', 'msg3']); 500 | }); 501 | 502 | it('Sends an enhanced message and fetches it', async () => { 503 | const receiver = new anchor.Wallet(anchor.web3.Keypair.generate()); 504 | const sender = new anchor.Wallet(anchor.web3.Keypair.generate()); 505 | await conn.confirmTransaction(await conn.requestAirdrop(sender.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 506 | 507 | const senderMailbox = new Mailbox(conn, sender); 508 | 509 | const testSubj = 'test'; 510 | const testBody = 'msg'; 511 | const testMeta = { demo: 'hi' }; 512 | const testNow = new Date().getTime(); 513 | await senderMailbox.sendMessage(testSubj, testBody, receiver.publicKey, {}, testMeta); 514 | 515 | const sentMessage = (await senderMailbox.fetchSentMessagesTo(receiver.publicKey))[0]; 516 | const innerData = sentMessage.data; 517 | assert.equal(innerData.subj, testSubj); 518 | assert.equal(innerData.body, testBody); 519 | assert.ok(innerData.ts.getTime() >= testNow && innerData.ts.getTime() < testNow + 10); 520 | assert.deepEqual(innerData.meta, testMeta); 521 | }); 522 | 523 | it('Sends a message with sol incentive and accepts it', async () => { 524 | const receiver = new anchor.Wallet(anchor.web3.Keypair.generate()); 525 | const sender = new anchor.Wallet(anchor.web3.Keypair.generate()); 526 | await conn.confirmTransaction(await conn.requestAirdrop(receiver.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 527 | await conn.confirmTransaction(await conn.requestAirdrop(sender.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 528 | 529 | const receiverMailbox = new Mailbox(conn, receiver); 530 | const senderMailbox = new Mailbox(conn, sender); 531 | 532 | const mint = splToken.NATIVE_MINT; 533 | const ata = await splToken.getAssociatedTokenAddress(mint, sender.publicKey); 534 | 535 | const incentiveAmount = 500_000; 536 | const sendOpts = { 537 | incentive: { 538 | mint, 539 | amount: incentiveAmount, 540 | payerAccount: ata, 541 | }, 542 | }; 543 | 544 | const sendTx = new anchor.web3.Transaction(); 545 | if (!(await conn.getAccountInfo(ata))) { 546 | sendTx.add(splToken.createAssociatedTokenAccountInstruction(sender.publicKey, ata, sender.publicKey, mint)); 547 | } 548 | sendTx.add( 549 | anchor.web3.SystemProgram.transfer({ fromPubkey: sender.publicKey, toPubkey: ata, lamports: incentiveAmount }), 550 | ); 551 | sendTx.add(splToken.createSyncNativeInstruction(ata)); 552 | sendTx.add(await senderMailbox.makeSendTx('message with incentive', receiver.publicKey, sendOpts)); 553 | 554 | const tx1 = await conn.sendTransaction(sendTx, [sender.payer]); 555 | await conn.confirmTransaction(tx1); 556 | 557 | const tokenAccount = await splToken.getAccount(conn, ata); 558 | assert.equal(tokenAccount.amount, BigInt(0)); 559 | 560 | await conn.confirmTransaction(await receiverMailbox.claimIncentive(await receiverMailbox.fetchMessageById(0))); 561 | const receiverAtaAddr = await splToken.getAssociatedTokenAddress(mint, receiver.publicKey); 562 | const receiverAta = await splToken.getAccount(conn, receiverAtaAddr); 563 | assert.equal(receiverAta.amount, BigInt(incentiveAmount)); 564 | }); 565 | 566 | it('Sends a deprecated message and fetches it', async () => { 567 | const receiver = new anchor.Wallet(anchor.web3.Keypair.generate()); 568 | const sender = new anchor.Wallet(anchor.web3.Keypair.generate()); 569 | await conn.confirmTransaction(await conn.requestAirdrop(sender.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 570 | 571 | const senderMailbox = new Mailbox(conn, sender); 572 | const receiverMailbox = new Mailbox(conn, receiver); 573 | 574 | const message = 'test'; 575 | await senderMailbox.send(message, receiver.publicKey); 576 | 577 | const sentMessage = (await receiverMailbox.fetch())[0]; 578 | assert.equal(sentMessage.data, message); 579 | }); 580 | }); 581 | -------------------------------------------------------------------------------- /programs/postbox/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anchor_lang::prelude::*; 2 | use anchor_spl::{token, associated_token}; 3 | use errors::PostboxErrorCode; 4 | use post_restrictions::AdditionalAccountIndices; 5 | use settings::{SettingsData, SettingsType}; 6 | use vote_entry::{VoteEntry}; 7 | 8 | mod errors; 9 | mod nft_metadata; 10 | mod post_restrictions; 11 | mod settings; 12 | mod treasury; 13 | mod vote_entry; 14 | 15 | #[cfg(feature = "mainnet")] 16 | declare_id!("DHepkufWDLJ9DCD37nbEDbPSFKjGiziQ6Lbgo1zgGX7S"); 17 | #[cfg(not(feature = "mainnet"))] 18 | declare_id!("Fs5wSa7GYtTqivXGqHyx673v5oPuD5Cb7ij9utsFKdLb"); 19 | 20 | const PROTOCOL_SEED: & str = "dispatch"; 21 | const POSTBOX_SEED: & str = "postbox"; 22 | const POST_SEED: & str = "post"; 23 | const MODERATOR_SEED: & str = "moderator"; 24 | const VOTE_TRACK_SEED: & str = "votes"; 25 | 26 | #[constant] 27 | const POSTBOX_GROW_CHILDREN_BY: u32 = 1_000; 28 | 29 | #[constant] 30 | const FEE_NEW_POSTBOX: u64 = 100_000; 31 | #[constant] 32 | const FEE_NEW_PERSONAL_BOX: u64 = 50_000; 33 | #[constant] 34 | const FEE_POST: u64 = 50_000; 35 | #[constant] 36 | const FEE_VOTE: u64 = 50_000; 37 | 38 | const MAX_VOTE: u16 = 60_000; 39 | 40 | // Features to support: 41 | // -------------------- 42 | // initialize postbox (done) 43 | // create post (done) 44 | // delete by poster (done) 45 | // delete by moderator (done) 46 | // issue moderator token (done) 47 | // vote (done) 48 | 49 | #[program] 50 | pub mod postbox { 51 | use super::*; 52 | 53 | pub fn initialize(ctx: Context, target: String, owners: Vec, desc: Option) -> Result<()> { 54 | let mut fee = FEE_NEW_POSTBOX; 55 | if 0 == target.len() { // Should be personal 56 | require!(ctx.accounts.target_account.key() == ctx.accounts.signer.key(), PostboxErrorCode::NotPersonalPostbox); 57 | fee = FEE_NEW_PERSONAL_BOX; 58 | } 59 | 60 | let postbox_account = &mut ctx.accounts.postbox; 61 | postbox_account.max_child_id = 0; 62 | postbox_account.moderator_mint = ctx.accounts.moderator_mint.key(); 63 | postbox_account.settings = vec!( 64 | SettingsData::OwnerInfo { owners }, 65 | ); 66 | 67 | match desc { 68 | None => {}, 69 | Some(SettingsData::Description { title: _, desc: _}) => postbox_account.settings.push(desc.unwrap()), 70 | _ => return Err(Error::from(PostboxErrorCode::BadDescriptionSetting).with_source(source!())), 71 | } 72 | 73 | treasury::transfer_lamports(&ctx.accounts.signer, &ctx.accounts.treasury, fee)?; 74 | Ok(()) 75 | } 76 | 77 | pub fn create_post (ctx: Context, data: Vec, post_id: u32, 78 | settings: Vec, 79 | additional_account_offsets: Vec, 80 | ) -> Result<()> { 81 | let postbox_account = &mut ctx.accounts.postbox; 82 | require!(post_id <= postbox_account.max_child_id + POSTBOX_GROW_CHILDREN_BY, PostboxErrorCode::PostIdTooLarge); 83 | if post_id >= postbox_account.max_child_id { 84 | postbox_account.max_child_id += POSTBOX_GROW_CHILDREN_BY; 85 | } 86 | 87 | let post_account = &mut ctx.accounts.post; 88 | post_account.poster = ctx.accounts.poster.key(); 89 | post_account.data = data; 90 | for setting in settings { 91 | post_account.set_setting(&setting)?; 92 | } 93 | 94 | let reply_to_post: Option> = if ctx.accounts.reply_to.key() == Pubkey::default() { 95 | None 96 | } else { 97 | // Check that we are actually replying to a post 98 | Some(Account::::try_from(&ctx.accounts.reply_to)?) 99 | }; 100 | post_account.reply_to = reply_to_post.as_ref().map(|p| p.key()); 101 | 102 | let optional_override = ctx.accounts.postbox.validate_post_interaction_is_allowed( 103 | reply_to_post.as_ref(), 104 | &ctx.accounts.poster.key(), 105 | ctx.remaining_accounts, 106 | &additional_account_offsets, 107 | post_account.get_setting(SettingsType::PostRestriction).is_some(), 108 | )?; 109 | if let Some(restriction) = optional_override { 110 | post_account.set_setting(&restriction)?; 111 | } 112 | 113 | emit!(PostEvent { 114 | poster_pubkey: ctx.accounts.poster.key(), 115 | postbox_pubkey: ctx.accounts.postbox.key(), 116 | post_pubkey: post_account.key(), 117 | post_id: post_id, 118 | data: post_account.data.clone(), 119 | reply_to: post_account.reply_to, 120 | }); 121 | 122 | treasury::transfer_lamports(&ctx.accounts.poster, &ctx.accounts.treasury, FEE_POST)?; 123 | Ok(()) 124 | } 125 | 126 | pub fn delete_own_post(ctx: Context, post_id: u32) -> Result<()> { 127 | emit!(DeleteEvent { 128 | deleter_pubkey: ctx.accounts.poster.key(), 129 | postbox_pubkey: ctx.accounts.postbox.key(), 130 | post_pubkey: ctx.accounts.post.key(), 131 | post_id: post_id, 132 | }); 133 | Ok(()) 134 | } 135 | 136 | pub fn delete_post_by_moderator(ctx: Context, post_id: u32) -> Result<()> { 137 | emit!(DeleteEvent { 138 | deleter_pubkey: ctx.accounts.moderator.key(), 139 | postbox_pubkey: ctx.accounts.postbox.key(), 140 | post_pubkey: ctx.accounts.post.key(), 141 | post_id: post_id, 142 | }); 143 | Ok(()) 144 | } 145 | 146 | pub fn vote(ctx: Context, post_id: u32, up_vote: bool, 147 | additional_account_offsets: Vec, 148 | ) -> Result<()> { 149 | let post_account = &mut ctx.accounts.post; 150 | 151 | ctx.accounts.postbox.validate_post_interaction_is_allowed( 152 | Some(post_account), 153 | &ctx.accounts.voter.key(), 154 | ctx.remaining_accounts, 155 | &additional_account_offsets, 156 | false, 157 | )?; 158 | 159 | // Check if we already voted 160 | let mut relevant_vote_entry: Option<&mut VoteEntry> = None; 161 | for vote_entry in &mut ctx.accounts.vote_tracker.votes { 162 | if vote_entry.post_id == post_id { 163 | relevant_vote_entry = Some(vote_entry); 164 | } 165 | } 166 | 167 | if let Some(vote_entry) = relevant_vote_entry { 168 | require!(vote_entry.up_vote != up_vote, PostboxErrorCode::AlreadyVoted); 169 | // If we already voted, we can only change the vote, so back out the old vote 170 | let old_vote_count = if vote_entry.up_vote {&mut post_account.up_votes} else {&mut post_account.down_votes}; 171 | *old_vote_count -= if 0 == *old_vote_count {0} else {1}; 172 | vote_entry.up_vote = up_vote; 173 | } else { 174 | // Allow a new vote 175 | ctx.accounts.vote_tracker.votes.push(VoteEntry {post_id, up_vote}); 176 | resize_account( 177 | ctx.accounts.vote_tracker.to_account_info().as_ref(), 178 | &ctx.accounts.voter, 179 | 8 + 4 + 5 * ctx.accounts.vote_tracker.votes.len(), 180 | )?; 181 | } 182 | 183 | let vote_count = if up_vote {&mut post_account.up_votes} else {&mut post_account.down_votes}; 184 | *vote_count += if MAX_VOTE == *vote_count {0} else {1}; 185 | 186 | treasury::transfer_lamports(&ctx.accounts.voter, &ctx.accounts.treasury, FEE_VOTE)?; 187 | Ok(()) 188 | } 189 | 190 | pub fn designate_moderator(ctx: Context, target: String) -> Result<()> { 191 | let target_account_address = ctx.accounts.target_account.key(); 192 | let signer_seeds: &[&[&[u8]]] = &[&[ 193 | PROTOCOL_SEED.as_bytes(), 194 | POSTBOX_SEED.as_bytes(), 195 | target_account_address.as_ref(), 196 | target.as_bytes(), 197 | &[*ctx.bumps.get("postbox").unwrap()], 198 | ]]; 199 | 200 | let mint_ctx = CpiContext::new_with_signer(ctx.accounts.token_program.to_account_info(), token::MintTo { 201 | mint: ctx.accounts.moderator_mint.to_account_info(), 202 | authority: ctx.accounts.postbox.to_account_info(), 203 | to: ctx.accounts.moderator_ata.to_account_info(), 204 | }, signer_seeds); 205 | token::mint_to(mint_ctx, 1)?; 206 | 207 | Ok(()) 208 | } 209 | 210 | pub fn add_or_update_setting(ctx: Context, settings_data: SettingsData) -> Result<()> { 211 | let postbox = & mut ctx.accounts.postbox; 212 | postbox.settings.retain(|s| s.get_type() != settings_data.get_type()); 213 | postbox.settings.push(settings_data); 214 | resize_account(postbox.to_account_info().as_ref(), & ctx.accounts.owner, postbox.get_size())?; 215 | Ok(()) 216 | } 217 | 218 | pub fn edit_post(ctx: Context, post_id: u32, new_data: Vec) -> Result<()> { 219 | let post = & mut ctx.accounts.post; 220 | let old_data = post.data.clone(); 221 | post.data = new_data.clone(); 222 | let new_size = post.to_account_info().data_len() + new_data.len() - old_data.len(); 223 | resize_account(post.to_account_info().as_ref(), & ctx.accounts.poster, new_size)?; 224 | 225 | emit!(EditedEvent { 226 | postbox_pubkey: ctx.accounts.postbox.key(), 227 | post_pubkey: post.key(), 228 | post_id: post_id, 229 | old_data: old_data, 230 | new_data: new_data, 231 | }); 232 | Ok(()) 233 | } 234 | 235 | pub fn create_vote_tracker(_ctx: Context) -> Result<()> { 236 | Ok(()) 237 | } 238 | 239 | pub fn change_post_setting(ctx: Context, _post_id: u32, 240 | new_restriction: SettingsData 241 | ) -> Result<()> { 242 | let post = &mut ctx.accounts.post; 243 | post.set_setting(&new_restriction)?; 244 | Ok(()) 245 | } 246 | } 247 | 248 | #[derive(Accounts)] 249 | #[instruction(target: String, owners: Vec, desc: Option)] 250 | pub struct Initialize<'info> { 251 | #[account(init, 252 | payer = signer, 253 | // discriminator, max_child_id, moderator_mint, settings vec size, owner enum type, owners vec size, owners, description 254 | space = 8 + 4 + 32 + 4 + 1 + 4 + 32 * owners.len() + if desc.is_some() {desc.unwrap().get_size()} else {0}, 255 | seeds = [PROTOCOL_SEED.as_bytes(), POSTBOX_SEED.as_bytes(), target_account.key().as_ref(), target.as_bytes()], 256 | bump, 257 | )] 258 | pub postbox: Box>, 259 | #[account(init, 260 | payer = signer, 261 | seeds = [PROTOCOL_SEED.as_bytes(), MODERATOR_SEED.as_bytes(), postbox.key().as_ref()], 262 | bump, 263 | mint::decimals = 0, 264 | mint::authority = postbox, 265 | )] 266 | pub moderator_mint: Box>, 267 | /// CHECK: we use this account's address only for generating the PDA, but it's useful for anchor's auto PDA to have here 268 | pub target_account: UncheckedAccount<'info>, 269 | #[account(mut, constraint = owners.contains(signer.key))] 270 | pub signer: Signer<'info>, 271 | /// CHECK: we do not access the data in the treasury other than to transfer lamports to it 272 | #[account(mut, address = treasury::TREASURY_ADDRESS)] 273 | pub treasury: UncheckedAccount<'info>, 274 | pub system_program: Program<'info, System>, 275 | pub token_program: Program<'info, token::Token>, 276 | pub rent: Sysvar<'info, Rent>, 277 | } 278 | 279 | #[derive(Accounts)] 280 | #[instruction(data: Vec, post_id: u32, settings: Vec)] 281 | pub struct CreatePost<'info> { 282 | #[account(init, 283 | payer = poster, 284 | space = get_post_projected_size(&settings, &reply_to, &data), 285 | seeds = [PROTOCOL_SEED.as_bytes(), POST_SEED.as_bytes(), postbox.key().as_ref(), &post_id.to_le_bytes()], 286 | bump, 287 | )] 288 | pub post: Box>, 289 | #[account(mut)] 290 | pub postbox: Box>, 291 | #[account(mut)] 292 | pub poster: Signer<'info>, 293 | /// CHECK: we do not access the data in the treasury other than to transfer lamports to it 294 | #[account(mut, address = treasury::TREASURY_ADDRESS)] 295 | pub treasury: UncheckedAccount<'info>, 296 | /// CHECK: we allow passing default or a post, checked in body 297 | pub reply_to: UncheckedAccount<'info>, 298 | pub system_program: Program<'info, System>, 299 | } 300 | 301 | #[derive(Accounts)] 302 | #[instruction(post_id: u32)] 303 | pub struct DeleteOwnPost<'info> { 304 | #[account(mut, close=poster, has_one=poster, 305 | seeds=[PROTOCOL_SEED.as_bytes(), POST_SEED.as_bytes(), postbox.key().as_ref(), &post_id.to_le_bytes()], 306 | bump, 307 | )] 308 | pub post: Box>, 309 | pub postbox: Box>, 310 | #[account(mut)] 311 | pub poster: Signer<'info>, 312 | } 313 | 314 | #[derive(Accounts)] 315 | #[instruction(post_id: u32)] 316 | pub struct DeletePostByModerator<'info> { 317 | #[account(mut, close=poster, has_one=poster, 318 | seeds=[PROTOCOL_SEED.as_bytes(), POST_SEED.as_bytes(), postbox.key().as_ref(), &post_id.to_le_bytes()], 319 | bump, 320 | )] 321 | pub post: Box>, 322 | pub postbox: Box>, 323 | /// CHECK: we do not access the data in the poster other than to transfer lamports to it 324 | #[account(mut)] 325 | pub poster: UncheckedAccount<'info>, 326 | pub moderator: Signer<'info>, 327 | #[account( 328 | associated_token::mint = postbox.moderator_mint, 329 | associated_token::authority = moderator, 330 | constraint = (moderator_token_ata.amount > 0), 331 | )] 332 | pub moderator_token_ata: Account<'info, token::TokenAccount>, 333 | } 334 | 335 | #[derive(Accounts)] 336 | #[instruction(post_id: u32)] 337 | pub struct Vote<'info> { 338 | #[account( 339 | mut, 340 | seeds = [PROTOCOL_SEED.as_bytes(), POST_SEED.as_bytes(), postbox.key().as_ref(), &post_id.to_le_bytes()], 341 | bump, 342 | )] 343 | pub post: Box>, 344 | #[account(mut)] 345 | pub postbox: Box>, 346 | #[account(mut)] 347 | pub voter: Signer<'info>, 348 | #[account( 349 | mut, 350 | seeds = [PROTOCOL_SEED.as_bytes(), VOTE_TRACK_SEED.as_bytes(), postbox.key().as_ref(), voter.key().as_ref()], 351 | bump, 352 | )] 353 | pub vote_tracker: Box>, 354 | /// CHECK: we do not access the data in the treasury other than to transfer lamports to it 355 | #[account(mut, address = treasury::TREASURY_ADDRESS)] 356 | pub treasury: UncheckedAccount<'info>, 357 | pub system_program: Program<'info, System>, 358 | } 359 | 360 | #[derive(Accounts)] 361 | #[instruction(target: String)] 362 | pub struct DesignateModerator<'info> { 363 | #[account( 364 | seeds = [PROTOCOL_SEED.as_bytes(), POSTBOX_SEED.as_bytes(), target_account.key().as_ref(), target.as_bytes()], 365 | bump, 366 | has_one = moderator_mint, 367 | )] 368 | pub postbox: Box>, 369 | /// CHECK: we use this account's address only for generating the auto PDA + signature 370 | pub target_account: UncheckedAccount<'info>, 371 | #[account( 372 | mut, 373 | seeds = [PROTOCOL_SEED.as_bytes(), MODERATOR_SEED.as_bytes(), postbox.key().as_ref()], 374 | bump, 375 | )] 376 | pub moderator_mint: Box>, 377 | #[account(mut, constraint = postbox.has_owner(&owner.key))] 378 | pub owner: Signer<'info>, 379 | /// CHECK: we do not access the account data other than for address for ATA 380 | pub new_moderator: UncheckedAccount<'info>, 381 | #[account(init, 382 | payer = owner, 383 | associated_token::mint = moderator_mint, 384 | associated_token::authority = new_moderator, 385 | )] 386 | pub moderator_ata: Box>, 387 | 388 | pub system_program: Program<'info, System>, 389 | pub token_program: Program<'info, token::Token>, 390 | pub associated_token_program: Program<'info, associated_token::AssociatedToken>, 391 | pub rent: Sysvar<'info, Rent>, 392 | } 393 | 394 | #[derive(Accounts)] 395 | pub struct AddOrUpdateSetting<'info> { 396 | #[account(mut)] 397 | pub postbox: Box>, 398 | #[account(mut, constraint = postbox.has_owner(&owner.key))] 399 | pub owner: Signer<'info>, 400 | pub system_program: Program<'info, System>, 401 | } 402 | 403 | #[derive(Accounts)] 404 | #[instruction(post_id: u32)] 405 | pub struct EditPost<'info> { 406 | #[account(mut, has_one=poster, 407 | seeds=[PROTOCOL_SEED.as_bytes(), POST_SEED.as_bytes(), postbox.key().as_ref(), &post_id.to_le_bytes()], 408 | bump, 409 | )] 410 | pub post: Box>, 411 | pub postbox: Box>, 412 | #[account(mut)] 413 | pub poster: Signer<'info>, 414 | pub system_program: Program<'info, System>, 415 | } 416 | 417 | #[derive(Accounts)] 418 | pub struct CreateVoteTracker<'info> { 419 | #[account(mut)] 420 | pub postbox: Box>, 421 | #[account(mut)] 422 | pub voter: Signer<'info>, 423 | #[account( 424 | init, 425 | payer = voter, 426 | space = 8 + 4, 427 | seeds = [PROTOCOL_SEED.as_bytes(), VOTE_TRACK_SEED.as_bytes(), postbox.key().as_ref(), voter.key().as_ref()], 428 | bump, 429 | )] 430 | pub vote_tracker: Box>, 431 | pub system_program: Program<'info, System>, 432 | } 433 | 434 | #[derive(Accounts)] 435 | #[instruction(post_id: u32)] 436 | pub struct ChangePostSetting<'info> { 437 | #[account(mut, 438 | seeds=[PROTOCOL_SEED.as_bytes(), POST_SEED.as_bytes(), postbox.key().as_ref(), &post_id.to_le_bytes()], 439 | bump, 440 | )] 441 | pub post: Box>, 442 | pub postbox: Box>, 443 | #[account(mut, 444 | constraint=post.user_can_edit_settings(&postbox, &editor, &potentially_moderator_ata) 445 | )] 446 | pub editor: Signer<'info>, 447 | /// CHECK: we allow passing default or a token account, checked in post.user_can_edit_settings 448 | pub potentially_moderator_ata: UncheckedAccount<'info>, 449 | pub system_program: Program<'info, System>, 450 | } 451 | 452 | impl Postbox { 453 | pub fn has_owner(&self, potential_owner: & Pubkey) -> bool { 454 | match self.get_setting(SettingsType::OwnerInfo) { 455 | Some(SettingsData::OwnerInfo { owners }) => return owners.contains(potential_owner), 456 | _ => return false, 457 | } 458 | } 459 | 460 | pub fn get_setting(&self, settings_type: SettingsType) -> Option<& SettingsData> { 461 | for setting in &self.settings { 462 | if setting.get_type() == settings_type { 463 | return Some(& setting); 464 | } 465 | } 466 | return None; 467 | } 468 | 469 | pub fn get_size(&self) -> usize { 470 | // discriminator + max_child_id + moderator_mint + settings_length 471 | let mut size = 8 + 4 + 32 + 4; 472 | for setting in & self.settings { 473 | size += setting.get_size(); 474 | } 475 | return size; 476 | } 477 | 478 | pub fn validate_post_interaction_is_allowed( 479 | &self, 480 | post_for_interaction: Option<& Account>, 481 | interactor_key: &Pubkey, 482 | remaining_accounts: &[AccountInfo], 483 | additional_account_offsets: &Vec, 484 | trying_to_override: bool, 485 | ) -> Result> { 486 | let mut post_restriction_to_use: Option<&SettingsData> = None; 487 | let mut post_specific: bool = false; 488 | if let Some(post) = post_for_interaction { 489 | post_restriction_to_use = post.get_setting(SettingsType::PostRestriction); 490 | } 491 | if post_restriction_to_use.is_some() { 492 | post_specific = true; 493 | } else { 494 | // Postbox setting specifies the default, which can be overriden in a post 495 | post_restriction_to_use = self.get_setting(SettingsType::PostRestriction); 496 | } 497 | if let Some(restriction) = post_restriction_to_use { 498 | match restriction { 499 | SettingsData::PostRestriction{ post_restriction } => post_restriction.validate_reply_allowed( 500 | interactor_key, 501 | remaining_accounts, 502 | additional_account_offsets, 503 | )?, 504 | _ => {return Err(Error::from(PostboxErrorCode::MalformedSetting).with_source(source!()))}, 505 | }; 506 | if post_specific { 507 | require!(!trying_to_override, PostboxErrorCode::ReplyCannotRestrictReplies); 508 | // We need the next post to inherit this, so return it 509 | return Ok(Some(restriction.clone())); 510 | } 511 | } 512 | Ok(None) 513 | } 514 | } 515 | 516 | impl Post { 517 | pub fn get_setting(&self, settings_type: SettingsType) -> Option<& SettingsData> { 518 | for setting in &self.settings { 519 | if setting.get_type() == settings_type { 520 | return Some(& setting); 521 | } 522 | } 523 | return None; 524 | } 525 | 526 | pub fn set_setting(&mut self, new_setting: &SettingsData) -> Result<()> { 527 | // Some settings types don't make sense on a post 528 | require!(new_setting.get_type() == SettingsType::PostRestriction, PostboxErrorCode::PostInvalidSettingsType); 529 | self.settings.retain(|s| s.get_type() != new_setting.get_type()); 530 | self.settings.push(new_setting.clone()); 531 | Ok(()) 532 | } 533 | 534 | pub fn user_can_edit_settings(& self, 535 | postbox: &Box>, 536 | editor: &Signer, 537 | potentially_moderator_ata: &UncheckedAccount, 538 | ) -> bool { 539 | // Never on a reply 540 | if self.reply_to.is_some() { return false; } 541 | // The original poster can edit the restrictions 542 | if self.poster == editor.key() { return true; } 543 | // Otherwise, check that it's a moderator 544 | if potentially_moderator_ata.key() == Pubkey::default() { return false; } 545 | return Account::::try_from(potentially_moderator_ata).map_or(false, 546 | |moderator_ata| 547 | moderator_ata.mint == postbox.moderator_mint 548 | && moderator_ata.owner == editor.key() 549 | && moderator_ata.amount > 0 550 | ); 551 | } 552 | } 553 | 554 | pub fn get_post_projected_size(passed_settings: &Vec, reply_to: &AccountInfo, data: &Vec) -> usize { 555 | // disc + poster + data.len + data + up_votes + down_votes + option + (reply_to) + settings.len 556 | let mut size = 8 + 32 + 4 + data.len() + 2 + 2 + 1 + (if reply_to.key() != Pubkey::default() {32} else {0}) + 4; 557 | let mut allow_restriction = true; 558 | // For post restriction, we inherit rather than allowing you to set it 559 | let maybe_reply_to_post = Account::::try_from(reply_to); 560 | if maybe_reply_to_post.is_ok() { 561 | if let Some(restriction) = & maybe_reply_to_post.unwrap().get_setting(SettingsType::PostRestriction) { 562 | size += restriction.get_size(); 563 | allow_restriction = false; 564 | } 565 | } 566 | for setting in passed_settings { 567 | if !allow_restriction && setting.get_type() == SettingsType::PostRestriction { 568 | continue; 569 | } 570 | size += setting.get_size(); 571 | } 572 | return size; 573 | } 574 | 575 | pub fn resize_account<'info>(data_account: &dyn ToAccountInfo<'info>, funding_account: &dyn ToAccountInfo<'info>, new_size: usize) -> Result<()> { 576 | let rent = Rent::get()?; 577 | let new_minimum_balance = rent.minimum_balance(new_size); 578 | let data_info = data_account.to_account_info(); 579 | 580 | if new_minimum_balance > data_info.lamports() { 581 | let lamports_diff = new_minimum_balance.saturating_sub(data_info.lamports()); 582 | treasury::transfer_lamports(funding_account, data_account, lamports_diff)?; 583 | } 584 | data_info.realloc(new_size, false)?; 585 | Ok(()) 586 | } 587 | 588 | #[account] 589 | #[derive(Default)] 590 | pub struct Postbox { 591 | pub max_child_id: u32, 592 | pub moderator_mint: Pubkey, 593 | pub settings: Vec, 594 | } 595 | 596 | #[account] 597 | #[derive(Default)] 598 | pub struct Post { 599 | poster: Pubkey, 600 | data: Vec, 601 | up_votes: u16, 602 | down_votes: u16, 603 | reply_to: Option, 604 | settings: Vec, 605 | } 606 | 607 | #[account] 608 | #[derive(Default)] 609 | pub struct VoteTracker { 610 | votes: Vec, 611 | } 612 | 613 | #[event] 614 | pub struct PostEvent { 615 | pub poster_pubkey: Pubkey, 616 | pub postbox_pubkey: Pubkey, 617 | pub post_pubkey: Pubkey, 618 | pub post_id: u32, 619 | pub data: Vec, 620 | pub reply_to: Option, 621 | } 622 | 623 | #[event] 624 | pub struct DeleteEvent { 625 | pub deleter_pubkey: Pubkey, 626 | pub postbox_pubkey: Pubkey, 627 | pub post_pubkey: Pubkey, 628 | pub post_id: u32, 629 | } 630 | 631 | #[event] 632 | pub struct EditedEvent { 633 | pub postbox_pubkey: Pubkey, 634 | pub post_pubkey: Pubkey, 635 | pub post_id: u32, 636 | old_data: Vec, 637 | new_data: Vec, 638 | } 639 | -------------------------------------------------------------------------------- /tests/postbox.ts: -------------------------------------------------------------------------------- 1 | import * as anchor from '@project-serum/anchor'; 2 | import * as splToken from '@solana/spl-token'; 3 | import { strict as assert } from 'assert'; 4 | 5 | import { 6 | Postbox, 7 | DispatchConnection, 8 | Forum, 9 | clusterAddresses, 10 | PostRestriction, 11 | VoteType, 12 | } from '../usedispatch_client/src'; 13 | 14 | describe('postbox', () => { 15 | // Configure the client to use the local cluster. 16 | anchor.setProvider(anchor.AnchorProvider.env()); 17 | 18 | const conn = anchor.getProvider().connection; 19 | const TREASURY = clusterAddresses.get('devnet').treasuryAddress; 20 | 21 | it('Initializes a postbox and creates a post', async () => { 22 | // Set up accounts 23 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 24 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 25 | await conn.confirmTransaction(await conn.requestAirdrop(TREASURY, 1 * anchor.web3.LAMPORTS_PER_SOL)); 26 | 27 | const treasuryBalance = await conn.getBalance(TREASURY); 28 | 29 | const postbox = new Postbox(new DispatchConnection(conn, owner), { key: owner.publicKey, str: 'Public' }); 30 | const tx0 = await postbox.initialize(); 31 | await conn.confirmTransaction(tx0); 32 | 33 | assert.equal(await conn.getBalance(TREASURY), treasuryBalance + 100_000); 34 | 35 | const testPost = { subj: 'Test', body: 'This is a test post' }; 36 | const tx1 = await postbox.createPost(testPost); 37 | await conn.confirmTransaction(tx1); 38 | 39 | assert.equal(await conn.getBalance(TREASURY), treasuryBalance + 100_000 + 50_000); 40 | 41 | const posts = await postbox.fetchPosts(); 42 | assert.equal(posts.length, 1); 43 | 44 | const firstPost = posts[0]; 45 | assert.equal(firstPost.data.subj, testPost.subj); 46 | assert.equal(firstPost.data.body, testPost.body); 47 | 48 | const oldPostAddress = firstPost.address; 49 | const tx2 = await postbox.deletePost(firstPost); 50 | await conn.confirmTransaction(tx2); 51 | await conn.confirmTransaction(tx2); 52 | const oldPost = await conn.getAccountInfo(oldPostAddress); 53 | assert.equal(oldPost, null); 54 | }); 55 | 56 | it('Creates a third party postbox', async () => { 57 | const target = anchor.web3.Keypair.generate().publicKey; 58 | const owner1 = new anchor.Wallet(anchor.web3.Keypair.generate()); 59 | const owner2 = new anchor.Wallet(anchor.web3.Keypair.generate()); 60 | await conn.confirmTransaction(await conn.requestAirdrop(owner1.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 61 | 62 | const postbox = new Postbox(new DispatchConnection(conn, owner1), { key: target, str: 'Public' }); 63 | const tx0 = await postbox.initialize([owner1.publicKey, owner2.publicKey]); 64 | await conn.confirmTransaction(tx0); 65 | 66 | const owners = await postbox.getOwners(); 67 | assert.equal(owners.length, 2); 68 | assert.ok(owners[0].equals(owner1.publicKey)); 69 | assert.ok(owners[1].equals(owner2.publicKey)); 70 | 71 | const testPost = { subj: 'Test', body: 'This is a test post' }; 72 | const tx1 = await postbox.createPost(testPost); 73 | await conn.confirmTransaction(tx1); 74 | 75 | const posts = await postbox.fetchPosts(); 76 | assert.equal(posts.length, 1); 77 | }); 78 | 79 | it('Replies to a post', async () => { 80 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 81 | const replier = new anchor.Wallet(anchor.web3.Keypair.generate()); 82 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 83 | await conn.confirmTransaction(await conn.requestAirdrop(replier.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 84 | 85 | const postbox = new Postbox(new DispatchConnection(conn, owner), { key: owner.publicKey }); 86 | const tx0 = await postbox.initialize(); 87 | await conn.confirmTransaction(tx0); 88 | 89 | const testPost = { subj: 'Test', body: 'This is a test post' }; 90 | const tx1 = await postbox.createPost(testPost); 91 | await conn.confirmTransaction(tx1); 92 | 93 | const posts = await postbox.fetchPosts(); 94 | const replyPost = { body: 'This is a reply post' }; 95 | const tx2 = await postbox.replyToPost(replyPost, posts[0]); 96 | await conn.confirmTransaction(tx2); 97 | 98 | const topLevelPosts = await postbox.fetchPosts(); 99 | assert.equal(topLevelPosts.length, 1); 100 | const replies = await postbox.fetchReplies(topLevelPosts[0]); 101 | assert.equal(replies.length, 1); 102 | assert.ok(replies[0].replyTo.equals(topLevelPosts[0].address)); 103 | }); 104 | 105 | it('Designates a moderator who deletes', async () => { 106 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 107 | const poster = new anchor.Wallet(anchor.web3.Keypair.generate()); 108 | const moderator = new anchor.Wallet(anchor.web3.Keypair.generate()); 109 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 110 | await conn.confirmTransaction(await conn.requestAirdrop(poster.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 111 | await conn.confirmTransaction(await conn.requestAirdrop(moderator.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 112 | 113 | const postboxAsOwner = new Postbox(new DispatchConnection(conn, owner), { key: owner.publicKey }); 114 | const postboxAsPoster = new Postbox(new DispatchConnection(conn, poster), { key: owner.publicKey }); 115 | const postboxAsModerator = new Postbox(new DispatchConnection(conn, moderator), { key: owner.publicKey }); 116 | const tx0 = await postboxAsOwner.initialize(); 117 | await conn.confirmTransaction(tx0); 118 | 119 | const testPost = { subj: 'Test', body: 'This is a test post' }; 120 | const tx1 = await postboxAsPoster.createPost(testPost); 121 | await conn.confirmTransaction(tx1); 122 | 123 | const topLevelPosts = await postboxAsOwner.fetchPosts(); 124 | assert.equal(topLevelPosts.length, 1); 125 | 126 | const tx2 = await postboxAsOwner.addModerator(moderator.publicKey); 127 | await conn.confirmTransaction(tx2); 128 | 129 | const tx3 = await postboxAsModerator.deletePostAsModerator(topLevelPosts[0]); 130 | await conn.confirmTransaction(tx3); 131 | 132 | const posts = await postboxAsOwner.fetchPosts(); 133 | assert.equal(posts.length, 0); 134 | }); 135 | 136 | xit('Designates a moderator while being a moderator', async () => { 137 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 138 | const moderatorA = new anchor.Wallet(anchor.web3.Keypair.generate()); 139 | const moderatorB = new anchor.Wallet(anchor.web3.Keypair.generate()); 140 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 141 | await conn.confirmTransaction(await conn.requestAirdrop(moderatorA.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 142 | 143 | const postboxAsOwner = new Postbox(new DispatchConnection(conn, owner), { key: owner.publicKey }); 144 | const postboxAsModerator = new Postbox(new DispatchConnection(conn, moderatorA), { key: owner.publicKey }); 145 | const tx0 = await postboxAsOwner.initialize(); 146 | await conn.confirmTransaction(tx0); 147 | 148 | const tx1 = await postboxAsOwner.addModerator(moderatorA.publicKey); 149 | await conn.confirmTransaction(tx1); 150 | 151 | const tx2 = await postboxAsModerator.addModerator(moderatorB.publicKey); 152 | await conn.confirmTransaction(tx2); 153 | }); 154 | 155 | it('Allows voting', async () => { 156 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 157 | const voter = new anchor.Wallet(anchor.web3.Keypair.generate()); 158 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 159 | await conn.confirmTransaction(await conn.requestAirdrop(voter.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 160 | 161 | const postboxAsOwner = new Postbox(new DispatchConnection(conn, owner), { key: owner.publicKey }); 162 | const postboxAsVoter = new Postbox(new DispatchConnection(conn, voter), { key: owner.publicKey }); 163 | const tx0 = await postboxAsOwner.initialize(); 164 | await conn.confirmTransaction(tx0); 165 | 166 | const testPost = { subj: 'Test', body: 'This is a test post' }; 167 | const tx1 = await postboxAsOwner.createPost(testPost); 168 | await conn.confirmTransaction(tx1); 169 | 170 | const topLevelPosts = await postboxAsOwner.fetchPosts(); 171 | assert.equal(topLevelPosts.length, 1); 172 | 173 | const treasuryBalance = await conn.getBalance(TREASURY); 174 | 175 | const tx2 = await postboxAsVoter.vote(topLevelPosts[0], true); 176 | await conn.confirmTransaction(tx2); 177 | 178 | const posts = await postboxAsOwner.fetchPosts(); 179 | assert.equal(posts[0].upVotes, 1); 180 | 181 | assert.equal(await conn.getBalance(TREASURY), treasuryBalance + 50_000); 182 | }); 183 | 184 | it('Restricts posts to token holders', async () => { 185 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 186 | const poster = new anchor.Wallet(anchor.web3.Keypair.generate()); 187 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 188 | await conn.confirmTransaction(await conn.requestAirdrop(poster.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 189 | 190 | const postboxAsOwner = new Postbox(new DispatchConnection(conn, owner), { key: owner.publicKey }); 191 | const postboxAsPoster = new Postbox(new DispatchConnection(conn, poster), { key: owner.publicKey }); 192 | const tx0 = await postboxAsOwner.initialize(); 193 | await conn.confirmTransaction(tx0); 194 | 195 | const mint = await splToken.createMint(conn, owner.payer, owner.publicKey, owner.publicKey, 9); 196 | const ata = await splToken.getOrCreateAssociatedTokenAccount(conn, owner.payer, mint, poster.publicKey); 197 | const tx1 = await splToken.mintTo(conn, owner.payer, mint, ata.address, owner.payer, 100); 198 | await conn.confirmTransaction(tx1); 199 | 200 | const testPost = { subj: 'Test', body: 'This is a test post' }; 201 | const tx2 = await postboxAsOwner.createPost(testPost, undefined, { tokenOwnership: { mint, amount: 1 } }); 202 | await conn.confirmTransaction(tx2); 203 | 204 | const topics = await postboxAsOwner.fetchPosts(); 205 | assert.equal(topics.length, 1); 206 | const topic = topics[0]; 207 | 208 | assert.ok(await postboxAsPoster.canPost()); 209 | assert.ok(await postboxAsPoster.canPost(topic)); 210 | const replyPost = { subj: 'Interesting', body: 'Reply' }; 211 | const tx3 = await postboxAsPoster.replyToPost(replyPost, topic); 212 | await conn.confirmTransaction(tx3); 213 | 214 | const txA = await postboxAsPoster.vote(topic, true); 215 | await conn.confirmTransaction(txA); 216 | 217 | try { 218 | await postboxAsOwner.vote(topic, true); 219 | assert.fail(); 220 | } catch (e) { 221 | const expectedError = 'custom program error: 0x1840'; 222 | assert.ok(e instanceof Error); 223 | assert.ok(e.message.includes(expectedError)); 224 | } 225 | 226 | assert.ok(await postboxAsOwner.canPost()); 227 | assert.ok(!(await postboxAsOwner.canPost(topic))); 228 | const replyPost2 = { subj: 'Should fail', body: 'Reply' }; 229 | try { 230 | await postboxAsOwner.replyToPost(replyPost2, topic); 231 | assert.fail(); 232 | } catch (e) { 233 | const expectedError = 'custom program error: 0x1840'; 234 | assert.ok(e instanceof Error); 235 | assert.ok(e.message.includes(expectedError)); 236 | } 237 | 238 | const replies = await postboxAsOwner.fetchReplies(topic); 239 | assert.equal(replies.length, 1); 240 | }); 241 | 242 | it('Uses the forum.ts wrapper', async () => { 243 | const collectionId = anchor.web3.Keypair.generate().publicKey; 244 | 245 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 246 | const moderator = new anchor.Wallet(anchor.web3.Keypair.generate()); 247 | const poster = new anchor.Wallet(anchor.web3.Keypair.generate()); 248 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 249 | await conn.confirmTransaction(await conn.requestAirdrop(moderator.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 250 | await conn.confirmTransaction(await conn.requestAirdrop(poster.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 251 | 252 | const forumAsOwner = new Forum(new DispatchConnection(conn, owner), collectionId); 253 | const forumAsModerator = new Forum(new DispatchConnection(conn, moderator), collectionId); 254 | const forumAsPoster = new Forum(new DispatchConnection(conn, poster), collectionId); 255 | 256 | const descStr = 'A forum for the test suite'; 257 | if (!(await forumAsOwner.exists())) { 258 | const txs = await forumAsOwner.createForum({ 259 | collectionId, 260 | owners: [owner.publicKey], 261 | moderators: [owner.publicKey], // We add the moderator below as a test 262 | title: 'Test Forum', 263 | description: descStr, 264 | }); 265 | await Promise.all(txs.map((t) => conn.confirmTransaction(t))); 266 | } 267 | 268 | const owners = await forumAsOwner.getOwners(); 269 | assert.ok(owners[0].equals(owner.publicKey)); 270 | const desc = await forumAsOwner.getDescription(); 271 | assert.equal(desc.title, 'Test Forum'); 272 | assert.equal(desc.desc, descStr); 273 | assert.ok(await forumAsOwner.isOwner()); 274 | 275 | const txA = await forumAsOwner.setDescription({ title: 'Test', desc: descStr }); 276 | await conn.confirmTransaction(txA); 277 | const desc2 = await forumAsOwner.getDescription(); 278 | assert.equal(desc2.title, 'Test'); 279 | 280 | const txB = await forumAsOwner.addModerator(moderator.publicKey); 281 | await conn.confirmTransaction(txB); 282 | assert.ok(await forumAsModerator.isModerator()); 283 | 284 | const moderators = await forumAsOwner.getModerators(); 285 | assert.equal(moderators.length, 2); 286 | assert.ok(moderators.map((m) => m.toBase58()).includes(moderator.publicKey.toBase58())); 287 | 288 | const topic0 = { subj: 'Test Topic', body: 'This is a test topic.' }; 289 | const tx0 = await forumAsPoster.createTopic(topic0); 290 | await conn.confirmTransaction(tx0); 291 | 292 | const topics = await forumAsPoster.getTopicsForForum(); 293 | assert.equal(topics.length, 1); 294 | 295 | const testPost0 = { subj: 'Test', body: 'This is a test post' }; 296 | const tx1 = await forumAsPoster.createForumPost(testPost0, topics[0]); 297 | await conn.confirmTransaction(tx1); 298 | 299 | const testPost1 = { subj: 'Spam', body: 'This is a spam post' }; 300 | const tx2 = await forumAsPoster.createForumPost(testPost1, topics[0]); 301 | await conn.confirmTransaction(tx2); 302 | 303 | let topicPosts = await forumAsModerator.getTopicMessages(topics[0]); 304 | assert.equal(topicPosts.length, 2); 305 | 306 | const delTxs = ( 307 | await Promise.all( 308 | topicPosts.map(async (p) => { 309 | if ((p.data.subj ?? '') === 'Spam') { 310 | return await forumAsModerator.deleteForumPost(p, true); 311 | } 312 | return null; 313 | }), 314 | ) 315 | ).filter((t) => t !== null); 316 | await Promise.all(delTxs.map((t) => conn.confirmTransaction(t))); 317 | 318 | topicPosts = await forumAsPoster.getTopicMessages(topics[0]); 319 | assert.equal(topicPosts.length, 1); 320 | 321 | const tx3 = await forumAsPoster.deleteForumPost(topicPosts[0]); 322 | await conn.confirmTransaction(tx3); 323 | 324 | topicPosts = await forumAsOwner.getTopicMessages(topics[0]); 325 | assert.equal(topicPosts.length, 0); 326 | 327 | const testPost2 = { subj: 'Test2', body: 'Another test' }; 328 | const tx4 = await forumAsPoster.createForumPost(testPost2, topics[0]); 329 | await conn.confirmTransaction(tx4); 330 | 331 | const posts = await forumAsModerator.getTopicMessages(topics[0]); 332 | const tx5 = await forumAsModerator.voteUpForumPost(posts[0]); 333 | await conn.confirmTransaction(tx5); 334 | 335 | const vote = await forumAsModerator.getVote(posts[0]); 336 | assert.equal(vote, VoteType.up); 337 | 338 | const postsAgain = await forumAsModerator.getTopicMessages(topics[0]); 339 | assert.equal(postsAgain[0].upVotes, 1); 340 | 341 | const replyPost = { subj: 'Reply', body: 'Testing reply' }; 342 | const tx6 = await forumAsModerator.replyToForumPost(postsAgain[0], replyPost); 343 | await conn.confirmTransaction(tx6); 344 | 345 | const replies = await forumAsModerator.getReplies(postsAgain[0]); 346 | assert.equal(replies.length, 1); 347 | assert.equal(replies[0].data.subj, 'Reply'); 348 | 349 | const newTopic = { subj: 'Test Topic Renamed', body: 'This is a test topic.' }; 350 | const tx7 = await forumAsPoster.editForumPost(topics[0], newTopic); 351 | await conn.confirmTransaction(tx7); 352 | 353 | const topicsPostEdit = await forumAsPoster.getTopicsForForum(); 354 | assert.equal(topicsPostEdit[0].data.subj, 'Test Topic Renamed'); 355 | }); 356 | 357 | it('UpdateOwner list', async () => { 358 | const collectionId = anchor.web3.Keypair.generate().publicKey; 359 | 360 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 361 | const user = new anchor.Wallet(anchor.web3.Keypair.generate()); 362 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 363 | await conn.confirmTransaction(await conn.requestAirdrop(user.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 364 | 365 | const forumAsOwner = new Forum(new DispatchConnection(conn, owner), collectionId); 366 | 367 | const descStr = 'A forum for the test suite'; 368 | if (!(await forumAsOwner.exists())) { 369 | const txs = await forumAsOwner.createForum({ 370 | collectionId, 371 | owners: [owner.publicKey], 372 | moderators: [], 373 | title: 'Test Forum', 374 | description: descStr, 375 | }); 376 | await Promise.all(txs.map((t) => conn.confirmTransaction(t))); 377 | } 378 | 379 | let owners = await forumAsOwner.getOwners(); 380 | assert.equal(owners.length, 1); 381 | 382 | const tx = await forumAsOwner.setOwners([owner.publicKey, user.publicKey]); 383 | await conn.confirmTransaction(tx); 384 | 385 | owners = await forumAsOwner.getOwners(); 386 | assert.equal(owners.length, 2); 387 | }); 388 | 389 | it('Sets token post restrictions on a forum', async () => { 390 | const collectionId = anchor.web3.Keypair.generate().publicKey; 391 | 392 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 393 | const poster = new anchor.Wallet(anchor.web3.Keypair.generate()); 394 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 395 | await conn.confirmTransaction(await conn.requestAirdrop(poster.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 396 | 397 | const forumAsOwner = new Forum(new DispatchConnection(conn, owner), collectionId); 398 | const forumAsPoster = new Forum(new DispatchConnection(conn, poster), collectionId); 399 | 400 | const txs = await forumAsOwner.createForum({ 401 | collectionId, 402 | owners: [owner.publicKey], 403 | moderators: [owner.publicKey], 404 | title: 'Test Forum', 405 | description: 'A forum for the test suite', 406 | }); 407 | await Promise.all(txs.map((t) => conn.confirmTransaction(t))); 408 | 409 | const restrictionMint = await splToken.createMint(conn, owner.payer, owner.publicKey, owner.publicKey, 9); 410 | const restrictionAmount = 50000; 411 | const restriction: PostRestriction = { 412 | tokenOwnership: { 413 | mint: restrictionMint, 414 | amount: restrictionAmount, 415 | }, 416 | }; 417 | 418 | const topic0 = { subj: 'Test Topic', body: 'This is a test topic.' }; 419 | const tx0 = await forumAsOwner.createTopic(topic0, restriction); 420 | await conn.confirmTransaction(tx0); 421 | 422 | assert.ok(await forumAsPoster.canCreateTopic()); 423 | const topics = await forumAsPoster.getTopicsForForum(); 424 | assert.ok(!(await forumAsPoster.canPost(topics[0]))); 425 | 426 | const tx1 = await forumAsOwner.setForumPostRestriction(restriction); 427 | await conn.confirmTransaction(tx1); 428 | 429 | const forumRestriction = await forumAsPoster.getForumPostRestriction(); 430 | assert.ok(restrictionMint.equals(forumRestriction?.tokenOwnership?.mint)); 431 | assert.equal(forumRestriction?.tokenOwnership?.amount, restrictionAmount); 432 | 433 | assert.ok(!(await forumAsPoster.canCreateTopic())); 434 | }); 435 | 436 | it('Removes token post restrictions on a forum', async () => { 437 | const collectionId = anchor.web3.Keypair.generate().publicKey; 438 | 439 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 440 | const poster = new anchor.Wallet(anchor.web3.Keypair.generate()); 441 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 442 | await conn.confirmTransaction(await conn.requestAirdrop(poster.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 443 | 444 | const forumAsOwner = new Forum(new DispatchConnection(conn, owner), collectionId); 445 | const forumAsPoster = new Forum(new DispatchConnection(conn, poster), collectionId); 446 | 447 | const txs = await forumAsOwner.createForum({ 448 | collectionId, 449 | owners: [owner.publicKey], 450 | moderators: [owner.publicKey], 451 | title: 'Test Forum', 452 | description: 'A forum for the test suite', 453 | }); 454 | await Promise.all(txs.map((t) => conn.confirmTransaction(t))); 455 | 456 | const restrictionMint = await splToken.createMint(conn, owner.payer, owner.publicKey, owner.publicKey, 9); 457 | const restrictionAmount = 50000; 458 | const restriction: PostRestriction = { 459 | tokenOwnership: { 460 | mint: restrictionMint, 461 | amount: restrictionAmount, 462 | }, 463 | }; 464 | 465 | const tx0 = await forumAsOwner.setForumPostRestriction(restriction); 466 | await conn.confirmTransaction(tx0); 467 | assert.ok(!(await forumAsPoster.canCreateTopic())); 468 | 469 | const tx1 = await forumAsOwner.deleteForumPostRestriction(); 470 | await conn.confirmTransaction(tx1); 471 | assert.ok(await forumAsPoster.canCreateTopic()); 472 | 473 | const topic0 = { subj: 'Test Topic', body: 'This is a test topic.' }; 474 | const tx2 = await forumAsPoster.createTopic(topic0); 475 | await conn.confirmTransaction(tx2); 476 | }); 477 | 478 | it('Sets images and retrieves them', async () => { 479 | const collectionId = anchor.web3.Keypair.generate().publicKey; 480 | 481 | const owner = new anchor.Wallet(anchor.web3.Keypair.generate()); 482 | await conn.confirmTransaction(await conn.requestAirdrop(owner.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL)); 483 | 484 | const forumAsOwner = new Forum(new DispatchConnection(conn, owner), collectionId); 485 | 486 | const descStr = 'A forum for the test suite'; 487 | if (!(await forumAsOwner.exists())) { 488 | const txs = await forumAsOwner.createForum({ 489 | collectionId, 490 | owners: [owner.publicKey], 491 | moderators: [owner.publicKey], 492 | title: 'Test Forum', 493 | description: descStr, 494 | }); 495 | await Promise.all(txs.map((t) => conn.confirmTransaction(t))); 496 | } 497 | 498 | const expectedImages = { 499 | background: 'https://imgs.xkcd.com/comics/nerd_sniping.png', 500 | thumbnail: 'https://imgs.xkcd.com/comics/spinthariscope_2x.png', 501 | }; 502 | const tx0 = await forumAsOwner.setImageUrls(expectedImages); 503 | await conn.confirmTransaction(tx0); 504 | 505 | const images = await forumAsOwner.getImageUrls(); 506 | assert.equal(images.background, expectedImages.background); 507 | assert.equal(images.thumbnail, expectedImages.thumbnail); 508 | }); 509 | 510 | it('Prevents double votes', async () => { 511 | const voter = new anchor.Wallet(anchor.web3.Keypair.generate()); 512 | await conn.confirmTransaction(await conn.requestAirdrop(voter.publicKey, 100 * anchor.web3.LAMPORTS_PER_SOL)); 513 | 514 | const postboxAsVoter = new Postbox(new DispatchConnection(conn, voter), { key: voter.publicKey }); 515 | await conn.confirmTransaction(await postboxAsVoter.initialize()); 516 | 517 | const testPost = { subj: 'T', body: 'T' }; 518 | await conn.confirmTransaction(await postboxAsVoter.createPost(testPost)); 519 | const topLevelPosts = await postboxAsVoter.fetchPosts(); 520 | const post = topLevelPosts[topLevelPosts.length - 1]; 521 | await conn.confirmTransaction(await postboxAsVoter.vote(post, true)); 522 | 523 | try { 524 | await conn.confirmTransaction(await postboxAsVoter.vote(post, true)); 525 | assert.fail(); 526 | } catch (e) { 527 | assert.ok(String(e).includes('custom program error: 0x1842')); 528 | } 529 | }); 530 | 531 | it('Allows changing a vote', async () => { 532 | const voter = new anchor.Wallet(anchor.web3.Keypair.generate()); 533 | await conn.confirmTransaction(await conn.requestAirdrop(voter.publicKey, 100 * anchor.web3.LAMPORTS_PER_SOL)); 534 | 535 | const postboxAsVoter = new Postbox(new DispatchConnection(conn, voter), { key: voter.publicKey }); 536 | await conn.confirmTransaction(await postboxAsVoter.initialize()); 537 | 538 | const testPost = { subj: 'T', body: 'T' }; 539 | await conn.confirmTransaction(await postboxAsVoter.createPost(testPost)); 540 | const topLevelPosts = await postboxAsVoter.fetchPosts(); 541 | const post = topLevelPosts[0]; 542 | // Flip back and forth to make sure the bookkeeping is ok 543 | await conn.confirmTransaction(await postboxAsVoter.vote(post, true)); 544 | await conn.confirmTransaction(await postboxAsVoter.vote(post, false)); 545 | await conn.confirmTransaction(await postboxAsVoter.vote(post, true)); 546 | await conn.confirmTransaction(await postboxAsVoter.vote(post, false)); 547 | 548 | const posts = await postboxAsVoter.fetchPosts(); 549 | assert.equal(posts[0].upVotes, 0); 550 | assert.equal(posts[0].downVotes, 1); 551 | }); 552 | 553 | xit('Tests large numbers of votes', async () => { 554 | const voter = new anchor.Wallet(anchor.web3.Keypair.generate()); 555 | await conn.confirmTransaction(await conn.requestAirdrop(voter.publicKey, 100 * anchor.web3.LAMPORTS_PER_SOL)); 556 | 557 | const postboxAsVoter = new Postbox(new DispatchConnection(conn, voter), { key: voter.publicKey }); 558 | await conn.confirmTransaction(await postboxAsVoter.initialize()); 559 | 560 | const iterations = 1500; 561 | for (let i = 0; i < iterations; ++i) { 562 | const testPost = { subj: String(i), body: 'T' }; 563 | await conn.confirmTransaction(await postboxAsVoter.createPost(testPost)); 564 | const topLevelPosts = await postboxAsVoter.fetchPosts(); 565 | await conn.confirmTransaction(await postboxAsVoter.vote(topLevelPosts[topLevelPosts.length - 1], true)); 566 | } 567 | }); 568 | }); 569 | --------------------------------------------------------------------------------