├── .eslintignore ├── .eslintrc.js ├── .eslintrc.json ├── .git-blame-ignore-revs ├── .github └── workflows │ ├── formatters.yml │ └── main.yaml ├── .gitignore ├── .npmignore ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc ├── Anchor.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── docker ├── Dockerfile └── Dockerfile.tests ├── examples └── hello-world.ts ├── package-lock.json ├── package.json ├── programs └── dialect │ ├── Cargo.toml │ ├── Xargo.toml │ └── src │ └── lib.rs ├── src ├── api │ ├── index.ts │ └── text-serde.ts ├── index.ts └── utils │ ├── Wallet │ ├── EmbeddedWallet.ts │ └── index.ts │ ├── countdown-latch.ts │ ├── cyclic-bytebuffer.spec.ts │ ├── cyclic-bytebuffer.ts │ ├── dialect.json │ ├── ecdh-encryption.spec.ts │ ├── ecdh-encryption.ts │ ├── index.ts │ ├── nonce-generator.spec.ts │ ├── nonce-generator.ts │ └── programs.json ├── tests └── test-v1.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | client/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | ecmaFeatures: { 11 | jsx: true, 12 | }, 13 | ecmaVersion: 12, 14 | sourceType: 'module', 15 | }, 16 | plugins: ['@typescript-eslint'], 17 | rules: { 18 | '@typescript-eslint/no-var-requires': 0, 19 | quotes: [2, 'single', { avoidEscape: true }], 20 | semi: 1, 21 | }, 22 | overrides: [ 23 | { 24 | files: ['*.ts'], 25 | extends: [ 26 | 'plugin:@typescript-eslint/eslint-recommended', 27 | 'plugin:@typescript-eslint/recommended', 28 | ], 29 | parser: '@typescript-eslint/parser', 30 | plugins: ['@typescript-eslint'], 31 | }, 32 | ], 33 | settings: {}, 34 | }; 35 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 12 | "prettier" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": 2020, 17 | "parser": "babel-eslint", 18 | "project": "./tsconfig.json", 19 | "sourceType": "module" 20 | }, 21 | "plugins": ["@typescript-eslint", "prettier"], 22 | "rules": { 23 | "no-console": 0, 24 | "semi": 1, 25 | "@typescript-eslint/no-explicit-any": 0, 26 | "quotes": [2, "single", { "avoidEscape": true }] 27 | }, 28 | "overrides": [ 29 | { 30 | "files": ["*.ts"], 31 | "extends": [ 32 | "plugin:@typescript-eslint/eslint-recommended", 33 | "plugin:@typescript-eslint/recommended" 34 | ], 35 | "parser": "@typescript-eslint/parser", 36 | "plugins": ["@typescript-eslint"] 37 | } 38 | ], 39 | "settings": {} 40 | } 41 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | d805b798f29771d73dc8e3583bd59f88f05866e6 2 | -------------------------------------------------------------------------------- /.github/workflows/formatters.yml: -------------------------------------------------------------------------------- 1 | name: Formatters 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | formatter: 11 | name: Prettier 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - uses: actionsx/prettier@v2 17 | with: 18 | # prettier CLI arguments. 19 | args: --check examples/ src/ tests/ 20 | 21 | fmt: 22 | name: Rustfmt 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions-rs/toolchain@v1 27 | with: 28 | toolchain: 1.57.0 29 | override: true 30 | components: rustfmt 31 | - uses: actions-rs/cargo@v1 32 | with: 33 | command: fmt 34 | args: --all -- --check 35 | # TODO: Address failing issue 36 | # clippy: 37 | # name: Clippy 38 | # runs-on: ubuntu-latest 39 | # steps: 40 | # - uses: actions/checkout@v2 41 | # - uses: actions-rs/toolchain@v1 42 | # with: 43 | # toolchain: 1.57.0 44 | # override: true 45 | # components: clippy 46 | # - uses: actions-rs/clippy-check@v1 47 | # with: 48 | # token: ${{ secrets.GITHUB_TOKEN }} 49 | # args: -- -D warnings 50 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Build & run tests 16 | run: | 17 | docker build -f docker/Dockerfile -t dialect/protocol:latest . 18 | docker build -f docker/Dockerfile.tests -t dialect/protocol-tests:latest . 19 | docker run --rm --name protocol-tests dialect/protocol-tests:latest 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/.anchor 3 | 4 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | .idea 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env.local 33 | .env.development.local 34 | .env.test.local 35 | .env.production.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # dist 41 | /lib 42 | 43 | # folder created by solana-test-validator --rpc-port 8899 44 | test-ledger -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/.anchor 3 | 4 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | .idea 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env.local 33 | .env.development.local 34 | .env.test.local 35 | .env.production.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # rust 41 | /programs 42 | 43 | # folder created by solana-test-validator --rpc-port 8899 44 | test-ledger 45 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/pre-commit/mirrors-prettier 9 | rev: 'v2.3.2' # Use the sha / tag you want to point at 10 | hooks: 11 | - id: prettier 12 | - repo: https://github.com/doublify/pre-commit-rust 13 | rev: master 14 | hooks: 15 | - id: fmt 16 | - id: clippy 17 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | programs 2 | target 3 | lib 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /Anchor.toml: -------------------------------------------------------------------------------- 1 | [provider] 2 | cluster = "localnet" 3 | wallet = "~/.config/solana/id.json" 4 | 5 | [programs.localnet] 6 | dialect = "CeNUxGUsSeb5RuAGvaMLNx3tEZrpBwQqA7Gs99vMPCAb" 7 | 8 | [programs.devnet] 9 | dialect = "2YFyZAg8rBtuvzFFiGvXwPHFAQJ2FXZoS7bYCKticpjk" 10 | 11 | [programs.mainnet] 12 | dialect = "CeNUxGUsSeb5RuAGvaMLNx3tEZrpBwQqA7Gs99vMPCAb" 13 | 14 | [scripts] 15 | test = "ts-mocha -t 1000000 tests/test-v1.ts" 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## [UNRELEASED] 4 | 5 | ## [0.3.2] - 2022-06-09 6 | 7 | - Return message after sending 8 | - Increase flexibility by overloading arguments of several functions 9 | 10 | ## [0.3.1] - 2022-04-28 11 | 12 | - fix .json import in esm build 13 | 14 | ## [0.3.0] - 2022-04-27 15 | 16 | - Add esm build 17 | - Update packages with vulnerabilities 18 | - Imports cleanup 19 | 20 | ## [0.2.0] - 2022-04-08 21 | 22 | - Support dialect messages encryption using Curve25519 key pair. 23 | 24 | ## [0.1.12] - 2022-04-02 25 | 26 | - Upgrade anchor to 0.23.0. 27 | - Move index.ts to src/index.ts. 28 | - Enforce that dialect owner must be a member with admin privileges. 29 | 30 | ## [0.1.6] - 2022-02-28 31 | 32 | - Update program ids for mainnet. 33 | 34 | ## [0.1.5] - 2022-02-27 35 | 36 | - Support `Wallet` argument for `sendMessage`. 37 | - Add `isDialectAdmin` utility function. 38 | - Export `Subscription` & `Message` types. 39 | - Sort results in `findDialects`. 40 | - Add `LICENSE`. 41 | 42 | ## [0.1.4] - 2022-02-13 43 | 44 | - Fix clippy actions #83. 45 | - Fix prettier actions #87. 46 | - Fix dockerized tests #84. 47 | - README improvements #80. 48 | 49 | ## [0.1.3] - 2022-02-06 50 | 51 | - Improve package management. 52 | - Remove vestiges of react in linting. 53 | 54 | ## [0.1.2] - 2022-02-03 55 | 56 | - Support `Wallet` argument to more functions. 57 | - Remove remaining bits of wip `MintDialect`. 58 | - Minor removals to fix build. 59 | 60 | ## [0.1.1] - 2022-02-02 61 | 62 | - Add support for closing dialects & metadata. 63 | - Remove `device_token` from metadata. 64 | - Add more documentation. 65 | 66 | ## [0.1.0] - 2022-01-12 67 | 68 | - Original deployment to devnet. 69 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "ahash" 7 | version = "0.4.7" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" 10 | 11 | [[package]] 12 | name = "aho-corasick" 13 | version = "0.7.18" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 16 | dependencies = [ 17 | "memchr", 18 | ] 19 | 20 | [[package]] 21 | name = "anchor-attribute-access-control" 22 | version = "0.23.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "fb917e636aa85cbb0d908e948cf7646c78a3e2fb06f396522d01fa55ec93412f" 25 | dependencies = [ 26 | "anchor-syn", 27 | "anyhow", 28 | "proc-macro2", 29 | "quote", 30 | "regex", 31 | "syn", 32 | ] 33 | 34 | [[package]] 35 | name = "anchor-attribute-account" 36 | version = "0.23.0" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "40132c6a9ecf26f6a1d0480824d4e7327bade791d6afe2e003546f58b450e760" 39 | dependencies = [ 40 | "anchor-syn", 41 | "anyhow", 42 | "bs58 0.4.0", 43 | "proc-macro2", 44 | "quote", 45 | "rustversion", 46 | "syn", 47 | ] 48 | 49 | [[package]] 50 | name = "anchor-attribute-constant" 51 | version = "0.23.0" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "90b229e8eb84adf4c282b45bf03354a265f629cb657323728570d994ca0f8ef0" 54 | dependencies = [ 55 | "anchor-syn", 56 | "proc-macro2", 57 | "syn", 58 | ] 59 | 60 | [[package]] 61 | name = "anchor-attribute-error" 62 | version = "0.23.0" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "0cc0aaf2dae975810ee9f09135cb91b4df66d3834c9bbb789f0930fde2b2a49c" 65 | dependencies = [ 66 | "anchor-syn", 67 | "proc-macro2", 68 | "quote", 69 | "syn", 70 | ] 71 | 72 | [[package]] 73 | name = "anchor-attribute-event" 74 | version = "0.23.0" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "1aefe4f159ae2ccaa908ad7625639294be02602543c5ea8e7b6c8c6126640a8e" 77 | dependencies = [ 78 | "anchor-syn", 79 | "anyhow", 80 | "proc-macro2", 81 | "quote", 82 | "syn", 83 | ] 84 | 85 | [[package]] 86 | name = "anchor-attribute-interface" 87 | version = "0.23.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "7885efaa71230455a55f2078c130c8168ba58a01271d769a9e3e7753041b023b" 90 | dependencies = [ 91 | "anchor-syn", 92 | "anyhow", 93 | "heck", 94 | "proc-macro2", 95 | "quote", 96 | "syn", 97 | ] 98 | 99 | [[package]] 100 | name = "anchor-attribute-program" 101 | version = "0.23.0" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "f962fa82552a2d36ac0a72410a7a63dce16ff84d5921f3195cf1427c89d3848d" 104 | dependencies = [ 105 | "anchor-syn", 106 | "anyhow", 107 | "proc-macro2", 108 | "quote", 109 | "syn", 110 | ] 111 | 112 | [[package]] 113 | name = "anchor-attribute-state" 114 | version = "0.23.0" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "acbb91124f1e49d5d84b4c812f47442830ddf8e5b25e7e7fbbe8027ea9a55f4e" 117 | dependencies = [ 118 | "anchor-syn", 119 | "anyhow", 120 | "proc-macro2", 121 | "quote", 122 | "syn", 123 | ] 124 | 125 | [[package]] 126 | name = "anchor-derive-accounts" 127 | version = "0.23.0" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "8acbc8ab9e4305ae5fdfecc0e40f1254db28747d0bb4a4adf0d63dd09366bac6" 130 | dependencies = [ 131 | "anchor-syn", 132 | "anyhow", 133 | "proc-macro2", 134 | "quote", 135 | "syn", 136 | ] 137 | 138 | [[package]] 139 | name = "anchor-lang" 140 | version = "0.23.0" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "720b81290f0323ab655380fe80d6aeb678c77500e07e5d75e7693b611895b5a4" 143 | dependencies = [ 144 | "anchor-attribute-access-control", 145 | "anchor-attribute-account", 146 | "anchor-attribute-constant", 147 | "anchor-attribute-error", 148 | "anchor-attribute-event", 149 | "anchor-attribute-interface", 150 | "anchor-attribute-program", 151 | "anchor-attribute-state", 152 | "anchor-derive-accounts", 153 | "arrayref", 154 | "base64 0.13.0", 155 | "bincode", 156 | "borsh", 157 | "bytemuck", 158 | "solana-program", 159 | "thiserror", 160 | ] 161 | 162 | [[package]] 163 | name = "anchor-spl" 164 | version = "0.23.0" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "302c7285127b097d90e188eb9a7488ea9099550cceb86f27177d51d67df609b5" 167 | dependencies = [ 168 | "anchor-lang", 169 | "solana-program", 170 | "spl-associated-token-account", 171 | "spl-token", 172 | ] 173 | 174 | [[package]] 175 | name = "anchor-syn" 176 | version = "0.23.0" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "b18fdb2a5bba16db5d2121935284d3fe226cf35ef1c549162abcb2c50c5b7e21" 179 | dependencies = [ 180 | "anyhow", 181 | "bs58 0.3.1", 182 | "heck", 183 | "proc-macro2", 184 | "proc-macro2-diagnostics", 185 | "quote", 186 | "serde", 187 | "serde_json", 188 | "sha2", 189 | "syn", 190 | "thiserror", 191 | ] 192 | 193 | [[package]] 194 | name = "anyhow" 195 | version = "1.0.44" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1" 198 | 199 | [[package]] 200 | name = "arrayref" 201 | version = "0.3.6" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" 204 | 205 | [[package]] 206 | name = "arrayvec" 207 | version = "0.5.2" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" 210 | 211 | [[package]] 212 | name = "atty" 213 | version = "0.2.14" 214 | source = "registry+https://github.com/rust-lang/crates.io-index" 215 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 216 | dependencies = [ 217 | "hermit-abi", 218 | "libc", 219 | "winapi", 220 | ] 221 | 222 | [[package]] 223 | name = "autocfg" 224 | version = "1.0.1" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 227 | 228 | [[package]] 229 | name = "base64" 230 | version = "0.12.3" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" 233 | 234 | [[package]] 235 | name = "base64" 236 | version = "0.13.0" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 239 | 240 | [[package]] 241 | name = "bincode" 242 | version = "1.3.3" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 245 | dependencies = [ 246 | "serde", 247 | ] 248 | 249 | [[package]] 250 | name = "blake3" 251 | version = "0.3.8" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "b64485778c4f16a6a5a9d335e80d449ac6c70cdd6a06d2af18a6f6f775a125b3" 254 | dependencies = [ 255 | "arrayref", 256 | "arrayvec", 257 | "cc", 258 | "cfg-if 0.1.10", 259 | "constant_time_eq", 260 | "crypto-mac", 261 | "digest 0.9.0", 262 | ] 263 | 264 | [[package]] 265 | name = "block-buffer" 266 | version = "0.9.0" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" 269 | dependencies = [ 270 | "block-padding", 271 | "generic-array 0.14.4", 272 | ] 273 | 274 | [[package]] 275 | name = "block-padding" 276 | version = "0.2.1" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" 279 | 280 | [[package]] 281 | name = "borsh" 282 | version = "0.9.1" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "18dda7dc709193c0d86a1a51050a926dc3df1cf262ec46a23a25dba421ea1924" 285 | dependencies = [ 286 | "borsh-derive", 287 | "hashbrown", 288 | ] 289 | 290 | [[package]] 291 | name = "borsh-derive" 292 | version = "0.9.1" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "684155372435f578c0fa1acd13ebbb182cc19d6b38b64ae7901da4393217d264" 295 | dependencies = [ 296 | "borsh-derive-internal", 297 | "borsh-schema-derive-internal", 298 | "proc-macro-crate 0.1.5", 299 | "proc-macro2", 300 | "syn", 301 | ] 302 | 303 | [[package]] 304 | name = "borsh-derive-internal" 305 | version = "0.9.1" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "2102f62f8b6d3edeab871830782285b64cc1830168094db05c8e458f209bc5c3" 308 | dependencies = [ 309 | "proc-macro2", 310 | "quote", 311 | "syn", 312 | ] 313 | 314 | [[package]] 315 | name = "borsh-schema-derive-internal" 316 | version = "0.9.1" 317 | source = "registry+https://github.com/rust-lang/crates.io-index" 318 | checksum = "196c978c4c9b0b142d446ef3240690bf5a8a33497074a113ff9a337ccb750483" 319 | dependencies = [ 320 | "proc-macro2", 321 | "quote", 322 | "syn", 323 | ] 324 | 325 | [[package]] 326 | name = "bs58" 327 | version = "0.3.1" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "476e9cd489f9e121e02ffa6014a8ef220ecb15c05ed23fc34cca13925dc283fb" 330 | 331 | [[package]] 332 | name = "bs58" 333 | version = "0.4.0" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" 336 | 337 | [[package]] 338 | name = "bv" 339 | version = "0.11.1" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340" 342 | dependencies = [ 343 | "feature-probe", 344 | "serde", 345 | ] 346 | 347 | [[package]] 348 | name = "bytemuck" 349 | version = "1.7.2" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "72957246c41db82b8ef88a5486143830adeb8227ef9837740bdec67724cf2c5b" 352 | dependencies = [ 353 | "bytemuck_derive", 354 | ] 355 | 356 | [[package]] 357 | name = "bytemuck_derive" 358 | version = "1.0.1" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "8e215f8c2f9f79cb53c8335e687ffd07d5bfcb6fe5fc80723762d0be46e7cc54" 361 | dependencies = [ 362 | "proc-macro2", 363 | "quote", 364 | "syn", 365 | ] 366 | 367 | [[package]] 368 | name = "byteorder" 369 | version = "1.4.3" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 372 | 373 | [[package]] 374 | name = "cc" 375 | version = "1.0.71" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "79c2681d6594606957bbb8631c4b90a7fcaaa72cdb714743a437b156d6a7eedd" 378 | 379 | [[package]] 380 | name = "cfg-if" 381 | version = "0.1.10" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 384 | 385 | [[package]] 386 | name = "cfg-if" 387 | version = "1.0.0" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 390 | 391 | [[package]] 392 | name = "constant_time_eq" 393 | version = "0.1.5" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" 396 | 397 | [[package]] 398 | name = "cpufeatures" 399 | version = "0.2.1" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" 402 | dependencies = [ 403 | "libc", 404 | ] 405 | 406 | [[package]] 407 | name = "crunchy" 408 | version = "0.2.2" 409 | source = "registry+https://github.com/rust-lang/crates.io-index" 410 | checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" 411 | 412 | [[package]] 413 | name = "crypto-mac" 414 | version = "0.8.0" 415 | source = "registry+https://github.com/rust-lang/crates.io-index" 416 | checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" 417 | dependencies = [ 418 | "generic-array 0.14.4", 419 | "subtle", 420 | ] 421 | 422 | [[package]] 423 | name = "curve25519-dalek" 424 | version = "2.1.3" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "4a9b85542f99a2dfa2a1b8e192662741c9859a846b296bef1c92ef9b58b5a216" 427 | dependencies = [ 428 | "byteorder", 429 | "digest 0.8.1", 430 | "rand_core", 431 | "subtle", 432 | "zeroize", 433 | ] 434 | 435 | [[package]] 436 | name = "derivative" 437 | version = "2.2.0" 438 | source = "registry+https://github.com/rust-lang/crates.io-index" 439 | checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" 440 | dependencies = [ 441 | "proc-macro2", 442 | "quote", 443 | "syn", 444 | ] 445 | 446 | [[package]] 447 | name = "dialect" 448 | version = "0.1.6" 449 | dependencies = [ 450 | "anchor-lang", 451 | "anchor-spl", 452 | "solana-program", 453 | ] 454 | 455 | [[package]] 456 | name = "digest" 457 | version = "0.8.1" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" 460 | dependencies = [ 461 | "generic-array 0.12.4", 462 | ] 463 | 464 | [[package]] 465 | name = "digest" 466 | version = "0.9.0" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" 469 | dependencies = [ 470 | "generic-array 0.14.4", 471 | ] 472 | 473 | [[package]] 474 | name = "either" 475 | version = "1.6.1" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" 478 | 479 | [[package]] 480 | name = "env_logger" 481 | version = "0.8.4" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" 484 | dependencies = [ 485 | "atty", 486 | "humantime", 487 | "log", 488 | "regex", 489 | "termcolor", 490 | ] 491 | 492 | [[package]] 493 | name = "feature-probe" 494 | version = "0.1.1" 495 | source = "registry+https://github.com/rust-lang/crates.io-index" 496 | checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" 497 | 498 | [[package]] 499 | name = "generic-array" 500 | version = "0.12.4" 501 | source = "registry+https://github.com/rust-lang/crates.io-index" 502 | checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" 503 | dependencies = [ 504 | "typenum", 505 | ] 506 | 507 | [[package]] 508 | name = "generic-array" 509 | version = "0.14.4" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" 512 | dependencies = [ 513 | "serde", 514 | "typenum", 515 | "version_check", 516 | ] 517 | 518 | [[package]] 519 | name = "getrandom" 520 | version = "0.1.16" 521 | source = "registry+https://github.com/rust-lang/crates.io-index" 522 | checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" 523 | dependencies = [ 524 | "cfg-if 1.0.0", 525 | "libc", 526 | "wasi", 527 | ] 528 | 529 | [[package]] 530 | name = "hashbrown" 531 | version = "0.9.1" 532 | source = "registry+https://github.com/rust-lang/crates.io-index" 533 | checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" 534 | dependencies = [ 535 | "ahash", 536 | ] 537 | 538 | [[package]] 539 | name = "heck" 540 | version = "0.3.3" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" 543 | dependencies = [ 544 | "unicode-segmentation", 545 | ] 546 | 547 | [[package]] 548 | name = "hermit-abi" 549 | version = "0.1.19" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 552 | dependencies = [ 553 | "libc", 554 | ] 555 | 556 | [[package]] 557 | name = "hex" 558 | version = "0.4.3" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 561 | 562 | [[package]] 563 | name = "hmac" 564 | version = "0.8.1" 565 | source = "registry+https://github.com/rust-lang/crates.io-index" 566 | checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" 567 | dependencies = [ 568 | "crypto-mac", 569 | "digest 0.9.0", 570 | ] 571 | 572 | [[package]] 573 | name = "hmac-drbg" 574 | version = "0.3.0" 575 | source = "registry+https://github.com/rust-lang/crates.io-index" 576 | checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" 577 | dependencies = [ 578 | "digest 0.9.0", 579 | "generic-array 0.14.4", 580 | "hmac", 581 | ] 582 | 583 | [[package]] 584 | name = "humantime" 585 | version = "2.1.0" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 588 | 589 | [[package]] 590 | name = "itertools" 591 | version = "0.9.0" 592 | source = "registry+https://github.com/rust-lang/crates.io-index" 593 | checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" 594 | dependencies = [ 595 | "either", 596 | ] 597 | 598 | [[package]] 599 | name = "itoa" 600 | version = "0.4.8" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" 603 | 604 | [[package]] 605 | name = "keccak" 606 | version = "0.1.0" 607 | source = "registry+https://github.com/rust-lang/crates.io-index" 608 | checksum = "67c21572b4949434e4fc1e1978b99c5f77064153c59d998bf13ecd96fb5ecba7" 609 | 610 | [[package]] 611 | name = "lazy_static" 612 | version = "1.4.0" 613 | source = "registry+https://github.com/rust-lang/crates.io-index" 614 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 615 | 616 | [[package]] 617 | name = "libc" 618 | version = "0.2.104" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "7b2f96d100e1cf1929e7719b7edb3b90ab5298072638fccd77be9ce942ecdfce" 621 | 622 | [[package]] 623 | name = "libsecp256k1" 624 | version = "0.5.0" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "bd1137239ab33b41aa9637a88a28249e5e70c40a42ccc92db7f12cc356c1fcd7" 627 | dependencies = [ 628 | "arrayref", 629 | "base64 0.12.3", 630 | "digest 0.9.0", 631 | "hmac-drbg", 632 | "libsecp256k1-core", 633 | "libsecp256k1-gen-ecmult", 634 | "libsecp256k1-gen-genmult", 635 | "rand", 636 | "serde", 637 | "sha2", 638 | "typenum", 639 | ] 640 | 641 | [[package]] 642 | name = "libsecp256k1-core" 643 | version = "0.2.2" 644 | source = "registry+https://github.com/rust-lang/crates.io-index" 645 | checksum = "d0f6ab710cec28cef759c5f18671a27dae2a5f952cdaaee1d8e2908cb2478a80" 646 | dependencies = [ 647 | "crunchy", 648 | "digest 0.9.0", 649 | "subtle", 650 | ] 651 | 652 | [[package]] 653 | name = "libsecp256k1-gen-ecmult" 654 | version = "0.2.1" 655 | source = "registry+https://github.com/rust-lang/crates.io-index" 656 | checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3" 657 | dependencies = [ 658 | "libsecp256k1-core", 659 | ] 660 | 661 | [[package]] 662 | name = "libsecp256k1-gen-genmult" 663 | version = "0.2.1" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d" 666 | dependencies = [ 667 | "libsecp256k1-core", 668 | ] 669 | 670 | [[package]] 671 | name = "log" 672 | version = "0.4.14" 673 | source = "registry+https://github.com/rust-lang/crates.io-index" 674 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 675 | dependencies = [ 676 | "cfg-if 1.0.0", 677 | ] 678 | 679 | [[package]] 680 | name = "memchr" 681 | version = "2.4.1" 682 | source = "registry+https://github.com/rust-lang/crates.io-index" 683 | checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" 684 | 685 | [[package]] 686 | name = "memmap2" 687 | version = "0.1.0" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "d9b70ca2a6103ac8b665dc150b142ef0e4e89df640c9e6cf295d189c3caebe5a" 690 | dependencies = [ 691 | "libc", 692 | ] 693 | 694 | [[package]] 695 | name = "num-derive" 696 | version = "0.3.3" 697 | source = "registry+https://github.com/rust-lang/crates.io-index" 698 | checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" 699 | dependencies = [ 700 | "proc-macro2", 701 | "quote", 702 | "syn", 703 | ] 704 | 705 | [[package]] 706 | name = "num-traits" 707 | version = "0.2.14" 708 | source = "registry+https://github.com/rust-lang/crates.io-index" 709 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 710 | dependencies = [ 711 | "autocfg", 712 | ] 713 | 714 | [[package]] 715 | name = "num_enum" 716 | version = "0.5.4" 717 | source = "registry+https://github.com/rust-lang/crates.io-index" 718 | checksum = "3f9bd055fb730c4f8f4f57d45d35cd6b3f0980535b056dc7ff119cee6a66ed6f" 719 | dependencies = [ 720 | "derivative", 721 | "num_enum_derive", 722 | ] 723 | 724 | [[package]] 725 | name = "num_enum_derive" 726 | version = "0.5.4" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "486ea01961c4a818096de679a8b740b26d9033146ac5291b1c98557658f8cdd9" 729 | dependencies = [ 730 | "proc-macro-crate 1.1.0", 731 | "proc-macro2", 732 | "quote", 733 | "syn", 734 | ] 735 | 736 | [[package]] 737 | name = "opaque-debug" 738 | version = "0.3.0" 739 | source = "registry+https://github.com/rust-lang/crates.io-index" 740 | checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" 741 | 742 | [[package]] 743 | name = "ppv-lite86" 744 | version = "0.2.14" 745 | source = "registry+https://github.com/rust-lang/crates.io-index" 746 | checksum = "c3ca011bd0129ff4ae15cd04c4eef202cadf6c51c21e47aba319b4e0501db741" 747 | 748 | [[package]] 749 | name = "proc-macro-crate" 750 | version = "0.1.5" 751 | source = "registry+https://github.com/rust-lang/crates.io-index" 752 | checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" 753 | dependencies = [ 754 | "toml", 755 | ] 756 | 757 | [[package]] 758 | name = "proc-macro-crate" 759 | version = "1.1.0" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "1ebace6889caf889b4d3f76becee12e90353f2b8c7d875534a71e5742f8f6f83" 762 | dependencies = [ 763 | "thiserror", 764 | "toml", 765 | ] 766 | 767 | [[package]] 768 | name = "proc-macro2" 769 | version = "1.0.30" 770 | source = "registry+https://github.com/rust-lang/crates.io-index" 771 | checksum = "edc3358ebc67bc8b7fa0c007f945b0b18226f78437d61bec735a9eb96b61ee70" 772 | dependencies = [ 773 | "unicode-xid", 774 | ] 775 | 776 | [[package]] 777 | name = "proc-macro2-diagnostics" 778 | version = "0.9.1" 779 | source = "registry+https://github.com/rust-lang/crates.io-index" 780 | checksum = "4bf29726d67464d49fa6224a1d07936a8c08bb3fba727c7493f6cf1616fdaada" 781 | dependencies = [ 782 | "proc-macro2", 783 | "quote", 784 | "syn", 785 | "version_check", 786 | "yansi", 787 | ] 788 | 789 | [[package]] 790 | name = "quote" 791 | version = "1.0.10" 792 | source = "registry+https://github.com/rust-lang/crates.io-index" 793 | checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" 794 | dependencies = [ 795 | "proc-macro2", 796 | ] 797 | 798 | [[package]] 799 | name = "rand" 800 | version = "0.7.3" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" 803 | dependencies = [ 804 | "getrandom", 805 | "libc", 806 | "rand_chacha", 807 | "rand_core", 808 | "rand_hc", 809 | ] 810 | 811 | [[package]] 812 | name = "rand_chacha" 813 | version = "0.2.2" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" 816 | dependencies = [ 817 | "ppv-lite86", 818 | "rand_core", 819 | ] 820 | 821 | [[package]] 822 | name = "rand_core" 823 | version = "0.5.1" 824 | source = "registry+https://github.com/rust-lang/crates.io-index" 825 | checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" 826 | dependencies = [ 827 | "getrandom", 828 | ] 829 | 830 | [[package]] 831 | name = "rand_hc" 832 | version = "0.2.0" 833 | source = "registry+https://github.com/rust-lang/crates.io-index" 834 | checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" 835 | dependencies = [ 836 | "rand_core", 837 | ] 838 | 839 | [[package]] 840 | name = "regex" 841 | version = "1.5.4" 842 | source = "registry+https://github.com/rust-lang/crates.io-index" 843 | checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" 844 | dependencies = [ 845 | "aho-corasick", 846 | "memchr", 847 | "regex-syntax", 848 | ] 849 | 850 | [[package]] 851 | name = "regex-syntax" 852 | version = "0.6.25" 853 | source = "registry+https://github.com/rust-lang/crates.io-index" 854 | checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" 855 | 856 | [[package]] 857 | name = "rustc_version" 858 | version = "0.2.3" 859 | source = "registry+https://github.com/rust-lang/crates.io-index" 860 | checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" 861 | dependencies = [ 862 | "semver", 863 | ] 864 | 865 | [[package]] 866 | name = "rustversion" 867 | version = "1.0.5" 868 | source = "registry+https://github.com/rust-lang/crates.io-index" 869 | checksum = "61b3909d758bb75c79f23d4736fac9433868679d3ad2ea7a61e3c25cfda9a088" 870 | 871 | [[package]] 872 | name = "ryu" 873 | version = "1.0.5" 874 | source = "registry+https://github.com/rust-lang/crates.io-index" 875 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 876 | 877 | [[package]] 878 | name = "semver" 879 | version = "0.9.0" 880 | source = "registry+https://github.com/rust-lang/crates.io-index" 881 | checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" 882 | dependencies = [ 883 | "semver-parser", 884 | ] 885 | 886 | [[package]] 887 | name = "semver-parser" 888 | version = "0.7.0" 889 | source = "registry+https://github.com/rust-lang/crates.io-index" 890 | checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" 891 | 892 | [[package]] 893 | name = "serde" 894 | version = "1.0.130" 895 | source = "registry+https://github.com/rust-lang/crates.io-index" 896 | checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" 897 | dependencies = [ 898 | "serde_derive", 899 | ] 900 | 901 | [[package]] 902 | name = "serde_bytes" 903 | version = "0.11.5" 904 | source = "registry+https://github.com/rust-lang/crates.io-index" 905 | checksum = "16ae07dd2f88a366f15bd0632ba725227018c69a1c8550a927324f8eb8368bb9" 906 | dependencies = [ 907 | "serde", 908 | ] 909 | 910 | [[package]] 911 | name = "serde_derive" 912 | version = "1.0.130" 913 | source = "registry+https://github.com/rust-lang/crates.io-index" 914 | checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" 915 | dependencies = [ 916 | "proc-macro2", 917 | "quote", 918 | "syn", 919 | ] 920 | 921 | [[package]] 922 | name = "serde_json" 923 | version = "1.0.68" 924 | source = "registry+https://github.com/rust-lang/crates.io-index" 925 | checksum = "0f690853975602e1bfe1ccbf50504d67174e3bcf340f23b5ea9992e0587a52d8" 926 | dependencies = [ 927 | "itoa", 928 | "ryu", 929 | "serde", 930 | ] 931 | 932 | [[package]] 933 | name = "sha2" 934 | version = "0.9.8" 935 | source = "registry+https://github.com/rust-lang/crates.io-index" 936 | checksum = "b69f9a4c9740d74c5baa3fd2e547f9525fa8088a8a958e0ca2409a514e33f5fa" 937 | dependencies = [ 938 | "block-buffer", 939 | "cfg-if 1.0.0", 940 | "cpufeatures", 941 | "digest 0.9.0", 942 | "opaque-debug", 943 | ] 944 | 945 | [[package]] 946 | name = "sha3" 947 | version = "0.9.1" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "f81199417d4e5de3f04b1e871023acea7389672c4135918f05aa9cbf2f2fa809" 950 | dependencies = [ 951 | "block-buffer", 952 | "digest 0.9.0", 953 | "keccak", 954 | "opaque-debug", 955 | ] 956 | 957 | [[package]] 958 | name = "solana-frozen-abi" 959 | version = "1.8.16" 960 | source = "registry+https://github.com/rust-lang/crates.io-index" 961 | checksum = "425be155319bda665dc3483f0c0267ac0fc89017812d0c5d9816b1c7a26c1532" 962 | dependencies = [ 963 | "bs58 0.3.1", 964 | "bv", 965 | "generic-array 0.14.4", 966 | "log", 967 | "memmap2", 968 | "rustc_version", 969 | "serde", 970 | "serde_derive", 971 | "sha2", 972 | "solana-frozen-abi-macro", 973 | "solana-logger", 974 | "thiserror", 975 | ] 976 | 977 | [[package]] 978 | name = "solana-frozen-abi-macro" 979 | version = "1.8.16" 980 | source = "registry+https://github.com/rust-lang/crates.io-index" 981 | checksum = "8d97737c34380c42c9b3e060cf68d1929ad81fea5a3c00887bb82314b788ba13" 982 | dependencies = [ 983 | "proc-macro2", 984 | "quote", 985 | "rustc_version", 986 | "syn", 987 | ] 988 | 989 | [[package]] 990 | name = "solana-logger" 991 | version = "1.8.16" 992 | source = "registry+https://github.com/rust-lang/crates.io-index" 993 | checksum = "659d836ac49f5a53481ead26f4ea78b688a91dedcbe6c51454169491e1648ceb" 994 | dependencies = [ 995 | "env_logger", 996 | "lazy_static", 997 | "log", 998 | ] 999 | 1000 | [[package]] 1001 | name = "solana-program" 1002 | version = "1.8.16" 1003 | source = "registry+https://github.com/rust-lang/crates.io-index" 1004 | checksum = "00b2b6d99b5c662975ead69a60ead75b820f2eaa42eb4512c79a919e91807d43" 1005 | dependencies = [ 1006 | "base64 0.13.0", 1007 | "bincode", 1008 | "blake3", 1009 | "borsh", 1010 | "borsh-derive", 1011 | "bs58 0.3.1", 1012 | "bv", 1013 | "bytemuck", 1014 | "curve25519-dalek", 1015 | "hex", 1016 | "itertools", 1017 | "lazy_static", 1018 | "libsecp256k1", 1019 | "log", 1020 | "num-derive", 1021 | "num-traits", 1022 | "rand", 1023 | "rustc_version", 1024 | "rustversion", 1025 | "serde", 1026 | "serde_bytes", 1027 | "serde_derive", 1028 | "sha2", 1029 | "sha3", 1030 | "solana-frozen-abi", 1031 | "solana-frozen-abi-macro", 1032 | "solana-logger", 1033 | "solana-sdk-macro", 1034 | "thiserror", 1035 | ] 1036 | 1037 | [[package]] 1038 | name = "solana-sdk-macro" 1039 | version = "1.8.16" 1040 | source = "registry+https://github.com/rust-lang/crates.io-index" 1041 | checksum = "a122a01e936f3b69064f0800e0488617833fc6a4dd86294cf7cc75f34511d6b5" 1042 | dependencies = [ 1043 | "bs58 0.3.1", 1044 | "proc-macro2", 1045 | "quote", 1046 | "rustversion", 1047 | "syn", 1048 | ] 1049 | 1050 | [[package]] 1051 | name = "spl-associated-token-account" 1052 | version = "1.0.3" 1053 | source = "registry+https://github.com/rust-lang/crates.io-index" 1054 | checksum = "393e2240d521c3dd770806bff25c2c00d761ac962be106e14e22dd912007f428" 1055 | dependencies = [ 1056 | "solana-program", 1057 | "spl-token", 1058 | ] 1059 | 1060 | [[package]] 1061 | name = "spl-token" 1062 | version = "3.2.0" 1063 | source = "registry+https://github.com/rust-lang/crates.io-index" 1064 | checksum = "93bfdd5bd7c869cb565c7d7635c4fafe189b988a0bdef81063cd9585c6b8dc01" 1065 | dependencies = [ 1066 | "arrayref", 1067 | "num-derive", 1068 | "num-traits", 1069 | "num_enum", 1070 | "solana-program", 1071 | "thiserror", 1072 | ] 1073 | 1074 | [[package]] 1075 | name = "subtle" 1076 | version = "2.4.1" 1077 | source = "registry+https://github.com/rust-lang/crates.io-index" 1078 | checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" 1079 | 1080 | [[package]] 1081 | name = "syn" 1082 | version = "1.0.80" 1083 | source = "registry+https://github.com/rust-lang/crates.io-index" 1084 | checksum = "d010a1623fbd906d51d650a9916aaefc05ffa0e4053ff7fe601167f3e715d194" 1085 | dependencies = [ 1086 | "proc-macro2", 1087 | "quote", 1088 | "unicode-xid", 1089 | ] 1090 | 1091 | [[package]] 1092 | name = "termcolor" 1093 | version = "1.1.2" 1094 | source = "registry+https://github.com/rust-lang/crates.io-index" 1095 | checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" 1096 | dependencies = [ 1097 | "winapi-util", 1098 | ] 1099 | 1100 | [[package]] 1101 | name = "thiserror" 1102 | version = "1.0.30" 1103 | source = "registry+https://github.com/rust-lang/crates.io-index" 1104 | checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" 1105 | dependencies = [ 1106 | "thiserror-impl", 1107 | ] 1108 | 1109 | [[package]] 1110 | name = "thiserror-impl" 1111 | version = "1.0.30" 1112 | source = "registry+https://github.com/rust-lang/crates.io-index" 1113 | checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" 1114 | dependencies = [ 1115 | "proc-macro2", 1116 | "quote", 1117 | "syn", 1118 | ] 1119 | 1120 | [[package]] 1121 | name = "toml" 1122 | version = "0.5.8" 1123 | source = "registry+https://github.com/rust-lang/crates.io-index" 1124 | checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" 1125 | dependencies = [ 1126 | "serde", 1127 | ] 1128 | 1129 | [[package]] 1130 | name = "typenum" 1131 | version = "1.14.0" 1132 | source = "registry+https://github.com/rust-lang/crates.io-index" 1133 | checksum = "b63708a265f51345575b27fe43f9500ad611579e764c79edbc2037b1121959ec" 1134 | 1135 | [[package]] 1136 | name = "unicode-segmentation" 1137 | version = "1.8.0" 1138 | source = "registry+https://github.com/rust-lang/crates.io-index" 1139 | checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" 1140 | 1141 | [[package]] 1142 | name = "unicode-xid" 1143 | version = "0.2.2" 1144 | source = "registry+https://github.com/rust-lang/crates.io-index" 1145 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 1146 | 1147 | [[package]] 1148 | name = "version_check" 1149 | version = "0.9.3" 1150 | source = "registry+https://github.com/rust-lang/crates.io-index" 1151 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 1152 | 1153 | [[package]] 1154 | name = "wasi" 1155 | version = "0.9.0+wasi-snapshot-preview1" 1156 | source = "registry+https://github.com/rust-lang/crates.io-index" 1157 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 1158 | 1159 | [[package]] 1160 | name = "winapi" 1161 | version = "0.3.9" 1162 | source = "registry+https://github.com/rust-lang/crates.io-index" 1163 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1164 | dependencies = [ 1165 | "winapi-i686-pc-windows-gnu", 1166 | "winapi-x86_64-pc-windows-gnu", 1167 | ] 1168 | 1169 | [[package]] 1170 | name = "winapi-i686-pc-windows-gnu" 1171 | version = "0.4.0" 1172 | source = "registry+https://github.com/rust-lang/crates.io-index" 1173 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1174 | 1175 | [[package]] 1176 | name = "winapi-util" 1177 | version = "0.1.5" 1178 | source = "registry+https://github.com/rust-lang/crates.io-index" 1179 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 1180 | dependencies = [ 1181 | "winapi", 1182 | ] 1183 | 1184 | [[package]] 1185 | name = "winapi-x86_64-pc-windows-gnu" 1186 | version = "0.4.0" 1187 | source = "registry+https://github.com/rust-lang/crates.io-index" 1188 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1189 | 1190 | [[package]] 1191 | name = "yansi" 1192 | version = "0.5.0" 1193 | source = "registry+https://github.com/rust-lang/crates.io-index" 1194 | checksum = "9fc79f4a1e39857fc00c3f662cbf2651c771f00e9c15fe2abc341806bd46bd71" 1195 | 1196 | [[package]] 1197 | name = "zeroize" 1198 | version = "1.4.2" 1199 | source = "registry+https://github.com/rust-lang/crates.io-index" 1200 | checksum = "bf68b08513768deaa790264a7fac27a58cbf2705cfcdc9448362229217d7e970" 1201 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "programs/*", 4 | ] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Enombic Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Protocol & web3 2 | 3 | ## Summary 4 | 5 | Dialect is a smart messaging protocol for dapp notifications and wallet-to-wallet messaging on the Solana Blockchain. 6 | 7 | Dialect works by decorating on-chain resources, or sets of resources, with publish-subscribe (pub-sub) messaging capabilities. This is accomplished by creating a PDA whose seeds are the (lexically sorted) resources' public keys. Each pub-sub messaging PDA is called a _dialect_. 8 | 9 | Dialect `v0` currently supports one-to-one messaging between wallets, which powers both dapp notifications as well as user-to-user chat. Future versions of Dialect will also support one-to-many and many-to-many messaging. 10 | 11 | This repository contains both the Dialect rust programs (protocol), in Anchor, as well as a typescript client, published to npm as `@dialectlabs/web3`. 12 | 13 | Currently, the dialect account rent cost is `~0.059 SOL`. 14 | 15 | ## Table of Contents 16 | 17 | 1. Installation 18 | 2. Usage 19 | 3. Local Development 20 | 4. Docker 21 | 5. Anchor Tests 22 | 6. Examples 23 | 7. Message Encryption 24 | 25 | ## Installation 26 | 27 | **npm:** 28 | 29 | ```shell 30 | npm install @dialectlabs/protocol --save 31 | ``` 32 | 33 | **yarn:** 34 | 35 | ```shell 36 | yarn add @dialectlabs/protocol 37 | ``` 38 | 39 | ## Usage 40 | 41 | This section describes how to use Dialect protocol in your app by showing you examples in the`examples/` directory of this repository. Follow along in this section, & refer to the code in those examples. 42 | 43 | ### Create your first dialect, send and receive message 44 | 45 | The example in `examples/hello-world.ts` demonstrates how to create a new dialect, send and receive message. 46 | 47 | ```typescript 48 | import { 49 | createDialect, 50 | getDialectForMembers, 51 | sendMessage, 52 | Member, 53 | } from '@dialectlabs/protocol'; 54 | 55 | const program = // ... initialize dialect program 56 | 57 | const [user1, user2] = [Keypair.generate(), Keypair.generate()]; 58 | // ... fund keypairs 59 | const dialectMembers: Member[] = [ 60 | { 61 | publicKey: user1.publicKey, 62 | scopes: [true, true], 63 | }, 64 | { 65 | publicKey: user2.publicKey, 66 | scopes: [false, true], 67 | }, 68 | ]; 69 | const user1Dialect = await createDialect( 70 | program, 71 | user1, 72 | dialectMembers, 73 | false, 74 | ); // crate dialect on behalf of 1st user 75 | await sendMessage(program, user1Dialect, user1, 'Hello dialect!'); // send message 76 | const { dialect: user2Dialect } = await getDialectForMembers( 77 | program, 78 | dialectMembers, 79 | user2, 80 | ); // get dialect on behalf of 2nd user 81 | console.log(JSON.stringify(user2Dialect.messages)); 82 | // Will print [{"text":"Hello dialect!", ...}] 83 | ``` 84 | 85 | Run the example above 86 | 87 | ```shell 88 | ts-node examples/hello-world.ts 89 | ``` 90 | 91 | ## Local Development 92 | 93 | Note: If you just need a local running instance of the Dialect program, it is easiest to simply run Dialect in a docker container. See the [Docker](###docker) section below. 94 | 95 | Dialect is built with Solana and Anchor. Install both dependencies first following their respective documentation 96 | 97 | - [Solana](https://docs.solana.com/cli/install-solana-cli-tools) 98 | - [Anchor](https://book.anchor-lang.com) v0.18.0 99 | 100 | We recommend installing anchor with [avm](https://book.anchor-lang.com/getting_started/installation.html#installing-using-anchor-version-manager-avm-recommended) and using the `"@project-serum/anchor"` version that's specified in our [package.json](https://github.com/dialectlabs/protocol/blob/master/package.json) file. 101 | 102 | Be sure you are targeting a Solana `localnet` instance: 103 | 104 | ```shell 105 | solana config set --url localhost 106 | ``` 107 | 108 | Next run the tests to verify everything is setup correctly: 109 | 110 | First ensure you have ts-mocha installed globally: 111 | 112 | ```shell 113 | npm install -g ts-mocha 114 | ``` 115 | 116 | Run the tests with: 117 | 118 | ```shell 119 | anchor test 120 | ``` 121 | 122 | Run a local validator: 123 | 124 | ```shell 125 | solana-test-validator --rpc-port 8899 126 | ``` 127 | 128 | Build the Dialect Solana program: 129 | 130 | ```shell 131 | anchor build 132 | ``` 133 | 134 | If you haven't deployed this program to localnet before, `anchor build` produces a program-id. To get this program-id use the command: 135 | 136 | ```bash 137 | solana address -k target/deploy/dialect-keypair.json 138 | ``` 139 | 140 | Add this program id in the following additional places before proceeding: 141 | 142 | 1. In the `dialect = ""` in `Anchor.toml` 143 | 2. In the `declare_id!("")` in `programs/dialect/src/lib.rs` 144 | 3. In the `localnet` key in `src/utils/program.json` (redundant, to be retired) 145 | 146 | Before deploying make sure you fund your Solana wallet: 147 | 148 | You can fund your wallet with: 149 | 150 | ```shell 151 | solana airdrop 2 152 | ``` 153 | 154 | You can verify your token balance with: 155 | 156 | ```shell 157 | solana balance 158 | ``` 159 | 160 | Deploy the Dialect Solana program with: 161 | 162 | ```shell 163 | anchor deploy 164 | ``` 165 | 166 | Finally, install the `js`/`ts` `npm` dependencies with 167 | 168 | ```shell 169 | npm i 170 | ``` 171 | 172 | ### Docker 173 | 174 | The Dialect docker image will get you a deployed Dialect program running on a Solana validator. This is ideal if you're building off of `@dialectlabs/protocol`, rather than actively developing on it. 175 | 176 | ```bash 177 | # build 178 | docker build -f docker/Dockerfile . -t dialect/protocol:latest 179 | 180 | # run 181 | docker run -i --rm -p 8899:8899 -p 8900:8900 -p 9900:9900 --name protocol dialect/protocol:latest 182 | ``` 183 | 184 | ## Tests 185 | 186 | First ensure you have ts-mocha install globally: 187 | 188 | ```shell 189 | npm install -g ts-mocha 190 | ``` 191 | 192 | Run the tests with: 193 | 194 | ```shell 195 | anchor test 196 | ``` 197 | 198 | ## Examples 199 | 200 | Run the example with: 201 | 202 | ```bash 203 | DIALECT_PUBLIC_KEY= ts-node examples/hello-world.ts 204 | ``` 205 | 206 | It is fine to omit the `DIALECT_PUBLIC_KEY` environment variable, the example will generate one on the fly. However, if you're using this example as an integration test with other services, such as the monitoring service, you'll need to set it to the public key corresponding to the private key in the monitoring service. 207 | 208 | ## Message Encryption 209 | 210 | A note about the encryption nonce. 211 | 212 | https://pynacl.readthedocs.io/en/v0.2.1/secret/ 213 | 214 | ### Nonce 215 | 216 | The 24 bytes nonce (Number used once) given to encrypt() and decrypt() must **_NEVER_** be reused for a particular key. 217 | Reusing the nonce means an attacker will have enough information to recover your secret key and encrypt or decrypt arbitrary messages. 218 | A nonce is not considered secret and may be freely transmitted or stored in plaintext alongside the ciphertext. 219 | 220 | A nonce does not need to be random, nor does the method of generating them need to be secret. 221 | A nonce could simply be a counter incremented with each message encrypted. 222 | 223 | Both the sender and the receiver should record every nonce both that they’ve used and they’ve received from the other. 224 | They should reject any message which reuses a nonce and they should make absolutely sure never to reuse a nonce. 225 | It is not enough to simply use a random value and hope that it’s not being reused (simply generating random values would open up the system to a Birthday Attack). 226 | 227 | One good method of generating nonces is for each person to pick a unique prefix, for example b"p1" and b"p2". When each person generates a nonce they prefix it, so instead of nacl.utils.random(24) you’d do b"p1" + nacl.utils.random(22). This prefix serves as a guarantee that no two messages from different people will inadvertently overlap nonces while in transit. They should still record every nonce they’ve personally used and every nonce they’ve received to prevent reuse or replays. 228 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Rust 1.57.0 base 2 | FROM rust:1.57 3 | 4 | RUN rustc --version 5 | 6 | # Install Solana 1.8.16 7 | RUN sh -c "$(curl -sSfL https://release.solana.com/v1.8.16/install)" 8 | ENV PATH="/root/.local/share/solana/install/releases/1.8.16/solana-release/bin:${PATH}" 9 | 10 | RUN solana --version 11 | 12 | # Install nodjs & npm 13 | RUN apt update 14 | RUN apt install -y nodejs npm 15 | 16 | RUN node --version 17 | RUN npm --version 18 | 19 | # Install Anchor CLI 20 | RUN npm install -g @project-serum/anchor-cli@0.18.2 21 | 22 | # Copy Anchor project 23 | WORKDIR /home/dialect/ 24 | # COPY package files & source 25 | # TODO: DON'T COPY target/deploy/dialect-keypair.json AS THIS IS INSECURE!!! 26 | COPY Anchor.toml Cargo.toml Cargo.lock ./ 27 | COPY programs ./programs 28 | # COPY target ./target 29 | 30 | # Create deployment keypair 31 | RUN solana-keygen new --no-bip39-passphrase -o /root/.config/solana/id.json 32 | 33 | # Build to pre-load BPF 34 | RUN anchor build 35 | 36 | CMD ["anchor", "localnet"] 37 | -------------------------------------------------------------------------------- /docker/Dockerfile.tests: -------------------------------------------------------------------------------- 1 | FROM dialect/protocol:latest 2 | 3 | # Install npm dependencies 4 | COPY ./package.json ./ 5 | RUN npm i 6 | RUN npm i -g typescript ts-mocha 7 | 8 | COPY ./src ./src 9 | COPY ./tests ./tests 10 | COPY ./tsconfig.json ./ 11 | 12 | CMD ["anchor", "test"] 13 | -------------------------------------------------------------------------------- /examples/hello-world.ts: -------------------------------------------------------------------------------- 1 | import * as anchor from '@project-serum/anchor'; 2 | import { Idl, Provider } from '@project-serum/anchor'; 3 | import * as web3 from '@solana/web3.js'; 4 | import { Keypair, PublicKey } from '@solana/web3.js'; 5 | import { 6 | createDialect, 7 | getDialectForMembers, 8 | idl as dialectIdl, 9 | Member, 10 | programs, 11 | sendMessage, 12 | Wallet_, 13 | } from '../src'; 14 | 15 | const NETWORK_NAME = 'localnet'; 16 | const local = new web3.Connection( 17 | programs[NETWORK_NAME].clusterAddress, 18 | 'recent', 19 | ); 20 | 21 | const users = [Keypair.generate(), Keypair.generate()]; 22 | const wallet = Wallet_.embedded(users[0].secretKey); 23 | const program = new anchor.Program( 24 | dialectIdl as Idl, 25 | new PublicKey(programs[NETWORK_NAME].programAddress), 26 | new Provider(local, wallet, anchor.Provider.defaultOptions()), 27 | ); 28 | 29 | async function fundUsers( 30 | keypairs: Keypair[], 31 | amount: number | undefined = 10 * web3.LAMPORTS_PER_SOL, 32 | ): Promise { 33 | await Promise.all( 34 | keypairs.map(async (keypair) => { 35 | const fromAirdropSignature = 36 | await program.provider.connection.requestAirdrop( 37 | keypair.publicKey, 38 | amount, 39 | ); 40 | await program.provider.connection.confirmTransaction( 41 | fromAirdropSignature, 42 | ); 43 | }), 44 | ); 45 | } 46 | 47 | const main = async (): Promise => { 48 | await fundUsers(users); 49 | const [user1, user2] = users; 50 | const dialectMembers: Member[] = [ 51 | { 52 | publicKey: user1.publicKey, 53 | scopes: [true, true], 54 | }, 55 | { 56 | publicKey: user2.publicKey, 57 | scopes: [false, true], 58 | }, 59 | ]; 60 | const user1Dialect = await createDialect( 61 | program, 62 | user1, 63 | dialectMembers, 64 | false, 65 | ); 66 | await sendMessage(program, user1Dialect, user1, 'Hello dialect!'); 67 | const { dialect: user2Dialect } = await getDialectForMembers( 68 | program, 69 | dialectMembers, 70 | user2, 71 | ); 72 | console.log(JSON.stringify(user2Dialect.messages)); 73 | }; 74 | 75 | main(); 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dialectlabs/web3", 3 | "description": "A smart messaging protocol for dapp notifications and wallet-to-wallet messaging on the Solana Blockchain.", 4 | "version": "0.3.2", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/dialectlabs/protocol" 8 | }, 9 | "main": "./lib/cjs/index.js", 10 | "module": "./lib/esm/index.js", 11 | "types": "./lib/types/index.d.ts", 12 | "exports": { 13 | "import": "./lib/esm/index.js", 14 | "require": "./lib/cjs/index.js" 15 | }, 16 | "scripts": { 17 | "publish": "npm run build && npm publish && rm -rf lib", 18 | "build": "npm run clean && npm run build:cjs || npm run build:esm", 19 | "build:cjs": "tsc --project tsconfig.cjs.json", 20 | "build:cjs:watch": "tsc --watch --project tsconfig.cjs.json", 21 | "build:esm": "tsc --project tsconfig.esm.json", 22 | "build:esm:watch": "tsc --project tsconfig.esm.json --watch", 23 | "clean": "rm -rf lib", 24 | "lint": "eslint --ext .js --ext .ts {examples,src,tests}/**/*.ts && prettier --check {examples,src,tests}/**/*.ts", 25 | "lint:fix": "eslint --ext .js --ext .ts {examples,src,tests}/**/*.ts --fix && prettier --write {examples,src,tests}/**/*.ts", 26 | "pretty": "prettier --write {examples,src,tests}/**/*.ts", 27 | "local-publish": "rm -rf node_modules && rm -rf .anchor" 28 | }, 29 | "keywords": [], 30 | "author": "c. b. osborn", 31 | "license": "Apache-2.0", 32 | "dependencies": { 33 | "@project-serum/anchor": "0.23.0", 34 | "@solana/spl-token": "^0.1.8", 35 | "@solana/web3.js": "^1.22.0", 36 | "bytebuffer": "^5.0.1", 37 | "copy-to-clipboard": "^3.3.1", 38 | "ed2curve": "0.3.0", 39 | "js-sha256": "^0.9.0", 40 | "tweetnacl": "1.0.3" 41 | }, 42 | "devDependencies": { 43 | "@types/bs58": "^4.0.1", 44 | "@types/bytebuffer": "^5.0.42", 45 | "@types/chai": "^4.2.22", 46 | "@types/chai-as-promised": "^7.1.4", 47 | "@types/ed2curve": "^0.2.2", 48 | "@types/mocha": "^9.0.0", 49 | "@typescript-eslint/eslint-plugin": "^4.29.0", 50 | "chai": "^4.3.4", 51 | "chai-as-promised": "^7.1.1", 52 | "eslint": "7.32.0", 53 | "mocha": "^9.1.1", 54 | "prettier": "2.5.1", 55 | "ts-mocha": "^9.0.2", 56 | "ts-node": "^10.2.0", 57 | "typescript": "^4.3.5" 58 | }, 59 | "engines": { 60 | "node": ">=14.18", 61 | "npm": ">=8" 62 | }, 63 | "engineStrict": true, 64 | "browser": { 65 | "fs": false, 66 | "path": false, 67 | "os": false 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /programs/dialect/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dialect" 3 | version = "0.1.6" 4 | description = "Created with Anchor" 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = ["cdylib", "lib"] 9 | name = "dialect" 10 | 11 | [features] 12 | no-entrypoint = [] 13 | no-idl = [] 14 | cpi = ["no-entrypoint"] 15 | default = [] 16 | 17 | [dependencies] 18 | anchor-lang = "0.23.0" 19 | anchor-spl = "0.23.0" 20 | solana-program = "1.8.16" 21 | -------------------------------------------------------------------------------- /programs/dialect/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] 3 | -------------------------------------------------------------------------------- /programs/dialect/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Dialect is a Solana program for on-chain messaging between addresses. 2 | //! 3 | //! Dialect uses the publish-subscribe messaging pattern. In this case, Dialect "decorates" a resource on chain with messaging capabilities. It does this by creating a "Dialect" - or a message thread - as a PDA whose seeds are an address, or (sorted) set of addresses. E.g. for one-on-one messaging between two wallets, the Dialect PDA's seeds would be the two participatns' wallet addresses, sorted alphabetically. 4 | //! 5 | //! The entrypoints and data structures below implement the one-on-one messaging thread use case, as well as associated authentication and management of such threads. 6 | 7 | use anchor_lang::prelude::*; 8 | use solana_program::entrypoint::ProgramResult; 9 | 10 | declare_id!("CeNUxGUsSeb5RuAGvaMLNx3tEZrpBwQqA7Gs99vMPCAb"); 11 | 12 | /// The dialect module contains all entrypoint functions for interacting with dialects. 13 | #[program] 14 | pub mod dialect { 15 | 16 | use super::*; 17 | 18 | // User metadata 19 | 20 | /// This function creates a metadata account for the signing user. 21 | /// 22 | /// ### Arguments 23 | /// 24 | /// * ctx: The context. 25 | /// * _metadata_nonce: The seed associated with the metadata account. 26 | /// 27 | /// See the CreateMetadata context & MetadataAccount structs below for more information. 28 | pub fn create_metadata(ctx: Context, _metadata_nonce: u8) -> ProgramResult { 29 | let metadata_loader = &ctx.accounts.metadata; 30 | let metadata = &mut metadata_loader.load_init()?; 31 | metadata.user = ctx.accounts.user.key(); 32 | metadata.subscriptions = [Subscription::default(); 32]; 33 | // Emit an event for monitoring services. 34 | emit!(MetadataCreatedEvent { 35 | metadata: metadata_loader.key(), 36 | user: ctx.accounts.user.key() 37 | }); 38 | 39 | Ok(()) 40 | } 41 | 42 | /// This function closes a metadata account and recovers its rent for the signing user, who must be the metadata account owner. 43 | /// 44 | /// * ctx: The context. 45 | /// * _metadata_nonce: The seed associated with the metadata account. 46 | /// 47 | /// See the CloseMetadata context & MetadataAccount structs below for more information. 48 | pub fn close_metadata(ctx: Context, _metadata_nonce: u8) -> ProgramResult { 49 | let metadata_loader = &ctx.accounts.metadata; 50 | let metadata = metadata_loader.load()?; 51 | // Emit an event for monitoring services. 52 | emit!(MetadataDeletedEvent { 53 | metadata: metadata_loader.key(), 54 | user: metadata.user, 55 | }); 56 | 57 | Ok(()) 58 | } 59 | 60 | // Dialects 61 | 62 | /// This function creates a dialect account for one-on-one messaging between two users. 63 | /// 64 | /// ### Arguments 65 | /// 66 | /// * dialect_nonce: The nonce for the Dialect account. 67 | /// * encrypted: Whether or not to encrypt the dialect. 68 | /// * scopes: The scopes for the dialect's members, implicitly ordered. 69 | /// 70 | /// See the CreateDialect context & DialectAccount structs below for more information. 71 | pub fn create_dialect( 72 | ctx: Context, 73 | _dialect_nonce: u8, 74 | encrypted: bool, 75 | scopes: [[bool; 2]; 2], 76 | ) -> Result<()> { 77 | let dialect_loader = &ctx.accounts.dialect; 78 | let mut dialect = dialect_loader.load_init()?; 79 | let _owner = &mut ctx.accounts.owner; 80 | let members = [&mut ctx.accounts.member0, &mut ctx.accounts.member1]; 81 | 82 | dialect.members = [ 83 | Member { 84 | public_key: *members[0].key, 85 | scopes: scopes[0], 86 | }, 87 | Member { 88 | public_key: *members[1].key, 89 | scopes: scopes[1], 90 | }, 91 | ]; 92 | 93 | if !dialect 94 | .members 95 | .iter() 96 | .any(|member| member.is_admin() && member.public_key == _owner.key()) 97 | { 98 | return err!(ErrorCode::DialectOwnerIsNotAdmin); 99 | } 100 | 101 | let now = Clock::get()?.unix_timestamp as u32; 102 | dialect.messages.read_offset = 0; 103 | dialect.messages.write_offset = 0; 104 | dialect.messages.items_count = 0; 105 | dialect.last_message_timestamp = now; 106 | dialect.encrypted = encrypted; 107 | 108 | emit!(DialectCreatedEvent { 109 | dialect: dialect_loader.key(), 110 | members: [*members[0].key, *members[1].key], 111 | }); 112 | 113 | Ok(()) 114 | } 115 | 116 | /// This function closes a dialect account and recovers its rent for the signing user, 117 | /// who must be the dialect account owner. 118 | /// 119 | /// ### Arguments 120 | /// 121 | /// * ctx: The context. 122 | /// * _dialect_nonce: The seed associated with the dialect account. 123 | /// 124 | /// See the CloseDialect context & DialectAccount structs below for more information. 125 | pub fn close_dialect(ctx: Context, _dialect_nonce: u8) -> ProgramResult { 126 | let dialect_loader = &ctx.accounts.dialect; 127 | let dialect = dialect_loader.load()?; 128 | 129 | emit!(DialectDeletedEvent { 130 | dialect: dialect_loader.key(), 131 | members: [dialect.members[0].public_key, dialect.members[1].public_key], 132 | }); 133 | 134 | Ok(()) 135 | } 136 | 137 | /// This function subscribes a user to a dialect by adding the dialect's public key to 138 | /// the subscriptions in the user's metadata account. 139 | /// 140 | /// ### Arguments 141 | /// 142 | /// * ctx: The context. 143 | /// * _dialect_nonce: The seed associated with the dialect account. 144 | /// * _metadata_nonce: The seed associated with the metadata account. 145 | /// 146 | /// See the SubscribeUser context, DialectAccount & MetadataAccount structs below for 147 | /// more information. 148 | pub fn subscribe_user( 149 | ctx: Context, 150 | _dialect_nonce: u8, 151 | _metadata_nonce: u8, 152 | ) -> ProgramResult { 153 | let dialect = &mut ctx.accounts.dialect; 154 | let metadata_loader = &mut ctx.accounts.metadata; 155 | let metadata = &mut metadata_loader.load_mut()?; 156 | let num_subscriptions = metadata 157 | .subscriptions 158 | .iter() 159 | .filter(|s| is_present(s)) 160 | .count(); 161 | if num_subscriptions < 32 { 162 | metadata.subscriptions[num_subscriptions] = Subscription { 163 | pubkey: dialect.key(), 164 | enabled: true, 165 | }; 166 | // Emit an event for monitoring services. 167 | emit!(UserSubscribedEvent { 168 | metadata: metadata_loader.key(), 169 | dialect: dialect.key() 170 | }); 171 | } else { 172 | // Handle max subscriptions 173 | msg!("User already subscribed to 32 dialects"); 174 | } 175 | Ok(()) 176 | } 177 | 178 | /// This function lets a member of a dialect with write privileges send a message in the dialect. 179 | /// 180 | /// ### Arguments 181 | /// 182 | /// * ctx: The context. 183 | /// * _dialect_nonce: The seed associated with the dialect account. 184 | /// * text: The message to send, encoded in u8 vec. 185 | /// 186 | /// See the SendMessage context & DialectAccount structs below for more information. 187 | pub fn send_message( 188 | ctx: Context, 189 | _dialect_nonce: u8, 190 | text: Vec, 191 | ) -> ProgramResult { 192 | let dialect_loader = &ctx.accounts.dialect; 193 | let mut dialect = dialect_loader.load_mut()?; 194 | let sender = &mut ctx.accounts.sender.to_account_info(); 195 | dialect.append(text, sender); 196 | // Emit an event for monitoring services. 197 | emit!(MessageSentEvent { 198 | dialect: dialect_loader.key(), 199 | sender: *sender.key, 200 | }); 201 | Ok(()) 202 | } 203 | } 204 | 205 | // Contexts 206 | 207 | /// Context to create a metadata account for a user, created by the user. 208 | #[derive(Accounts)] 209 | pub struct CreateMetadata<'info> { 210 | // The metadata owner and the signer for this transaction. 211 | #[account(mut)] 212 | pub user: Signer<'info>, 213 | // The metadata account being created 214 | #[account( 215 | init, 216 | seeds = [ 217 | b"metadata".as_ref(), 218 | user.key.as_ref(), 219 | ], 220 | bump, 221 | payer = user, 222 | // discriminator (8) + user + 32 x (subscription) = 1096 223 | space = 8 + 32 + (32 * 33), 224 | )] 225 | pub metadata: AccountLoader<'info, MetadataAccount>, 226 | pub rent: Sysvar<'info, Rent>, 227 | pub system_program: Program<'info, System>, 228 | } 229 | 230 | /// Context to close a metadata account and recover its rent. This action is permanent, and all data is lost. 231 | /// 232 | /// Only the owner of a metadata account can close it. 233 | #[derive(Accounts)] 234 | #[instruction(metadata_nonce: u8)] 235 | pub struct CloseMetadata<'info> { 236 | // The metadata owner and the signer for this transaction. 237 | #[account(mut)] 238 | pub user: Signer<'info>, 239 | // The metadata account being closed. 240 | #[account( 241 | mut, 242 | close = user, 243 | // The user closing the metadata account must be its owner. 244 | seeds = [ 245 | b"metadata".as_ref(), 246 | user.key.as_ref(), 247 | ], 248 | has_one = user, // TODO: Does seeds address this, is this redundant? 249 | bump = metadata_nonce, 250 | )] 251 | pub metadata: AccountLoader<'info, MetadataAccount>, 252 | pub rent: Sysvar<'info, Rent>, 253 | pub system_program: Program<'info, System>, 254 | } 255 | 256 | /// Context for subscribing a user to a dialect, which adds the dialect's address to the user's 257 | /// metadata's list of subscriptions. 258 | #[derive(Accounts)] 259 | #[instruction(dialect_nonce: u8, metadata_nonce: u8)] // metadata0_nonce: u8, metadata1_nonce: u8)] 260 | pub struct SubscribeUser<'info> { 261 | // The signing key, a.k.a. the user taking action to subscribe the other user to a new dialect. 262 | #[account(mut)] 263 | pub signer: Signer<'info>, 264 | /// CHECK: N.b. we do not enforce that user is the signer. Meaning, users can subscribe other users 265 | // to dialects. 266 | pub user: AccountInfo<'info>, 267 | // The metadata account belonging to the user, & to whose subscriptions the dialect will be added. 268 | #[account( 269 | mut, 270 | // Enforce the same constraint on the metadata account to ensure it belongs to the user. 271 | seeds = [ 272 | b"metadata".as_ref(), 273 | user.key().as_ref(), 274 | ], 275 | bump = metadata_nonce, 276 | // Enforce no duplicate subscriptions. 277 | constraint = metadata 278 | .load()? 279 | .subscriptions 280 | .iter() 281 | .filter(|s| s.pubkey == dialect.key()) 282 | .count() < 1 283 | )] 284 | pub metadata: AccountLoader<'info, MetadataAccount>, 285 | pub dialect: AccountLoader<'info, DialectAccount>, 286 | pub rent: Sysvar<'info, Rent>, 287 | pub system_program: Program<'info, System>, 288 | } 289 | 290 | /// Context for creating a new dialect for one-on-one messaging. The owner deposits the rent, 291 | /// must be one of the members, and has special privileges for e.g. closing a dialect and 292 | /// recovering the deposited rent. 293 | #[derive(Accounts)] 294 | #[instruction(dialect_nonce: u8)] 295 | pub struct CreateDialect<'info> { 296 | #[account(mut)] // mut is needed because they're the payer for PDA initialization 297 | // We dupe the owner in one of the members, since the members must be sorted 298 | pub owner: Signer<'info>, 299 | /// CHECK: First member, alphabetically 300 | pub member0: AccountInfo<'info>, 301 | /// CHECK: Second member, alphabetically 302 | pub member1: AccountInfo<'info>, 303 | // Support more users in this or other dialect struct 304 | #[account( 305 | init, 306 | // The dialect's PDA is determined by its members' public keys, sorted alphabetically 307 | seeds = [ 308 | b"dialect".as_ref(), 309 | member0.key().as_ref(), 310 | member1.key().as_ref(), 311 | ], 312 | // TODO: Assert that owner is a member with admin privileges 313 | // Assert that the members are sorted alphabetically, & unique 314 | constraint = member0.key().cmp(&member1.key()) == std::cmp::Ordering::Less, 315 | bump, 316 | payer = owner, 317 | // NB: max space for PDA = 10240 318 | // space = discriminator + dialect account size 319 | space = 8 + 68 + (2 + 2 + 2 + 8192) + 4 + 1 320 | )] 321 | pub dialect: AccountLoader<'info, DialectAccount>, 322 | pub rent: Sysvar<'info, Rent>, 323 | pub system_program: Program<'info, System>, 324 | } 325 | 326 | /// Context for closing a dialect account and recovering the rent. Only the owning user who 327 | /// created the dialect can close it. 328 | #[derive(Accounts)] 329 | #[instruction(dialect_nonce: u8)] 330 | pub struct CloseDialect<'info> { 331 | // The owner, who originally created the dialect. 332 | #[account( 333 | mut, 334 | constraint = dialect.load()?.members.iter().filter(|m| m.public_key == *owner.key && m.scopes[0]).count() > 0, 335 | )] 336 | pub owner: Signer<'info>, 337 | // The dialect account being closed. 338 | #[account( 339 | mut, 340 | close = owner, 341 | seeds = [ 342 | b"dialect".as_ref(), 343 | dialect.load()?.members[0].public_key.as_ref(), 344 | dialect.load()?.members[1].public_key.as_ref(), 345 | ], 346 | bump = dialect_nonce, 347 | )] 348 | pub dialect: AccountLoader<'info, DialectAccount>, 349 | pub rent: Sysvar<'info, Rent>, 350 | pub system_program: Program<'info, System>, 351 | } 352 | 353 | /// Context for sending a message in a dialect. Only a member with write privileges can send messages. 354 | #[derive(Accounts)] 355 | #[instruction(dialect_nonce: u8)] 356 | pub struct SendMessage<'info> { 357 | // The signer. Must also be the message sender. 358 | #[account( 359 | mut, 360 | // The sender must be a member with write privileges. 361 | constraint = dialect.load()?.members.iter().filter(|m| m.public_key == *sender.key && m.scopes[1]).count() > 0, 362 | )] 363 | pub sender: Signer<'info>, 364 | // The dialect in which the message is being sent. 365 | #[account( 366 | mut, 367 | seeds = [ 368 | b"dialect".as_ref(), 369 | dialect.load()?.members[0].public_key.as_ref(), 370 | dialect.load()?.members[1].public_key.as_ref(), 371 | ], 372 | bump = dialect_nonce, 373 | )] 374 | pub dialect: AccountLoader<'info, DialectAccount>, 375 | pub rent: Sysvar<'info, Rent>, 376 | pub system_program: Program<'info, System>, 377 | } 378 | 379 | // Accounts 380 | 381 | /// The MetadataAccount is an account that holds metadata about a user, who is likely a wallet. 382 | /// 383 | /// For now, this metadata includes: 384 | /// 385 | /// 1. A reference back to the user's account via their pubkey. 386 | /// 2. The user's subscriptions, which are a list of dialect PDAs. 387 | /// 388 | /// The MetadataAccount will be expanded in the future to account for more information about the user. 389 | #[account(zero_copy)] 390 | #[derive(Default)] 391 | pub struct MetadataAccount { 392 | /// Backward reference to the user's account with which this metadata is associated. 393 | user: Pubkey, // 32 394 | /// A list of dialects the user has subscribed to. 395 | subscriptions: [Subscription; 32], // 32 * space(Subscription) 396 | } 397 | 398 | const MESSAGE_BUFFER_LENGTH: usize = 8192; 399 | const ITEM_METADATA_OVERHEAD: u16 = 2; 400 | 401 | /// The DialectAccount is the main account for creating messaging. 402 | /// 403 | /// The DialectAccount stores 404 | /// 405 | /// 1. references to the dialect's members and their scopes (currently two members for one-on-one messaging), 406 | /// 2. its messages, which are stored in a CyclicByteBuffer (see below), 407 | /// 3. the time stamp of the last message sent in the dialect, and 408 | /// 4. whether or not the dialect is encrypted. 409 | #[account(zero_copy)] 410 | // zero_copy used to use repr(packed) rather than its new default, repr(C), so 411 | // we need to explicitly use repr(packed) here to maintain backwards 412 | // compatibility with old dialect accounts. 413 | #[repr(packed)] 414 | /// NB: max space for PDA = 10240 415 | /// space = 8 + 68 + (2 + 2 + 2 + 8192) + 4 + 1 416 | pub struct DialectAccount { 417 | /// The Dialect members. See the Member struct below 418 | pub members: [Member; 2], // 2 * Member = 68 419 | /// The dalect's messages. See the CyclicByteButffer below 420 | pub messages: CyclicByteBuffer, // 2 + 2 + 2 + 8192 421 | /// The last message timestamp, for convenience, it should always match the timestamp of the last message sent, or if there are no messages yet the timestamp of the dialect's creation. 422 | pub last_message_timestamp: u32, // 4, UTC seconds, max value = Sunday, February 7, 2106 6:28:15 AM 423 | /// A bool representing whether or not the dialect is encrypted. 424 | pub encrypted: bool, // 1 425 | } 426 | 427 | impl DialectAccount { 428 | /// Append another message to the dialect's messages. See the CyclicByteBuffer for more information on implementation. 429 | /// 430 | /// Arguments 431 | /// 432 | /// * text: The message to append, encoded in u8. 433 | /// * sender: The sender of the message, as a generic AccountInfo. 434 | fn append(&mut self, text: Vec, sender: &mut AccountInfo) { 435 | let now = Clock::get().unwrap().unix_timestamp as u32; 436 | self.last_message_timestamp = now; 437 | let sender_member_idx = self 438 | .members 439 | .iter() 440 | .position(|m| m.public_key == *sender.key) 441 | .unwrap() as u8; 442 | let mut serialized_message = Vec::new(); 443 | serialized_message.extend(sender_member_idx.to_be_bytes().into_iter()); 444 | serialized_message.extend(now.to_be_bytes().into_iter()); 445 | serialized_message.extend(text); 446 | self.messages.append(serialized_message) 447 | } 448 | } 449 | 450 | /// A special data structure that is used to efficiently store arbitrary length byte arrays. 451 | /// Maintains FIFO attributes on top of cyclic buffer. 452 | /// Ensures there's a space to append new item by erasing old items, if no space available. 453 | #[zero_copy] 454 | // space = 2 + 2 + 2 + 8192 455 | pub struct CyclicByteBuffer { 456 | /// Offset of first item in [buffer]. 457 | pub read_offset: u16, // 2 458 | /// Offset of next item in [buffer]. 459 | pub write_offset: u16, // 2 460 | /// Current number of items in [buffer]. 461 | pub items_count: u16, // 2 462 | /// Underlying bytebuffer, stores items. 463 | pub buffer: [u8; 8192], // 8192 464 | } 465 | 466 | impl CyclicByteBuffer { 467 | /// Appends an arbitrary length item passed in the parameter to the end of the buffer. 468 | /// If the buffer has no space for insertion, it returns removes old items until there's enough space. 469 | /// 470 | /// ### Arguments 471 | /// 472 | /// * item: an bytebuffer/bytearray to be appended. 473 | fn append(&mut self, item: Vec) { 474 | let item_with_metadata = &mut Vec::new(); 475 | let item_len = (item.len() as u16).to_be_bytes(); 476 | item_with_metadata.extend(item_len.into_iter()); 477 | item_with_metadata.extend(item); 478 | 479 | let new_write_offset: u16 = self.mod_(self.write_offset + item_with_metadata.len() as u16); 480 | while self.no_space_available_for(item_with_metadata.len() as u16) { 481 | self.erase_oldest_item() 482 | } 483 | self.write_new_item(item_with_metadata, new_write_offset); 484 | } 485 | 486 | /// Returns a number by modulo of buffer length. 487 | /// 488 | /// Used to re-calculate offset within cyclic buffer structure w/o moving out of buffer boundaries. 489 | /// 490 | /// ### Arguments 491 | /// 492 | /// * value: an offset/position to be re-calculated. 493 | fn mod_(&mut self, value: u16) -> u16 { 494 | value % MESSAGE_BUFFER_LENGTH as u16 495 | } 496 | 497 | /// Returns true if there's no free space to append item of size [item_size] to buffer. 498 | /// 499 | /// Used to re-calculate offset within cyclic buffer structure w/o moving out of buffer boundaries. 500 | /// 501 | /// ### Arguments 502 | /// 503 | /// * item_size: a size of an item that is added to buffer. 504 | fn no_space_available_for(&mut self, item_size: u16) -> bool { 505 | self.get_available_space() < item_size 506 | } 507 | 508 | /// Returns the amount of available free space. 509 | fn get_available_space(&mut self) -> u16 { 510 | if self.items_count == 0 { 511 | return MESSAGE_BUFFER_LENGTH as u16; 512 | } 513 | self.mod_(MESSAGE_BUFFER_LENGTH as u16 + self.read_offset - self.write_offset) 514 | } 515 | 516 | /// Erases the oldest item from buffer by zeroing and recalculating [read_offset] and [items_count]. 517 | fn erase_oldest_item(&mut self) { 518 | let item_size = ITEM_METADATA_OVERHEAD + self.read_item_size(); 519 | let zeros = &mut vec![0u8; item_size as usize]; 520 | self.write(zeros, self.read_offset); 521 | self.read_offset = self.mod_(self.read_offset + item_size); 522 | self.items_count -= 1; 523 | } 524 | 525 | /// Returns the size of the item that is present in buffer at [read_offset] position. 526 | fn read_item_size(&mut self) -> u16 { 527 | let read_offset = self.read_offset; 528 | let tail_size = MESSAGE_BUFFER_LENGTH as u16 - read_offset; 529 | if tail_size >= ITEM_METADATA_OVERHEAD { 530 | return u16::from_be_bytes([ 531 | self.buffer[read_offset as usize], 532 | self.buffer[read_offset as usize + 1], 533 | ]); 534 | } 535 | u16::from_be_bytes([self.buffer[read_offset as usize], self.buffer[0]]) 536 | } 537 | 538 | /// Performs writing of [item] to buffer at [write_offset] position. 539 | /// 540 | /// ### Arguments 541 | /// 542 | /// * item: an bytebuffer/bytearray to be written at [write_offset] position. 543 | /// * new_write_offset: a new [write_offset] to be set after writing. 544 | fn write_new_item(&mut self, item: &mut Vec, new_write_offset: u16) { 545 | self.write(item, self.write_offset); 546 | self.write_offset = new_write_offset; 547 | self.items_count += 1; 548 | } 549 | 550 | /// Performs writing of [item] to buffer at [offset] position. 551 | /// 552 | /// Maintains cyclic structure by writing bytes at positions by calculating position by modulo of buffer size. 553 | /// 554 | /// ### Arguments 555 | /// 556 | /// * item: an bytebuffer/bytearray to be written at [offset] position. 557 | /// * offset: an [offset] where to write item. 558 | fn write(&mut self, item: &mut Vec, offset: u16) { 559 | for (idx, e) in item.iter().enumerate() { 560 | let pos = self.mod_(offset + idx as u16); 561 | self.buffer[pos as usize] = *e; 562 | } 563 | } 564 | 565 | /// Returns an underlying [buffer] that contains all items. 566 | fn _raw(&mut self) -> [u8; MESSAGE_BUFFER_LENGTH] { 567 | self.buffer 568 | } 569 | } 570 | 571 | // Data 572 | 573 | /// A subscription used to store information about which dialect accounts user is subscribed to. 574 | /// 575 | /// Multiple subscriptions can be stored in user's metadata account. 576 | #[zero_copy] 577 | #[derive(Default)] 578 | // space = 33 579 | pub struct Subscription { 580 | /// Address of dialect account subscribed to. 581 | pub pubkey: Pubkey, // 32 582 | /// A switcher to enable/disable subscription. 583 | pub enabled: bool, // 1 584 | } 585 | 586 | /// User who can exchange messages using a dialect account. 587 | #[zero_copy] 588 | #[derive(Default)] 589 | // space = 34 590 | pub struct Member { 591 | /// User public key. 592 | pub public_key: Pubkey, // 32 593 | /// Flags that are used to support basic RBAC authorization to dialect account. 594 | /// - When ```scopes[0]``` is set to true, the user is granted admin role. 595 | /// - When ```scopes[1]``` is set to true, the user is granted writer role. 596 | /// ``` 597 | /// // Examples 598 | /// scopes: [true, true] // allows to administer account + read messages + write messages 599 | /// scopes: [false, true] // allows to read messages + write messages 600 | /// scopes: [false, false] // allows to read messages 601 | /// ``` 602 | pub scopes: [bool; 2], // 2 603 | } 604 | 605 | impl Member { 606 | fn is_admin(&self) -> bool { 607 | return self.scopes[0]; 608 | } 609 | } 610 | 611 | #[error_code] 612 | pub enum ErrorCode { 613 | #[msg("The dialect owner must be a member with admin privileges")] 614 | DialectOwnerIsNotAdmin, 615 | } 616 | 617 | /// An event that is fired new dialect account is created. 618 | #[event] 619 | pub struct DialectCreatedEvent { 620 | /// Address of newly created dialect account. 621 | pub dialect: Pubkey, 622 | /// A list of dialect members: two users who exchange messages using single dialect account. 623 | pub members: [Pubkey; 2], 624 | } 625 | 626 | // Events 627 | 628 | /// An event that is fired when some user sends message to dialect account. 629 | #[event] 630 | pub struct DialectDeletedEvent { 631 | /// Address of deleted dialect account. 632 | pub dialect: Pubkey, 633 | /// A list of dialect members: two users who exchange messages using single dialect account. 634 | pub members: [Pubkey; 2], 635 | } 636 | 637 | #[event] 638 | pub struct MessageSentEvent { 639 | /// Address of dialect account where messaging happens. 640 | pub dialect: Pubkey, 641 | /// User that sent a message. 642 | pub sender: Pubkey, 643 | } 644 | 645 | /// An event that is fired when the metadata account owner is subscribed to dialect. 646 | #[event] 647 | pub struct UserSubscribedEvent { 648 | /// Address of owner metadata account, where subscription to dialect is stored. 649 | pub metadata: Pubkey, 650 | /// Address of dialect account to which user was subscribed. 651 | pub dialect: Pubkey, 652 | } 653 | 654 | /// An event that is fired when new metadata account is created. 655 | #[event] 656 | pub struct MetadataCreatedEvent { 657 | /// Address of metadata account. 658 | pub metadata: Pubkey, 659 | /// Owner of metadata account. 660 | pub user: Pubkey, 661 | } 662 | 663 | /// An event that is fired when new metadata account is deleted. 664 | #[event] 665 | pub struct MetadataDeletedEvent { 666 | /// Address of deleted metadata account. 667 | pub metadata: Pubkey, 668 | /// Owner of metadata account. 669 | pub user: Pubkey, 670 | } 671 | 672 | // Helper functions 673 | 674 | /// This function simply checks whether an entry in the metadata account subscriptions 675 | /// array is empty or not. Empty values are encoded with the default public key value. 676 | /// If the entry is a default public key, it is empty, and therefore is_present is false. 677 | /// 678 | /// ### Arguments 679 | /// 680 | /// * subscription: a pointer to a Subscription, which is the entry being checked. 681 | pub fn is_present(subscription: &Subscription) -> bool { 682 | subscription.pubkey != Pubkey::default() 683 | } 684 | 685 | #[cfg(test)] 686 | mod tests { 687 | use crate::CyclicByteBuffer; 688 | 689 | // #[test] 690 | // fn correctly_does_first_append_when_size_lt_buffer_size() { 691 | // // given 692 | // let mut buffer: CyclicByteBuffer = CyclicByteBuffer { 693 | // read_offset: 0, 694 | // write_offset: 0, 695 | // items_count: 0, 696 | // buffer: [0; 5], 697 | // }; 698 | // let item = vec![1u8, 2u8]; 699 | // // when 700 | // buffer.append(item); 701 | // // then 702 | // assert_eq!(buffer.write_offset, 4); 703 | // assert_eq!(buffer.read_offset, 0); 704 | // assert_eq!(buffer.raw(), [0, 2, 1, 2, 0]); 705 | // } 706 | // 707 | // #[test] 708 | // fn correctly_does_first_append_when_size_eq_buffer_size() { 709 | // // given 710 | // let mut buffer: CyclicByteBuffer = CyclicByteBuffer { 711 | // read_offset: 0, 712 | // write_offset: 0, 713 | // items_count: 0, 714 | // buffer: [0; 5], 715 | // }; 716 | // let item = vec![1u8, 2u8, 3u8]; 717 | // // when 718 | // buffer.append(item); 719 | // // then 720 | // assert_eq!(buffer.write_offset, 0); 721 | // assert_eq!(buffer.read_offset, 0); 722 | // assert_eq!(buffer.raw(), [0, 3, 1, 2, 3]); 723 | // } 724 | // 725 | // #[test] 726 | // fn correctly_does_first_append_and_overwrite_when_size_eq_buffer_size() { 727 | // // given 728 | // let mut buffer: CyclicByteBuffer = CyclicByteBuffer { 729 | // read_offset: 0, 730 | // write_offset: 0, 731 | // items_count: 0, 732 | // buffer: [0; 5], 733 | // }; 734 | // let item1 = vec![1u8, 2u8, 3u8]; 735 | // let item2 = vec![4u8, 5u8, 6u8]; 736 | // // when 737 | // buffer.append(item1); 738 | // buffer.append(item2); 739 | // // then 740 | // assert_eq!(buffer.write_offset, 0); 741 | // assert_eq!(buffer.read_offset, 0); 742 | // assert_eq!(buffer.raw(), [0, 3, 4, 5, 6]); 743 | // } 744 | // 745 | // #[test] 746 | // fn correctly_does_first_append_and_overwrite_when_size_lt_buffer_size() { 747 | // // given 748 | // let mut buffer: CyclicByteBuffer = CyclicByteBuffer { 749 | // read_offset: 0, 750 | // write_offset: 0, 751 | // items_count: 0, 752 | // buffer: [0; 5], 753 | // }; 754 | // let item1 = vec![1u8, 2u8]; 755 | // let item2 = vec![3u8, 4u8]; 756 | // // when 757 | // buffer.append(item1); 758 | // buffer.append(item2); 759 | // // then 760 | // assert_eq!(buffer.write_offset, 3); 761 | // assert_eq!(buffer.read_offset, 4); 762 | // assert_eq!(buffer.raw(), [2, 3, 4, 0, 0]); 763 | // } 764 | // 765 | // #[test] 766 | // fn correctly_does_first_append_and_overwrite_when_size_lt_buffer_size() { 767 | // // given 768 | // let mut buffer: CyclicByteBuffer = CyclicByteBuffer { 769 | // read_offset: 0, 770 | // write_offset: 0, 771 | // items_count: 0, 772 | // buffer: [0; 7], 773 | // }; 774 | // let item1 = vec![1u8, 2u8]; 775 | // let item2 = vec![3u8, 4u8, 5u8]; 776 | // let item3 = vec![6u8, 7u8]; 777 | // // when 778 | // buffer.append(item1); 779 | // // [0, 2, 1, 2, 0, 0, 0] 780 | // assert_eq!(buffer.read_offset, 0); 781 | // buffer.append(item2); 782 | // // [4, 5, 0, 0, 0, 3, 3] 783 | // assert_eq!(buffer.read_offset, 4); 784 | // buffer.append(item3); 785 | // // then 786 | // assert_eq!(buffer.write_offset, 6); 787 | // assert_eq!(buffer.read_offset, 2); 788 | // assert_eq!(buffer.raw(), [0, 0, 0, 2, 6, 7, 0]); 789 | // } 790 | } 791 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import * as anchor from '@project-serum/anchor'; 2 | import { EventParser } from '@project-serum/anchor'; 3 | import type { Connection, Keypair, PublicKey } from '@solana/web3.js'; 4 | 5 | import { sleep, waitForFinality, Wallet_ } from '../utils'; 6 | import { ENCRYPTION_OVERHEAD_BYTES } from '../utils/ecdh-encryption'; 7 | import { CyclicByteBuffer } from '../utils/cyclic-bytebuffer'; 8 | import ByteBuffer from 'bytebuffer'; 9 | import { EncryptionProps, TextSerdeFactory } from './text-serde'; 10 | import type { Wallet } from '../utils/Wallet'; 11 | 12 | // TODO: Switch from types to classes 13 | 14 | /* 15 | User metadata 16 | */ 17 | // TODO: Remove device token consts here 18 | export const DEVICE_TOKEN_LENGTH = 64; 19 | export const DEVICE_TOKEN_PAYLOAD_LENGTH = 128; 20 | export const DEVICE_TOKEN_PADDING_LENGTH = 21 | DEVICE_TOKEN_PAYLOAD_LENGTH - DEVICE_TOKEN_LENGTH - ENCRYPTION_OVERHEAD_BYTES; 22 | 23 | const ACCOUNT_DESCRIPTOR_SIZE = 8; 24 | const DIALECT_ACCOUNT_MEMBER_SIZE = 34; 25 | const DIALECT_ACCOUNT_MEMBER0_OFFSET = ACCOUNT_DESCRIPTOR_SIZE; 26 | const DIALECT_ACCOUNT_MEMBER1_OFFSET = 27 | DIALECT_ACCOUNT_MEMBER0_OFFSET + DIALECT_ACCOUNT_MEMBER_SIZE; 28 | 29 | export type Subscription = { 30 | pubkey: PublicKey; 31 | enabled: boolean; 32 | }; 33 | 34 | type RawDialect = { 35 | members: Member[]; 36 | messages: RawCyclicByteBuffer; 37 | lastMessageTimestamp: number; 38 | encrypted: boolean; 39 | }; 40 | 41 | type RawCyclicByteBuffer = { 42 | readOffset: number; 43 | writeOffset: number; 44 | itemsCount: number; 45 | buffer: Uint8Array; 46 | }; 47 | 48 | export type Metadata = { 49 | subscriptions: Subscription[]; 50 | }; 51 | 52 | export type DialectAccount = { 53 | dialect: Dialect; 54 | publicKey: PublicKey; 55 | }; 56 | 57 | export type Dialect = { 58 | members: Member[]; 59 | messages: Message[]; 60 | nextMessageIdx: number; 61 | lastMessageTimestamp: number; 62 | encrypted: boolean; 63 | }; 64 | 65 | export type Message = { 66 | owner: PublicKey; 67 | text: string; 68 | timestamp: number; 69 | }; 70 | 71 | export type FindDialectQuery = { 72 | userPk?: anchor.web3.PublicKey; 73 | }; 74 | 75 | export function isDialectAdmin( 76 | dialect: DialectAccount, 77 | user: anchor.web3.PublicKey, 78 | ): boolean { 79 | return dialect.dialect.members.some( 80 | (m) => m.publicKey.equals(user) && m.scopes[0], 81 | ); 82 | } 83 | 84 | export async function accountInfoGet( 85 | connection: Connection, 86 | publicKey: PublicKey, 87 | ): Promise | null> { 88 | return await connection.getAccountInfo(publicKey); 89 | } 90 | 91 | export async function accountInfoFetch( 92 | _url: string, 93 | connection: Connection, 94 | publicKeyStr: string, 95 | ): Promise | null> { 96 | const publicKey = new anchor.web3.PublicKey(publicKeyStr); 97 | return await accountInfoGet(connection, publicKey); 98 | } 99 | 100 | export function ownerFetcher( 101 | _url: string, 102 | wallet: Wallet_, 103 | connection: Connection, 104 | ): Promise | null> { 105 | return accountInfoGet(connection, wallet.publicKey); 106 | } 107 | 108 | export async function getMetadataProgramAddress( 109 | program: anchor.Program, 110 | user: PublicKey, 111 | ): Promise<[anchor.web3.PublicKey, number]> { 112 | return await anchor.web3.PublicKey.findProgramAddress( 113 | [Buffer.from('metadata'), user.toBuffer()], 114 | program.programId, 115 | ); 116 | } 117 | 118 | // TODO: Simplify this function further now that we're no longer decrypting the device token. 119 | export async function getMetadata( 120 | program: anchor.Program, 121 | user: PublicKey | anchor.web3.Keypair, 122 | otherParty?: PublicKey | anchor.web3.Keypair | null, 123 | ): Promise { 124 | let shouldDecrypt = false; 125 | let userIsKeypair = false; 126 | let otherPartyIsKeypair = false; 127 | 128 | try { 129 | // assume user is pubkey 130 | new anchor.web3.PublicKey(user.toString()); 131 | } catch { 132 | // user is keypair 133 | userIsKeypair = true; 134 | } 135 | 136 | try { 137 | // assume otherParty is pubkey 138 | new anchor.web3.PublicKey(otherParty?.toString() || ''); 139 | } catch { 140 | // otherParty is keypair or null 141 | otherPartyIsKeypair = (otherParty && true) || false; 142 | } 143 | 144 | if (otherParty && (userIsKeypair || otherPartyIsKeypair)) { 145 | // cases 3 - 5 146 | shouldDecrypt = true; 147 | } 148 | 149 | const [metadataAddress] = await getMetadataProgramAddress( 150 | program, 151 | userIsKeypair ? (user as Keypair).publicKey : (user as PublicKey), 152 | ); 153 | const metadata = await program.account.metadataAccount.fetch(metadataAddress); 154 | 155 | // TODO RM this code chunk and change function signature 156 | return { 157 | subscriptions: metadata.subscriptions.filter( 158 | (s: Subscription) => !s.pubkey.equals(anchor.web3.PublicKey.default), 159 | ), 160 | }; 161 | } 162 | 163 | export async function createMetadata( 164 | program: anchor.Program, 165 | user: anchor.web3.Keypair | Wallet, 166 | ): Promise { 167 | const [metadataAddress, metadataNonce] = await getMetadataProgramAddress( 168 | program, 169 | user.publicKey, 170 | ); 171 | const tx = await program.rpc.createMetadata(new anchor.BN(metadataNonce), { 172 | accounts: { 173 | user: user.publicKey, 174 | metadata: metadataAddress, 175 | rent: anchor.web3.SYSVAR_RENT_PUBKEY, 176 | systemProgram: anchor.web3.SystemProgram.programId, 177 | }, 178 | signers: 'secretKey' in user ? [user] : [], 179 | }); 180 | await waitForFinality(program, tx); 181 | return await getMetadata(program, user.publicKey); 182 | } 183 | 184 | export async function deleteMetadata( 185 | program: anchor.Program, 186 | user: anchor.web3.Keypair | Wallet, 187 | ): Promise { 188 | const [metadataAddress, metadataNonce] = await getMetadataProgramAddress( 189 | program, 190 | user.publicKey, 191 | ); 192 | await program.rpc.closeMetadata(new anchor.BN(metadataNonce), { 193 | accounts: { 194 | user: user.publicKey, 195 | metadata: metadataAddress, 196 | rent: anchor.web3.SYSVAR_RENT_PUBKEY, 197 | systemProgram: anchor.web3.SystemProgram.programId, 198 | }, 199 | signers: 'secretKey' in user ? [user] : [], 200 | }); 201 | } 202 | 203 | export async function subscribeUser( 204 | program: anchor.Program, 205 | dialect: DialectAccount, 206 | user: PublicKey, 207 | signer: Keypair, 208 | ): Promise { 209 | const [publicKey, nonce] = await getDialectProgramAddress( 210 | program, 211 | dialect.dialect.members, 212 | ); 213 | const [metadata, metadataNonce] = await getMetadataProgramAddress( 214 | program, 215 | user, 216 | ); 217 | const tx = await program.rpc.subscribeUser( 218 | new anchor.BN(nonce), 219 | new anchor.BN(metadataNonce), 220 | { 221 | accounts: { 222 | dialect: publicKey, 223 | signer: signer.publicKey, 224 | user: user, 225 | metadata, 226 | rent: anchor.web3.SYSVAR_RENT_PUBKEY, 227 | systemProgram: anchor.web3.SystemProgram.programId, 228 | }, 229 | signers: [signer], 230 | }, 231 | ); 232 | await waitForFinality(program, tx); 233 | return await getMetadata(program, user); 234 | } 235 | 236 | /* 237 | Dialect 238 | */ 239 | 240 | export async function getDialectProgramAddress( 241 | programOrProgramAddress: PublicKey | anchor.Program, 242 | membersOrMemberPubKeys: (Member | PublicKey)[], 243 | ): Promise<[anchor.web3.PublicKey, number]> { 244 | const programAddress = 245 | 'programId' in programOrProgramAddress 246 | ? programOrProgramAddress.programId 247 | : programOrProgramAddress; 248 | return await anchor.web3.PublicKey.findProgramAddress( 249 | [ 250 | Buffer.from('dialect'), 251 | ...membersOrMemberPubKeys // sort for deterministic PDA 252 | .map((m) => ('publicKey' in m ? m.publicKey.toBuffer() : m.toBuffer())) 253 | .sort((a, b) => a.compare(b)), // TODO: test that buffers sort as expected 254 | ], 255 | programAddress, 256 | ); 257 | } 258 | 259 | function parseMessages( 260 | { messages: rawMessagesBuffer, members, encrypted }: RawDialect, 261 | encryptionProps?: EncryptionProps | null, 262 | ) { 263 | if (encrypted && !encryptionProps) { 264 | return []; 265 | } 266 | const messagesBuffer = new CyclicByteBuffer( 267 | rawMessagesBuffer.readOffset, 268 | rawMessagesBuffer.writeOffset, 269 | rawMessagesBuffer.itemsCount, 270 | rawMessagesBuffer.buffer, 271 | ); 272 | const textSerde = TextSerdeFactory.create( 273 | { 274 | encrypted, 275 | memberPubKeys: members.map((it) => it.publicKey), 276 | }, 277 | encryptionProps, 278 | ); 279 | const allMessages: Message[] = messagesBuffer.items().map(({ buffer }) => { 280 | const byteBuffer = new ByteBuffer(buffer.length).append(buffer).flip(); 281 | const ownerMemberIndex = byteBuffer.readByte(); 282 | const messageOwner = members[ownerMemberIndex]; 283 | const timestamp = byteBuffer.readUint32() * 1000; 284 | const serializedText = new Uint8Array(byteBuffer.toBuffer(true)); 285 | const text = textSerde.deserialize(serializedText); 286 | return { 287 | owner: messageOwner.publicKey, 288 | text, 289 | timestamp: timestamp, 290 | }; 291 | }); 292 | return allMessages.reverse(); 293 | } 294 | 295 | function parseRawDialect( 296 | rawDialect: RawDialect, 297 | encryptionProps?: EncryptionProps | null, 298 | ) { 299 | return { 300 | encrypted: rawDialect.encrypted, 301 | members: rawDialect.members, 302 | nextMessageIdx: rawDialect.messages.writeOffset, 303 | lastMessageTimestamp: rawDialect.lastMessageTimestamp * 1000, 304 | messages: parseMessages(rawDialect, encryptionProps), 305 | }; 306 | } 307 | 308 | export async function getDialect( 309 | program: anchor.Program, 310 | publicKey: PublicKey, 311 | encryptionProps?: EncryptionProps | null, 312 | ): Promise { 313 | const rawDialect = (await program.account.dialectAccount.fetch( 314 | publicKey, 315 | )) as RawDialect; 316 | const account = await program.provider.connection.getAccountInfo(publicKey); 317 | const dialect = parseRawDialect(rawDialect, encryptionProps); 318 | return { 319 | ...account, 320 | publicKey: publicKey, 321 | dialect, 322 | } as DialectAccount; 323 | } 324 | 325 | export async function getDialects( 326 | program: anchor.Program, 327 | user: anchor.web3.Keypair | Wallet, 328 | encryptionProps?: EncryptionProps | null, 329 | ): Promise { 330 | const metadata = await getMetadata(program, user.publicKey); 331 | const enabledSubscriptions = metadata.subscriptions.filter( 332 | (it) => it.enabled, 333 | ); 334 | return Promise.all( 335 | enabledSubscriptions.map(async ({ pubkey }) => 336 | getDialect(program, pubkey, encryptionProps), 337 | ), 338 | ).then((dialects) => 339 | dialects.sort( 340 | ({ dialect: d1 }, { dialect: d2 }) => 341 | d2.lastMessageTimestamp - d1.lastMessageTimestamp, 342 | ), 343 | ); 344 | } 345 | 346 | export async function getDialectForMembers( 347 | program: anchor.Program, 348 | membersOrMemberPubKeys: (Member | PublicKey)[], 349 | encryptionProps?: EncryptionProps | null, 350 | ): Promise { 351 | const sortedMemberPks = membersOrMemberPubKeys 352 | .map((it) => ('publicKey' in it ? it.publicKey : it)) 353 | .sort((a, b) => a.toBuffer().compare(b.toBuffer())); 354 | const [publicKey] = await getDialectProgramAddress(program, sortedMemberPks); 355 | return await getDialect(program, publicKey, encryptionProps); 356 | } 357 | 358 | export async function findDialects( 359 | program: anchor.Program, 360 | { userPk }: FindDialectQuery, 361 | ): Promise { 362 | const memberFilters = userPk 363 | ? [ 364 | { 365 | memcmp: { 366 | offset: DIALECT_ACCOUNT_MEMBER0_OFFSET, 367 | bytes: userPk.toBase58(), 368 | }, 369 | }, 370 | { 371 | memcmp: { 372 | offset: DIALECT_ACCOUNT_MEMBER1_OFFSET, 373 | bytes: userPk.toBase58(), 374 | }, 375 | }, 376 | ] 377 | : []; 378 | return Promise.all( 379 | memberFilters.map((it) => program.account.dialectAccount.all([it])), 380 | ) 381 | .then((it) => 382 | it.flat().map((a) => { 383 | const rawDialect = a.account as RawDialect; 384 | const dialectAccount: DialectAccount = { 385 | publicKey: a.publicKey, 386 | dialect: parseRawDialect(rawDialect), 387 | }; 388 | return dialectAccount; 389 | }), 390 | ) 391 | .then((dialects) => 392 | dialects.sort( 393 | ({ dialect: d1 }, { dialect: d2 }) => 394 | d2.lastMessageTimestamp - d1.lastMessageTimestamp, // descending 395 | ), 396 | ); 397 | } 398 | 399 | export async function createDialect( 400 | program: anchor.Program, 401 | owner: anchor.web3.Keypair | Wallet, 402 | members: Member[], 403 | encrypted = false, 404 | encryptionProps?: EncryptionProps | null, 405 | ): Promise { 406 | const sortedMembers = members.sort((a, b) => 407 | a.publicKey.toBuffer().compare(b.publicKey.toBuffer()), 408 | ); 409 | const [publicKey, nonce] = await getDialectProgramAddress( 410 | program, 411 | sortedMembers, 412 | ); 413 | // TODO: assert owner in members 414 | const keyedMembers = sortedMembers.reduce( 415 | (ms, m, idx) => ({ ...ms, [`member${idx}`]: m.publicKey }), 416 | {}, 417 | ); 418 | const tx = await program.rpc.createDialect( 419 | new anchor.BN(nonce), 420 | encrypted, 421 | sortedMembers.map((m) => m.scopes), 422 | { 423 | accounts: { 424 | dialect: publicKey, 425 | owner: owner.publicKey, 426 | ...keyedMembers, 427 | rent: anchor.web3.SYSVAR_RENT_PUBKEY, 428 | systemProgram: anchor.web3.SystemProgram.programId, 429 | }, 430 | signers: 'secretKey' in owner ? [owner] : [], 431 | }, 432 | ); 433 | await waitForFinality(program, tx); 434 | return await getDialectForMembers(program, members, encryptionProps); 435 | } 436 | 437 | export async function deleteDialect( 438 | program: anchor.Program, 439 | { dialect }: DialectAccount, 440 | owner: anchor.web3.Keypair | Wallet, 441 | ): Promise { 442 | const [dialectPublicKey, nonce] = await getDialectProgramAddress( 443 | program, 444 | dialect.members, 445 | ); 446 | await program.rpc.closeDialect(new anchor.BN(nonce), { 447 | accounts: { 448 | dialect: dialectPublicKey, 449 | owner: owner.publicKey, 450 | rent: anchor.web3.SYSVAR_RENT_PUBKEY, 451 | systemProgram: anchor.web3.SystemProgram.programId, 452 | }, 453 | signers: 'secretKey' in owner ? [owner] : [], 454 | }); 455 | } 456 | 457 | /* 458 | Members 459 | */ 460 | 461 | export type Member = { 462 | publicKey: anchor.web3.PublicKey; 463 | scopes: [boolean, boolean]; 464 | }; 465 | 466 | /* 467 | Messages 468 | */ 469 | 470 | export async function sendMessage( 471 | program: anchor.Program, 472 | { dialect, publicKey }: DialectAccount, 473 | sender: anchor.web3.Keypair | Wallet, 474 | text: string, 475 | encryptionProps?: EncryptionProps | null, 476 | ): Promise { 477 | const [dialectPublicKey, nonce] = await getDialectProgramAddress( 478 | program, 479 | dialect.members, 480 | ); 481 | const textSerde = TextSerdeFactory.create( 482 | { 483 | encrypted: dialect.encrypted, 484 | memberPubKeys: dialect.members.map((it) => it.publicKey), 485 | }, 486 | encryptionProps, 487 | ); 488 | const serializedText = textSerde.serialize(text); 489 | await program.rpc.sendMessage( 490 | new anchor.BN(nonce), 491 | Buffer.from(serializedText), 492 | { 493 | accounts: { 494 | dialect: dialectPublicKey, 495 | sender: sender ? sender.publicKey : program.provider.wallet.publicKey, 496 | member0: dialect.members[0].publicKey, 497 | member1: dialect.members[1].publicKey, 498 | rent: anchor.web3.SYSVAR_RENT_PUBKEY, 499 | systemProgram: anchor.web3.SystemProgram.programId, 500 | }, 501 | signers: sender && 'secretKey' in sender ? [sender] : [], 502 | }, 503 | ); 504 | const d = await getDialect(program, publicKey, encryptionProps); 505 | return d.dialect.messages[0]; // TODO: Support ring 506 | } 507 | 508 | // Events 509 | // An event is something that has happened in the past 510 | export type Event = 511 | | DialectCreatedEvent 512 | | DialectDeletedEvent 513 | | MetadataCreatedEvent 514 | | MetadataDeletedEvent 515 | | MessageSentEvent 516 | | UserSubscribedEvent; 517 | 518 | export interface DialectCreatedEvent { 519 | type: 'dialect-created'; 520 | dialect: PublicKey; 521 | members: PublicKey[]; 522 | } 523 | 524 | export interface DialectDeletedEvent { 525 | type: 'dialect-deleted'; 526 | dialect: PublicKey; 527 | members: PublicKey[]; 528 | } 529 | 530 | export interface MetadataCreatedEvent { 531 | type: 'metadata-created'; 532 | metadata: PublicKey; 533 | user: PublicKey; 534 | } 535 | 536 | export interface MetadataDeletedEvent { 537 | type: 'metadata-deleted'; 538 | metadata: PublicKey; 539 | user: PublicKey; 540 | } 541 | 542 | export interface MessageSentEvent { 543 | type: 'message-sent'; 544 | dialect: PublicKey; 545 | sender: PublicKey; 546 | } 547 | 548 | export interface UserSubscribedEvent { 549 | type: 'user-subscribed'; 550 | metadata: PublicKey; 551 | dialect: PublicKey; 552 | } 553 | 554 | export type EventHandler = (event: Event) => Promise; 555 | 556 | export interface EventSubscription { 557 | unsubscribe(): Promise; 558 | } 559 | 560 | class DefaultSubscription implements EventSubscription { 561 | private readonly eventParser: EventParser; 562 | private isInterrupted = false; 563 | private subscriptionId?: number; 564 | 565 | constructor( 566 | private readonly program: anchor.Program, 567 | private readonly eventHandler: EventHandler, 568 | ) { 569 | this.eventParser = new EventParser(program.programId, program.coder); 570 | } 571 | 572 | async start(): Promise { 573 | this.periodicallyReconnect(); 574 | return this; 575 | } 576 | 577 | async reconnectSubscriptions() { 578 | await this.unsubscribeFromLogsIfSubscribed(); 579 | this.subscriptionId = this.program.provider.connection.onLogs( 580 | this.program.programId, 581 | async (logs) => { 582 | if (logs.err) { 583 | console.error(logs); 584 | return; 585 | } 586 | this.eventParser.parseLogs(logs.logs, (event) => { 587 | if (!this.isInterrupted) { 588 | switch (event.name) { 589 | case 'DialectCreatedEvent': 590 | this.eventHandler({ 591 | type: 'dialect-created', 592 | dialect: event.data.dialect as PublicKey, 593 | members: event.data.members as PublicKey[], 594 | }); 595 | break; 596 | case 'DialectDeletedEvent': 597 | this.eventHandler({ 598 | type: 'dialect-deleted', 599 | dialect: event.data.dialect as PublicKey, 600 | members: event.data.members as PublicKey[], 601 | }); 602 | break; 603 | case 'MessageSentEvent': 604 | this.eventHandler({ 605 | type: 'message-sent', 606 | dialect: event.data.dialect as PublicKey, 607 | sender: event.data.sender as PublicKey, 608 | }); 609 | break; 610 | case 'UserSubscribedEvent': 611 | this.eventHandler({ 612 | type: 'user-subscribed', 613 | metadata: event.data.metadata as PublicKey, 614 | dialect: event.data.dialect as PublicKey, 615 | }); 616 | break; 617 | case 'MetadataCreatedEvent': 618 | this.eventHandler({ 619 | type: 'metadata-created', 620 | metadata: event.data.metadata as PublicKey, 621 | user: event.data.user as PublicKey, 622 | }); 623 | break; 624 | case 'MetadataDeletedEvent': 625 | this.eventHandler({ 626 | type: 'metadata-deleted', 627 | metadata: event.data.metadata as PublicKey, 628 | user: event.data.user as PublicKey, 629 | }); 630 | break; 631 | default: 632 | console.log('Unsupported event type', event.name); 633 | } 634 | } 635 | }); 636 | }, 637 | ); 638 | } 639 | 640 | unsubscribe(): Promise { 641 | this.isInterrupted = true; 642 | return this.unsubscribeFromLogsIfSubscribed(); 643 | } 644 | 645 | private async periodicallyReconnect() { 646 | while (!this.isInterrupted) { 647 | await this.reconnectSubscriptions(); 648 | await sleep(1000 * 60); 649 | } 650 | } 651 | 652 | private unsubscribeFromLogsIfSubscribed() { 653 | return this.subscriptionId 654 | ? this.program.provider.connection.removeOnLogsListener( 655 | this.subscriptionId, 656 | ) 657 | : Promise.resolve(); 658 | } 659 | } 660 | 661 | export async function subscribeToEvents( 662 | program: anchor.Program, 663 | eventHandler: EventHandler, 664 | ): Promise { 665 | return new DefaultSubscription(program, eventHandler).start(); 666 | } 667 | -------------------------------------------------------------------------------- /src/api/text-serde.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateRandomNonceWithPrefix, 3 | NONCE_SIZE_BYTES, 4 | } from '../utils/nonce-generator'; 5 | import { 6 | Curve25519KeyPair, 7 | ecdhDecrypt, 8 | ecdhEncrypt, 9 | Ed25519Key, 10 | } from '../utils/ecdh-encryption'; 11 | import type * as anchor from '@project-serum/anchor'; 12 | import { PublicKey } from '@solana/web3.js'; 13 | 14 | export interface TextSerde { 15 | serialize(text: string): Uint8Array; 16 | 17 | deserialize(bytes: Uint8Array): string; 18 | } 19 | 20 | export class EncryptedTextSerde implements TextSerde { 21 | private readonly unencryptedTextSerde: UnencryptedTextSerde = 22 | new UnencryptedTextSerde(); 23 | 24 | constructor( 25 | private readonly encryptionProps: EncryptionProps, 26 | private readonly members: PublicKey[], 27 | ) {} 28 | 29 | deserialize(bytes: Uint8Array): string { 30 | const encryptionNonce = bytes.slice(0, NONCE_SIZE_BYTES); 31 | const encryptedText = bytes.slice(NONCE_SIZE_BYTES, bytes.length); 32 | const otherMember = this.findOtherMember( 33 | new PublicKey(this.encryptionProps.ed25519PublicKey), 34 | ); 35 | const encodedText = ecdhDecrypt( 36 | encryptedText, 37 | this.encryptionProps.diffieHellmanKeyPair, 38 | otherMember.toBytes(), 39 | encryptionNonce, 40 | ); 41 | return this.unencryptedTextSerde.deserialize(encodedText); 42 | } 43 | 44 | serialize(text: string): Uint8Array { 45 | const publicKey = new PublicKey(this.encryptionProps.ed25519PublicKey); 46 | const senderMemberIdx = this.findMemberIdx(publicKey); 47 | const textBytes = this.unencryptedTextSerde.serialize(text); 48 | const otherMember = this.findOtherMember(publicKey); 49 | const encryptionNonce = generateRandomNonceWithPrefix(senderMemberIdx); 50 | const encryptedText = ecdhEncrypt( 51 | textBytes, 52 | this.encryptionProps.diffieHellmanKeyPair, 53 | otherMember.toBytes(), 54 | encryptionNonce, 55 | ); 56 | return new Uint8Array([...encryptionNonce, ...encryptedText]); 57 | } 58 | 59 | private findMemberIdx(member: anchor.web3.PublicKey) { 60 | const memberIdx = this.members.findIndex((it) => it.equals(member)); 61 | if (memberIdx === -1) { 62 | throw new Error('Expected to have other member'); 63 | } 64 | return memberIdx; 65 | } 66 | 67 | private findOtherMember(member: anchor.web3.PublicKey) { 68 | const otherMember = this.members.find((it) => !it.equals(member)); 69 | if (!otherMember) { 70 | throw new Error('Expected to have other member'); 71 | } 72 | return otherMember; 73 | } 74 | } 75 | 76 | export class UnencryptedTextSerde implements TextSerde { 77 | deserialize(bytes: Uint8Array): string { 78 | return new TextDecoder().decode(bytes); 79 | } 80 | 81 | serialize(text: string): Uint8Array { 82 | return new TextEncoder().encode(text); 83 | } 84 | } 85 | 86 | export type DialectAttributes = { 87 | encrypted: boolean; 88 | memberPubKeys: PublicKey[]; 89 | }; 90 | 91 | export interface EncryptionProps { 92 | diffieHellmanKeyPair: Curve25519KeyPair; 93 | ed25519PublicKey: Ed25519Key; 94 | } 95 | 96 | export class TextSerdeFactory { 97 | static create( 98 | { encrypted, memberPubKeys }: DialectAttributes, 99 | encryptionProps?: EncryptionProps | null, 100 | ): TextSerde { 101 | if (!encrypted) { 102 | return new UnencryptedTextSerde(); 103 | } 104 | if (encrypted && encryptionProps) { 105 | return new EncryptedTextSerde(encryptionProps, memberPubKeys); 106 | } 107 | throw new Error('Cannot proceed without encryptionProps'); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api'; 2 | export * from './utils'; 3 | export * from './api/text-serde'; 4 | -------------------------------------------------------------------------------- /src/utils/Wallet/EmbeddedWallet.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Node only wallet. 3 | */ 4 | 5 | import { Keypair, Signer, PublicKey, Transaction } from '@solana/web3.js'; 6 | 7 | export type SendTxRequest = { 8 | tx: Transaction; 9 | signers: Array; 10 | }; 11 | 12 | /** 13 | * Wallet interface for objects that can be used to sign provider transactions. 14 | */ 15 | export interface Wallet { 16 | signTransaction(tx: Transaction): Promise; 17 | signAllTransactions(txs: Transaction[]): Promise; 18 | publicKey: PublicKey; 19 | } 20 | 21 | export class EmbeddedWallet implements Wallet { 22 | constructor(readonly signer: Keypair) {} 23 | 24 | static embedded(secretKey: Uint8Array): EmbeddedWallet { 25 | const signer = Keypair.fromSecretKey(secretKey); // :( 26 | return new EmbeddedWallet(signer); 27 | } 28 | 29 | async signTransaction(tx: Transaction): Promise { 30 | tx.partialSign(this.signer); 31 | return tx; 32 | } 33 | 34 | async signAllTransactions(txs: Transaction[]): Promise { 35 | return txs.map((t) => { 36 | t.partialSign(this.signer); 37 | return t; 38 | }); 39 | } 40 | 41 | get publicKey(): PublicKey { 42 | return this.signer.publicKey; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/Wallet/index.ts: -------------------------------------------------------------------------------- 1 | // TDOO: Ported from github.com/project-serum/sol-wallet-adapter. Use that as a dep instead. 2 | import EventEmitter from 'eventemitter3'; 3 | import { PublicKey, Transaction } from '@solana/web3.js'; 4 | import bs58 from 'bs58'; 5 | 6 | export { EmbeddedWallet } from './EmbeddedWallet'; 7 | export type { Wallet, SendTxRequest } from './EmbeddedWallet'; 8 | 9 | type InjectedProvider = { postMessage: (params: unknown) => void }; 10 | 11 | // const MOCK_WALLET_PK = process.env.MOCK_WALLET_PK; 12 | const MOCK_WALLET_PK = 'GTkEx4vdnMfLc8uDxGEbJsxtw8k5pcxtLwHzSMbL7bU1'; 13 | // const MOCK_WALLET_PK = null; 14 | export default class Wallet extends EventEmitter { 15 | private _providerUrl: URL | undefined; 16 | private _injectedProvider?: InjectedProvider; 17 | private _publicKey: PublicKey | null = null; 18 | private _popup: Window | null = null; 19 | private _handlerAdded = false; 20 | private _nextRequestId = 1; 21 | private _autoApprove = false; 22 | private _responsePromises: Map< 23 | number, 24 | [(value: string) => void, (reason: Error) => void] 25 | > = new Map(); 26 | 27 | constructor(provider: unknown, private _network: string) { 28 | super(); 29 | if (isInjectedProvider(provider)) { 30 | this._injectedProvider = provider; 31 | } else if (isString(provider)) { 32 | this._providerUrl = new URL(provider); 33 | this._providerUrl.hash = new URLSearchParams({ 34 | origin: window.location.origin, 35 | network: this._network, 36 | }).toString(); 37 | } else { 38 | throw new Error( 39 | 'provider parameter must be an injected provider or a URL string.', 40 | ); 41 | } 42 | } 43 | 44 | handleMessage = ( 45 | e: MessageEvent<{ 46 | id: number; 47 | method: string; 48 | params: { 49 | autoApprove: boolean; 50 | publicKey: string; 51 | }; 52 | result?: string; 53 | error?: string; 54 | }>, 55 | ): void => { 56 | if ( 57 | (this._injectedProvider && e.source === window) || 58 | (e.origin === this._providerUrl?.origin && e.source === this._popup) 59 | ) { 60 | if (e.data.method === 'connected') { 61 | const newPublicKey = new PublicKey(e.data.params.publicKey); 62 | if (!this._publicKey || !this._publicKey.equals(newPublicKey)) { 63 | if (this._publicKey && !this._publicKey.equals(newPublicKey)) { 64 | this.handleDisconnect(); 65 | } 66 | this._publicKey = newPublicKey; 67 | this._autoApprove = !!e.data.params.autoApprove; 68 | this.emit('connect', this._publicKey); 69 | } 70 | } else if (e.data.method === 'disconnected') { 71 | this.handleDisconnect(); 72 | } else if (e.data.result || e.data.error) { 73 | const promises = this._responsePromises.get(e.data.id); 74 | if (promises) { 75 | const [resolve, reject] = promises; 76 | if (e.data.result) { 77 | resolve(e.data.result); 78 | } else { 79 | reject(new Error(e.data.error)); 80 | } 81 | } 82 | } 83 | } 84 | }; 85 | 86 | private handleConnect() { 87 | if (MOCK_WALLET_PK) { 88 | const newPublicKey = new PublicKey(MOCK_WALLET_PK); 89 | this._publicKey = newPublicKey; 90 | this.emit('connect', this._publicKey); 91 | return; 92 | } 93 | if (!this._handlerAdded) { 94 | this._handlerAdded = true; 95 | window.addEventListener('message', this.handleMessage); 96 | window.addEventListener('beforeunload', this._beforeUnload); 97 | } 98 | if (this._injectedProvider) { 99 | return new Promise((resolve) => { 100 | void this.sendRequest('connect', {}); 101 | resolve(); 102 | }); 103 | } else { 104 | if (!MOCK_WALLET_PK) { 105 | window.name = 'parent'; 106 | this._popup = window.open( 107 | this._providerUrl?.toString(), 108 | '_blank', 109 | 'location,resizable,width=460,height=675', 110 | ); 111 | } 112 | return new Promise((resolve) => { 113 | this.once('connect', resolve); 114 | }); 115 | } 116 | } 117 | 118 | private handleDisconnect() { 119 | if (this._handlerAdded) { 120 | this._handlerAdded = false; 121 | window.removeEventListener('message', this.handleMessage); 122 | window.removeEventListener('beforeunload', this._beforeUnload); 123 | } 124 | if (this._publicKey) { 125 | this._publicKey = null; 126 | this.emit('disconnect'); 127 | } 128 | this._responsePromises.forEach(([, reject], id) => { 129 | this._responsePromises.delete(id); 130 | reject(new Error('Wallet disconnected')); 131 | }); 132 | } 133 | 134 | private async sendRequest(method: string, params: Record) { 135 | if (method !== 'connect' && !this.connected) { 136 | throw new Error('Wallet not connected'); 137 | } 138 | const requestId = this._nextRequestId; 139 | ++this._nextRequestId; 140 | return new Promise((resolve, reject) => { 141 | this._responsePromises.set(requestId, [resolve, reject]); 142 | if (this._injectedProvider) { 143 | this._injectedProvider.postMessage({ 144 | jsonrpc: '2.0', 145 | id: requestId, 146 | method, 147 | params: { 148 | network: this._network, 149 | ...params, 150 | }, 151 | }); 152 | } else { 153 | this._popup?.postMessage( 154 | { 155 | jsonrpc: '2.0', 156 | id: requestId, 157 | method, 158 | params, 159 | }, 160 | this._providerUrl?.origin ?? '', 161 | ); 162 | 163 | if (!this.autoApprove) { 164 | this._popup?.focus(); 165 | } 166 | } 167 | }); 168 | } 169 | 170 | get publicKey(): PublicKey | null { 171 | return this._publicKey; 172 | } 173 | 174 | get connected(): boolean { 175 | return this._publicKey !== null; 176 | } 177 | 178 | get autoApprove(): boolean { 179 | return this._autoApprove; 180 | } 181 | 182 | async connect(): Promise { 183 | if (this._popup) { 184 | this._popup.close(); 185 | } 186 | await this.handleConnect(); 187 | } 188 | 189 | async disconnect(): Promise { 190 | if (this._injectedProvider) { 191 | await this.sendRequest('disconnect', {}); 192 | } 193 | if (this._popup) { 194 | this._popup.close(); 195 | } 196 | this.handleDisconnect(); 197 | } 198 | 199 | private _beforeUnload = (): void => { 200 | void this.disconnect(); 201 | }; 202 | 203 | async sign( 204 | data: Uint8Array, 205 | display: unknown, 206 | ): Promise<{ 207 | signature: Buffer; 208 | publicKey: PublicKey; 209 | }> { 210 | if (!(data instanceof Uint8Array)) { 211 | throw new Error('Data must be an instance of Uint8Array'); 212 | } 213 | 214 | const response = (await this.sendRequest('sign', { 215 | data, 216 | display, 217 | })) as { publicKey: string; signature: string }; 218 | const signature = bs58.decode(response.signature); 219 | const publicKey = new PublicKey(response.publicKey); 220 | return { 221 | signature, 222 | publicKey, 223 | }; 224 | } 225 | 226 | async signTransaction(transaction: Transaction): Promise { 227 | const response = (await this.sendRequest('signTransaction', { 228 | message: bs58.encode(transaction.serializeMessage()), 229 | })) as { publicKey: string; signature: string }; 230 | const signature = bs58.decode(response.signature); 231 | const publicKey = new PublicKey(response.publicKey); 232 | transaction.addSignature(publicKey, signature); 233 | return transaction; 234 | } 235 | 236 | async signAllTransactions( 237 | transactions: Transaction[], 238 | ): Promise { 239 | const response = (await this.sendRequest('signAllTransactions', { 240 | messages: transactions.map((tx) => bs58.encode(tx.serializeMessage())), 241 | })) as { publicKey: string; signatures: string[] }; 242 | const signatures = response.signatures.map((s) => bs58.decode(s)); 243 | const publicKey = new PublicKey(response.publicKey); 244 | transactions = transactions.map((tx, idx) => { 245 | tx.addSignature(publicKey, signatures[idx]); 246 | return tx; 247 | }); 248 | return transactions; 249 | } 250 | } 251 | 252 | function isString(a: unknown): a is string { 253 | return typeof a === 'string'; 254 | } 255 | 256 | function isInjectedProvider(a: unknown): a is InjectedProvider { 257 | return ( 258 | isObject(a) && 'postMessage' in a && typeof a.postMessage === 'function' 259 | ); 260 | } 261 | 262 | function isObject(a: unknown): a is Record { 263 | return typeof a === 'object' && a !== null; 264 | } 265 | -------------------------------------------------------------------------------- /src/utils/countdown-latch.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './index'; 2 | 3 | export class CountDownLatch { 4 | private count: number; 5 | 6 | constructor(count: number) { 7 | if (count < 0) { 8 | throw new Error('count cannot be negative'); 9 | } 10 | this.count = count; 11 | } 12 | 13 | public countDown(value = 1) { 14 | this.count -= value; 15 | } 16 | 17 | public async await(timeout: number): Promise { 18 | const failsAt = new Date().getTime() + timeout; 19 | while (this.count !== 0) { 20 | if (new Date().getTime() > failsAt) { 21 | throw new Error( 22 | `Timeout ${timeout} reached, remaining count = ${this.count}`, 23 | ); 24 | } 25 | await sleep(100); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/cyclic-bytebuffer.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { CyclicByteBuffer } from './cyclic-bytebuffer'; 3 | 4 | describe('Test cyclic buffer', async () => { 5 | it('correctly does first append when size < buffer size', () => { 6 | // given 7 | const buffer = CyclicByteBuffer.empty(5); 8 | // when 9 | const item = new Uint8Array([1, 2]); 10 | buffer.append(item); 11 | // then 12 | expect(buffer.writeOffset).to.be.eq(4); 13 | expect(buffer.readOffset).to.be.eq(0); 14 | expect(buffer.raw()).to.be.deep.eq(new Uint8Array([0, 2, 1, 2, 0])); 15 | }); 16 | 17 | it('correctly does first append when item size === buffer size', () => { 18 | // given 19 | const buffer = CyclicByteBuffer.empty(5); 20 | // when 21 | const item = new Uint8Array([1, 2, 3]); 22 | buffer.append(item); 23 | // then 24 | expect(buffer.writeOffset).to.be.eq(0); 25 | expect(buffer.readOffset).to.be.eq(0); 26 | expect(buffer.raw()).to.be.deep.eq(new Uint8Array([0, 3, 1, 2, 3])); 27 | }); 28 | 29 | it('correctly does first append + overwrite when size eq buffer size', () => { 30 | // given 31 | const buffer = CyclicByteBuffer.empty(5); 32 | // when 33 | const item1 = new Uint8Array([1, 2, 3]); 34 | const item2 = new Uint8Array([4, 5, 6]); 35 | buffer.append(item1); 36 | buffer.append(item2); 37 | // then 38 | expect(buffer.writeOffset).to.be.eq(0); 39 | expect(buffer.readOffset).to.be.eq(0); 40 | expect(buffer.raw()).to.be.deep.eq(new Uint8Array([0, 3, 4, 5, 6])); 41 | }); 42 | 43 | it('correctly does first append + overwrite when size lt buffer size', () => { 44 | // given 45 | const buffer = CyclicByteBuffer.empty(5); 46 | // when 47 | const item1 = new Uint8Array([1, 2]); 48 | const item2 = new Uint8Array([3, 4]); 49 | buffer.append(item1); 50 | // [0, 2, 1, 2, 0] 51 | buffer.append(item2); 52 | // then 53 | expect(buffer.writeOffset).to.be.eq(3); 54 | expect(buffer.readOffset).to.be.eq(4); 55 | expect(buffer.raw()).to.be.deep.eq(new Uint8Array([2, 3, 4, 0, 0])); 56 | }); 57 | 58 | it('read offset increment with item spanning tail and head', () => { 59 | // given 60 | const buffer = CyclicByteBuffer.empty(7); 61 | // when 62 | const item1 = new Uint8Array([1, 2]); 63 | const item2 = new Uint8Array([3, 4, 5]); 64 | buffer.append(item1); 65 | // [0, 2, 1, 2, 0, 0, 0] 66 | buffer.append(item2); 67 | // then 68 | expect(buffer.writeOffset).to.be.eq(2); 69 | expect(buffer.readOffset).to.be.eq(4); 70 | expect(buffer.raw()).to.be.deep.eq(new Uint8Array([4, 5, 0, 0, 0, 3, 3])); 71 | }); 72 | 73 | it('read offset increment with modular', () => { 74 | // given 75 | const buffer = CyclicByteBuffer.empty(7); 76 | // when 77 | const item1 = new Uint8Array([1, 2]); 78 | const item2 = new Uint8Array([3, 4, 5]); 79 | const item3 = new Uint8Array([6, 7]); 80 | buffer.append(item1); 81 | // [0, 2, 1, 2, 0, 0, 0] 82 | buffer.append(item2); 83 | // [4, 5, 0, 0, 0, 3, 3] 84 | buffer.append(item3); 85 | // then 86 | expect(buffer.writeOffset).to.be.eq(6); 87 | expect(buffer.readOffset).to.be.eq(2); 88 | expect(buffer.raw()).to.be.deep.eq(new Uint8Array([0, 0, 0, 2, 6, 7, 0])); 89 | }); 90 | 91 | it('read offset increment with modular', () => { 92 | // given 93 | const buffer = CyclicByteBuffer.empty(10); 94 | // when 95 | const item1 = new Uint8Array([1]); 96 | const item2 = new Uint8Array([2]); 97 | const item3 = new Uint8Array([3]); 98 | const item4 = new Uint8Array([4]); 99 | buffer.append(item1); 100 | // [0, 1, 1, 0, 0, 0, 0, 0, 0, 0] 101 | buffer.append(item2); 102 | // [0, 1, 1, 0, 1, 2, 0, 0, 0, 0] 103 | buffer.append(item3); 104 | // [0, 1, 1, 0, 1, 2, 0, 1, 3, 0] 105 | buffer.append(item4); 106 | // then 107 | expect(buffer.writeOffset).to.be.eq(2); 108 | expect(buffer.readOffset).to.be.eq(3); 109 | expect(buffer.raw()).to.be.deep.eq( 110 | new Uint8Array([1, 4, 0, 0, 1, 2, 0, 1, 3, 0]), 111 | ); 112 | }); 113 | 114 | it('read offset increment with modular', () => { 115 | // given 116 | const buffer = CyclicByteBuffer.empty(7); 117 | // when 118 | const item1 = new Uint8Array([1, 2]); 119 | const item2 = new Uint8Array([3]); 120 | const item3 = new Uint8Array([4, 5]); 121 | buffer.append(item1); 122 | // [0, 2, 1, 2, 0, 0, 0] 123 | buffer.append(item2); 124 | // [0, 2, 1, 2, 0, 1, 3] 125 | buffer.append(item3); 126 | // then 127 | expect(buffer.writeOffset).to.be.eq(4); 128 | expect(buffer.readOffset).to.be.eq(4); 129 | expect(buffer.raw()).to.be.deep.eq(new Uint8Array([0, 2, 4, 5, 0, 1, 3])); 130 | }); 131 | 132 | it('correctly does first append when size < buffer size', () => { 133 | // given 134 | const buffer = CyclicByteBuffer.empty(6); 135 | // when 136 | const item1 = new Uint8Array([1, 2, 3]); 137 | const item2 = new Uint8Array([4, 5, 6]); 138 | const item3 = new Uint8Array([7, 8, 9]); 139 | buffer.append(item1); 140 | // [0, 3, 1, 2, 3, 0] 141 | buffer.append(item2); 142 | // [3, 4, 5, 6, 0, 0] 143 | buffer.append(item3); 144 | // then 145 | expect(buffer.writeOffset).to.be.eq(3); 146 | expect(buffer.readOffset).to.be.eq(4); 147 | expect(buffer.raw()).to.be.deep.eq(new Uint8Array([7, 8, 9, 0, 0, 3])); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /src/utils/cyclic-bytebuffer.ts: -------------------------------------------------------------------------------- 1 | export type BufferItem = { offset: number; buffer: Uint8Array }; 2 | 3 | export const ITEM_METADATA_OVERHEAD = 2; // used to store item size, uint16 4 | 5 | export class CyclicByteBuffer { 6 | readOffset!: number; 7 | writeOffset!: number; 8 | itemsCount!: number; 9 | private readonly buffer!: Uint8Array; 10 | 11 | constructor( 12 | readOffset: number, 13 | writeOffset: number, 14 | itemsCount: number, 15 | buffer: Uint8Array, 16 | ) { 17 | this.readOffset = readOffset; 18 | this.writeOffset = writeOffset; 19 | this.itemsCount = itemsCount; 20 | this.buffer = buffer; 21 | } 22 | 23 | static empty(size: number): CyclicByteBuffer { 24 | return new CyclicByteBuffer(0, 0, 0, new Uint8Array(size).fill(0)); 25 | } 26 | 27 | raw(): Uint8Array { 28 | return this.buffer; 29 | } 30 | 31 | append(item: Uint8Array) { 32 | const metadata = this.uint16ToBytes(item.length); 33 | const itemWithMetadata = new Uint8Array([...metadata, ...item]); 34 | const newWriteOffset = this.mod(this.writeOffset + itemWithMetadata.length); 35 | while (this.noSpaceAvailableFor(itemWithMetadata)) { 36 | this.eraseOldestItem(); 37 | } 38 | this.writeNewItem(itemWithMetadata, newWriteOffset); 39 | } 40 | 41 | uint16ToBytes(value: number) { 42 | return new Uint8Array([(value & 0xff00) >>> 8, value & 0x00ff]); 43 | } 44 | 45 | uint16FromBytes(bytes: Uint8Array) { 46 | return (bytes[0] << 8) | bytes[1]; 47 | } 48 | 49 | items(): BufferItem[] { 50 | let readOffset = this.readOffset; 51 | let itemsRead = 0; 52 | const acc: BufferItem[] = []; 53 | while (this.canRead(itemsRead)) { 54 | const itemSize = this.uint16FromBytes( 55 | this.read(ITEM_METADATA_OVERHEAD, readOffset), 56 | ); 57 | const item = this.read( 58 | itemSize, 59 | this.mod(readOffset + ITEM_METADATA_OVERHEAD), 60 | ); 61 | acc.push({ 62 | offset: readOffset, 63 | buffer: item, 64 | }); 65 | readOffset = this.mod(readOffset + ITEM_METADATA_OVERHEAD + itemSize); 66 | itemsRead++; 67 | } 68 | return acc; 69 | } 70 | 71 | private mod(n: number) { 72 | return n % this.buffer.length; 73 | } 74 | 75 | private canRead(readCount: number) { 76 | return readCount < this.itemsCount; 77 | } 78 | 79 | private writeNewItem(itemWithMetadata: Uint8Array, newWriteOffset: number) { 80 | this.write(itemWithMetadata, this.writeOffset); 81 | this.writeOffset = newWriteOffset; 82 | this.itemsCount++; 83 | } 84 | 85 | private noSpaceAvailableFor(item: Uint8Array) { 86 | return this.getAvailableSpace() < item.length; 87 | } 88 | 89 | private eraseOldestItem() { 90 | const itemSize = ITEM_METADATA_OVERHEAD + this.readItemSize(); 91 | this.write(this.zeros(itemSize), this.readOffset); 92 | this.readOffset = this.mod(this.readOffset + itemSize); 93 | this.itemsCount--; 94 | } 95 | 96 | private zeros(oldestItemSize: number) { 97 | return new Uint8Array(new Array(oldestItemSize).fill(0)); 98 | } 99 | 100 | private readItemSize(): number { 101 | const readOffset = this.readOffset; 102 | const tailSize = this.buffer.length - readOffset; 103 | if (tailSize >= ITEM_METADATA_OVERHEAD) { 104 | return this.uint16FromBytes( 105 | new Uint8Array([ 106 | this.buffer[this.readOffset], 107 | this.buffer[this.readOffset + 1], 108 | ]), 109 | ); 110 | } 111 | return this.uint16FromBytes( 112 | new Uint8Array([this.buffer[this.readOffset], this.buffer[0]]), 113 | ); 114 | } 115 | 116 | private read(size: number, offset: number): Uint8Array { 117 | const tailSize = this.buffer.length - offset; 118 | if (tailSize >= size) { 119 | return this.buffer.slice(offset, offset + size); 120 | } 121 | const tail: Uint8Array = this.buffer.slice(offset, this.buffer.length); 122 | const head: Uint8Array = this.buffer.slice(0, size - tail.length); 123 | return new Uint8Array([...tail, ...head]); 124 | } 125 | 126 | private write(data: Uint8Array, offset: number) { 127 | data.forEach((value, idx) => { 128 | const pos = this.mod(offset + idx); 129 | this.buffer[pos] = value; 130 | }); 131 | } 132 | 133 | private getAvailableSpace() { 134 | if (this.itemsCount === 0) { 135 | return this.buffer.length; 136 | } 137 | return this.mod(this.readOffset - this.writeOffset + this.buffer.length); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/utils/dialect.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.6", 3 | "name": "dialect", 4 | "instructions": [ 5 | { 6 | "name": "createMetadata", 7 | "accounts": [ 8 | { 9 | "name": "user", 10 | "isMut": true, 11 | "isSigner": true 12 | }, 13 | { 14 | "name": "metadata", 15 | "isMut": true, 16 | "isSigner": false 17 | }, 18 | { 19 | "name": "rent", 20 | "isMut": false, 21 | "isSigner": false 22 | }, 23 | { 24 | "name": "systemProgram", 25 | "isMut": false, 26 | "isSigner": false 27 | } 28 | ], 29 | "args": [ 30 | { 31 | "name": "metadataNonce", 32 | "type": "u8" 33 | } 34 | ] 35 | }, 36 | { 37 | "name": "closeMetadata", 38 | "accounts": [ 39 | { 40 | "name": "user", 41 | "isMut": true, 42 | "isSigner": true 43 | }, 44 | { 45 | "name": "metadata", 46 | "isMut": true, 47 | "isSigner": false 48 | }, 49 | { 50 | "name": "rent", 51 | "isMut": false, 52 | "isSigner": false 53 | }, 54 | { 55 | "name": "systemProgram", 56 | "isMut": false, 57 | "isSigner": false 58 | } 59 | ], 60 | "args": [ 61 | { 62 | "name": "metadataNonce", 63 | "type": "u8" 64 | } 65 | ] 66 | }, 67 | { 68 | "name": "createDialect", 69 | "accounts": [ 70 | { 71 | "name": "owner", 72 | "isMut": true, 73 | "isSigner": true 74 | }, 75 | { 76 | "name": "member0", 77 | "isMut": false, 78 | "isSigner": false 79 | }, 80 | { 81 | "name": "member1", 82 | "isMut": false, 83 | "isSigner": false 84 | }, 85 | { 86 | "name": "dialect", 87 | "isMut": true, 88 | "isSigner": false 89 | }, 90 | { 91 | "name": "rent", 92 | "isMut": false, 93 | "isSigner": false 94 | }, 95 | { 96 | "name": "systemProgram", 97 | "isMut": false, 98 | "isSigner": false 99 | } 100 | ], 101 | "args": [ 102 | { 103 | "name": "dialectNonce", 104 | "type": "u8" 105 | }, 106 | { 107 | "name": "encrypted", 108 | "type": "bool" 109 | }, 110 | { 111 | "name": "scopes", 112 | "type": { 113 | "array": [ 114 | { 115 | "array": ["bool", 2] 116 | }, 117 | 2 118 | ] 119 | } 120 | } 121 | ] 122 | }, 123 | { 124 | "name": "closeDialect", 125 | "accounts": [ 126 | { 127 | "name": "owner", 128 | "isMut": true, 129 | "isSigner": true 130 | }, 131 | { 132 | "name": "dialect", 133 | "isMut": true, 134 | "isSigner": false 135 | }, 136 | { 137 | "name": "rent", 138 | "isMut": false, 139 | "isSigner": false 140 | }, 141 | { 142 | "name": "systemProgram", 143 | "isMut": false, 144 | "isSigner": false 145 | } 146 | ], 147 | "args": [ 148 | { 149 | "name": "dialectNonce", 150 | "type": "u8" 151 | } 152 | ] 153 | }, 154 | { 155 | "name": "subscribeUser", 156 | "accounts": [ 157 | { 158 | "name": "signer", 159 | "isMut": true, 160 | "isSigner": true 161 | }, 162 | { 163 | "name": "user", 164 | "isMut": false, 165 | "isSigner": false 166 | }, 167 | { 168 | "name": "metadata", 169 | "isMut": true, 170 | "isSigner": false 171 | }, 172 | { 173 | "name": "dialect", 174 | "isMut": false, 175 | "isSigner": false 176 | }, 177 | { 178 | "name": "rent", 179 | "isMut": false, 180 | "isSigner": false 181 | }, 182 | { 183 | "name": "systemProgram", 184 | "isMut": false, 185 | "isSigner": false 186 | } 187 | ], 188 | "args": [ 189 | { 190 | "name": "dialectNonce", 191 | "type": "u8" 192 | }, 193 | { 194 | "name": "metadataNonce", 195 | "type": "u8" 196 | } 197 | ] 198 | }, 199 | { 200 | "name": "sendMessage", 201 | "accounts": [ 202 | { 203 | "name": "sender", 204 | "isMut": true, 205 | "isSigner": true 206 | }, 207 | { 208 | "name": "dialect", 209 | "isMut": true, 210 | "isSigner": false 211 | }, 212 | { 213 | "name": "rent", 214 | "isMut": false, 215 | "isSigner": false 216 | }, 217 | { 218 | "name": "systemProgram", 219 | "isMut": false, 220 | "isSigner": false 221 | } 222 | ], 223 | "args": [ 224 | { 225 | "name": "dialectNonce", 226 | "type": "u8" 227 | }, 228 | { 229 | "name": "text", 230 | "type": "bytes" 231 | } 232 | ] 233 | }, 234 | { 235 | "name": "backwardsCompatibility", 236 | "accounts": [ 237 | { 238 | "name": "dialect", 239 | "isMut": false, 240 | "isSigner": false 241 | } 242 | ], 243 | "args": [] 244 | } 245 | ], 246 | "accounts": [ 247 | { 248 | "name": "MetadataAccount", 249 | "type": { 250 | "kind": "struct", 251 | "fields": [ 252 | { 253 | "name": "user", 254 | "type": "publicKey" 255 | }, 256 | { 257 | "name": "subscriptions", 258 | "type": { 259 | "array": [ 260 | { 261 | "defined": "Subscription" 262 | }, 263 | 32 264 | ] 265 | } 266 | } 267 | ] 268 | } 269 | }, 270 | { 271 | "name": "DialectAccount", 272 | "type": { 273 | "kind": "struct", 274 | "fields": [ 275 | { 276 | "name": "members", 277 | "type": { 278 | "array": [ 279 | { 280 | "defined": "Member" 281 | }, 282 | 2 283 | ] 284 | } 285 | }, 286 | { 287 | "name": "messages", 288 | "type": { 289 | "defined": "CyclicByteBuffer" 290 | } 291 | }, 292 | { 293 | "name": "lastMessageTimestamp", 294 | "type": "u32" 295 | }, 296 | { 297 | "name": "encrypted", 298 | "type": "bool" 299 | } 300 | ] 301 | } 302 | } 303 | ], 304 | "types": [ 305 | { 306 | "name": "CyclicByteBuffer", 307 | "type": { 308 | "kind": "struct", 309 | "fields": [ 310 | { 311 | "name": "readOffset", 312 | "type": "u16" 313 | }, 314 | { 315 | "name": "writeOffset", 316 | "type": "u16" 317 | }, 318 | { 319 | "name": "itemsCount", 320 | "type": "u16" 321 | }, 322 | { 323 | "name": "buffer", 324 | "type": { 325 | "array": ["u8", 8192] 326 | } 327 | } 328 | ] 329 | } 330 | }, 331 | { 332 | "name": "Subscription", 333 | "type": { 334 | "kind": "struct", 335 | "fields": [ 336 | { 337 | "name": "pubkey", 338 | "type": "publicKey" 339 | }, 340 | { 341 | "name": "enabled", 342 | "type": "bool" 343 | } 344 | ] 345 | } 346 | }, 347 | { 348 | "name": "Member", 349 | "type": { 350 | "kind": "struct", 351 | "fields": [ 352 | { 353 | "name": "publicKey", 354 | "type": "publicKey" 355 | }, 356 | { 357 | "name": "scopes", 358 | "type": { 359 | "array": ["bool", 2] 360 | } 361 | } 362 | ] 363 | } 364 | } 365 | ], 366 | "events": [ 367 | { 368 | "name": "DialectCreatedEvent", 369 | "fields": [ 370 | { 371 | "name": "dialect", 372 | "type": "publicKey", 373 | "index": false 374 | }, 375 | { 376 | "name": "members", 377 | "type": { 378 | "array": ["publicKey", 2] 379 | }, 380 | "index": false 381 | } 382 | ] 383 | }, 384 | { 385 | "name": "DialectDeletedEvent", 386 | "fields": [ 387 | { 388 | "name": "dialect", 389 | "type": "publicKey", 390 | "index": false 391 | }, 392 | { 393 | "name": "members", 394 | "type": { 395 | "array": ["publicKey", 2] 396 | }, 397 | "index": false 398 | } 399 | ] 400 | }, 401 | { 402 | "name": "MessageSentEvent", 403 | "fields": [ 404 | { 405 | "name": "dialect", 406 | "type": "publicKey", 407 | "index": false 408 | }, 409 | { 410 | "name": "sender", 411 | "type": "publicKey", 412 | "index": false 413 | } 414 | ] 415 | }, 416 | { 417 | "name": "UserSubscribedEvent", 418 | "fields": [ 419 | { 420 | "name": "metadata", 421 | "type": "publicKey", 422 | "index": false 423 | }, 424 | { 425 | "name": "dialect", 426 | "type": "publicKey", 427 | "index": false 428 | } 429 | ] 430 | }, 431 | { 432 | "name": "MetadataCreatedEvent", 433 | "fields": [ 434 | { 435 | "name": "metadata", 436 | "type": "publicKey", 437 | "index": false 438 | }, 439 | { 440 | "name": "user", 441 | "type": "publicKey", 442 | "index": false 443 | } 444 | ] 445 | }, 446 | { 447 | "name": "MetadataDeletedEvent", 448 | "fields": [ 449 | { 450 | "name": "metadata", 451 | "type": "publicKey", 452 | "index": false 453 | }, 454 | { 455 | "name": "user", 456 | "type": "publicKey", 457 | "index": false 458 | } 459 | ] 460 | } 461 | ], 462 | "errors": [ 463 | { 464 | "code": 6000, 465 | "name": "DialectOwnerIsNotAdmin", 466 | "msg": "The dialect owner must be a member with admin privileges" 467 | } 468 | ], 469 | "metadata": { 470 | "address": "CeNUxGUsSeb5RuAGvaMLNx3tEZrpBwQqA7Gs99vMPCAb" 471 | } 472 | } 473 | -------------------------------------------------------------------------------- /src/utils/ecdh-encryption.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { 3 | Curve25519KeyPair, 4 | ecdhDecrypt, 5 | ecdhEncrypt, 6 | ENCRYPTION_OVERHEAD_BYTES, 7 | } from './ecdh-encryption'; 8 | import { randomBytes } from 'tweetnacl'; 9 | import { NONCE_SIZE_BYTES } from './nonce-generator'; 10 | import { Keypair } from '@solana/web3.js'; 11 | import ed2curve from 'ed2curve'; 12 | 13 | function generateKeypair() { 14 | const { publicKey, secretKey } = new Keypair(); 15 | const curve25519: Curve25519KeyPair = ed2curve.convertKeyPair({ 16 | publicKey: publicKey.toBytes(), 17 | secretKey, 18 | })!; 19 | return { 20 | ed25519: { 21 | publicKey: publicKey.toBytes(), 22 | secretKey, 23 | }, 24 | curve25519, 25 | }; 26 | } 27 | 28 | describe('ECDH encryptor/decryptor test', async () => { 29 | /* 30 | tweetnacl source code references: 31 | https://github.com/dchest/tweetnacl-js/blob/master/nacl-fast.js#L2076 32 | https://github.com/dchest/tweetnacl-js/blob/master/nacl-fast.js#L2202 33 | https://github.com/dchest/tweetnacl-js/blob/master/nacl-fast.js#L2266 34 | */ 35 | it('should be always 16 bytes overhead after encryption', () => { 36 | // given 37 | // generate arithmetic progression a_0 = 1, d = 8, n = 128 38 | const messageSizes = Array(128) 39 | .fill(1) 40 | .map((element, index) => (index + 1) * 8); 41 | // when 42 | const sizesComparison = messageSizes.map((size) => { 43 | const unencrypted = randomBytes(size); 44 | const nonce = randomBytes(NONCE_SIZE_BYTES); 45 | const keyPair1 = generateKeypair(); 46 | const keyPair2 = generateKeypair(); 47 | 48 | const encrypted = ecdhEncrypt( 49 | unencrypted, 50 | keyPair1.curve25519, 51 | keyPair2.ed25519.publicKey, 52 | nonce, 53 | ); 54 | return { 55 | sizeBefore: unencrypted.byteLength, 56 | sizeAfter: encrypted.byteLength, 57 | sizeDiff: encrypted.byteLength - unencrypted.byteLength, 58 | }; 59 | }); 60 | // then 61 | sizesComparison.forEach(({ sizeDiff }) => { 62 | expect(sizeDiff).to.eq(ENCRYPTION_OVERHEAD_BYTES); 63 | }); 64 | }); 65 | 66 | it('should be possible to decrypt using the same keypair that was used in encryption', () => { 67 | // given 68 | const unencrypted = randomBytes(10); 69 | const nonce = randomBytes(NONCE_SIZE_BYTES); 70 | const party1KeyPair = generateKeypair(); 71 | const party2KeyPair = generateKeypair(); 72 | const encrypted = ecdhEncrypt( 73 | unencrypted, 74 | party1KeyPair.curve25519, 75 | party2KeyPair.ed25519.publicKey, 76 | nonce, 77 | ); 78 | // when 79 | const decrypted = ecdhDecrypt( 80 | encrypted, 81 | party1KeyPair.curve25519, 82 | party2KeyPair.ed25519.publicKey, 83 | nonce, 84 | ); 85 | // then 86 | expect(unencrypted).to.not.deep.eq(encrypted); 87 | expect(decrypted).to.deep.eq(unencrypted); 88 | }); 89 | 90 | it('should be possible to decrypt using other party keypair', () => { 91 | // given 92 | const unencrypted = randomBytes(10); 93 | const nonce = randomBytes(NONCE_SIZE_BYTES); 94 | const party1KeyPair = generateKeypair(); 95 | const party2KeyPair = generateKeypair(); 96 | const encrypted = ecdhEncrypt( 97 | unencrypted, 98 | party1KeyPair.curve25519, 99 | party2KeyPair.ed25519.publicKey, 100 | nonce, 101 | ); 102 | // when 103 | const decrypted = ecdhDecrypt( 104 | encrypted, 105 | party2KeyPair.curve25519, 106 | party1KeyPair.ed25519.publicKey, 107 | nonce, 108 | ); 109 | // then 110 | expect(unencrypted).to.not.deep.eq(encrypted); 111 | expect(decrypted).to.deep.eq(unencrypted); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/utils/ecdh-encryption.ts: -------------------------------------------------------------------------------- 1 | import ed2curve from 'ed2curve'; 2 | import nacl from 'tweetnacl'; 3 | 4 | export const ENCRYPTION_OVERHEAD_BYTES = 16; 5 | 6 | export class IncorrectPublicKeyFormatError extends Error { 7 | constructor() { 8 | super('IncorrectPublicKeyFormatError'); 9 | } 10 | } 11 | 12 | export class AuthenticationFailedError extends Error { 13 | constructor() { 14 | super('Authentication failed during decryption attempt'); 15 | } 16 | } 17 | 18 | export type Curve25519Key = Uint8Array; 19 | 20 | export type Curve25519KeyPair = { 21 | publicKey: Curve25519Key; 22 | secretKey: Curve25519Key; 23 | }; 24 | 25 | export type Ed25519Key = Uint8Array; 26 | 27 | export type Ed25519KeyPair = { 28 | publicKey: Curve25519Key; 29 | secretKey: Curve25519Key; 30 | }; 31 | 32 | export function ed25519KeyPairToCurve25519({ 33 | publicKey, 34 | secretKey, 35 | }: Ed25519KeyPair): Curve25519KeyPair { 36 | const curve25519KeyPair = ed2curve.convertKeyPair({ 37 | publicKey, 38 | secretKey, 39 | }); 40 | if (!curve25519KeyPair) { 41 | throw new IncorrectPublicKeyFormatError(); 42 | } 43 | return curve25519KeyPair; 44 | } 45 | 46 | export function ed25519PublicKeyToCurve25519(key: Ed25519Key): Curve25519Key { 47 | const curve25519PublicKey = ed2curve.convertPublicKey(key); 48 | if (!curve25519PublicKey) { 49 | throw new IncorrectPublicKeyFormatError(); 50 | } 51 | return curve25519PublicKey; 52 | } 53 | 54 | export function ecdhEncrypt( 55 | payload: Uint8Array, 56 | { secretKey, publicKey }: Curve25519KeyPair, 57 | otherPartyPublicKey: Ed25519Key, 58 | nonce: Uint8Array, 59 | ): Uint8Array { 60 | return nacl.box( 61 | payload, 62 | nonce, 63 | ed25519PublicKeyToCurve25519(otherPartyPublicKey), 64 | secretKey, 65 | ); 66 | } 67 | 68 | export function ecdhDecrypt( 69 | payload: Uint8Array, 70 | { secretKey, publicKey }: Curve25519KeyPair, 71 | otherPartyPublicKey: Ed25519Key, 72 | nonce: Uint8Array, 73 | ): Uint8Array { 74 | const decrypted = nacl.box.open( 75 | payload, 76 | nonce, 77 | ed25519PublicKeyToCurve25519(otherPartyPublicKey), 78 | secretKey, 79 | ); 80 | if (!decrypted) { 81 | throw new AuthenticationFailedError(); 82 | } 83 | return decrypted; 84 | } 85 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import type * as anchor from '@project-serum/anchor'; 2 | import { Keypair, PublicKey } from '@solana/web3.js'; 3 | 4 | import { EmbeddedWallet } from './Wallet'; 5 | import idl_ from './dialect.json'; 6 | import programs_ from './programs.json'; 7 | import { ecdhDecrypt, ecdhEncrypt } from './ecdh-encryption'; 8 | 9 | export const idl = idl_; 10 | export const programs = programs_; 11 | 12 | export const display = (publicKey: PublicKey | string): string => { 13 | const s = publicKey.toString(); 14 | return `${s.slice(0, 4)}...${s.slice(s.length - 4)}`; 15 | }; 16 | 17 | export const getPublicKey = ( 18 | wallet: EmbeddedWallet | null | undefined, 19 | abbreviate = false, 20 | ): string | null => { 21 | // if (!wallet || !wallet.connected) return null; 22 | if (!wallet) return null; 23 | 24 | const pubkeyStr = `${wallet?.publicKey?.toBase58()}`; 25 | if (!abbreviate) return pubkeyStr; 26 | 27 | return ( 28 | `${pubkeyStr?.slice(0, 4)}...${pubkeyStr?.slice(pubkeyStr?.length - 4)}` || 29 | null 30 | ); 31 | }; 32 | 33 | export class Wallet_ extends EmbeddedWallet { 34 | // anchor needs a non-optional publicKey attribute, sollet says it's optional, so we need to fix it here. 35 | get publicKey(): PublicKey { 36 | const pkornull = super.publicKey; 37 | let pk: PublicKey; 38 | if (!pkornull) { 39 | const kp = Keypair.generate(); 40 | pk = kp.publicKey; 41 | } else { 42 | pk = pkornull as PublicKey; 43 | } 44 | return pk; 45 | } 46 | } 47 | 48 | export function sleep( 49 | ms: number, 50 | ): Promise<(value: (() => void) | PromiseLike<() => void>) => void> { 51 | return new Promise((resolve) => setTimeout(resolve, ms)); 52 | } 53 | 54 | /* 55 | Transactions 56 | */ 57 | 58 | export async function waitForFinality( 59 | program: anchor.Program, 60 | transactionStr: string, 61 | finality: anchor.web3.Finality | undefined = 'confirmed', 62 | maxRetries = 10, // try 10 times 63 | sleepDuration = 500, // wait 0.5s between tries 64 | ): Promise { 65 | try { 66 | return await waitForFinality_inner( 67 | program, 68 | transactionStr, 69 | finality, 70 | maxRetries, 71 | sleepDuration, 72 | ); 73 | } catch (e) { 74 | console.error(e); 75 | throw e; 76 | } 77 | } 78 | 79 | async function waitForFinality_inner( 80 | program: anchor.Program, 81 | transactionStr: string, 82 | finality: anchor.web3.Finality | undefined = 'confirmed', 83 | maxRetries = 10, // try 10 times 84 | sleepDuration = 500, // wait 0.5s between tries 85 | ): Promise { 86 | let transaction: anchor.web3.TransactionResponse | null = null; 87 | for (let n = 0; n < maxRetries; n++) { 88 | transaction = await program.provider.connection.getTransaction( 89 | transactionStr, 90 | { commitment: finality }, 91 | ); 92 | if (transaction) { 93 | break; 94 | } 95 | await sleep(sleepDuration); 96 | } 97 | if (!transaction) { 98 | throw new Error('Transaction failed to finalize'); 99 | } 100 | return transaction; 101 | } 102 | 103 | // TODO: instead use separate diffie-helman key with public key signed by the RSA private key. 104 | export function encryptMessage( 105 | msg: Uint8Array, 106 | sAccount: anchor.web3.Signer, 107 | rPublicKey: PublicKey, 108 | nonce: Uint8Array, 109 | ): Uint8Array { 110 | return ecdhEncrypt( 111 | msg, 112 | { 113 | publicKey: sAccount.publicKey.toBytes(), 114 | secretKey: sAccount.secretKey, 115 | }, 116 | rPublicKey.toBytes(), 117 | nonce, 118 | ); 119 | } 120 | 121 | export function decryptMessage( 122 | msg: Uint8Array, 123 | account: anchor.web3.Keypair, 124 | fromPk: PublicKey, 125 | nonce: Uint8Array, 126 | ): Uint8Array | null { 127 | return ecdhDecrypt( 128 | msg, 129 | { 130 | publicKey: account.publicKey.toBytes(), 131 | secretKey: account.secretKey, 132 | }, 133 | fromPk.toBytes(), 134 | nonce, 135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /src/utils/nonce-generator.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { 3 | generateNonce, 4 | generateRandomNonceWithPrefix, 5 | } from './nonce-generator'; 6 | 7 | describe('Nonce generator test', async () => { 8 | it('should always return 24 bytes', () => { 9 | expect(generateNonce(0)).to.be.deep.eq(new Uint8Array(Array(24).fill(0))); 10 | expect(generateNonce(1)).to.be.deep.eq( 11 | new Uint8Array([...Array(23).fill(0), 1]), 12 | ); 13 | expect(generateNonce(333)).be.deep.eq( 14 | new Uint8Array([...Array(21).fill(0), 3, 3, 3]), 15 | ); 16 | expect(generateNonce(55555)).to.be.deep.eq( 17 | new Uint8Array([...Array(19).fill(0), 5, 5, 5, 5, 5]), 18 | ); 19 | expect(generateNonce(-55555)).to.be.deep.eq( 20 | new Uint8Array([...Array(19).fill(0), 5, 5, 5, 5, 5]), 21 | ); 22 | }); 23 | 24 | it('should always return 24 bytes for nonce w/ prefix', () => { 25 | const nonce1 = generateRandomNonceWithPrefix(0); 26 | const nonce2 = generateRandomNonceWithPrefix(128); 27 | const nonce3 = generateRandomNonceWithPrefix(512); 28 | expect(nonce1.length).to.be.eq(24); 29 | expect(nonce2.length).to.be.eq(24); 30 | expect(nonce3.length).to.be.eq(24); 31 | }); 32 | 33 | it('should always set prefix in higher byte', () => { 34 | const nonce1 = generateRandomNonceWithPrefix(0); 35 | const nonce2 = generateRandomNonceWithPrefix(4); 36 | const nonce3 = generateRandomNonceWithPrefix(200); 37 | const nonce4 = generateRandomNonceWithPrefix(257); 38 | expect(nonce1[0]).to.be.eq(0); 39 | expect(nonce2[0]).to.be.eq(4); 40 | expect(nonce3[0]).to.be.eq(200); 41 | expect(nonce4[0]).to.be.eq(1); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/utils/nonce-generator.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | import nacl from 'tweetnacl'; 4 | 5 | export const NONCE_SIZE_BYTES = 24; 6 | 7 | /** 8 | * Generates 24 byte nonce from message counter. 9 | * Sender and receiver should keep a counter of messages and increment it for each message. 10 | */ 11 | export function generateNonce(messageCounter: number): Uint8Array { 12 | const nonce = new Uint8Array(Array(NONCE_SIZE_BYTES).fill(0)); 13 | let messageCounterModule = Math.abs(messageCounter); 14 | let nonceByteIndex = 0; 15 | while (messageCounterModule) { 16 | nonce[nonceByteIndex++] = messageCounterModule % 10; 17 | messageCounterModule = Math.floor(messageCounterModule / 10); 18 | } 19 | return nonce.reverse(); 20 | } 21 | 22 | export function generateRandomNonce(): Buffer { 23 | return crypto.randomBytes(NONCE_SIZE_BYTES); 24 | } 25 | 26 | export function generateRandomNonceWithPrefix(memberId: number) { 27 | return new Uint8Array([memberId, ...nacl.randomBytes(NONCE_SIZE_BYTES - 1)]); 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/programs.json: -------------------------------------------------------------------------------- 1 | { 2 | "localnet": { 3 | "clusterAddress": "http://127.0.0.1:8899", 4 | "programAddress": "2YFyZAg8rBtuvzFFiGvXwPHFAQJ2FXZoS7bYCKticpjk" 5 | }, 6 | "devnet": { 7 | "clusterAddress": "https://api.devnet.solana.com", 8 | "programAddress": "2YFyZAg8rBtuvzFFiGvXwPHFAQJ2FXZoS7bYCKticpjk" 9 | }, 10 | "testnet": { 11 | "clusterAddress": "", 12 | "programAddress": "" 13 | }, 14 | "mainnet": { 15 | "clusterAddress": "https://api.mainnet-beta.solana.com", 16 | "programAddress": "CeNUxGUsSeb5RuAGvaMLNx3tEZrpBwQqA7Gs99vMPCAb" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/test-v1.ts: -------------------------------------------------------------------------------- 1 | import * as anchor from '@project-serum/anchor'; 2 | import { AnchorError, Program } from '@project-serum/anchor'; 3 | import * as web3 from '@solana/web3.js'; 4 | import chai, { expect } from 'chai'; 5 | import chaiAsPromised from 'chai-as-promised'; 6 | import { 7 | createDialect, 8 | createMetadata, 9 | deleteDialect, 10 | deleteMetadata, 11 | DialectAccount, 12 | Event, 13 | findDialects, 14 | getDialect, 15 | getDialectForMembers, 16 | getDialectProgramAddress, 17 | getDialects, 18 | getMetadata, 19 | Member, 20 | sendMessage, 21 | subscribeToEvents, 22 | subscribeUser, 23 | } from '../src/api'; 24 | import { sleep } from '../src/utils'; 25 | import { ITEM_METADATA_OVERHEAD } from '../src/utils/cyclic-bytebuffer'; 26 | import { 27 | ed25519KeyPairToCurve25519, 28 | ENCRYPTION_OVERHEAD_BYTES, 29 | } from '../src/utils/ecdh-encryption'; 30 | import { NONCE_SIZE_BYTES } from '../src/utils/nonce-generator'; 31 | import { randomInt } from 'crypto'; 32 | import { CountDownLatch } from '../src/utils/countdown-latch'; 33 | import { EncryptionProps } from '../src/api/text-serde'; 34 | 35 | chai.use(chaiAsPromised); 36 | anchor.setProvider(anchor.Provider.local()); 37 | 38 | describe('Protocol v1 test', () => { 39 | const program: anchor.Program = anchor.workspace.Dialect; 40 | const connection = program.provider.connection; 41 | 42 | describe('Metadata tests', () => { 43 | let owner: web3.Keypair; 44 | let writer: web3.Keypair; 45 | 46 | beforeEach(async () => { 47 | owner = ( 48 | await createUser({ 49 | requestAirdrop: true, 50 | createMeta: false, 51 | }) 52 | ).user; 53 | writer = ( 54 | await createUser({ 55 | requestAirdrop: true, 56 | createMeta: false, 57 | }) 58 | ).user; 59 | }); 60 | 61 | it('Create user metadata object(s)', async () => { 62 | for (const member of [owner, writer]) { 63 | const metadata = await createMetadata(program, member); 64 | const gottenMetadata = await getMetadata(program, member.publicKey); 65 | expect(metadata).to.be.deep.eq(gottenMetadata); 66 | } 67 | }); 68 | 69 | it('Owner deletes metadata', async () => { 70 | for (const member of [owner, writer]) { 71 | await createMetadata(program, member); 72 | await getMetadata(program, member.publicKey); 73 | await deleteMetadata(program, member); 74 | chai 75 | .expect(getMetadata(program, member.publicKey)) 76 | .to.eventually.be.rejectedWith(Error); 77 | } 78 | }); 79 | }); 80 | 81 | describe('Dialect initialization tests', () => { 82 | let owner: web3.Keypair; 83 | let writer: web3.Keypair; 84 | let nonmember: web3.Keypair; 85 | 86 | let members: Member[] = []; 87 | 88 | beforeEach(async () => { 89 | owner = ( 90 | await createUser({ 91 | requestAirdrop: true, 92 | createMeta: true, 93 | }) 94 | ).user; 95 | writer = ( 96 | await createUser({ 97 | requestAirdrop: true, 98 | createMeta: true, 99 | }) 100 | ).user; 101 | nonmember = ( 102 | await createUser({ 103 | requestAirdrop: true, 104 | createMeta: false, 105 | }) 106 | ).user; 107 | members = [ 108 | { 109 | publicKey: owner.publicKey, 110 | scopes: [true, false], // owner, read-only 111 | }, 112 | { 113 | publicKey: writer.publicKey, 114 | scopes: [false, true], // non-owner, read-write 115 | }, 116 | ]; 117 | }); 118 | 119 | it('Confirm only each user (& dialect) can read encrypted device tokens', async () => { 120 | // TODO: Implement 121 | chai.expect(true).to.be.true; 122 | }); 123 | 124 | it("Fail to create a dialect if the owner isn't a member with admin privileges", async () => { 125 | try { 126 | await createDialect(program, nonmember, members, true); 127 | chai.assert( 128 | false, 129 | "Creating a dialect whose owner isn't a member should fail.", 130 | ); 131 | } catch (e) { 132 | chai.assert( 133 | (e as AnchorError).message.includes( 134 | 'The dialect owner must be a member with admin privileges.', 135 | ), 136 | ); 137 | } 138 | 139 | try { 140 | // TODO: write this in a nicer way 141 | await createDialect(program, writer, members, true); 142 | chai.assert( 143 | false, 144 | "Creating a dialect whose owner isn't a member should fail.", 145 | ); 146 | } catch (e) { 147 | chai.assert( 148 | (e as AnchorError).message.includes( 149 | 'The dialect owner must be a member with admin privileges.', 150 | ), 151 | ); 152 | } 153 | }); 154 | 155 | it('Fail to create a dialect for unsorted members', async () => { 156 | // use custom unsorted version of createDialect for unsorted members 157 | const unsortedMembers = members.sort( 158 | (a, b) => -a.publicKey.toBuffer().compare(b.publicKey.toBuffer()), 159 | ); 160 | const [publicKey, nonce] = await getDialectProgramAddress( 161 | program, 162 | unsortedMembers, 163 | ); 164 | // TODO: assert owner in members 165 | const keyedMembers = unsortedMembers.reduce( 166 | (ms, m, idx) => ({ ...ms, [`member${idx}`]: m.publicKey }), 167 | {}, 168 | ); 169 | chai 170 | .expect( 171 | program.rpc.createDialect( 172 | new anchor.BN(nonce), 173 | members.map((m) => m.scopes), 174 | { 175 | accounts: { 176 | dialect: publicKey, 177 | owner: owner.publicKey, 178 | ...keyedMembers, 179 | rent: anchor.web3.SYSVAR_RENT_PUBKEY, 180 | systemProgram: anchor.web3.SystemProgram.programId, 181 | }, 182 | signers: [owner], 183 | }, 184 | ), 185 | ) 186 | .to.eventually.be.rejectedWith(Error); 187 | }); 188 | 189 | it('Create encrypted dialect for 2 members, with owner and write scopes, respectively', async () => { 190 | const dialectAccount = await createDialect(program, owner, members, true); 191 | expect(dialectAccount.dialect.encrypted).to.be.true; 192 | }); 193 | 194 | it('Create unencrypted dialect for 2 members, with owner and write scopes, respectively', async () => { 195 | const dialectAccount = await createDialect( 196 | program, 197 | owner, 198 | members, 199 | false, 200 | ); 201 | expect(dialectAccount.dialect.encrypted).to.be.false; 202 | }); 203 | 204 | it('Creates unencrypted dialect by default', async () => { 205 | const dialectAccount = await createDialect(program, owner, members); 206 | expect(dialectAccount.dialect.encrypted).to.be.false; 207 | }); 208 | 209 | it('Fail to create a second dialect for the same members', async () => { 210 | chai 211 | .expect(createDialect(program, owner, members)) 212 | .to.eventually.be.rejectedWith(Error); 213 | }); 214 | 215 | it('Fail to create a dialect for duplicate members', async () => { 216 | const duplicateMembers = [ 217 | { publicKey: owner.publicKey, scopes: [true, true] } as Member, 218 | { publicKey: owner.publicKey, scopes: [true, true] } as Member, 219 | ]; 220 | chai 221 | .expect(createDialect(program, owner, duplicateMembers)) 222 | .to.be.rejectedWith(Error); 223 | }); 224 | 225 | it('Find a dialect for a given member pair, verify correct scopes.', async () => { 226 | await createDialect(program, owner, members); 227 | const dialect = await getDialectForMembers(program, members); 228 | members.every((m, i) => 229 | expect( 230 | m.publicKey.equals(dialect.dialect.members[i].publicKey) && 231 | m.scopes.every( 232 | (s, j) => s === dialect.dialect.members[i].scopes[j], 233 | ), 234 | ), 235 | ); 236 | }); 237 | 238 | it('Subscribe users to dialect', async () => { 239 | const dialect = await createDialect(program, owner, members); 240 | // owner subscribes themselves 241 | await subscribeUser(program, dialect, owner.publicKey, owner); 242 | // owner subscribes writer 243 | await subscribeUser(program, dialect, writer.publicKey, owner); 244 | const ownerMeta = await getMetadata(program, owner.publicKey); 245 | const writerMeta = await getMetadata(program, writer.publicKey); 246 | chai 247 | .expect( 248 | ownerMeta.subscriptions.filter((s) => 249 | s.pubkey.equals(dialect.publicKey), 250 | ).length, 251 | ) 252 | .to.equal(1); 253 | chai 254 | .expect( 255 | writerMeta.subscriptions.filter((s) => 256 | s.pubkey.equals(dialect.publicKey), 257 | ).length, 258 | ) 259 | .to.equal(1); 260 | }); 261 | 262 | it('Should return list of dialects sorted by time desc', async () => { 263 | // given 264 | console.log('Creating users'); 265 | const [user1, user2, user3] = await Promise.all([ 266 | createUser({ 267 | requestAirdrop: true, 268 | createMeta: true, 269 | }).then((it) => it.user), 270 | createUser({ 271 | requestAirdrop: true, 272 | createMeta: true, 273 | }).then((it) => it.user), 274 | createUser({ 275 | requestAirdrop: true, 276 | createMeta: true, 277 | }).then((it) => it.user), 278 | ]); 279 | console.log('Creating dialects'); 280 | // create first dialect and subscribe users 281 | const dialect1 = await createDialectAndSubscribeAllMembers( 282 | program, 283 | user1, 284 | user2, 285 | false, 286 | ); 287 | const dialect2 = await createDialectAndSubscribeAllMembers( 288 | program, 289 | user1, 290 | user3, 291 | false, 292 | ); 293 | // when 294 | const afterCreatingDialects = await getDialects(program, user1); 295 | await sleep(3000); // wait a bit to avoid equal timestamp, since since we get utc seconds as a timestamp 296 | await sendMessage( 297 | program, 298 | dialect1, 299 | user1, 300 | 'Dummy message to increment latest message timestamp', 301 | ); 302 | const afterSendingMessageToDialect1 = await getDialects(program, user1); 303 | await sleep(3000); // wait a bit to avoid equal timestamp, since since we get utc seconds as a timestamp 304 | await sendMessage( 305 | program, 306 | dialect2, 307 | user1, 308 | 'Dummy message to increment latest message timestamp', 309 | ); 310 | const afterSendingMessageToDialect2 = await getDialects(program, user1); 311 | // then 312 | // assert dialects before sending messages 313 | chai 314 | .expect(afterCreatingDialects.map((it) => it.publicKey)) 315 | .to.be.deep.eq([dialect2.publicKey, dialect1.publicKey]); // dialect 2 was created after dialect 1 316 | // assert dialects after sending message to first dialect 317 | chai 318 | .expect(afterSendingMessageToDialect1.map((it) => it.publicKey)) 319 | .to.be.deep.eq([dialect1.publicKey, dialect2.publicKey]); 320 | // assert dialects after sending message to second dialect 321 | chai 322 | .expect(afterSendingMessageToDialect2.map((it) => it.publicKey)) 323 | .to.be.deep.eq([dialect2.publicKey, dialect1.publicKey]); 324 | }); 325 | 326 | it('Non-owners fail to delete the dialect', async () => { 327 | const dialect = await createDialect(program, owner, members); 328 | chai 329 | .expect(deleteDialect(program, dialect, writer)) 330 | .to.eventually.be.rejectedWith(Error); 331 | chai 332 | .expect(deleteDialect(program, dialect, nonmember)) 333 | .to.eventually.be.rejectedWith(Error); 334 | }); 335 | 336 | it('Owner deletes the dialect', async () => { 337 | const dialect = await createDialect(program, owner, members); 338 | await deleteDialect(program, dialect, owner); 339 | chai 340 | .expect(getDialectForMembers(program, members)) 341 | .to.eventually.be.rejectedWith(Error); 342 | }); 343 | 344 | it('Fail to subscribe a user twice to the same dialect (silent, noop)', async () => { 345 | const dialect = await createDialect(program, owner, members); 346 | await subscribeUser(program, dialect, writer.publicKey, owner); 347 | const metadata = await getMetadata(program, writer.publicKey); 348 | // subscribed once 349 | chai 350 | .expect( 351 | metadata.subscriptions.filter((s) => 352 | s.pubkey.equals(dialect.publicKey), 353 | ).length, 354 | ) 355 | .to.equal(1); 356 | chai 357 | .expect(subscribeUser(program, dialect, writer.publicKey, owner)) 358 | .to.be.rejectedWith(Error); 359 | // still subscribed just once 360 | chai 361 | .expect( 362 | metadata.subscriptions.filter((s) => 363 | s.pubkey.equals(dialect.publicKey), 364 | ).length, 365 | ) 366 | .to.equal(1); 367 | }); 368 | }); 369 | 370 | describe('Find dialects', () => { 371 | it('Can find all dialects filtering by user public key', async () => { 372 | // given 373 | const [user1, user2, user3] = await Promise.all([ 374 | createUser({ 375 | requestAirdrop: true, 376 | createMeta: false, 377 | }).then((it) => it.user), 378 | createUser({ 379 | requestAirdrop: true, 380 | createMeta: false, 381 | }).then((it) => it.user), 382 | createUser({ 383 | requestAirdrop: true, 384 | createMeta: false, 385 | }).then((it) => it.user), 386 | ]); 387 | const [user1User2Dialect, user1User3Dialect, user2User3Dialect] = 388 | await Promise.all([ 389 | createDialect(program, user1, [ 390 | { 391 | publicKey: user1.publicKey, 392 | scopes: [true, true], 393 | }, 394 | { 395 | publicKey: user2.publicKey, 396 | scopes: [false, true], 397 | }, 398 | ]), 399 | createDialect(program, user1, [ 400 | { 401 | publicKey: user1.publicKey, 402 | scopes: [true, true], 403 | }, 404 | { 405 | publicKey: user3.publicKey, 406 | scopes: [false, true], 407 | }, 408 | ]), 409 | createDialect(program, user2, [ 410 | { 411 | publicKey: user2.publicKey, 412 | scopes: [true, true], 413 | }, 414 | { 415 | publicKey: user3.publicKey, 416 | scopes: [false, true], 417 | }, 418 | ]), 419 | ]); 420 | // when 421 | const [ 422 | user1Dialects, 423 | user2Dialects, 424 | user3Dialects, 425 | nonExistingUserDialects, 426 | ] = await Promise.all([ 427 | findDialects(program, { 428 | userPk: user1.publicKey, 429 | }), 430 | findDialects(program, { 431 | userPk: user2.publicKey, 432 | }), 433 | findDialects(program, { 434 | userPk: user3.publicKey, 435 | }), 436 | findDialects(program, { 437 | userPk: anchor.web3.Keypair.generate().publicKey, 438 | }), 439 | ]); 440 | // then 441 | expect( 442 | user1Dialects.map((it) => it.publicKey), 443 | ).to.deep.contain.all.members([ 444 | user1User2Dialect.publicKey, 445 | user1User3Dialect.publicKey, 446 | ]); 447 | expect( 448 | user2Dialects.map((it) => it.publicKey), 449 | ).to.deep.contain.all.members([ 450 | user1User2Dialect.publicKey, 451 | user2User3Dialect.publicKey, 452 | ]); 453 | expect( 454 | user3Dialects.map((it) => it.publicKey), 455 | ).to.deep.contain.all.members([ 456 | user2User3Dialect.publicKey, 457 | user1User3Dialect.publicKey, 458 | ]); 459 | expect(nonExistingUserDialects.length).to.be.eq(0); 460 | }); 461 | }); 462 | 463 | describe('Unencrypted messaging tests', () => { 464 | let owner: web3.Keypair; 465 | let writer: web3.Keypair; 466 | let nonmember: web3.Keypair; 467 | let members: Member[] = []; 468 | let dialect: DialectAccount; 469 | 470 | beforeEach(async () => { 471 | (owner = await createUser({ 472 | requestAirdrop: true, 473 | createMeta: true, 474 | }).then((it) => it.user)), 475 | (writer = await createUser({ 476 | requestAirdrop: true, 477 | createMeta: true, 478 | }).then((it) => it.user)), 479 | (nonmember = await createUser({ 480 | requestAirdrop: true, 481 | createMeta: false, 482 | }).then((it) => it.user)), 483 | (members = [ 484 | { 485 | publicKey: owner.publicKey, 486 | scopes: [true, false], // owner, read-only 487 | }, 488 | { 489 | publicKey: writer.publicKey, 490 | scopes: [false, true], // non-owner, read-write 491 | }, 492 | ]); 493 | dialect = await createDialect(program, owner, members, false); 494 | }); 495 | 496 | it('Message sender and receiver can read the message text and time', async () => { 497 | // given 498 | const dialect = await getDialectForMembers(program, members); 499 | const text = generateRandomText(256); 500 | // when 501 | await sendMessage(program, dialect, writer, text); 502 | // then 503 | const senderDialect = await getDialectForMembers( 504 | program, 505 | dialect.dialect.members, 506 | ); 507 | const message = senderDialect.dialect.messages[0]; 508 | chai.expect(message.text).to.be.eq(text); 509 | chai 510 | .expect(senderDialect.dialect.lastMessageTimestamp) 511 | .to.be.eq(message.timestamp); 512 | }); 513 | 514 | it('Anonymous user can read any of the messages', async () => { 515 | // given 516 | const senderDialect = await getDialectForMembers(program, members); 517 | const text = generateRandomText(256); 518 | await sendMessage(program, senderDialect, writer, text); 519 | // when / then 520 | const nonMemberDialect = await getDialectForMembers( 521 | program, 522 | dialect.dialect.members, 523 | ); 524 | const message = nonMemberDialect.dialect.messages[0]; 525 | chai.expect(message.text).to.be.eq(text); 526 | chai.expect(message.owner).to.be.deep.eq(writer.publicKey); 527 | chai 528 | .expect(nonMemberDialect.dialect.lastMessageTimestamp) 529 | .to.be.eq(message.timestamp); 530 | }); 531 | 532 | it('New messages overwrite old, retrieved messages are in order.', async () => { 533 | // emulate ideal message alignment withing buffer 534 | const rawBufferSize = 8192; 535 | const messagesPerDialect = 16; 536 | const numMessages = messagesPerDialect * 2; 537 | const salt = 3; 538 | const targetRawMessageSize = rawBufferSize / messagesPerDialect - salt; 539 | const timestampSize = 4; 540 | const ownerMemberIdxSize = 1; 541 | const messageSerializationOverhead = 542 | ITEM_METADATA_OVERHEAD + timestampSize + ownerMemberIdxSize; 543 | const targetTextSize = 544 | targetRawMessageSize - messageSerializationOverhead; 545 | const texts = Array(numMessages) 546 | .fill(0) 547 | .map(() => generateRandomText(targetTextSize)); 548 | for (let messageIdx = 0; messageIdx < numMessages; messageIdx++) { 549 | // verify last last N messages look correct 550 | const messageCounter = messageIdx + 1; 551 | const text = texts[messageIdx]; 552 | const dialect = await getDialectForMembers(program, members); 553 | console.log( 554 | `Sending message ${messageCounter}/${texts.length} 555 | len = ${text.length} 556 | idx: ${dialect.dialect.nextMessageIdx}`, 557 | ); 558 | await sendMessage(program, dialect, writer, text); 559 | const sliceStart = 560 | messageCounter <= messagesPerDialect 561 | ? 0 562 | : messageCounter - messagesPerDialect; 563 | const expectedMessagesCount = Math.min( 564 | messageCounter, 565 | messagesPerDialect, 566 | ); 567 | const sliceEnd = sliceStart + expectedMessagesCount; 568 | const expectedMessages = texts.slice(sliceStart, sliceEnd).reverse(); 569 | const d = await getDialect(program, dialect.publicKey); 570 | const actualMessages = d.dialect.messages.map((m) => m.text); 571 | console.log(` msgs count after send: ${actualMessages.length}\n`); 572 | expect(actualMessages).to.be.deep.eq(expectedMessages); 573 | } 574 | }); 575 | 576 | it('Message text limit of 853 bytes can be sent/received', async () => { 577 | const maxMessageSizeBytes = 853; 578 | const texts = Array(30) 579 | .fill(0) 580 | .map(() => generateRandomText(maxMessageSizeBytes)); 581 | for (let messageIdx = 0; messageIdx < texts.length; messageIdx++) { 582 | const text = texts[messageIdx]; 583 | const messageCounter = messageIdx + 1; 584 | const dialect = await getDialectForMembers(program, members); 585 | console.log( 586 | `Sending message ${messageCounter}/${texts.length} 587 | len = ${text.length} 588 | idx: ${dialect.dialect.nextMessageIdx}`, 589 | ); 590 | // when 591 | await sendMessage(program, dialect, writer, text); 592 | const d = await getDialect(program, dialect.publicKey); 593 | const actualMessages = d.dialect.messages; 594 | const lastMessage = actualMessages[0]; 595 | console.log(` msgs count after send: ${actualMessages.length}\n`); 596 | // then 597 | expect(lastMessage.text).to.be.deep.eq(text); 598 | } 599 | }); 600 | }); 601 | 602 | describe('Encrypted messaging tests', () => { 603 | let owner: web3.Keypair; 604 | let ownerEncryptionProps: EncryptionProps; 605 | let writer: web3.Keypair; 606 | let writerEncryptionProps: EncryptionProps; 607 | let nonmember: web3.Keypair; 608 | let nonmemberEncryptionProps: EncryptionProps; 609 | let members: Member[] = []; 610 | let dialect: DialectAccount; 611 | 612 | beforeEach(async () => { 613 | const ownerUser = await createUser({ 614 | requestAirdrop: true, 615 | createMeta: true, 616 | }); 617 | owner = ownerUser.user; 618 | ownerEncryptionProps = ownerUser.encryptionProps; 619 | const writerUser = await createUser({ 620 | requestAirdrop: true, 621 | createMeta: true, 622 | }); 623 | writer = writerUser.user; 624 | writerEncryptionProps = writerUser.encryptionProps; 625 | const nonmemberUser = await createUser({ 626 | requestAirdrop: true, 627 | createMeta: false, 628 | }); 629 | nonmember = nonmemberUser.user; 630 | nonmemberEncryptionProps = nonmemberUser.encryptionProps; 631 | members = [ 632 | { 633 | publicKey: owner.publicKey, 634 | scopes: [true, false], // owner, read-only 635 | }, 636 | { 637 | publicKey: writer.publicKey, 638 | scopes: [false, true], // non-owner, read-write 639 | }, 640 | ]; 641 | dialect = await createDialect(program, owner, members, true); 642 | }); 643 | 644 | it('Message sender can send msg and then read the message text and time', async () => { 645 | // given 646 | const dialect = await getDialectForMembers( 647 | program, 648 | members, 649 | writerEncryptionProps, 650 | ); 651 | const text = generateRandomText(256); 652 | // when 653 | await sendMessage(program, dialect, writer, text, writerEncryptionProps); 654 | // then 655 | const senderDialect = await getDialectForMembers( 656 | program, 657 | dialect.dialect.members, 658 | writerEncryptionProps, 659 | ); 660 | const message = senderDialect.dialect.messages[0]; 661 | chai.expect(message.text).to.be.eq(text); 662 | chai.expect(message.owner).to.be.deep.eq(writer.publicKey); 663 | chai 664 | .expect(senderDialect.dialect.lastMessageTimestamp) 665 | .to.be.eq(message.timestamp); 666 | }); 667 | 668 | it('Message receiver can read the message text and time sent by sender', async () => { 669 | // given 670 | const senderDialect = await getDialectForMembers( 671 | program, 672 | members, 673 | writerEncryptionProps, 674 | ); 675 | const text = generateRandomText(256); 676 | // when 677 | await sendMessage( 678 | program, 679 | senderDialect, 680 | writer, 681 | text, 682 | writerEncryptionProps, 683 | ); 684 | // then 685 | const receiverDialect = await getDialectForMembers( 686 | program, 687 | dialect.dialect.members, 688 | ownerEncryptionProps, 689 | ); 690 | const message = receiverDialect.dialect.messages[0]; 691 | chai.expect(message.text).to.be.eq(text); 692 | chai.expect(message.owner).to.be.deep.eq(writer.publicKey); 693 | chai 694 | .expect(receiverDialect.dialect.lastMessageTimestamp) 695 | .to.be.eq(message.timestamp); 696 | }); 697 | 698 | it("Non-member can't read (decrypt) any of the messages", async () => { 699 | // given 700 | const senderDialect = await getDialectForMembers( 701 | program, 702 | members, 703 | writerEncryptionProps, 704 | ); 705 | const text = generateRandomText(256); 706 | await sendMessage( 707 | program, 708 | senderDialect, 709 | writer, 710 | text, 711 | writerEncryptionProps, 712 | ); 713 | // when / then 714 | expect( 715 | getDialectForMembers( 716 | program, 717 | dialect.dialect.members, 718 | nonmemberEncryptionProps, 719 | ), 720 | ).to.eventually.be.rejected; 721 | }); 722 | 723 | it('New messages overwrite old, retrieved messages are in order.', async () => { 724 | // emulate ideal message alignment withing buffer 725 | const rawBufferSize = 8192; 726 | const messagesPerDialect = 16; 727 | const numMessages = messagesPerDialect * 2; 728 | const salt = 3; 729 | const targetRawMessageSize = rawBufferSize / messagesPerDialect - salt; 730 | const timestampSize = 4; 731 | const ownerMemberIdxSize = 1; 732 | const messageSerializationOverhead = 733 | ITEM_METADATA_OVERHEAD + 734 | ENCRYPTION_OVERHEAD_BYTES + 735 | NONCE_SIZE_BYTES + 736 | timestampSize + 737 | ownerMemberIdxSize; 738 | const targetTextSize = 739 | targetRawMessageSize - messageSerializationOverhead; 740 | const texts = Array(numMessages) 741 | .fill(0) 742 | .map(() => generateRandomText(targetTextSize)); 743 | for (let messageIdx = 0; messageIdx < numMessages; messageIdx++) { 744 | // verify last last N messages look correct 745 | const messageCounter = messageIdx + 1; 746 | const text = texts[messageIdx]; 747 | const dialect = await getDialectForMembers( 748 | program, 749 | members, 750 | writerEncryptionProps, 751 | ); 752 | console.log( 753 | `Sending message ${messageCounter}/${texts.length} 754 | len = ${text.length} 755 | idx: ${dialect.dialect.nextMessageIdx}`, 756 | ); 757 | await sendMessage( 758 | program, 759 | dialect, 760 | writer, 761 | text, 762 | writerEncryptionProps, 763 | ); 764 | const sliceStart = 765 | messageCounter <= messagesPerDialect 766 | ? 0 767 | : messageCounter - messagesPerDialect; 768 | const expectedMessagesCount = Math.min( 769 | messageCounter, 770 | messagesPerDialect, 771 | ); 772 | const sliceEnd = sliceStart + expectedMessagesCount; 773 | const expectedMessages = texts.slice(sliceStart, sliceEnd).reverse(); 774 | const d = await getDialect( 775 | program, 776 | dialect.publicKey, 777 | writerEncryptionProps, 778 | ); 779 | const actualMessages = d.dialect.messages.map((m) => m.text); 780 | console.log(` msgs count after send: ${actualMessages.length}\n`); 781 | expect(actualMessages).to.be.deep.eq(expectedMessages); 782 | } 783 | }); 784 | 785 | it('Send/receive random size messages.', async () => { 786 | const texts = Array(32) 787 | .fill(0) 788 | .map(() => generateRandomText(randomInt(256, 512))); 789 | for (let messageIdx = 0; messageIdx < texts.length; messageIdx++) { 790 | const text = texts[messageIdx]; 791 | const messageCounter = messageIdx + 1; 792 | const dialect = await getDialectForMembers( 793 | program, 794 | members, 795 | writerEncryptionProps, 796 | ); 797 | console.log( 798 | `Sending message ${messageCounter}/${texts.length} 799 | len = ${text.length} 800 | idx: ${dialect.dialect.nextMessageIdx}`, 801 | ); 802 | // when 803 | await sendMessage( 804 | program, 805 | dialect, 806 | writer, 807 | text, 808 | writerEncryptionProps, 809 | ); 810 | const d = await getDialect( 811 | program, 812 | dialect.publicKey, 813 | writerEncryptionProps, 814 | ); 815 | const actualMessages = d.dialect.messages; 816 | const lastMessage = actualMessages[0]; 817 | console.log(` msgs count after send: ${actualMessages.length}\n`); 818 | // then 819 | expect(lastMessage.text).to.be.deep.eq(text); 820 | } 821 | }); 822 | 823 | /* UTF-8 encoding summary: 824 | - ASCII characters are encoded using 1 byte 825 | - Roman, Greek, Cyrillic, Coptic, Armenian, Hebrew, Arabic characters are encoded using 2 bytes 826 | - Chinese and Japanese among others are encoded using 3 bytes 827 | - Emoji are encoded using 4 bytes 828 | A note about message length limit and summary: 829 | - len >= 814 hits max transaction size limit = 1232 bytes https://docs.solana.com/ru/proposals/transactions-v2 830 | - => best case: 813 symbols per msg (ascii only) 831 | - => worst case: 203 symbols (e.g. emoji only) 832 | - => average case depends on character set, see details below: 833 | ---- ASCII: ±800 characters 834 | ---- Roman, Greek, Cyrillic, Coptic, Armenian, Hebrew, Arabic: ± 406 characters 835 | ---- Chinese and japanese: ± 270 characters 836 | ---- Emoji: ± 203 characters*/ 837 | it('Message text limit of 813 bytes can be sent/received', async () => { 838 | const maxMessageSizeBytes = 813; 839 | const texts = Array(30) 840 | .fill(0) 841 | .map(() => generateRandomText(maxMessageSizeBytes)); 842 | for (let messageIdx = 0; messageIdx < texts.length; messageIdx++) { 843 | const text = texts[messageIdx]; 844 | const messageCounter = messageIdx + 1; 845 | const dialect = await getDialectForMembers( 846 | program, 847 | members, 848 | writerEncryptionProps, 849 | ); 850 | console.log( 851 | `Sending message ${messageCounter}/${texts.length} 852 | len = ${text.length} 853 | idx: ${dialect.dialect.nextMessageIdx}`, 854 | ); 855 | // when 856 | await sendMessage( 857 | program, 858 | dialect, 859 | writer, 860 | text, 861 | writerEncryptionProps, 862 | ); 863 | const d = await getDialect( 864 | program, 865 | dialect.publicKey, 866 | writerEncryptionProps, 867 | ); 868 | const actualMessages = d.dialect.messages; 869 | const lastMessage = actualMessages[0]; 870 | console.log(` msgs count after send: ${actualMessages.length}\n`); 871 | // then 872 | expect(lastMessage.text).to.be.deep.eq(text); 873 | } 874 | }); 875 | 876 | it('2 writers can send a messages and read them when dialect state is linearized before sending msg', async () => { 877 | // given 878 | const writer1 = await createUser({ 879 | requestAirdrop: true, 880 | createMeta: true, 881 | }); 882 | const writer2 = await createUser({ 883 | requestAirdrop: true, 884 | createMeta: true, 885 | }); 886 | members = [ 887 | { 888 | publicKey: writer1.user.publicKey, 889 | scopes: [true, true], // owner, read-only 890 | }, 891 | { 892 | publicKey: writer2.user.publicKey, 893 | scopes: [false, true], // non-owner, read-write 894 | }, 895 | ]; 896 | await createDialect(program, writer1.user, members, true); 897 | // when 898 | let writer1Dialect = await getDialectForMembers( 899 | program, 900 | members, 901 | writer1.encryptionProps, 902 | ); 903 | const writer1Text = generateRandomText(256); 904 | await sendMessage( 905 | program, 906 | writer1Dialect, 907 | writer1.user, 908 | writer1Text, 909 | writer1.encryptionProps, 910 | ); 911 | let writer2Dialect = await getDialectForMembers( 912 | program, 913 | members, 914 | writer2.encryptionProps, 915 | ); // ensures dialect state linearization 916 | const writer2Text = generateRandomText(256); 917 | await sendMessage( 918 | program, 919 | writer2Dialect, 920 | writer2.user, 921 | writer2Text, 922 | writer2.encryptionProps, 923 | ); 924 | 925 | writer1Dialect = await getDialectForMembers( 926 | program, 927 | members, 928 | writer1.encryptionProps, 929 | ); 930 | writer2Dialect = await getDialectForMembers( 931 | program, 932 | members, 933 | writer2.encryptionProps, 934 | ); 935 | 936 | // then check writer1 dialect state 937 | const message1Writer1 = writer1Dialect.dialect.messages[1]; 938 | const message2Writer1 = writer1Dialect.dialect.messages[0]; 939 | chai.expect(message1Writer1.text).to.be.eq(writer1Text); 940 | chai.expect(message1Writer1.owner).to.be.deep.eq(writer1.user.publicKey); 941 | chai.expect(message2Writer1.text).to.be.eq(writer2Text); 942 | chai.expect(message2Writer1.owner).to.be.deep.eq(writer2.user.publicKey); 943 | // then check writer2 dialect state 944 | const message1Writer2 = writer2Dialect.dialect.messages[1]; 945 | const message2Writer2 = writer2Dialect.dialect.messages[0]; 946 | chai.expect(message1Writer2.text).to.be.eq(writer1Text); 947 | chai.expect(message1Writer2.owner).to.be.deep.eq(writer1.user.publicKey); 948 | chai.expect(message2Writer2.text).to.be.eq(writer2Text); 949 | chai.expect(message2Writer2.owner).to.be.deep.eq(writer2.user.publicKey); 950 | }); 951 | 952 | // This test was failing before changing nonce generation algorithm 953 | it('2 writers can send a messages and read them when dialect state is not linearized before sending msg', async () => { 954 | // given 955 | const writer1 = await createUser({ 956 | requestAirdrop: true, 957 | createMeta: true, 958 | }); 959 | const writer2 = await createUser({ 960 | requestAirdrop: true, 961 | createMeta: true, 962 | }); 963 | members = [ 964 | { 965 | publicKey: writer1.user.publicKey, 966 | scopes: [true, true], // owner, read-only 967 | }, 968 | { 969 | publicKey: writer2.user.publicKey, 970 | scopes: [false, true], // non-owner, read-write 971 | }, 972 | ]; 973 | await createDialect(program, writer1.user, members, true); 974 | // when 975 | let writer1Dialect = await getDialectForMembers( 976 | program, 977 | members, 978 | writer1.encryptionProps, 979 | ); 980 | let writer2Dialect = await getDialectForMembers( 981 | program, 982 | members, 983 | writer2.encryptionProps, 984 | ); // ensures no dialect state linearization 985 | const writer1Text = generateRandomText(256); 986 | await sendMessage( 987 | program, 988 | writer1Dialect, 989 | writer1.user, 990 | writer1Text, 991 | writer1.encryptionProps, 992 | ); 993 | const writer2Text = generateRandomText(256); 994 | await sendMessage( 995 | program, 996 | writer2Dialect, 997 | writer2.user, 998 | writer2Text, 999 | writer2.encryptionProps, 1000 | ); 1001 | 1002 | writer1Dialect = await getDialectForMembers( 1003 | program, 1004 | members, 1005 | writer1.encryptionProps, 1006 | ); 1007 | writer2Dialect = await getDialectForMembers( 1008 | program, 1009 | members, 1010 | writer2.encryptionProps, 1011 | ); 1012 | 1013 | // then check writer1 dialect state 1014 | const message1Writer1 = writer1Dialect.dialect.messages[1]; 1015 | const message2Writer1 = writer1Dialect.dialect.messages[0]; 1016 | chai.expect(message1Writer1.text).to.be.eq(writer1Text); 1017 | chai.expect(message1Writer1.owner).to.be.deep.eq(writer1.user.publicKey); 1018 | chai.expect(message2Writer1.text).to.be.eq(writer2Text); 1019 | chai.expect(message2Writer1.owner).to.be.deep.eq(writer2.user.publicKey); 1020 | // then check writer2 dialect state 1021 | const message1Writer2 = writer2Dialect.dialect.messages[1]; 1022 | const message2Writer2 = writer2Dialect.dialect.messages[0]; 1023 | chai.expect(message1Writer2.text).to.be.eq(writer1Text); 1024 | chai.expect(message1Writer2.owner).to.be.deep.eq(writer1.user.publicKey); 1025 | chai.expect(message2Writer2.text).to.be.eq(writer2Text); 1026 | chai.expect(message2Writer2.owner).to.be.deep.eq(writer2.user.publicKey); 1027 | }); 1028 | }); 1029 | 1030 | describe('Subscription tests', () => { 1031 | let owner: web3.Keypair; 1032 | let writer: web3.Keypair; 1033 | 1034 | beforeEach(async () => { 1035 | owner = await createUser({ 1036 | requestAirdrop: true, 1037 | createMeta: false, 1038 | }).then((it) => it.user); 1039 | writer = await createUser({ 1040 | requestAirdrop: true, 1041 | createMeta: false, 1042 | }).then((it) => it.user); 1043 | }); 1044 | 1045 | it('Can subscribe to events and receive them and unsubscribe', async () => { 1046 | // given 1047 | const eventsAccumulator: Event[] = []; 1048 | const expectedEvents = 8; 1049 | const countDownLatch = new CountDownLatch(expectedEvents); 1050 | const subscription = await subscribeToEvents(program, async (it) => { 1051 | console.log('event', it); 1052 | countDownLatch.countDown(); 1053 | return eventsAccumulator.push(it); 1054 | }); 1055 | // when 1056 | await createMetadata(program, owner); // 1 event 1057 | await createMetadata(program, writer); // 1 event 1058 | const dialectAccount = await createDialectAndSubscribeAllMembers( 1059 | program, 1060 | owner, 1061 | writer, 1062 | false, 1063 | ); // 3 events 1064 | await deleteMetadata(program, owner); // 1 event 1065 | await deleteMetadata(program, writer); // 1 event 1066 | await deleteDialect(program, dialectAccount, owner); // 1 event 1067 | await countDownLatch.await(5000); 1068 | await subscription.unsubscribe(); 1069 | // events below should be ignored 1070 | await createMetadata(program, owner); 1071 | await createMetadata(program, writer); 1072 | // then 1073 | chai.expect(eventsAccumulator.length).to.be.eq(expectedEvents); 1074 | }); 1075 | }); 1076 | 1077 | async function createUser( 1078 | { requestAirdrop, createMeta }: CreateUserOptions = { 1079 | requestAirdrop: true, 1080 | createMeta: true, 1081 | }, 1082 | ) { 1083 | const user = web3.Keypair.generate(); 1084 | if (requestAirdrop) { 1085 | const airDropRequest = await connection.requestAirdrop( 1086 | user.publicKey, 1087 | 10 * web3.LAMPORTS_PER_SOL, 1088 | ); 1089 | await connection.confirmTransaction(airDropRequest); 1090 | } 1091 | if (createMeta) { 1092 | await createMetadata(program, user); 1093 | } 1094 | const encryptionProps = { 1095 | ed25519PublicKey: user.publicKey.toBytes(), 1096 | diffieHellmanKeyPair: ed25519KeyPairToCurve25519({ 1097 | publicKey: user.publicKey.toBytes(), 1098 | secretKey: user.secretKey, 1099 | }), 1100 | }; 1101 | return { user, encryptionProps }; 1102 | } 1103 | }); 1104 | 1105 | interface CreateUserOptions { 1106 | requestAirdrop: boolean; 1107 | createMeta: boolean; 1108 | } 1109 | 1110 | function generateRandomText(length: number) { 1111 | let result = ''; 1112 | const characters = 1113 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 1114 | for (let i = 0; i < length; i++) { 1115 | result += characters.charAt(Math.floor(Math.random() * characters.length)); 1116 | } 1117 | return result; 1118 | } 1119 | 1120 | async function createDialectAndSubscribeAllMembers( 1121 | program: Program, 1122 | owner: anchor.web3.Keypair, 1123 | member: anchor.web3.Keypair, 1124 | encrypted: boolean, 1125 | ) { 1126 | const members: Member[] = [ 1127 | { 1128 | publicKey: owner.publicKey, 1129 | scopes: [true, true], // owner, read-only 1130 | }, 1131 | { 1132 | publicKey: member.publicKey, 1133 | scopes: [false, true], // non-owner, read-write 1134 | }, 1135 | ]; 1136 | const dialect = await createDialect(program, owner, members, encrypted); 1137 | await subscribeUser(program, dialect, owner.publicKey, owner); 1138 | await subscribeUser(program, dialect, member.publicKey, member); 1139 | return dialect; 1140 | } 1141 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "target": "ES6", 6 | "module": "CommonJS", 7 | "outDir": "lib/cjs", 8 | "declarationDir": "lib/types" 9 | }, 10 | "rootDir": "./src", 11 | "include": ["src/**/*.ts"], 12 | "exclude": ["node_modules", "lib"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "module": "ESNext", 6 | "outDir": "lib/esm", 7 | "declarationDir": "lib/types" 8 | }, 9 | "rootDir": "./src", 10 | "include": ["src/**/*.ts"], 11 | "exclude": ["node_modules", "lib"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "lib"], 3 | "compilerOptions": { 4 | // target 5 | "target": "ES2019", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | // build 9 | "types": [], 10 | "esModuleInterop": true, 11 | "preserveConstEnums": true, 12 | "skipLibCheck": true, 13 | "importHelpers": true, 14 | "noEmit": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | // linting 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitOverride": true, 22 | "noUncheckedIndexedAccess": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "forceConsistentCasingInFileNames": true, 25 | "noErrorTruncation": true, 26 | "importsNotUsedAsValues": "error", 27 | // sources 28 | "sourceMap": true, 29 | "declaration": true, 30 | "declarationMap": true, 31 | "inlineSources": true 32 | }, 33 | "rootDir": "./src", 34 | "include": ["src/**/*.ts", "tests/**/*.ts"] 35 | } 36 | --------------------------------------------------------------------------------