├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── examples ├── client │ ├── create_account.ts │ ├── submit_dedupe.ts │ ├── submit_earn_batch.ts │ └── submit_payment.ts └── webhook │ └── webhook.ts ├── package-lock.json ├── package.json ├── src ├── buffer-layout.d.ts ├── client │ ├── client.ts │ ├── index.ts │ ├── internal.ts │ └── utils.ts ├── errors.ts ├── index.ts ├── keys.ts ├── memo.ts ├── proto │ └── utils.ts ├── retry │ └── index.ts ├── solana │ ├── memo-program.ts │ └── token-program.ts └── webhook │ └── index.ts ├── test ├── client │ ├── client.spec.ts │ └── internal.spec.ts ├── data │ ├── get_transaction_test_kin_2.json │ ├── get_transaction_test_kin_3.json │ ├── get_transaction_test_kin_4.json │ ├── get_transaction_v3_test_kin_2.json │ └── get_transaction_v3_test_kin_3.json ├── errors.spec.ts ├── jest-env.js ├── keys.spec.ts ├── memo.spec.ts ├── model.spec.ts ├── retry │ └── retry.spec.ts ├── solana │ ├── memo-program.spec.ts │ └── token-program.spec.ts └── webhook │ └── webhook.spec.ts └── tsconfig.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/repo 3 | docker: 4 | - image: circleci/node:erbium # codename for 12.x 5 | 6 | whitelist: &whitelist 7 | paths: 8 | - .npmignore 9 | - coverage/* 10 | - dist/* 11 | - node_modules/* 12 | - src/* 13 | - test/* 14 | - CODE_OF_CONDUCT.md 15 | - LICENSE.md 16 | - package.json 17 | - README.md 18 | - tsconfig.json 19 | - tslint.json 20 | 21 | version: 2 22 | jobs: 23 | checkout: 24 | <<: *defaults 25 | 26 | steps: 27 | - checkout 28 | 29 | - restore_cache: 30 | keys: 31 | - v1-dependencies-{{node --version}}-{{ checksum "package.json" }} 32 | - v1-dependencies- 33 | 34 | - run: 35 | name: npm secret 36 | command: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc 37 | 38 | - run: 39 | name: Install Dependencies 40 | command: npm install 41 | 42 | - save_cache: 43 | paths: 44 | - node_modules 45 | key: v1-dependencies-{{node --version}}-{{ checksum "package.json" }} 46 | 47 | - persist_to_workspace: 48 | root: ~/repo 49 | <<: *whitelist 50 | 51 | test: 52 | <<: *defaults 53 | 54 | steps: 55 | - attach_workspace: 56 | at: ~/repo 57 | 58 | - run: 59 | name: Test TypeScript code 60 | command: npm run-script test 61 | 62 | - persist_to_workspace: 63 | root: ~/repo 64 | <<: *whitelist 65 | 66 | build: 67 | <<: *defaults 68 | 69 | steps: 70 | - attach_workspace: 71 | at: ~/repo 72 | 73 | - run: 74 | name: Build TypeScript code 75 | command: npm run-script build 76 | 77 | - persist_to_workspace: 78 | root: ~/repo 79 | <<: *whitelist 80 | 81 | workflows: 82 | version: 2 83 | 84 | build: 85 | jobs: 86 | - checkout 87 | - test: 88 | requires: 89 | - checkout 90 | - build: 91 | requires: 92 | - test 93 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "rules": { 13 | "@typescript-eslint/no-non-null-assertion": "off", 14 | "@typescript-eslint/semi": "warn" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | .vscode/ 33 | 34 | # OS metadata 35 | .DS_Store 36 | Thumbs.db 37 | 38 | # Ignore built ts files 39 | dist/**/* 40 | 41 | # ignore yarn.lock 42 | yarn-error.log 43 | yarn.lock 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.5.0](https://github.com/kinecosystem/kin-node/releases/tag/0.5.0) 4 | - Remove Stellar (Kin 2 & Kin 3) support 5 | - Only Kin 4 and v4 Agora APIs are supported 6 | - Removed `accountClient`, `txClient`, `kinVersion`, `whitelistKey`, and `desiredKinVersion` from `ClientConfig` 7 | - Removed `channel` from `Payment` and `EarnBatch` 8 | - Removed `envelope` and `txHash()` from `SignTransactionRequest` 9 | - Removed `envelope`, `signedEnvelope`, and `networkPassphrase` from `SignTransactionResponse` 10 | - Removed `kin_version`, `tx_hash`, and `stellar_event` from `Event.transaction_event` 11 | - Add sender create support for `Client.submitPayment` 12 | - Add `mergeTokenAccounts` to `Client` 13 | - Add create account webhook support 14 | - Add creation parsing to `SignTransactionRequest` 15 | - `SignTransactionResponse.sign` now signs Solana transactions 16 | - Rename `SignTransactionRequest.solanaTransaction` to `SignTransactionRequest.transaction` 17 | 18 | ## [0.4.0](https://github.com/kinecosystem/kin-node/releases/tag/0.4.0) 19 | - Expose `requestAirdrop` on `Client` for Kin 4 20 | 21 | ## [0.3.11](https://github.com/kinecosystem/kin-node/releases/tag/0.3.11) 22 | - Add AccountExists to the default non-retriable error list. This should the decrease 23 | latencies in situations where a Resolve() is required by about 8 seconds (with the 24 | default retry configuration) 25 | - Fix Solana create account error crash 26 | 27 | ## [0.3.10](https://github.com/kinecosystem/kin-node/releases/tag/0.3.10) 28 | - Add `dedupeId` support on payments (`Client.submitPayment`) and earn batches (`Client.submitEarnBatch`) 29 | - `Client.submitEarnBatch` now supports submitting only a single transaction and up to 15 earns 30 | - `EarnBatchResult` is now an interface with `txId`, `txError` and `earnErrors` 31 | 32 | ## [0.3.9](https://github.com/kinecosystem/kin-node/releases/tag/0.3.9) 33 | - Add `PaymentErrors` on `TransactionErrors` 34 | - Fix parsing transaction error in `Client.submitPayment` 35 | 36 | ## [0.3.8](https://github.com/kinecosystem/kin-node/releases/tag/0.3.8) 37 | - Set operation errors on `TransactionErrors` 38 | 39 | ## [0.3.7](https://github.com/kinecosystem/kin-node/releases/tag/0.3.7) 40 | - Fix client error handling 41 | 42 | ## [0.3.6](https://github.com/kinecosystem/kin-node/releases/tag/0.3.6) 43 | - Add optional `accountResolution` parameter to `Client.getBalance` 44 | 45 | ## [0.3.5](https://github.com/kinecosystem/kin-node/releases/tag/0.3.5) 46 | - Create new accounts with different token account address 47 | 48 | ## [0.3.4](https://github.com/kinecosystem/kin-node/releases/tag/0.3.4) 49 | - Do not reject Kin 4 payments with channel set 50 | - Check for duplicate signers for Stellar transactions 51 | 52 | ## [0.3.3](https://github.com/kinecosystem/kin-node/releases/tag/0.3.3) 53 | - Call v3 `GetTransaction` API for Kin 2 & 3 54 | 55 | ## [0.3.2](https://github.com/kinecosystem/kin-node/releases/tag/0.3.2) 56 | - Fixed invoice count check bug 57 | 58 | ## [0.3.1](https://github.com/kinecosystem/kin-node/releases/tag/0.3.1) 59 | - Fixed uploaded package 60 | 61 | ## [0.3.0](https://github.com/kinecosystem/kin-node/releases/tag/0.3.0) 62 | - Add Kin 4 support 63 | - Rename `txHash` to `txId` in `Client.getTransaction`, `TransactionData` and `EarnResult` 64 | - Add `defaultCommitment` to `ClientConfig` 65 | - Add optional `commitment` parameter to `Client` methods (`createAccount`, `getBalance`, `getTransaction`, `submitPayment`, `submitEarnBatch`) 66 | - Add optional `subsidizer` parameter to `Client.createAcount`, `Payment`, and `EarnBatch` 67 | - Add optional `senderResolution` and `destinationResolution` parameters to `Client.submitPayment` and `Client.submitEarnBatch` 68 | - Mark `tx_hash` property in `Event` as deprecated. 69 | - Mark `SignTransactionRequest.txHash()` as deprecated in favour of `SignTransactionRequest.txId()`. 70 | 71 | ## [0.2.3](https://github.com/kinecosystem/kin-node/releases/tag/0.2.3) 72 | - Add Kin 2 support 73 | 74 | ## [0.2.2](https://github.com/kinecosystem/kin-node/releases/tag/0.2.2) 75 | - Update API version 76 | 77 | ## [0.2.1](https://github.com/kinecosystem/kin-node/releases/tag/0.2.1) 78 | - Add user-agent metadata to Agora requests 79 | 80 | ## [0.2.0](https://github.com/kinecosystem/kin-node/releases/tag/0.2.0) 81 | - Rename `source` in `Payment` and `EarnBatch` to `channel` for clarity 82 | - Adjust `BadNonceError` handling 83 | 84 | ## [0.1.2](https://github.com/kinecosystem/kin-node/releases/tag/0.1.2) 85 | - Add a `NONE` transaction type 86 | 87 | ## [0.1.1](https://github.com/kinecosystem/kin-node/releases/tag/0.1.1) 88 | - Update installation commands 89 | 90 | ## [0.1.0](https://github.com/kinecosystem/kin-node/releases/tag/0.1.0) 91 | - Initial release with Kin 3 support 92 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2020 Kin Foundation https://www.kin.org/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecation Warning 2 | 3 | Kins's Agora powered SDKs have been deprecated and Agora replaced with Kinetic. 4 | 5 | ## Kinetic 6 | 7 | Kinetic is an open-source suite of tools that make it easy to build apps that integrate Solana. 8 | 9 | It provides a consistent and clean abstraction over the Solana SDKs and enhances it with some commonly requested features like paying Solana fees on behalf of the user, tracking and timing the users transactions and sending out webhooks. 10 | 11 | Kinetic is aimed at developers that want to build crypto-experiences for the users of their app, hiding a lot of the details about the blockchain out of sight for both the developer and the end user. 12 | 13 | Learn more about Kinetic [here](https://developer.kin.org/docs/kinetic). 14 | 15 | See our new suite of Kinetic SDK's [here](https://developer.kin.org/docs/developers). 16 | 17 | # Kin Node SDK 18 | 19 | The Kin Node SDK enables developers use Kin inside their backend servers. It contains support for blockchain actions 20 | such as creating accounts and sending payments, as well a webhook handler class to assist with implementing Agora webhooks. It is recommended that developers read the [website documentation](https://docs.kin.org) prior to using this SDK. 21 | 22 | ## Requirements 23 | * Node supporting ES2015 or higher 24 | 25 | ## Installation 26 | ``` 27 | npm install @kinecosystem/kin-sdk-v2 28 | ``` 29 | 30 | ``` 31 | yarn add @kinecosystem/kin-sdk-v2 32 | ``` 33 | 34 | Note: `stellar-base` uses `tweetnacl` and `sodium-native` as dependencies. If `sodium-native` cannot be built, 35 | or is absent, `stellar-base` falls back to the slower `tweetnacl`. There are certain cases where `sodium-native` 36 | may have issues. Notably: 37 | 38 | 1. Browser environments 39 | 2. Serverless functions with tight package sizing. For example, AWS Lambda has a 50 MiB limit, and `sodium-native` 40 | takes up a majority of this space. Developers may wish to delete the `sodium_native` directory in `node_modules/` 41 | to save space. 42 | 43 | ## Overview 44 | The SDK contains two main components: the `Client` and webhook handlers. The `Client` is used for blockchain 45 | actions, such as creating accounts sending payments, while the web hook handlers are meant for developers who wish to make 46 | use of Agora Webhooks. For a high-level overview of using Agora, please refer to the [website documentation](https://docs.kin.org). 47 | 48 | ## Client 49 | The main component of this library is the `Client` class, which facilitates access to the Kin blockchain. 50 | 51 | ### Initialization 52 | At a minimum, the client needs to be instantiated with an `Environment`. 53 | 54 | ```typescript 55 | import {Client, Environment} from "@kinecosystem/kin-sdk-v2"; 56 | const client = new Client(Environment.Test); 57 | ``` 58 | 59 | Apps with [registered](https://docs.kin.org/app-registration) app indexes should initialize the client with their index: 60 | 61 | ```typescript 62 | import {Client, Environment} from "@kinecosystem/kin-sdk-v2"; 63 | const client = new Client(Environment.Test, { 64 | appIndex: 1 65 | }); 66 | ``` 67 | 68 | Additional options include: 69 | - `endpoint`: (optional) A specific endpoint to use in the client. This will be inferred by default from the Environment. Cannot be set if `internal` is set. 70 | - `internal`: (optional) An `agora.client.InternalClient` instance to use in the client. This will created using default values if not included. Cannot be set if `endpoint` is set. 71 | - `retryConfig`: A custom `agora.client.RetryConfig` to configure how the client retries requests. 72 | - `defaultCommitment`: (Kin 4 only) The commitment requirement to use by default for Kin 4 Agora requests. See the [website documentation](https://docs.kin.org/solana#commitment) for more information. 73 | 74 | ### Usage 75 | #### Create an Account 76 | The `createAccount` method creates an account with the provided private key. 77 | ```typescript 78 | const privateKey = PrivateKey.random(); 79 | await client.createAccount(privateKey); 80 | ``` 81 | 82 | In addition to the mandatory `key` parameter, `createAccount` has the following optional parameters: 83 | - `commitment`: Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. 84 | - `subsidizer`: The private key of an account to use as the funder of the transaction instead of the subsidizer configured on Agora. 85 | 86 | #### Get a Transaction 87 | The `getTransaction` method gets transaction data by transaction id. 88 | ```typescript 89 | // txId is either a 32-byte Stellar transaction hash or a 64-byte Solana transaction signature 90 | const txId = bs58.decode(''); // solana transaction signature 91 | const transactionData = await client.getTransaction(txId); 92 | ``` 93 | 94 | In addition to the mandatory `txId` parameter, `getTransaction` has the following optional parameters: 95 | - `commitment`: Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. 96 | 97 | #### Get an Account Balance 98 | The `getBalance` method gets the balance of the provided account, in [quarks](https://docs.kin.org/terms-and-concepts#quark) 99 | ```typescript 100 | const publicKey = PublicKey.fromString(""); 101 | const balance = await client.getBalance(publicKey); 102 | ``` 103 | 104 | In addition to the mandatory `account` parameter, `getBalance` has the following optional parameters: 105 | - `commitment`: Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. 106 | - `accountResolution`: Indicates which type of account resolution to use if the account is not found. 107 | 108 | #### Submit a Payment 109 | The `submitPayment` method submits the provided payment to Agora. 110 | ```typescript 111 | const sender: PrivateKey; 112 | const dest: PublicKey; 113 | 114 | let txId = await client.submitPayment({ 115 | sender: sender, 116 | destination: dest, 117 | type: TransactionType.Earn, 118 | quarks: kinToQuarks("1"), 119 | }); 120 | ``` 121 | 122 | A `Payment` has the following required properties: 123 | - `sender`: The private key of the account from which the payment will be sent. 124 | - `destination`: The public key of the account to which the payment will be sent. 125 | - `type`: The transaction type of the payment. 126 | - `quarks`: The amount of the payment, in [quarks](https://docs.kin.org/terms-and-concepts#quark). 127 | 128 | Additionally, it has the following optional properties: 129 | - `subsidizer`: The private key of an account to use as the funder of the transaction instead of the subsidizer configured on Agora. 130 | - `invoice`: An [Invoice](https://docs.kin.org/how-it-works#invoices) to associate with this payment. Cannot be set if `memo` is set. 131 | - `memo`: A text memo to include in the transaction. Cannot be set if `invoice` is set. 132 | - `dedupeId`: A unique identifier used by the service to help prevent the accidental submission of the same intended transaction twice. 133 | 134 | `submitPayment` also has the following optional parameters: 135 | - `commitment`: Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. 136 | - `senderResolution`: Indicates which type of account resolution to use for the payment sender. 137 | - `destinationResolution`: Indicates which type of account resolution to use for the payment destination. 138 | - `senderCreate`: If set to `true` and the destination account is not found, the client will create a token account owned by the submitted destination account. 139 | 140 | #### Submit an Earn Batch 141 | The `submitEarnBatch` method submits a batch of earns to Agora from a single account. It batches the earns into fewer 142 | transactions where possible and submits as many transactions as necessary to submit all the earns. 143 | ```typescript 144 | const earns: Earn[] = [ 145 | { 146 | destination: PublicKey.fromString("xx"), 147 | quarks: kinToQuarks("1"), 148 | }, 149 | { 150 | destination: PublicKey.fromString("yy"), 151 | quarks: kinToQuarks("1"), 152 | } 153 | ]; 154 | const result = await client.submitEarnBatch({ 155 | sender: sender, 156 | earns: earns, 157 | }) 158 | ``` 159 | 160 | 161 | A single `Earn` has the following properties: 162 | - `destination`: The public key of the account to which the earn will be sent. 163 | - `quarks`: The amount of the earn, in [quarks](https://docs.kin.org/terms-and-concepts#quark). 164 | - `invoice`: (optional) An [Invoice](https://docs.kin.org/how-it-works#invoices) to associate with this earn. 165 | 166 | An `EarnBatch` has the following parameters: 167 | - `sender`: The private key of the account from which the earns will be sent. 168 | - `earns`: The list of earns to send. 169 | - `memo`: (optional) A text memo to include in the transaction. Cannot be used if the earns have invoices associated with them. 170 | - `subsidizer`: (optional) The private key of an account to use as the funder of the transaction instead of the subsidizer configured on Agora. 171 | - `dedupeId`: (optinoal) a unique identifier used by the service to help prevent the accidental submission of the same intended transaction twice. 172 | 173 | `submitEarnBatch` also has the following optional parameters: 174 | - `commitment`: Indicates to Solana which bank state to query. See the [website documentation](https://docs.kin.org/solana#commitment) for more details. 175 | - `senderResolution`: Indicates which type of account resolution to use for the payment sender. 176 | - `destinationResolution`: Indicates which type of account resolution to use for the payment destination. 177 | 178 | ### Examples 179 | A few examples for creating an account and different ways of submitting payments and batched earns can be found in `examples/client`. 180 | 181 | ## Webhook Handlers 182 | 183 | The SDK offers handler functions to assist developers with implementing the [Agora webhooks](https://docs.kin.org/how-it-works#webhooks). 184 | 185 | Only apps that have been assigned an [app index](https://docs.kin.org/app-registration) can make use of Agora webhooks. 186 | 187 | ### Prerequisites 188 | 189 | The handlers assume usage of the `express` framework, as the default `http` library does not offer 190 | much support for body reading, and middleware. 191 | 192 | ### Usage 193 | 194 | There are currently two handlers: 195 | 196 | - [Events](https://docs.kin.org/how-it-works#events) with `EventsHandler` 197 | - [Sign Transaction](https://docs.kin.org/how-it-works#sign-transaction) with `SignTransactionHandler` 198 | 199 | When configuring a webhook, a [webhook secret](https://docs.kin.org/agora/webhook#authentication) can be specified. 200 | 201 | #### Events Webhook 202 | 203 | To consume events from Agora: 204 | 205 | ```typescript 206 | import { express, json } from "express"; 207 | import { Event, EventsHandler } from "@kinecosystem/kin-sdk-v2/webhook"; 208 | 209 | // Note: if no secret is provided to the handler, all requests will be processed. 210 | // otherwise, the request signature will be validated to ensure it came from agora. 211 | const secret = "WEBHOOK_SECRET"; 212 | 213 | const app = express(); 214 | 215 | // json() properly reads the entire response body and transforms it into a 216 | // object suitable for use by the EventsHandler. 217 | app.use("/events", json()); 218 | app.use("/events", EventsHandler(events: []Event) => { 219 | // processing logic 220 | }, secret), 221 | ``` 222 | 223 | #### Sign Transaction Webhook 224 | The sign transaction webhook is used to sign Kin 3 transactions with a whitelisted Kin 3 account to remove fees. On Kin 4, the webhook can be used to simply approve or reject transactions submitted by mobile clients. 225 | 226 | To verify and sign transactions related to your app: 227 | 228 | ```typescript 229 | import { express, json } from "express"; 230 | import { 231 | SignTransactionRequest, 232 | SignTransactionResponse, 233 | SignTransactionHandler, 234 | } from "@kinecosystem/kin-sdk-v2/webhook"; 235 | 236 | // Note: if no secret is provided to the handler, all requests will be processed. 237 | // otherwise, the request signature will be validated to ensure it came from agora. 238 | const secret = "WEBHOOK_SECRET"; 239 | 240 | const app = express(); 241 | 242 | // json() properly reads the entire response body and transforms it into a 243 | // object suitable for use by the EventsHandler. 244 | app.use("/sign_transaction", json()); 245 | app.use("/sign_transaction", SignTransactionHandler(req: SignTransactionRequest, resp: SignTransactionResponse) => { 246 | // decide whether or not to sign() or reject() the request. 247 | }, secret), 248 | ``` 249 | 250 | ### Example Code 251 | 252 | A simple example Express server implementing both the Events and Sign Transaction webhooks can be found in `examples/webhook/webhook.tx`. To run it, first install all required dependencies: 253 | ``` 254 | $ npm i 255 | or 256 | $ yarn install 257 | ``` 258 | 259 | 260 | Next, run it as follows from the root directory (it will run on port 8080): 261 | ``` 262 | export WEBHOOK_SECRET=yoursecrethere 263 | export WEBHOOK_SEED=SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 264 | 265 | npx ts-node examples/webhook/webhook.ts 266 | ``` 267 | -------------------------------------------------------------------------------- /examples/client/create_account.ts: -------------------------------------------------------------------------------- 1 | import process from "process"; 2 | import { Client, Environment, PrivateKey } from "../../src"; 3 | 4 | async function run(): Promise { 5 | const seed = process.env["SEED"]; 6 | if (!seed) { 7 | return Promise.reject("no seed specified"); 8 | } 9 | 10 | const key = PrivateKey.fromBase58(seed); 11 | const client = new Client(Environment.Test); 12 | await client.createAccount(key); 13 | console.log(`created account with owner ${key.publicKey().toBase58()}`); 14 | 15 | const tokenAccounts = await client.resolveTokenAccounts(key.publicKey()); 16 | for (const tokenAccount of tokenAccounts) { 17 | const balance = await client.getBalance(tokenAccount); 18 | console.log(`balance of token account ${tokenAccount.toBase58()}: ${balance}`); 19 | } 20 | } 21 | 22 | run().catch(e => console.log(e)); 23 | -------------------------------------------------------------------------------- /examples/client/submit_dedupe.ts: -------------------------------------------------------------------------------- 1 | import process from "process"; 2 | import { Environment, Client, PrivateKey, PublicKey, kinToQuarks, TransactionType, Payment, EarnBatch, EarnBatchResult } from "../../src"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | 5 | async function run(): Promise { 6 | const seed = process.env["SENDER_SEED"]; 7 | if (!seed) { 8 | return Promise.reject("no seed specified"); 9 | } 10 | const sender = PrivateKey.fromString(seed); 11 | 12 | // A comma delimited set of receivers. 13 | const destinations = process.env["DESTINATIONS"]; 14 | if (!destinations) { 15 | return Promise.reject("no destination specified"); 16 | } 17 | 18 | const destKeys = destinations.split(",").map(addr => PublicKey.fromString(addr)); 19 | 20 | const client = new Client(Environment.Test, { 21 | appIndex: 1, 22 | }); 23 | 24 | // Send a payment with a dedupeId 25 | const payment: Payment = { 26 | sender: sender, 27 | destination: destKeys[0], 28 | type: TransactionType.None, 29 | quarks: kinToQuarks("1"), 30 | dedupeId: Buffer.from(uuidv4()), 31 | }; 32 | 33 | let txId: Buffer; 34 | try { 35 | txId = await client.submitPayment(payment); 36 | console.log(`payment with dedupeId ${payment.dedupeId} succeeded with txId ${txId}`); 37 | } catch (error) { 38 | console.log(`payment with dedupeId ${payment.dedupeId} failed due to unexpected error, safe to retry since dedupeId was set`); 39 | txId = await client.submitPayment(payment); 40 | console.log(`payment with dedupeId ${payment.dedupeId} succeeded with txId ${txId}`); 41 | } 42 | 43 | // Send an earn batch with a dedupeId 44 | const batch: EarnBatch = { 45 | sender: sender, 46 | earns: destKeys.map(dest => { 47 | return { 48 | destination: dest, 49 | quarks: kinToQuarks("1"), 50 | }; 51 | }), 52 | dedupeId: Buffer.from(uuidv4()), 53 | }; 54 | 55 | let result: EarnBatchResult; 56 | try { 57 | result = await client.submitEarnBatch(batch); 58 | logBatchResult(batch, result); 59 | } catch (error) { 60 | console.log(`earn batch with dedupeId ${batch.dedupeId} failed due to unexpected error, safe to retry since dedupeId was set`); 61 | result = await client.submitEarnBatch(batch); 62 | logBatchResult(batch, result); 63 | } 64 | } 65 | 66 | function logBatchResult(batch: EarnBatch, result: EarnBatchResult) { 67 | if (result.txError) { 68 | console.log(`failed to send earn batch with dedupeId ${batch.dedupeId} (txId: ${result.txId}): ${result.txError}`); 69 | 70 | if (result.earnErrors) { 71 | result.earnErrors.forEach(e => { 72 | console.log(`earn ${e.earnIndex} failed due to: ${e.error}`); 73 | }); 74 | } 75 | } else { 76 | console.log(`successfully sent eacn batch with dedupeId ${batch.dedupeId} (txId: ${result.txId})`); 77 | } 78 | } 79 | 80 | run().catch(e => console.log(e)); 81 | -------------------------------------------------------------------------------- /examples/client/submit_earn_batch.ts: -------------------------------------------------------------------------------- 1 | import process from "process"; 2 | import { Environment, Client, PrivateKey, PublicKey, kinToQuarks } from "../../src"; 3 | 4 | async function run(): Promise { 5 | const seed = process.env["SENDER_SEED"]; 6 | if (!seed) { 7 | return Promise.reject("no seed specified"); 8 | } 9 | const sender = PrivateKey.fromString(seed); 10 | 11 | // A comma delimited set of receivers. 12 | const destinations = process.env["DESTINATIONS"]; 13 | if (!destinations) { 14 | return Promise.reject("no destination specified"); 15 | } 16 | 17 | const destKeys = destinations.split(",").map(addr => PublicKey.fromString(addr)); 18 | 19 | const client = new Client(Environment.Test, { 20 | appIndex: 1 21 | }); 22 | 23 | // Send an earn batch with 1 kin each 24 | let result = await client.submitEarnBatch({ 25 | sender: sender, 26 | earns: destKeys.map(dest => { 27 | return { 28 | destination: dest, 29 | quarks: kinToQuarks("1"), 30 | }; 31 | }), 32 | }); 33 | 34 | if (result.txError) { 35 | console.log(`failed to send earn batch (txId: ${result.txId}): ${result.txError}`); 36 | 37 | if (result.earnErrors) { 38 | result.earnErrors.forEach(e => { 39 | console.log(`earn ${e.earnIndex} failed due to: ${e.error}`); 40 | }); 41 | } 42 | } else { 43 | console.log(`successfully sent batch (txId: ${result.txId})`); 44 | } 45 | 46 | // Send an earn batch with 1 kin each, with invoices 47 | result = await client.submitEarnBatch({ 48 | sender: sender, 49 | earns: destKeys.map(dest => { 50 | return { 51 | destination: dest, 52 | quarks: kinToQuarks("1"), 53 | invoice: { 54 | Items: [ 55 | { 56 | title: "Example Payment", 57 | amount: kinToQuarks("1"), 58 | } 59 | ], 60 | } 61 | }; 62 | }) 63 | }); 64 | 65 | if (result.txError) { 66 | console.log(`failed to send earn batch (txId: ${result.txId}): ${result.txError}`); 67 | 68 | if (result.earnErrors) { 69 | result.earnErrors.forEach(e => { 70 | console.log(`earn ${e.earnIndex} failed due to: ${e.error}`); 71 | }); 72 | } 73 | } else { 74 | console.log(`successfully sent batch (txId: ${result.txId})`); 75 | } 76 | } 77 | 78 | run().catch(e => console.log(e)); 79 | -------------------------------------------------------------------------------- /examples/client/submit_payment.ts: -------------------------------------------------------------------------------- 1 | import bs58 from "bs58"; 2 | import process from "process"; 3 | import { Environment, Client, PrivateKey, PublicKey, TransactionType, kinToQuarks } from "../../src"; 4 | 5 | async function run(): Promise { 6 | const seed = process.env["SENDER_SEED"]; 7 | if (!seed) { 8 | return Promise.reject("no seed specified"); 9 | } 10 | const destination = process.env["DESTINATION"]; 11 | if (!destination) { 12 | return Promise.reject("no destination specified"); 13 | } 14 | 15 | const sender = PrivateKey.fromString(seed); 16 | const dest = PublicKey.fromString(destination); 17 | 18 | const client = new Client(Environment.Test, { 19 | appIndex: 1 20 | }); 21 | 22 | // Send 1 kin. 23 | let txId = await client.submitPayment({ 24 | sender: sender, 25 | destination: dest, 26 | type: TransactionType.Spend, 27 | quarks: kinToQuarks("1"), 28 | }); 29 | console.log(`tx: ${bs58.encode(txId)}`); 30 | 31 | // Send 1 kin with a text memo. 32 | txId = await client.submitPayment({ 33 | sender: sender, 34 | destination: dest, 35 | type: TransactionType.Spend, 36 | quarks: kinToQuarks("1"), 37 | memo: "1-test" 38 | }); 39 | console.log(`tx: ${bs58.encode(txId)}`); 40 | 41 | // Send 1 kin with an invoice 42 | txId = await client.submitPayment({ 43 | sender: sender, 44 | destination: dest, 45 | type: TransactionType.Spend, 46 | quarks: kinToQuarks("1"), 47 | invoice: { 48 | Items: [ 49 | { 50 | title: "TestPayment", 51 | description: "Optional desc about the payment", 52 | amount: kinToQuarks("1"), 53 | sku: Buffer.from("hello", 'utf-8'), 54 | }, 55 | ], 56 | }, 57 | }); 58 | console.log(`tx: ${bs58.encode(txId)}`); 59 | } 60 | 61 | run().catch(e => console.log(e)); 62 | -------------------------------------------------------------------------------- /examples/webhook/webhook.ts: -------------------------------------------------------------------------------- 1 | import bs58 from "bs58"; 2 | import express from "express"; 3 | import { Environment, PrivateKey } from "../../src"; 4 | import { Event, EventsHandler, SignTransactionHandler, SignTransactionRequest, SignTransactionResponse } from "../../src/webhook"; 5 | 6 | const port = process.env["PORT"] || 8080; 7 | const secret = process.env["WEBHOOK_SECRET"]; 8 | const webhookSeed = process.env["WEBHOOK_SEED"]; 9 | if (!webhookSeed) { 10 | console.log("missing webhook seed"); 11 | process.exit(1); 12 | } 13 | 14 | const whitelistKey = PrivateKey.fromString(webhookSeed); 15 | 16 | const app = express(); 17 | 18 | app.use("/events", express.json()); 19 | app.use("/events", EventsHandler((events: Event[]) => { 20 | for (const e of events) { 21 | console.log(`received event: ${JSON.stringify(e)}`); 22 | } 23 | }, secret)); 24 | 25 | app.use("/sign_transaction", express.json()); 26 | app.use("/sign_transaction", SignTransactionHandler(Environment.Test, (req: SignTransactionRequest, resp: SignTransactionResponse) => { 27 | console.log(`sign request for <'${req.userId}', '${req.userPassKey}'>: ${bs58.encode(req.txId())}`); 28 | 29 | for (let i = 0; i < req.payments.length; i++) { 30 | const p = req.payments[i]; 31 | 32 | // Double check that the transaction isn't trying to impersonate us 33 | if (p.sender.equals(whitelistKey.publicKey())) { 34 | resp.reject(); 35 | return; 36 | } 37 | 38 | // In this example, we don't want to whitelist transactions that aren't sending 39 | // kin to us. 40 | // 41 | // Note: this is purely demonstrating WrongDestination. Some apps may wish to 42 | // whitelist everything. 43 | if (!p.destination.equals(whitelistKey.publicKey())) { 44 | resp.markWrongDestination(i); 45 | } 46 | 47 | if (p.invoice) { 48 | for (const item of p.invoice.Items) { 49 | if (!item.sku) { 50 | // Note: in general the sku is optional. However, in this example we 51 | // mark it as SkuNotFound to facilitate testing. 52 | resp.markSkuNotFound(i); 53 | } 54 | } 55 | } 56 | } 57 | 58 | // Note: if we _don't_ do this check here, the SDK won't send back a signed 59 | // transaction if this is set. 60 | if (resp.isRejected()) { 61 | return; 62 | } 63 | 64 | resp.sign(whitelistKey); 65 | }, secret)); 66 | 67 | 68 | app.listen(port, () => { 69 | console.log(`server started at http://localhost:${ port }`); 70 | }); 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kinecosystem/kin-sdk-v2", 3 | "version": "0.6.0", 4 | "description": "Kin Node SDK", 5 | "author": "Kik Engineering", 6 | "email": "engineering@kik.com", 7 | "license": "MIT", 8 | "exclude": [ 9 | "node_modules", 10 | "**/*.spec.ts" 11 | ], 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist/" 16 | ], 17 | "dependencies": { 18 | "@grpc/grpc-js": "^1.1.2", 19 | "@kinecosystem/agora-api": "^0.26.1", 20 | "@solana/spl-token": "^0.1.8", 21 | "@solana/web3.js": "^1.9.1", 22 | "@types/bn.js": "^5.1.0", 23 | "@types/bs58": "^4.0.1", 24 | "@types/express": "^4.17.7", 25 | "@types/google-protobuf": "^3.7.2", 26 | "@types/lru-cache": "^5.1.0", 27 | "@types/uuid": "^8.3.0", 28 | "bignumber.js": "^9.0.0", 29 | "bs58": "^4.0.1", 30 | "bytebuffer": "^5.0.1", 31 | "express": "^4.17.1", 32 | "google-protobuf": "^3.20.1", 33 | "grpc": "^1.24.3", 34 | "hash.js": "^1.1.7", 35 | "lru-cache": "^6.0.0", 36 | "protobufjs": "^6.10.1", 37 | "stellar-base": "3.0.3", 38 | "tweetnacl": "^1.0.3", 39 | "uuid": "^8.3.2" 40 | }, 41 | "devDependencies": { 42 | "@babel/preset-typescript": "^7.10.4", 43 | "@types/jest": "^26.0.5", 44 | "@types/supertest": "^2.0.10", 45 | "@typescript-eslint/eslint-plugin": "^3.9.0", 46 | "@typescript-eslint/parser": "^3.9.0", 47 | "eslint": "^7.6.0", 48 | "jest": "^26.1.0", 49 | "supertest": "^4.0.2", 50 | "ts-jest": "^26.1.3", 51 | "ts-mockito": "^2.6.1", 52 | "tslint": "^6.1.2", 53 | "typescript": "^3.9.7" 54 | }, 55 | "scripts": { 56 | "test": "jest", 57 | "build": "tsc", 58 | "lint": "eslint . --ext .ts" 59 | }, 60 | "jest": { 61 | "preset": "ts-jest", 62 | "testEnvironment": "./test/jest-env.js" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/buffer-layout.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'buffer-layout'; -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "./client"; 2 | import { Internal as InternalClient, SubmitTransactionResult } from "./internal"; 3 | 4 | export { 5 | Client, 6 | InternalClient, 7 | SubmitTransactionResult, 8 | }; 9 | -------------------------------------------------------------------------------- /src/client/internal.ts: -------------------------------------------------------------------------------- 1 | import accountgrpcv4 from "@kinecosystem/agora-api/node/account/v4/account_service_grpc_pb"; 2 | import accountpbv4 from "@kinecosystem/agora-api/node/account/v4/account_service_pb"; 3 | import airdropgrpcv4 from "@kinecosystem/agora-api/node/airdrop/v4/airdrop_service_grpc_pb"; 4 | import airdroppbv4 from "@kinecosystem/agora-api/node/airdrop/v4/airdrop_service_pb"; 5 | import commonpb from "@kinecosystem/agora-api/node/common/v3/model_pb"; 6 | import commonpbv4 from "@kinecosystem/agora-api/node/common/v4/model_pb"; 7 | import transactiongrpcv4 from "@kinecosystem/agora-api/node/transaction/v4/transaction_service_grpc_pb"; 8 | import transactionpbv4 from "@kinecosystem/agora-api/node/transaction/v4/transaction_service_pb"; 9 | import { ASSOCIATED_TOKEN_PROGRAM_ID, Token, TOKEN_PROGRAM_ID } from "@solana/spl-token"; 10 | import { 11 | Account as SolanaAccount, 12 | PublicKey as SolanaPublicKey, 13 | 14 | Transaction as SolanaTransaction, Transaction 15 | } from "@solana/web3.js"; 16 | import BigNumber from "bignumber.js"; 17 | import bs58 from "bs58"; 18 | import grpc from "grpc"; 19 | import LRUCache from "lru-cache"; 20 | import { 21 | Commitment, commitmentToProto, Memo, PrivateKey, 22 | PublicKey, 23 | TransactionData, 24 | TransactionErrors, 25 | transactionStateFromProto, 26 | TransactionType, 27 | txDataFromProto 28 | } from "../"; 29 | import { AccountDoesNotExist, AccountExists, AlreadySubmitted, BadNonce, errorsFromSolanaTx, InsufficientBalance, nonRetriableErrors as nonRetriableErrorsList, NoSubsidizerError, PayerRequired, TransactionRejected } from "../errors"; 30 | import { limit, nonRetriableErrors, retryAsync, ShouldRetry } from "../retry"; 31 | import { MemoProgram } from "../solana/memo-program"; 32 | import { AccountSize } from "../solana/token-program"; 33 | 34 | 35 | export const SDK_VERSION = "0.2.3"; 36 | export const USER_AGENT_HEADER = "kin-user-agent"; 37 | export const KIN_VERSION_HEADER = "kin-version"; 38 | export const APP_INDEX_HEADER = "app-index"; 39 | export const DESIRED_KIN_VERSION_HEADER = "desired-kin-version"; 40 | export const USER_AGENT = `KinSDK/${SDK_VERSION} node/${process.version}`; 41 | const SERVICE_CONFIG_CACHE_KEY = "GetServiceConfig"; 42 | const SIGNATURE_LENGTH = 64; // Ref: https://github.com/solana-labs/solana-web3.js/blob/5fbdb96bdd9174f0874c450db64acddaa2b004c1/src/transaction.ts#L35 43 | 44 | export class SignTransactionResult { 45 | TxId?: Buffer; 46 | InvoiceErrors?: commonpb.InvoiceError[]; 47 | } 48 | 49 | export class SubmitTransactionResult { 50 | TxId: Buffer; 51 | InvoiceErrors?: commonpb.InvoiceError[]; 52 | Errors?: TransactionErrors; 53 | 54 | constructor() { 55 | this.TxId = Buffer.alloc(32); 56 | } 57 | } 58 | 59 | export interface InternalClientConfig { 60 | endpoint?: string 61 | accountClientV4?: accountgrpcv4.AccountClient 62 | airdropClientV4?: airdropgrpcv4.AirdropClient 63 | txClientV4?: transactiongrpcv4.TransactionClient 64 | 65 | strategies?: ShouldRetry[] 66 | appIndex?: number 67 | } 68 | 69 | // Internal is the low level gRPC client for Agora used by Client. 70 | // 71 | // The interface is _not_ stable, and should not be used. However, 72 | // it is exported in case there is some strong reason that access 73 | // to the underlying blockchain primitives are required. 74 | export class Internal { 75 | accountClientV4: accountgrpcv4.AccountClient; 76 | airdropClientV4: airdropgrpcv4.AirdropClient; 77 | txClientV4: transactiongrpcv4.TransactionClient; 78 | strategies: ShouldRetry[]; 79 | metadata: grpc.Metadata; 80 | appIndex: number; 81 | private responseCache: LRUCache; 82 | 83 | constructor(config: InternalClientConfig) { 84 | if (config.endpoint) { 85 | if (config.accountClientV4 || config.airdropClientV4 || config.txClientV4) { 86 | throw new Error("cannot specify endpoint and clients"); 87 | } 88 | 89 | const sslCreds = grpc.credentials.createSsl(); 90 | this.accountClientV4 = new accountgrpcv4.AccountClient(config.endpoint, sslCreds); 91 | this.airdropClientV4 = new airdropgrpcv4.AirdropClient(config.endpoint, sslCreds); 92 | this.txClientV4 = new transactiongrpcv4.TransactionClient(config.endpoint, sslCreds); 93 | } else if (config.accountClientV4) { 94 | if (!config.accountClientV4 || !config.airdropClientV4 || !config.txClientV4) { 95 | throw new Error("must specify all gRPC clients"); 96 | } 97 | 98 | this.accountClientV4 = config.accountClientV4; 99 | this.airdropClientV4 = config.airdropClientV4; 100 | this.txClientV4 = config.txClientV4; 101 | } else { 102 | throw new Error("must specify endpoint or gRPC clients"); 103 | } 104 | 105 | if (config.strategies) { 106 | this.strategies = config.strategies; 107 | } else { 108 | this.strategies = [ 109 | limit(3), 110 | nonRetriableErrors(...nonRetriableErrorsList), 111 | ]; 112 | } 113 | 114 | this.metadata = new grpc.Metadata(); 115 | this.metadata.set(USER_AGENT_HEADER, USER_AGENT); 116 | this.metadata.set(KIN_VERSION_HEADER, "4"); 117 | 118 | this.appIndex = config.appIndex ? config.appIndex! : 0; 119 | if (this.appIndex > 0) { 120 | this.metadata.set(APP_INDEX_HEADER, this.appIndex.toString()); 121 | } 122 | 123 | // Currently only caching GetServiceConfig, so limit to 1 entry 124 | this.responseCache = new LRUCache({ 125 | max: 1, 126 | maxAge: 24 * 60 * 60 * 1000, // 24 hours 127 | }); 128 | } 129 | 130 | async getBlockchainVersion(): Promise { 131 | const req = new transactionpbv4.GetMinimumKinVersionRequest(); 132 | return retryAsync(() => { 133 | return new Promise((resolve, reject) => { 134 | this.txClientV4.getMinimumKinVersion(req, this.metadata, (err, resp) => { 135 | if (err) { 136 | reject(err); 137 | return; 138 | } 139 | 140 | resolve(resp.getVersion()); 141 | }); 142 | }); 143 | }, ...this.strategies); 144 | } 145 | 146 | async createAccount(key: PrivateKey, commitment: Commitment = Commitment.Single, subsidizer?: PrivateKey): Promise { 147 | const fn = async() => { 148 | const [serviceConfigResp, recentBlockhash] = await Promise.all([ 149 | this.getServiceConfig(), 150 | this.getRecentBlockhash(), 151 | ]); 152 | if (!subsidizer && !serviceConfigResp.getSubsidizerAccount()) { 153 | throw new NoSubsidizerError(); 154 | } 155 | 156 | let subsidizerKey: SolanaPublicKey; 157 | if (subsidizer) { 158 | subsidizerKey = subsidizer!.publicKey().solanaKey(); 159 | } else { 160 | subsidizerKey = new SolanaPublicKey(Buffer.from(serviceConfigResp.getSubsidizerAccount()!.getValue_asU8())); 161 | } 162 | const mint = new SolanaPublicKey(Buffer.from(serviceConfigResp.getToken()!.getValue_asU8())); 163 | 164 | const instructions = []; 165 | if (this.appIndex > 0) { 166 | const kinMemo = Memo.new(1, TransactionType.None, this.appIndex!, Buffer.alloc(29)); 167 | instructions.push(MemoProgram.memo({data: kinMemo.buffer.toString("base64")})); 168 | } 169 | 170 | const assocAddr = await Token.getAssociatedTokenAddress( 171 | ASSOCIATED_TOKEN_PROGRAM_ID, 172 | TOKEN_PROGRAM_ID, 173 | mint, 174 | key.publicKey().solanaKey(), 175 | ); 176 | instructions.push(Token.createAssociatedTokenAccountInstruction( 177 | ASSOCIATED_TOKEN_PROGRAM_ID, 178 | TOKEN_PROGRAM_ID, 179 | mint, 180 | assocAddr, 181 | key.publicKey().solanaKey(), 182 | subsidizerKey, 183 | )); 184 | 185 | instructions.push(Token.createSetAuthorityInstruction( 186 | TOKEN_PROGRAM_ID, 187 | assocAddr, 188 | subsidizerKey, 189 | "CloseAccount", 190 | key.publicKey().solanaKey(), 191 | [] 192 | )); 193 | 194 | const transaction = new Transaction({ 195 | feePayer: subsidizerKey, 196 | recentBlockhash: recentBlockhash, 197 | }).add(...instructions); 198 | 199 | transaction.partialSign(new SolanaAccount(key.secretKey())); 200 | if (subsidizer) { 201 | transaction.partialSign(new SolanaAccount(subsidizer.secretKey())); 202 | } 203 | 204 | const protoTx = new commonpbv4.Transaction(); 205 | protoTx.setValue(transaction.serialize({ 206 | requireAllSignatures: false, 207 | verifySignatures: false, 208 | })); 209 | 210 | const req = new accountpbv4.CreateAccountRequest(); 211 | req.setTransaction(protoTx); 212 | req.setCommitment(commitmentToProto(commitment)); 213 | 214 | return new Promise((resolve, reject) => { 215 | this.accountClientV4.createAccount(req, this.metadata, (err, resp) => { 216 | if (err) { 217 | reject(err); 218 | return; 219 | } 220 | 221 | switch (resp.getResult()) { 222 | case accountpbv4.CreateAccountResponse.Result.EXISTS: 223 | reject(new AccountExists()); 224 | break; 225 | case accountpbv4.CreateAccountResponse.Result.PAYER_REQUIRED: 226 | reject(new PayerRequired()); 227 | break; 228 | case accountpbv4.CreateAccountResponse.Result.BAD_NONCE: 229 | reject(new BadNonce()); 230 | break; 231 | case accountpbv4.CreateAccountResponse.Result.OK: 232 | resolve(); 233 | break; 234 | default: 235 | reject(Error("unexpected result from Agora: " + resp.getResult())); 236 | break; 237 | } 238 | }); 239 | }); 240 | }; 241 | 242 | return retryAsync(fn, ...this.strategies).catch(err => { 243 | return Promise.reject(err); 244 | }); 245 | } 246 | 247 | async getAccountInfo(account: PublicKey, commitment: Commitment = Commitment.Single): Promise { 248 | const accountId = new commonpbv4.SolanaAccountId(); 249 | accountId.setValue(account.buffer); 250 | 251 | const req = new accountpbv4.GetAccountInfoRequest(); 252 | req.setAccountId(accountId); 253 | req.setCommitment(commitmentToProto(commitment)); 254 | 255 | return retryAsync(() => { 256 | return new Promise((resolve, reject) => { 257 | this.accountClientV4.getAccountInfo(req, this.metadata, (err, resp) => { 258 | if (err) { 259 | reject(err); 260 | return; 261 | } 262 | 263 | if (resp.getResult() === accountpbv4.GetAccountInfoResponse.Result.NOT_FOUND) { 264 | reject(new AccountDoesNotExist()); 265 | return; 266 | } 267 | 268 | resolve(resp.getAccountInfo()!); 269 | }); 270 | }); 271 | }, ...this.strategies); 272 | } 273 | 274 | async resolveTokenAccounts(publicKey: PublicKey, includeAccountInfo = false): Promise { 275 | const accountId = new commonpbv4.SolanaAccountId(); 276 | accountId.setValue(publicKey.buffer); 277 | 278 | const req = new accountpbv4.ResolveTokenAccountsRequest(); 279 | req.setAccountId(accountId); 280 | req.setIncludeAccountInfo(includeAccountInfo); 281 | 282 | return retryAsync(() => { 283 | return new Promise((resolve, reject) => { 284 | this.accountClientV4.resolveTokenAccounts(req, this.metadata, (err, resp) => { 285 | if (err) { 286 | reject(err); 287 | return; 288 | } 289 | 290 | resolve(resp); 291 | }); 292 | }); 293 | }, ...this.strategies).then(resp => { 294 | const tokenAccounts = resp.getTokenAccountsList(); 295 | const tokenAccountInfos = resp.getTokenAccountInfosList(); 296 | 297 | // This is currently in place for backward compat with the server - `tokenAccounts` is deprecated 298 | if (tokenAccounts.length > 0 && tokenAccountInfos.length != tokenAccounts.length) { 299 | // If we aren't requesting account info, we can interpolate the results ourselves 300 | if (!includeAccountInfo) { 301 | resp.setTokenAccountInfosList(tokenAccounts.map(tokenAccount => { 302 | const accountInfo = new accountpbv4.AccountInfo(); 303 | accountInfo.setAccountId(tokenAccount); 304 | return accountInfo; 305 | })); 306 | } else { 307 | throw new Error("server does not support resolving with account info"); 308 | } 309 | } 310 | 311 | return Promise.resolve(resp.getTokenAccountInfosList()); 312 | }); 313 | } 314 | 315 | async signTransaction(tx: SolanaTransaction, invoiceList?: commonpb.InvoiceList): Promise { 316 | const protoTx = new commonpbv4.Transaction(); 317 | protoTx.setValue(tx.serialize({ 318 | requireAllSignatures: false, 319 | verifySignatures: false, 320 | })); 321 | 322 | const req = new transactionpbv4.SignTransactionRequest(); 323 | req.setTransaction(protoTx); 324 | req.setInvoiceList(invoiceList); 325 | 326 | return retryAsync(() => { 327 | return new Promise((resolve, reject) => { 328 | this.txClientV4.signTransaction(req, this.metadata, (err, resp) => { 329 | if (err) { 330 | reject(err); 331 | return; 332 | } 333 | 334 | const result = new SignTransactionResult(); 335 | 336 | if (resp.getSignature()?.getValue_asU8().length === SIGNATURE_LENGTH) { 337 | result.TxId = Buffer.from(resp.getSignature()!.getValue_asU8()); 338 | } 339 | 340 | switch (resp.getResult()) { 341 | case transactionpbv4.SignTransactionResponse.Result.OK: 342 | break; 343 | case transactionpbv4.SignTransactionResponse.Result.REJECTED: 344 | reject(new TransactionRejected()); 345 | break; 346 | case transactionpbv4.SignTransactionResponse.Result.INVOICE_ERROR: 347 | result.InvoiceErrors = resp.getInvoiceErrorsList(); 348 | break; 349 | default: 350 | reject("unexpected result from agora: " + resp.getResult()); 351 | return; 352 | } 353 | 354 | resolve(result); 355 | }); 356 | }); 357 | }, ...this.strategies); 358 | } 359 | 360 | async submitTransaction(tx: SolanaTransaction, invoiceList?: commonpb.InvoiceList, commitment: Commitment = Commitment.Single, dedupeId?: Buffer): Promise { 361 | const protoTx = new commonpbv4.Transaction(); 362 | protoTx.setValue(tx.serialize({ 363 | requireAllSignatures: false, 364 | verifySignatures: false, 365 | })); 366 | 367 | const req = new transactionpbv4.SubmitTransactionRequest(); 368 | req.setTransaction(protoTx); 369 | req.setInvoiceList(invoiceList); 370 | req.setCommitment(commitmentToProto(commitment)); 371 | if (dedupeId) { 372 | req.setDedupeId(dedupeId!); 373 | } 374 | 375 | let attempt = 0; 376 | return retryAsync(() => { 377 | return new Promise((resolve, reject) => { 378 | attempt = attempt + 1; 379 | this.txClientV4.submitTransaction(req, this.metadata, (err, resp) => { 380 | if (err) { 381 | reject(err); 382 | return; 383 | } 384 | 385 | const result = new SubmitTransactionResult(); 386 | if (resp.getSignature()?.getValue_asU8().length === SIGNATURE_LENGTH) { 387 | result.TxId = Buffer.from(resp.getSignature()!.getValue_asU8()); 388 | } 389 | 390 | switch (resp.getResult()) { 391 | case transactionpbv4.SubmitTransactionResponse.Result.OK: { 392 | break; 393 | } 394 | case transactionpbv4.SubmitTransactionResponse.Result.ALREADY_SUBMITTED: { 395 | // If this occurs on the first attempt, it's likely due to the submission of two identical transactions 396 | // in quick succession and we should raise the error to the caller. Otherwise, it's likely that the 397 | // transaction completed successfully on a previous attempt that failed due to a transient error. 398 | if (attempt == 1) { 399 | reject(new AlreadySubmitted("", result.TxId)); 400 | return; 401 | } 402 | break; 403 | } 404 | case transactionpbv4.SubmitTransactionResponse.Result.REJECTED: { 405 | reject(new TransactionRejected()); 406 | return; 407 | } 408 | case transactionpbv4.SubmitTransactionResponse.Result.PAYER_REQUIRED: { 409 | reject(new PayerRequired()); 410 | return; 411 | } 412 | case transactionpbv4.SubmitTransactionResponse.Result.INVOICE_ERROR: { 413 | result.InvoiceErrors = resp.getInvoiceErrorsList(); 414 | break; 415 | } 416 | case transactionpbv4.SubmitTransactionResponse.Result.FAILED: { 417 | result.Errors = errorsFromSolanaTx(tx, resp.getTransactionError()!, result.TxId); 418 | break; 419 | } 420 | default: 421 | reject("unexpected result from agora: " + resp.getResult()); 422 | return; 423 | } 424 | 425 | resolve(result); 426 | }); 427 | }); 428 | }, ...this.strategies); 429 | } 430 | 431 | async getTransaction(id: Buffer, commitment: Commitment = Commitment.Single): Promise { 432 | const transactionId = new commonpbv4.TransactionId(); 433 | transactionId.setValue(id); 434 | 435 | const req = new transactionpbv4.GetTransactionRequest(); 436 | req.setTransactionId(transactionId); 437 | req.setCommitment(commitmentToProto(commitment)); 438 | 439 | return retryAsync(() => { 440 | return new Promise((resolve, reject) => { 441 | this.txClientV4.getTransaction(req, this.metadata, (err, resp) => { 442 | if (err) { 443 | reject(err); 444 | return; 445 | } 446 | 447 | let data: TransactionData; 448 | if (resp.getItem()) { 449 | data = txDataFromProto(resp.getItem()!, resp.getState()); 450 | } else { 451 | data = new TransactionData(); 452 | data.txId = id; 453 | data.txState = transactionStateFromProto(resp.getState()); 454 | } 455 | 456 | resolve(data); 457 | }); 458 | }); 459 | }, ...this.strategies); 460 | } 461 | 462 | async getServiceConfig(): Promise { 463 | const req = new transactionpbv4.GetServiceConfigRequest(); 464 | return retryAsync(() => { 465 | return new Promise((resolve, reject) => { 466 | const cached = this.responseCache.get(SERVICE_CONFIG_CACHE_KEY); 467 | if (cached) { 468 | const resp = transactionpbv4.GetServiceConfigResponse.deserializeBinary(Buffer.from(cached, "base64")); 469 | resolve(resp); 470 | return; 471 | } 472 | 473 | this.txClientV4.getServiceConfig(req, this.metadata, (err, resp) => { 474 | if (err) { 475 | reject(err); 476 | return; 477 | } 478 | 479 | this.responseCache.set(SERVICE_CONFIG_CACHE_KEY, Buffer.from(resp.serializeBinary()).toString("base64")); 480 | resolve(resp); 481 | }); 482 | }); 483 | }, ...this.strategies); 484 | } 485 | 486 | async getRecentBlockhash(): Promise { 487 | const req = new transactionpbv4.GetRecentBlockhashRequest(); 488 | return retryAsync(() => { 489 | return new Promise((resolve, reject) => { 490 | this.txClientV4.getRecentBlockhash(req, this.metadata, (err, resp) => { 491 | if (err) { 492 | reject(err); 493 | return; 494 | } 495 | 496 | resolve(bs58.encode(Buffer.from(resp.getBlockhash()!.getValue_asU8()))); 497 | }); 498 | }); 499 | }, ...this.strategies); 500 | } 501 | 502 | async getMinimumBalanceForRentExemption(): Promise { 503 | const req = new transactionpbv4.GetMinimumBalanceForRentExemptionRequest(); 504 | req.setSize(AccountSize); 505 | 506 | return retryAsync(() => { 507 | return new Promise((resolve, reject) => { 508 | this.txClientV4.getMinimumBalanceForRentExemption(req, this.metadata, (err, resp) => { 509 | if (err) { 510 | reject(err); 511 | return; 512 | } 513 | 514 | resolve(resp.getLamports()); 515 | }); 516 | }); 517 | }, ...this.strategies); 518 | } 519 | 520 | async requestAirdrop(publicKey: PublicKey, quarks: BigNumber, commitment: Commitment = Commitment.Single): Promise { 521 | const accountId = new commonpbv4.SolanaAccountId(); 522 | accountId.setValue(publicKey.buffer); 523 | 524 | const req = new airdroppbv4.RequestAirdropRequest(); 525 | req.setAccountId(accountId); 526 | req.setQuarks(quarks.toNumber()); 527 | req.setCommitment(commitmentToProto(commitment)); 528 | 529 | return retryAsync(() => { 530 | return new Promise((resolve, reject) => { 531 | this.airdropClientV4.requestAirdrop(req, this.metadata, (err, resp) => { 532 | if (err) { 533 | reject(err); 534 | return; 535 | } 536 | 537 | switch (resp.getResult()) { 538 | case (airdroppbv4.RequestAirdropResponse.Result.OK): 539 | resolve(Buffer.from(resp.getSignature()!.getValue_asU8())); 540 | return; 541 | case (airdroppbv4.RequestAirdropResponse.Result.NOT_FOUND): 542 | reject(new AccountDoesNotExist()); 543 | return; 544 | case (airdroppbv4.RequestAirdropResponse.Result.INSUFFICIENT_KIN): 545 | reject(new InsufficientBalance()); 546 | return; 547 | default: 548 | reject("unexpected result from agora: " + resp.getResult()); 549 | return; 550 | } 551 | }); 552 | }); 553 | }, ...this.strategies); 554 | } 555 | } 556 | -------------------------------------------------------------------------------- /src/client/utils.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from "hash.js"; 2 | import { Keypair } from "stellar-base"; 3 | import { PrivateKey } from ".."; 4 | 5 | export function generateTokenAccount(key: PrivateKey): PrivateKey { 6 | return new PrivateKey(Keypair.fromRawEd25519Seed(Buffer.from(sha256().update(key.kp.rawSecretKey()).digest()))); 7 | } 8 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import commonpb from "@kinecosystem/agora-api/node/common/v3/model_pb"; 2 | import commonpbv4 from "@kinecosystem/agora-api/node/common/v4/model_pb"; 3 | import { Transaction } from "@solana/web3.js"; 4 | import { xdr } from "stellar-base"; 5 | import { TokenInstruction } from "./solana/token-program"; 6 | 7 | // TransactionErrors contains the error details for a transaction. 8 | // 9 | export class TransactionErrors { 10 | // If TxError is defined, the transaction failed. 11 | TxError?: Error; 12 | 13 | // OpErrors may or may not be set if TxErrors is set. If set, the length of 14 | // OpErrors will match the number of operations/instructions in the transaction. 15 | OpErrors?: Error[]; 16 | 17 | // PaymentErrors may or may not be set if TxErrors is set. If set, the length of 18 | // PaymentErrors will match the number of payments/transfers in the transaction. 19 | PaymentErrors?: Error[]; 20 | } 21 | 22 | export function errorsFromSolanaTx(tx: Transaction, protoError: commonpbv4.TransactionError, txId?: Buffer): TransactionErrors { 23 | const errors = new TransactionErrors(); 24 | const err = errorFromProto(protoError, txId); 25 | if (!err) { 26 | return errors; 27 | } 28 | 29 | errors.TxError = err; 30 | if (protoError.getInstructionIndex() >= 0) { 31 | errors.OpErrors = new Array(tx.instructions.length); 32 | errors.OpErrors[protoError.getInstructionIndex()] = err; 33 | 34 | let pIndex = protoError.getInstructionIndex(); 35 | let pCount = 0; 36 | 37 | for (let i = 0; i < tx.instructions.length; i++) { 38 | try { 39 | TokenInstruction.decodeTransfer(tx.instructions[i]); 40 | pCount++; 41 | } catch (err) { 42 | if (i < protoError.getInstructionIndex()) { 43 | pIndex--; 44 | } else if (i == protoError.getInstructionIndex()) { 45 | // the errored instruction is not a payment 46 | pIndex = -1; 47 | } 48 | } 49 | } 50 | if (pIndex > -1) { 51 | errors.PaymentErrors = new Array(pCount); 52 | errors.PaymentErrors[pIndex] = err; 53 | } 54 | } 55 | 56 | return errors; 57 | } 58 | 59 | export function errorsFromStellarTx(env: xdr.TransactionEnvelope, protoError: commonpbv4.TransactionError): TransactionErrors { 60 | const errors = new TransactionErrors(); 61 | const err = errorFromProto(protoError); 62 | if (!err) { 63 | return errors; 64 | } 65 | 66 | errors.TxError = err; 67 | if (protoError.getInstructionIndex() >= 0) { 68 | const ops = env.v0().tx().operations(); 69 | errors.OpErrors = new Array(ops.length); 70 | errors.OpErrors[protoError.getInstructionIndex()] = err; 71 | 72 | let pIndex = protoError.getInstructionIndex(); 73 | let pCount = 0; 74 | for (let i = 0; i < ops.length; i++) { 75 | if (ops[i].body().switch() === xdr.OperationType.payment()) { 76 | pCount++; 77 | } else if (i < protoError.getInstructionIndex()) { 78 | pIndex--; 79 | } else if (i == protoError.getInstructionIndex()) { 80 | pIndex = -1; 81 | } 82 | } 83 | 84 | if (pIndex > -1) { 85 | errors.PaymentErrors = new Array(pCount); 86 | errors.PaymentErrors[pIndex] = err; 87 | } 88 | } 89 | 90 | return errors; 91 | } 92 | 93 | export function errorFromProto(protoError: commonpbv4.TransactionError, txId?: Buffer): Error | undefined { 94 | switch (protoError.getReason()) { 95 | case commonpbv4.TransactionError.Reason.NONE: 96 | return undefined; 97 | case commonpbv4.TransactionError.Reason.UNAUTHORIZED: 98 | return new InvalidSignature("", txId); 99 | case commonpbv4.TransactionError.Reason.BAD_NONCE: 100 | return new BadNonce("", txId); 101 | case commonpbv4.TransactionError.Reason.INSUFFICIENT_FUNDS: 102 | return new InsufficientBalance("", txId); 103 | case commonpbv4.TransactionError.Reason.INVALID_ACCOUNT: 104 | return new AccountDoesNotExist("", txId); 105 | default: 106 | return Error("unknown error reason: " + protoError.getReason()); 107 | } 108 | } 109 | 110 | export function invoiceErrorFromProto(protoError: commonpb.InvoiceError): Error { 111 | switch (protoError.getReason()) { 112 | case commonpb.InvoiceError.Reason.ALREADY_PAID: 113 | return new AlreadyPaid(); 114 | case commonpb.InvoiceError.Reason.WRONG_DESTINATION: 115 | return new WrongDestination(); 116 | case commonpb.InvoiceError.Reason.SKU_NOT_FOUND: 117 | return new SkuNotFound(); 118 | default: 119 | return new Error("unknown invoice error"); 120 | } 121 | } 122 | 123 | export function errorsFromXdr(result: xdr.TransactionResult): TransactionErrors { 124 | const errors = new TransactionErrors(); 125 | switch (result.result().switch()) { 126 | case xdr.TransactionResultCode.txSuccess(): 127 | return errors; 128 | case xdr.TransactionResultCode.txMissingOperation(): 129 | errors.TxError = new Malformed(); 130 | break; 131 | case xdr.TransactionResultCode.txBadSeq(): 132 | errors.TxError = new BadNonce(); 133 | break; 134 | case xdr.TransactionResultCode.txBadAuth(): 135 | errors.TxError = new InvalidSignature(); 136 | break; 137 | case xdr.TransactionResultCode.txInsufficientBalance(): 138 | errors.TxError = new InsufficientBalance(); 139 | break; 140 | case xdr.TransactionResultCode.txInsufficientFee(): 141 | errors.TxError = new InsufficientFee(); 142 | break; 143 | case xdr.TransactionResultCode.txNoAccount(): 144 | errors.TxError = new SenderDoesNotExist(); 145 | break; 146 | case xdr.TransactionResultCode.txFailed(): 147 | errors.TxError = new TransactionFailed(); 148 | break; 149 | default: 150 | errors.TxError = Error("unknown transaction result code: " + result.result().switch().value); 151 | break; 152 | } 153 | 154 | if (result.result().switch() != xdr.TransactionResultCode.txFailed()) { 155 | return errors; 156 | } 157 | 158 | errors.OpErrors = new Array(result.result().results().length); 159 | result.result().results().forEach((opResult, i) => { 160 | switch (opResult.switch()) { 161 | case xdr.OperationResultCode.opInner(): 162 | break; 163 | case xdr.OperationResultCode.opBadAuth(): 164 | errors.OpErrors![i] = new InvalidSignature(); 165 | return; 166 | case xdr.OperationResultCode.opNoAccount(): 167 | errors.OpErrors![i] = new SenderDoesNotExist(); 168 | return; 169 | default: 170 | errors.OpErrors![i] = new Error("unknown operation result code: " + opResult.switch().value); 171 | return; 172 | } 173 | 174 | switch (opResult.tr().switch()) { 175 | case xdr.OperationType.createAccount(): 176 | switch (opResult.tr().createAccountResult().switch()) { 177 | case xdr.CreateAccountResultCode.createAccountSuccess(): 178 | break; 179 | case xdr.CreateAccountResultCode.createAccountMalformed(): 180 | errors.OpErrors![i] = new Malformed(); 181 | break; 182 | case xdr.CreateAccountResultCode.createAccountAlreadyExist(): 183 | errors.OpErrors![i] = new AccountExists(); 184 | break; 185 | case xdr.CreateAccountResultCode.createAccountUnderfunded(): 186 | errors.OpErrors![i] = new InsufficientBalance(); 187 | break; 188 | default: 189 | errors.OpErrors![i] = new Error("unknown create operation result code: " + opResult.switch().value); 190 | } 191 | break; 192 | case xdr.OperationType.payment(): 193 | switch (opResult.tr().paymentResult().switch()) { 194 | case xdr.PaymentResultCode.paymentSuccess(): 195 | break; 196 | case xdr.PaymentResultCode.paymentMalformed(): 197 | case xdr.PaymentResultCode.paymentNoTrust(): 198 | case xdr.PaymentResultCode.paymentSrcNoTrust(): 199 | case xdr.PaymentResultCode.paymentNoIssuer(): 200 | errors.OpErrors![i] = new Malformed(); 201 | break; 202 | case xdr.PaymentResultCode.paymentUnderfunded(): 203 | errors.OpErrors![i] = new InsufficientBalance(); 204 | break; 205 | case xdr.PaymentResultCode.paymentSrcNotAuthorized(): 206 | case xdr.PaymentResultCode.paymentNotAuthorized(): 207 | errors.OpErrors![i] = new InvalidSignature(); 208 | break; 209 | case xdr.PaymentResultCode.paymentNoDestination(): 210 | errors.OpErrors![i] = new DestinationDoesNotExist(); 211 | break; 212 | default: 213 | errors.OpErrors![i] = new Error("unknown payment operation result code: " + opResult.switch().value); 214 | break; 215 | } 216 | break; 217 | default: 218 | errors.OpErrors![i] = new Error("unknown operation result at: " + i); 219 | } 220 | }); 221 | 222 | return errors; 223 | } 224 | 225 | export class TransactionError extends Error { 226 | txId?: Buffer; 227 | 228 | constructor(m?: string, txId?: Buffer) { 229 | super(m); 230 | this.txId = txId; 231 | } 232 | } 233 | 234 | export class TransactionFailed extends Error { 235 | constructor(m?: string) { 236 | super(m); 237 | this.name = "TransactionFailed"; 238 | Object.setPrototypeOf(this, TransactionFailed.prototype); 239 | } 240 | } 241 | export class AccountExists extends Error { 242 | constructor(m?: string) { 243 | super(m); 244 | this.name = "AccountExists"; 245 | Object.setPrototypeOf(this, AccountExists.prototype); 246 | } 247 | } 248 | export class AccountDoesNotExist extends TransactionError { 249 | constructor(m?: string, txId?: Buffer) { 250 | super(m, txId); 251 | this.name = "AccountDoesNotExist"; 252 | Object.setPrototypeOf(this, AccountDoesNotExist.prototype); 253 | } 254 | } 255 | export class TransactionNotFound extends Error { 256 | constructor(m?: string) { 257 | super(m); 258 | this.name = "TransactionNotFound"; 259 | Object.setPrototypeOf(this, TransactionNotFound.prototype); 260 | } 261 | } 262 | 263 | export class Malformed extends Error { 264 | constructor(m?: string) { 265 | super(m); 266 | this.name = "Malformed"; 267 | Object.setPrototypeOf(this, Malformed.prototype); 268 | } 269 | } 270 | export class BadNonce extends TransactionError { 271 | constructor(m?: string, txId?: Buffer) { 272 | super(m, txId); 273 | this.name = "BadNonce"; 274 | Object.setPrototypeOf(this, BadNonce.prototype); 275 | } 276 | } 277 | export class InsufficientBalance extends TransactionError { 278 | constructor(m?: string, txId?: Buffer) { 279 | super(m, txId); 280 | this.name = "InsufficientBalance"; 281 | Object.setPrototypeOf(this, InsufficientBalance.prototype); 282 | } 283 | } 284 | export class InsufficientFee extends Error { 285 | constructor(m?: string) { 286 | super(m); 287 | this.name = "InsufficientFee"; 288 | Object.setPrototypeOf(this, InsufficientFee.prototype); 289 | } 290 | } 291 | export class SenderDoesNotExist extends Error { 292 | constructor(m?: string) { 293 | super(m); 294 | this.name = "SenderDoesNotExist"; 295 | Object.setPrototypeOf(this, SenderDoesNotExist.prototype); 296 | } 297 | } 298 | export class DestinationDoesNotExist extends Error { 299 | constructor(m?: string) { 300 | super(m); 301 | this.name = "DestinationDoesNotExist"; 302 | Object.setPrototypeOf(this, DestinationDoesNotExist.prototype); 303 | } 304 | } 305 | export class InvalidSignature extends TransactionError { 306 | constructor(m?: string, txId?: Buffer) { 307 | super(m, txId); 308 | this.name = "InvalidSignature"; 309 | Object.setPrototypeOf(this, InvalidSignature.prototype); 310 | } 311 | } 312 | 313 | export class AlreadyPaid extends Error { 314 | constructor(m?: string) { 315 | super(m); 316 | this.name = "AlreadyPaid"; 317 | Object.setPrototypeOf(this, AlreadyPaid.prototype); 318 | } 319 | } 320 | export class WrongDestination extends Error { 321 | constructor(m?: string) { 322 | super(m); 323 | this.name = "WrongDestination"; 324 | Object.setPrototypeOf(this, WrongDestination.prototype); 325 | } 326 | } 327 | export class SkuNotFound extends Error { 328 | constructor(m?: string) { 329 | super(m); 330 | this.name = "SkuNotFound"; 331 | Object.setPrototypeOf(this, SkuNotFound.prototype); 332 | } 333 | } 334 | 335 | export class TransactionRejected extends Error { 336 | constructor(m?: string) { 337 | super(m); 338 | this.name = "TransactionRejected"; 339 | Object.setPrototypeOf(this, TransactionRejected.prototype); 340 | } 341 | } 342 | 343 | export class PayerRequired extends Error { 344 | constructor(m?: string) { 345 | super(m); 346 | this.name = "PayerRequired"; 347 | Object.setPrototypeOf(this, PayerRequired.prototype); 348 | } 349 | } 350 | 351 | export class NoSubsidizerError extends Error { 352 | constructor(m?: string) { 353 | super(m); 354 | this.name = "NoSubsidizerError"; 355 | Object.setPrototypeOf(this, NoSubsidizerError.prototype); 356 | } 357 | } 358 | 359 | export class AlreadySubmitted extends TransactionError { 360 | constructor(m?: string, txId?: Buffer) { 361 | super(m, txId); 362 | this.name = "AlreadySubmittedError"; 363 | Object.setPrototypeOf(this, AlreadySubmitted.prototype); 364 | } 365 | } 366 | 367 | export class NoTokenAccounts extends Error { 368 | constructor(m?: string) { 369 | super(m); 370 | this.name = "NoTokenAccounts"; 371 | Object.setPrototypeOf(this, NoTokenAccounts.prototype); 372 | } 373 | } 374 | 375 | // nonRetriableErrors contains the set of errors that should not be retried without modifications to the transaction. 376 | export const nonRetriableErrors = [ 377 | AccountExists, 378 | AccountDoesNotExist, 379 | Malformed, 380 | SenderDoesNotExist, 381 | DestinationDoesNotExist, 382 | InsufficientBalance, 383 | InsufficientFee, 384 | TransactionRejected, 385 | AlreadyPaid, 386 | WrongDestination, 387 | SkuNotFound, 388 | BadNonce, 389 | AlreadySubmitted, 390 | NoTokenAccounts, 391 | ]; 392 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import commonpb from "@kinecosystem/agora-api/node/common/v3/model_pb"; 2 | import commonpbv4 from "@kinecosystem/agora-api/node/common/v4/model_pb"; 3 | import txpbv4 from "@kinecosystem/agora-api/node/transaction/v4/transaction_service_pb"; 4 | import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, u64 } from "@solana/spl-token"; 5 | import { SystemInstruction, SystemProgram, Transaction as SolanaTransaction } from "@solana/web3.js"; 6 | import BigNumber from "bignumber.js"; 7 | import hash from "hash.js"; 8 | import { xdr } from "stellar-base"; 9 | import { Client } from "./client"; 10 | import { errorsFromSolanaTx, errorsFromStellarTx, TransactionErrors } from "./errors"; 11 | import { PrivateKey, PublicKey } from "./keys"; 12 | import { Memo } from "./memo"; 13 | import { MemoInstruction, MemoProgram } from "./solana/memo-program"; 14 | import { AccountSize as ACCOUNT_SIZE, Command, getTokenCommand, SetAuthorityParams, TokenInstruction } from "./solana/token-program"; 15 | 16 | export { 17 | Client, 18 | TransactionErrors, 19 | PublicKey, 20 | PrivateKey, 21 | Memo, 22 | }; 23 | 24 | export const MAX_TRANSACTION_TYPE = 3; 25 | export enum TransactionType { 26 | Unknown = -1, 27 | None = 0, 28 | Earn = 1, 29 | Spend = 2, 30 | P2P = 3, 31 | } 32 | 33 | export enum TransactionState { 34 | Unknown = 0, 35 | Success = 1, 36 | Failed = 2, 37 | Pending = 3, 38 | } 39 | 40 | export function transactionStateFromProto(state: txpbv4.GetTransactionResponse.State): TransactionState { 41 | switch (state) { 42 | case txpbv4.GetTransactionResponse.State.SUCCESS: 43 | return TransactionState.Success; 44 | case txpbv4.GetTransactionResponse.State.FAILED: 45 | return TransactionState.Failed; 46 | case txpbv4.GetTransactionResponse.State.PENDING: 47 | return TransactionState.Pending; 48 | default: 49 | return TransactionState.Unknown; 50 | } 51 | } 52 | 53 | // Commitment is used to indicate to Solana nodes which bank state to query. 54 | // See: https://docs.solana.com/apps/jsonrpc-api#configuring-state-commitment 55 | export enum Commitment { 56 | // The node will query its most recent block. 57 | Recent = 0, 58 | // The node will query the most recent block that has been voted on by supermajority of the cluster. 59 | Single = 1, 60 | // The node will query the most recent block having reached maximum lockout on this node. 61 | Root = 2, 62 | // The node will query the most recent block confirmed by supermajority of the cluster as having reached maximum lockout. 63 | Max = 3, 64 | } 65 | 66 | export function commitmentToProto(commitment: Commitment): commonpbv4.Commitment { 67 | switch (commitment) { 68 | case Commitment.Single: 69 | return commonpbv4.Commitment.SINGLE; 70 | case Commitment.Recent: 71 | return commonpbv4.Commitment.RECENT; 72 | case Commitment.Root: 73 | return commonpbv4.Commitment.ROOT; 74 | case Commitment.Max: 75 | return commonpbv4.Commitment.MAX; 76 | default: 77 | throw new Error("unexpected commitment value: " + commitment); 78 | } 79 | } 80 | 81 | // AccountResolution is used to indicate which type of account resolution should be used if a transaction on Kin 4 fails due to 82 | // an account being unavailable. 83 | export enum AccountResolution { 84 | // No account resolution will be used. 85 | Exact = 0, 86 | // When used for a sender key, in a payment or earn request, if Agora is able to resolve the original sender public key to 87 | // a set of token accounts, the original sender will be used as the owner in the Solana transfer instruction and the first 88 | // resolved token account will be used as the sender. 89 | // 90 | // When used for a destination key in a payment or earn request, if Agora is able to resolve the destination key to a set 91 | // of token accounts, the first resolved token account will be used as the destination in the Solana transfer instruction. 92 | Preferred = 1, 93 | } 94 | 95 | // Environment specifies the desired Kin environment to use. 96 | export enum Environment { 97 | Prod, 98 | Test, 99 | } 100 | export enum NetworkPasshrase { 101 | Prod = "Kin Mainnet ; December 2018", 102 | Test = "Kin Testnet ; December 2018", 103 | Kin2Prod = "Public Global Kin Ecosystem Network ; June 2018", 104 | Kin2Test = "Kin Playground Network ; June 2018", 105 | } 106 | 107 | export const KinAssetCode = "KIN"; 108 | const KinAssetCodeBuffer = Buffer.from([75, 73, 78, 0]); 109 | 110 | export enum Kin2Issuers { 111 | Prod = "GDF42M3IPERQCBLWFEZKQRK77JQ65SCKTU3CW36HZVCX7XX5A5QXZIVK", 112 | Test = "GBC3SG6NGTSZ2OMH3FFGB7UVRQWILW367U4GSOOF4TFSZONV42UJXUH7", 113 | } 114 | 115 | // kinToQuarks converts a string representation of kin 116 | // to the quark value. 117 | // 118 | // If the provided kin amount contains more than 5 decimal 119 | // places (i.e. an inexact number of quarks), additional 120 | // decimal places will be ignored. 121 | // 122 | // For example, passing in a value of "0.000009" will result 123 | // in a value of 0 quarks being returned. 124 | // 125 | export function kinToQuarks(amount: string): BigNumber { 126 | const b = new BigNumber(amount).decimalPlaces(5, BigNumber.ROUND_DOWN); 127 | return b.multipliedBy(1e5); 128 | } 129 | 130 | export function quarksToKin(amount: BigNumber | string): string { 131 | return new BigNumber(amount).dividedBy(1e5).toString(); 132 | } 133 | 134 | export function xdrInt64ToBigNumber(i64: xdr.Int64): BigNumber { 135 | const amount = BigNumber.sum( 136 | new BigNumber(i64.high).multipliedBy(Math.pow(2, 32)), 137 | new BigNumber(i64.low) 138 | ); 139 | return amount; 140 | } 141 | 142 | export function bigNumberToU64(bn: BigNumber): u64 { 143 | const b = Buffer.alloc(8); 144 | b.writeBigUInt64LE(BigInt(bn), 0); 145 | return u64.fromBuffer(b); 146 | } 147 | 148 | // Invoice represents a transaction invoice for a single payment. 149 | // 150 | // See https://github.com/kinecosystem/agora-api for details. 151 | export interface Invoice { 152 | Items: InvoiceItem[] 153 | } 154 | 155 | export function protoToInvoice(invoice: commonpb.Invoice): Invoice { 156 | const result: Invoice = { 157 | Items: invoice.getItemsList().map(x => { 158 | const item: InvoiceItem = { 159 | title: x.getTitle(), 160 | amount: new BigNumber(x.getAmount()), 161 | }; 162 | if (x.getDescription()) { 163 | item.description = x.getDescription(); 164 | } 165 | if (x.getSku()) { 166 | item.sku = Buffer.from(x.getSku()); 167 | } 168 | 169 | return item; 170 | }) 171 | }; 172 | 173 | return result; 174 | } 175 | export function invoiceToProto(invoice: Invoice): commonpb.Invoice { 176 | const result = new commonpb.Invoice(); 177 | result.setItemsList(invoice.Items.map(x => { 178 | const item = new commonpb.Invoice.LineItem(); 179 | item.setTitle(x.title); 180 | item.setAmount(x.amount.toString()); 181 | 182 | if (x.description) { 183 | item.setDescription(x.description); 184 | } 185 | if (x.sku) { 186 | item.setSku(x.sku); 187 | } 188 | 189 | return item; 190 | })); 191 | return result; 192 | } 193 | 194 | // InvoiceItem is a single line item within an invoice. 195 | // 196 | // See https://github.com/kinecosystem/agora-api for details. 197 | export interface InvoiceItem { 198 | title: string 199 | description?: string 200 | amount: BigNumber 201 | sku?: Buffer 202 | } 203 | 204 | // Payment represents a payment that will be submitted. 205 | export interface Payment { 206 | sender: PrivateKey 207 | destination: PublicKey 208 | type: TransactionType 209 | quarks: BigNumber 210 | 211 | subsidizer?: PrivateKey 212 | 213 | invoice?: Invoice 214 | memo?: string 215 | 216 | // dedupeId is a unique identifier used by the service to help prevent the 217 | // accidental submission of the same intended transaction twice. 218 | 219 | // If dedupeId is set, the service will check to see if a transaction 220 | // was previously submitted with the same dedupeId. If one is found, 221 | // it will NOT submit the transaction again, and will return the status 222 | // of the previously submitted transaction. 223 | dedupeId?: Buffer 224 | } 225 | 226 | // ReadOnlyPayment represents a payment where the sender's 227 | // private key is not known. For example, when retrieved off of 228 | // the block chain, or being used as a signing request. 229 | export interface ReadOnlyPayment { 230 | sender: PublicKey 231 | destination: PublicKey 232 | type: TransactionType 233 | quarks: string 234 | 235 | invoice?: Invoice 236 | memo?: string 237 | } 238 | 239 | // Creation represents a Kin token account creation. 240 | export interface Creation { 241 | owner: PublicKey 242 | address: PublicKey 243 | } 244 | 245 | export function parseTransaction(tx: SolanaTransaction, invoiceList?: commonpb.InvoiceList): [Creation[], ReadOnlyPayment[]] { 246 | const payments: ReadOnlyPayment[] = []; 247 | const creations: Creation[] = []; 248 | 249 | let invoiceHash: Buffer | undefined; 250 | if (invoiceList) { 251 | invoiceHash = Buffer.from(hash.sha224().update(invoiceList.serializeBinary()).digest('hex'), "hex"); 252 | } 253 | 254 | let textMemo: string | undefined; 255 | let agoraMemo: Memo | undefined; 256 | 257 | let ilRefCount = 0; 258 | let invoiceTransfers = 0; 259 | 260 | let hasEarn = false; 261 | let hasSpend = false; 262 | let hasP2P = false; 263 | 264 | let appIndex = 0; 265 | let appId: string | undefined; 266 | 267 | for (let i = 0; i < tx.instructions.length; i++) { 268 | if (isMemo(tx, i)) { 269 | const decodedMemo = MemoInstruction.decodeMemo(tx.instructions[i]); 270 | try { 271 | agoraMemo = Memo.fromB64String(decodedMemo.data, false); 272 | } catch (error) { 273 | textMemo = decodedMemo.data; 274 | } 275 | 276 | if (textMemo) { 277 | let parsedId: string | undefined; 278 | try { 279 | parsedId = appIdFromTextMemo(textMemo); 280 | } catch (error) { 281 | continue; 282 | } 283 | 284 | if (appId && parsedId != appId) { 285 | throw new Error("multiple app IDs"); 286 | } 287 | 288 | appId = parsedId; 289 | continue; 290 | } 291 | 292 | // From this point on we can assume as have an Agora memo 293 | const fk = agoraMemo!.ForeignKey(); 294 | if (invoiceHash && fk.slice(0, 28).equals(invoiceHash) && fk[28] === 0) { 295 | ilRefCount++; 296 | } 297 | 298 | if (appIndex > 0 && appIndex != agoraMemo!.AppIndex()) { 299 | throw new Error("multiple app indexes"); 300 | } 301 | 302 | appIndex = agoraMemo!.AppIndex(); 303 | switch (agoraMemo!.TransactionType()) { 304 | case TransactionType.Earn: 305 | hasEarn = true; 306 | break; 307 | case TransactionType.Spend: 308 | hasSpend = true; 309 | break; 310 | case TransactionType.P2P: 311 | hasP2P = true; 312 | break; 313 | default: 314 | } 315 | } else if (isSystem(tx, i)) { 316 | const create = SystemInstruction.decodeCreateAccount(tx.instructions[i]); 317 | if (!create.programId.equals(TOKEN_PROGRAM_ID)) { 318 | throw new Error("System::CreateAccount must assign owner to the SplToken program"); 319 | } 320 | if (create.space != ACCOUNT_SIZE) { 321 | throw new Error("invalid size in System::CreateAccount"); 322 | } 323 | 324 | i++; 325 | if (i === tx.instructions.length) { 326 | throw new Error("missing SplToken::InitializeAccount instruction"); 327 | } 328 | 329 | const init = TokenInstruction.decodeInitializeAccount(tx.instructions[i]); 330 | if (!create.newAccountPubkey.equals(init.account)) { 331 | throw new Error("SplToken::InitializeAccount address does not match System::CreateAccount address"); 332 | } 333 | 334 | i++; 335 | if (i === tx.instructions.length) { 336 | throw new Error("missing SplToken::SetAuthority(Close) instruction"); 337 | } 338 | 339 | const closeAuth = TokenInstruction.decodeSetAuthority(tx.instructions[i]); 340 | if (closeAuth.authorityType !== 'CloseAccount') { 341 | throw new Error("SplToken::SetAuthority must be of type Close following an initialize"); 342 | } 343 | if (!closeAuth.account.equals(init.account)) { 344 | throw new Error("SplToken::SetAuthority(Close) authority must be for the created account"); 345 | } 346 | if (!closeAuth.newAuthority?.equals(create.fromPubkey)) { 347 | throw new Error("SplToken::SetAuthority has incorrect new authority"); 348 | } 349 | 350 | // Changing of the account owner is optional 351 | i++; 352 | if (i === tx.instructions.length) { 353 | creations.push({ 354 | owner: PublicKey.fromSolanaKey(init.owner), 355 | address: PublicKey.fromSolanaKey(init.account), 356 | }); 357 | break; 358 | } 359 | 360 | let ownerAuth: SetAuthorityParams; 361 | try { 362 | ownerAuth = TokenInstruction.decodeSetAuthority(tx.instructions[i]); 363 | } catch (error) { 364 | i--; 365 | creations.push({ 366 | owner: PublicKey.fromSolanaKey(init.owner), 367 | address: PublicKey.fromSolanaKey(init.account), 368 | }); 369 | continue; 370 | } 371 | 372 | if (ownerAuth.authorityType !== 'AccountOwner') { 373 | throw new Error("SplToken::SetAuthority must be of type AccountHolder following a close authority"); 374 | } 375 | if (!ownerAuth.account.equals(init.account)) { 376 | throw new Error("SplToken::SetAuthority(AccountHolder) must be for the created account"); 377 | } 378 | 379 | creations.push({ 380 | owner: PublicKey.fromSolanaKey(ownerAuth.newAuthority!), 381 | address: PublicKey.fromSolanaKey(init.account), 382 | }); 383 | } else if (isSPLAssoc(tx, i)) { 384 | const create = TokenInstruction.decodeCreateAssociatedAccount(tx.instructions[i]); 385 | 386 | i++; 387 | if (i === tx.instructions.length) { 388 | throw new Error("missing SplToken::SetAuthority(Close) instruction"); 389 | } 390 | 391 | const closeAuth = TokenInstruction.decodeSetAuthority(tx.instructions[i]); 392 | if (closeAuth.authorityType !== 'CloseAccount') { 393 | throw new Error("SplToken::SetAuthority must be of type Close following an assoc creation"); 394 | } 395 | if (!closeAuth.account.equals(create.address)) { 396 | throw new Error("SplToken::SetAuthority(Close) authority must be for the created account"); 397 | } 398 | if (!closeAuth.newAuthority?.equals(create.subsidizer)) { 399 | throw new Error("SplToken::SetAuthority has incorrect new authority"); 400 | } 401 | creations.push({ 402 | owner: PublicKey.fromSolanaKey(create.owner), 403 | address: PublicKey.fromSolanaKey(create.address), 404 | }); 405 | } else if (isSpl(tx, i)) { 406 | const cmd = getTokenCommand(tx.instructions[i]); 407 | if (cmd === Command.Transfer) { 408 | const transfer = TokenInstruction.decodeTransfer(tx.instructions[i]); 409 | if (transfer.owner.equals(tx.feePayer!)) { 410 | throw new Error("cannot transfer from a subsidizer-owned account"); 411 | } 412 | 413 | let inv: commonpb.Invoice | undefined; 414 | if (agoraMemo) { 415 | const fk = agoraMemo.ForeignKey(); 416 | if (invoiceHash && fk.slice(0, 28).equals(invoiceHash) && fk[28] === 0) { 417 | // If the number of parsed transfers matching this invoice is >= the number of invoices, 418 | // raise an error 419 | const invoices = invoiceList!.getInvoicesList(); 420 | if (invoiceTransfers >= invoices.length) { 421 | throw new Error(`invoice list doesn't have sufficient invoicesi for this transaction (parsed: ${invoiceTransfers}, invoices: ${invoices.length})`); 422 | } 423 | inv = invoices[invoiceTransfers]; 424 | invoiceTransfers++; 425 | } 426 | } 427 | 428 | payments.push({ 429 | sender: PublicKey.fromSolanaKey(transfer.source), 430 | destination: PublicKey.fromSolanaKey(transfer.dest), 431 | type: agoraMemo ? agoraMemo.TransactionType() : TransactionType.Unknown, 432 | quarks: transfer.amount.toString(), 433 | invoice: inv ? protoToInvoice(inv) : undefined, 434 | memo: textMemo ? textMemo : undefined, 435 | }); 436 | } else if (cmd !== Command.CloseAccount) { 437 | // Closures are valid, but otherwise the instruction is not supported 438 | throw new Error(`unsupported instruction at ${i}`); 439 | } 440 | } else { 441 | throw new Error(`unsupported instruction at ${i}`); 442 | } 443 | } 444 | 445 | if (hasEarn && (hasSpend || hasP2P)) { 446 | throw new Error("cannot mix earns with P2P/spends"); 447 | } 448 | if (invoiceList && ilRefCount != 1) { 449 | throw new Error(`invoice list does not match exactly to one memo in the transaction (matched: ${ilRefCount})`); 450 | } 451 | if (invoiceList && invoiceList.getInvoicesList().length != invoiceTransfers) { 452 | throw new Error(`invoice count (${invoiceList.getInvoicesList().length}) does not match number of transfers referencing the invoice list ${invoiceTransfers}`); 453 | } 454 | 455 | return [creations, payments]; 456 | } 457 | 458 | // TransactionData contains both metadata and payment data related to 459 | // a blockchain transaction. 460 | export class TransactionData { 461 | txId: Buffer; 462 | txState: TransactionState; 463 | payments: ReadOnlyPayment[]; 464 | errors?: TransactionErrors; 465 | 466 | constructor() { 467 | this.txId = Buffer.alloc(0); 468 | this.txState = TransactionState.Unknown; 469 | this.payments = new Array(); 470 | } 471 | } 472 | 473 | export function txDataFromProto(item: txpbv4.HistoryItem, state: txpbv4.GetTransactionResponse.State): TransactionData { 474 | const data = new TransactionData(); 475 | data.txId = Buffer.from(item.getTransactionId()!.getValue_asU8()); 476 | data.txState = transactionStateFromProto(state); 477 | 478 | const invoiceList = item.getInvoiceList(); 479 | if (invoiceList && invoiceList.getInvoicesList().length !== item.getPaymentsList().length) { 480 | throw new Error("number of invoices does not match number of payments"); 481 | } 482 | 483 | let txType: TransactionType = TransactionType.Unknown; 484 | let stringMemo: string | undefined; 485 | if (item.getSolanaTransaction()) { 486 | const val = item.getSolanaTransaction()!.getValue_asU8(); 487 | const solanaTx = SolanaTransaction.from(Buffer.from(val)); 488 | if (solanaTx.instructions[0].programId.equals(MemoProgram.programId)) { 489 | const memoParams = MemoInstruction.decodeMemo(solanaTx.instructions[0]); 490 | let agoraMemo: Memo | undefined; 491 | try { 492 | agoraMemo = Memo.fromB64String(memoParams.data, false); 493 | txType = agoraMemo!.TransactionType(); 494 | stringMemo = memoParams.data; 495 | } catch (e) { 496 | // not a valid agora memo 497 | stringMemo = memoParams.data; 498 | } 499 | } 500 | if (item.getTransactionError()) { 501 | data.errors = errorsFromSolanaTx(solanaTx, item.getTransactionError()!); 502 | } 503 | } 504 | else if (item.getStellarTransaction()?.getEnvelopeXdr()) { 505 | const envelope = xdr.TransactionEnvelope.fromXDR(Buffer.from(item.getStellarTransaction()!.getEnvelopeXdr())); 506 | const agoraMemo = Memo.fromXdr(envelope.v0().tx().memo(), true); 507 | if (agoraMemo) { 508 | txType = agoraMemo.TransactionType(); 509 | } else if (envelope.v0().tx().memo().switch() === xdr.MemoType.memoText()) { 510 | stringMemo = envelope.v0().tx().memo().text().toString(); 511 | } 512 | 513 | if (item.getTransactionError()) { 514 | data.errors = errorsFromStellarTx(envelope, item.getTransactionError()!); 515 | } 516 | } else { 517 | // This case *shouldn't* happen since either a solana or stellar should be set 518 | throw new Error("invalid transaction"); 519 | } 520 | 521 | const payments: ReadOnlyPayment[] = []; 522 | item.getPaymentsList().forEach((payment, i) => { 523 | const p: ReadOnlyPayment = { 524 | sender: new PublicKey(payment.getSource()!.getValue_asU8()), 525 | destination: new PublicKey(payment.getDestination()!.getValue_asU8()), 526 | quarks: new BigNumber(payment.getAmount()).toString(), 527 | type: txType 528 | }; 529 | if (item.getInvoiceList()) { 530 | p.invoice = protoToInvoice(item.getInvoiceList()!.getInvoicesList()[i]); 531 | } else if (stringMemo) { 532 | p.memo = stringMemo; 533 | } 534 | payments.push(p); 535 | }); 536 | data.payments = payments; 537 | 538 | return data; 539 | } 540 | 541 | // EarnBatch is a batch of earn payments to be sent in a transaction. 542 | export interface EarnBatch { 543 | sender: PrivateKey 544 | subsidizer?: PrivateKey 545 | 546 | memo?: string 547 | 548 | // The length of `earns` must be less than or equal to 15. 549 | earns: Earn[] 550 | 551 | // dedupeId is a unique identifier used by the service to help prevent the 552 | // accidental submission of the same intended transaction twice. 553 | 554 | // If dedupeId is set, the service will check to see if a transaction 555 | // was previously submitted with the same dedupeId. If one is found, 556 | // it will NOT submit the transaction again, and will return the status 557 | // of the previously submitted transaction. 558 | // 559 | // Only available on Kin 4. 560 | dedupeId?: Buffer 561 | } 562 | 563 | // Earn represents a earn payment in an earn batch. 564 | export interface Earn { 565 | destination: PublicKey 566 | quarks: BigNumber 567 | invoice?: Invoice 568 | } 569 | 570 | // EarnBatchResult contains the results from an earn batch. 571 | export interface EarnBatchResult { 572 | txId: Buffer 573 | 574 | // If TxError is defined, the transaction failed. 575 | txError?: Error 576 | 577 | // earnErrors contains any available earn-specific error 578 | // information. 579 | // 580 | // earnErrors may or may not be set if TxError is set. 581 | earnErrors?: EarnError[] 582 | } 583 | 584 | export interface EarnError { 585 | // The error related to an earn. 586 | error: Error 587 | // The index of the earn that caused the 588 | earnIndex: number 589 | } 590 | 591 | function isMemo(tx: SolanaTransaction, index: number): boolean { 592 | return tx.instructions[index].programId.equals(MemoProgram.programId); 593 | } 594 | 595 | function isSpl(tx: SolanaTransaction, index: number): boolean { 596 | return tx.instructions[index].programId.equals(TOKEN_PROGRAM_ID); 597 | } 598 | 599 | function isSPLAssoc(tx: SolanaTransaction, index: number): boolean { 600 | return tx.instructions[index].programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID); 601 | } 602 | 603 | function isSystem(tx: SolanaTransaction, index: number): boolean { 604 | return tx.instructions[index].programId.equals(SystemProgram.programId); 605 | } 606 | 607 | function appIdFromTextMemo(textMemo: string): string { 608 | const parts = textMemo.split('-'); 609 | if (parts.length < 2) { 610 | throw new Error("no app id in memo"); 611 | } 612 | 613 | if (parts[0] != "1") { 614 | throw new Error("no app id in memo"); 615 | } 616 | 617 | if (!isValidAppId(parts[1])) { 618 | throw new Error("no valid app id in memo"); 619 | } 620 | 621 | return parts[1]; 622 | } 623 | 624 | function isValidAppId(appId: string): boolean { 625 | if (appId.length < 3 || appId.length > 4) { 626 | return false; 627 | } 628 | 629 | if (!isAlphaNumeric(appId)) { 630 | return false; 631 | } 632 | 633 | return true; 634 | } 635 | 636 | function isAlphaNumeric(s: string): boolean { 637 | for (let i = 0; i < s.length; i++) { 638 | const code = s.charCodeAt(i); 639 | if (!(code > 47 && code < 58) && // numeric (0-9) 640 | !(code > 64 && code < 91) && // upper alpha (A-Z) 641 | !(code > 96 && code < 123)) { // lower alpha (a-z) 642 | return false; 643 | } 644 | } 645 | return true; 646 | } 647 | -------------------------------------------------------------------------------- /src/keys.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey as SolanaPublicKey } from "@solana/web3.js"; 2 | import bs58 from 'bs58'; 3 | import { Keypair, StrKey } from "stellar-base"; 4 | 5 | 6 | // PublicKey is a blockchain agnostic representation 7 | // of an ed25519 public key. 8 | export class PublicKey { 9 | buffer: Buffer; 10 | 11 | constructor(b: Buffer | Uint8Array) { 12 | if (b instanceof Uint8Array) { 13 | this.buffer = Buffer.from(b); 14 | } else { 15 | this.buffer = b; 16 | } 17 | } 18 | 19 | static fromString(address: string): PublicKey { 20 | if (address.length != 56) { 21 | throw new Error("address format not supported"); 22 | } 23 | 24 | if (address[0] == "G") { 25 | return new PublicKey(StrKey.decodeEd25519PublicKey(address)); 26 | } 27 | 28 | const decoded58 = bs58.decode(address); 29 | if (decoded58.length == 32) { 30 | return new PublicKey(decoded58); 31 | } 32 | 33 | throw new Error("address is not a public key"); 34 | } 35 | 36 | static fromBase58(address: string): PublicKey { 37 | const decoded58 = bs58.decode(address); 38 | if (decoded58.length == 32) { 39 | return new PublicKey(decoded58); 40 | } 41 | 42 | throw new Error("address is not a base58-encoded public key"); 43 | } 44 | 45 | static fromSolanaKey(pk: SolanaPublicKey): PublicKey { 46 | return this.fromBase58(pk.toBase58()); 47 | } 48 | 49 | toBase58(): string { 50 | return bs58.encode(this.buffer); 51 | } 52 | 53 | stellarAddress(): string { 54 | return StrKey.encodeEd25519PublicKey(this.buffer); 55 | } 56 | 57 | equals(other: PublicKey): boolean { 58 | return this.buffer.equals(other.buffer); 59 | } 60 | 61 | solanaKey(): SolanaPublicKey { 62 | return new SolanaPublicKey(this.buffer); 63 | } 64 | } 65 | 66 | // PrivateKey is a blockchain agnostic representation of an 67 | // ed25519 private key. 68 | export class PrivateKey { 69 | kp: Keypair; 70 | 71 | constructor(kp: Keypair) { 72 | this.kp = kp; 73 | } 74 | 75 | static random(): PrivateKey { 76 | return new PrivateKey(Keypair.random()); 77 | } 78 | 79 | static fromString(seed: string): PrivateKey { 80 | if (seed[0] == "S" && seed.length == 56) { 81 | return new PrivateKey(Keypair.fromSecret(seed)); 82 | } 83 | 84 | // attempt to parse 85 | return new PrivateKey(Keypair.fromRawEd25519Seed(Buffer.from(seed, "hex"))); 86 | } 87 | 88 | static fromBase58(seed: string): PrivateKey { 89 | const decoded58 = bs58.decode(seed); 90 | if (decoded58.length == 32) { 91 | return new PrivateKey(Keypair.fromRawEd25519Seed(Buffer.from(decoded58))); 92 | } 93 | 94 | throw new Error("seed is not a valid base58-encoded secret seed"); 95 | } 96 | 97 | toBase58(): string { 98 | return bs58.encode(this.kp.rawSecretKey()); 99 | } 100 | 101 | publicKey(): PublicKey { 102 | return new PublicKey(this.kp.rawPublicKey()); 103 | } 104 | stellarSeed(): string { 105 | return this.kp.secret(); 106 | } 107 | secretKey(): Buffer { 108 | return Buffer.concat([this.kp.rawSecretKey(), this.kp.rawPublicKey()]); 109 | } 110 | 111 | equals(other: PrivateKey): boolean { 112 | return this.kp.rawSecretKey().equals(other.kp.rawSecretKey()); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/memo.ts: -------------------------------------------------------------------------------- 1 | import {xdr} from "stellar-base"; 2 | import { TransactionType, MAX_TRANSACTION_TYPE } from "."; 3 | 4 | const magicByte = 0x1; 5 | 6 | export const MAX_APP_INDEX = Math.pow(2, 16) - 1; 7 | export const MAX_VERSION = 1; 8 | 9 | // Memo implements the Agora memo specification. 10 | // 11 | // Spec: https://github.com/kinecosystem/agora-api 12 | export class Memo { 13 | buffer: Buffer; 14 | constructor(buf: Buffer) { 15 | this.buffer = buf; 16 | } 17 | 18 | static from(b: Buffer): Memo { 19 | const buf = Buffer.alloc(b.length); 20 | b.copy(buf); 21 | return new this(buf); 22 | } 23 | 24 | static fromXdr(memo: xdr.Memo, strict: boolean): Memo | undefined { 25 | if (memo.switch() != xdr.MemoType.memoHash()) { 26 | return undefined; 27 | } 28 | 29 | const m = Memo.from(memo.hash()); 30 | if (!Memo.IsValid(m, strict)) { 31 | throw new Error("invalid memo"); 32 | } 33 | 34 | return m; 35 | } 36 | 37 | static fromB64String(s: string, strict: boolean): Memo | undefined { 38 | const raw = Buffer.from(s, 'base64'); 39 | const m = Memo.from(raw); 40 | if (!Memo.IsValid(m, strict)) { 41 | throw new Error("invalid memo"); 42 | } 43 | 44 | return m; 45 | } 46 | 47 | static new(version: number, type: TransactionType, appIndex: number, fk: Buffer): Memo { 48 | if (fk.length > 29) { 49 | throw new Error("invalid foreign key length"); 50 | } 51 | if (version > 7) { 52 | throw new Error("invalid version"); 53 | } 54 | if (type < 0) { 55 | throw new Error("cannot use unknown transaction type"); 56 | } 57 | 58 | const b = Buffer.alloc(32); 59 | 60 | // encode magic byte + version 61 | b[0] = magicByte; 62 | b[0] |= version << 2; 63 | 64 | // encode transaction type 65 | b[0] |= (type & 0x7) << 5; 66 | b[1] = (type & 0x18) >> 3; 67 | 68 | // encode AppIndex 69 | b[1] |= (appIndex & 0x3f) << 2; 70 | b[2] = (appIndex & 0x3fc0) >> 6; 71 | b[3] = (appIndex & 0xc000) >> 14; 72 | 73 | if (fk.byteLength > 0) { 74 | b[3] |= (fk[0] & 0x3f) << 2; 75 | // insert the rest of the fk. since each loop references fk[n] and fk[n+1], the upper bound is offset by 3 instead of 4. 76 | for (let i = 4; i < 3 + fk.byteLength; i++) { 77 | // apply last 2-bits of current byte 78 | // apply first 6-bits of next byte 79 | b[i] = (fk[i-4] >> 6) & 0x3; 80 | b[i] |= (fk[i-3] & 0x3f) << 2; 81 | } 82 | 83 | // if the foreign key is less than 29 bytes, the last 2 bits of the FK can be included in the memo 84 | if (fk.byteLength < 29) { 85 | b[fk.byteLength + 3] = (fk[fk.byteLength-1] >> 6) & 0x3; 86 | } 87 | } 88 | 89 | return new this(b); 90 | } 91 | 92 | static IsValid(m: Memo, strict?: boolean): boolean { 93 | if (Number(m.buffer[0]&0x3) != magicByte) { 94 | return false; 95 | } 96 | 97 | if (m.TransactionTypeRaw() == -1) { 98 | return false; 99 | } 100 | 101 | if (!strict) { 102 | return true; 103 | } 104 | 105 | if (m.Version() > MAX_VERSION) { 106 | return false; 107 | } 108 | 109 | return m.TransactionType() >= 0 && m.TransactionType() <= MAX_TRANSACTION_TYPE; 110 | } 111 | 112 | // Version returns the memo encoding version. 113 | Version(): number { 114 | return (this.buffer[0] & 0x1c) >> 2; 115 | } 116 | 117 | // TransactionType returns the type of the transaction the memo is 118 | // attached to. 119 | TransactionType(): TransactionType { 120 | const raw = this.TransactionTypeRaw(); 121 | if (raw >= 0 && raw <= MAX_TRANSACTION_TYPE) { 122 | return raw; 123 | } 124 | 125 | return TransactionType.Unknown; 126 | } 127 | 128 | // TransactionTypeRaw returns the type of the transaction the memo is 129 | // attached to, even if it is unsupported by this implementation. It should 130 | // only be used as a fallback if the raw value is needed when TransactionType() 131 | // yieleds TransactionType.Unknown. 132 | TransactionTypeRaw(): TransactionType { 133 | return (this.buffer[0] >> 5) | (this.buffer[1]&0x3)<<3; 134 | } 135 | 136 | // AppIndex returns the index of the app the transaction relates to. 137 | AppIndex(): number { 138 | const a = Number(this.buffer[1]) >> 2; 139 | const b = Number(this.buffer[2]) << 6; 140 | const c = Number(this.buffer[3] & 0x3) << 14; 141 | return a | b | c; 142 | } 143 | 144 | // ForeignKey returns an identifier in an auxiliary service that contains 145 | // additional information related to the transaction. 146 | ForeignKey(): Buffer { 147 | const fk = Buffer.alloc(29); 148 | 149 | for (let i = 0; i < 28; i++) { 150 | fk[i] |= this.buffer[i+3] >> 2; 151 | fk[i] |= (this.buffer[i+4] & 0x3) << 6; 152 | } 153 | 154 | // We only have 230 bits, which results in 155 | // our last fk byte only having 6 'valid' bits 156 | fk[28] = this.buffer[31] >> 2; 157 | 158 | return fk; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/proto/utils.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from "bignumber.js"; 2 | import { Invoice, invoiceToProto, PublicKey } from ".."; 3 | import modelpbv3 from "@kinecosystem/agora-api/node/common/v3/model_pb"; 4 | import modelpb from "@kinecosystem/agora-api/node/common/v4/model_pb"; 5 | import txpb from "@kinecosystem/agora-api/node/transaction/v4/transaction_service_pb"; 6 | 7 | export interface InvoiceListParams { 8 | invoices: Invoice[] 9 | } 10 | 11 | export interface PaymentParams { 12 | source: PublicKey 13 | destination: PublicKey 14 | amount: BigNumber 15 | } 16 | 17 | 18 | export interface HistoryItemParams { 19 | transactionId: Buffer 20 | cursor: Buffer | undefined 21 | stellarTxEnvelope: Buffer | undefined 22 | solanaTx: Buffer | undefined 23 | payments: PaymentParams[] 24 | invoices: Invoice[] 25 | } 26 | 27 | export function createInvoiceList(params: InvoiceListParams): modelpbv3.InvoiceList { 28 | const invoiceList = new modelpbv3.InvoiceList(); 29 | const invoices: modelpbv3.Invoice[] = []; 30 | params.invoices.forEach(invoice => { 31 | invoices.push(invoiceToProto(invoice)); 32 | }); 33 | invoiceList.setInvoicesList(invoices); 34 | return invoiceList; 35 | } 36 | 37 | export function createPayment(params: PaymentParams): txpb.HistoryItem.Payment { 38 | const payment = new txpb.HistoryItem.Payment(); 39 | 40 | const source = new modelpb.TransactionId(); 41 | source.setValue(params.source.buffer); 42 | payment.setSource(source); 43 | 44 | const destination = new modelpb.TransactionId(); 45 | destination.setValue(params.destination.buffer); 46 | payment.setDestination(destination); 47 | 48 | payment.setAmount(params.amount.toNumber()); 49 | 50 | return payment; 51 | } 52 | 53 | 54 | export function createHistoryItem(params: HistoryItemParams): txpb.HistoryItem { 55 | const item = new txpb.HistoryItem(); 56 | 57 | const txId = new modelpb.TransactionId(); 58 | txId.setValue(params.transactionId); 59 | item.setTransactionId(txId); 60 | 61 | if (params.cursor) { 62 | const cursor = new txpb.Cursor(); 63 | cursor.setValue(params.cursor); 64 | item.setCursor(cursor); 65 | } 66 | 67 | if (params.stellarTxEnvelope) { 68 | const stellarTx = new modelpb.StellarTransaction(); 69 | stellarTx.setEnvelopeXdr(params.stellarTxEnvelope); 70 | item.setStellarTransaction(stellarTx); 71 | } 72 | 73 | if (params.solanaTx) { 74 | const solanaTx = new modelpb.Transaction(); 75 | solanaTx.setValue(params.solanaTx); 76 | item.setSolanaTransaction(solanaTx); 77 | } 78 | 79 | const payments: txpb.HistoryItem.Payment[] = []; 80 | params.payments.forEach(payment => { 81 | payments.push(createPayment(payment)); 82 | }); 83 | item.setPaymentsList(payments); 84 | 85 | if (params.invoices.length > 0) { 86 | item.setInvoiceList(createInvoiceList({ invoices: params.invoices })); 87 | } 88 | return item; 89 | } 90 | -------------------------------------------------------------------------------- /src/retry/index.ts: -------------------------------------------------------------------------------- 1 | export async function retry(fn: () => T, ...strategies: ShouldRetry[]): Promise { 2 | return await retryAsync((): Promise => { 3 | return new Promise((resolve, reject) => { 4 | try { 5 | resolve(fn()); 6 | } catch (err) { 7 | reject(err); 8 | } 9 | }) 10 | }, ...strategies); 11 | } 12 | 13 | export async function retryAsync(fn: () => Promise, ...strategies: ShouldRetry[]): Promise { 14 | for (let i = 1; ; i++) { 15 | try { 16 | return await fn(); 17 | } catch (err) { 18 | for (const s of strategies) { 19 | if (!await s(i, err)) { 20 | return Promise.reject(err); 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | export type ShouldRetry = (attempt: number, err: Error) => Promise; 28 | 29 | export function limit(maxAttempts: number): ShouldRetry { 30 | return (attempt: number, _: Error): Promise => { 31 | return Promise.resolve(attempt < maxAttempts); 32 | } 33 | } 34 | 35 | export function retriableErrors(...errors: any[]): ShouldRetry { 36 | return (_: number, err: Error): Promise => { 37 | for (const allowed of errors) { 38 | if (err instanceof allowed) { 39 | return Promise.resolve(true); 40 | } 41 | } 42 | 43 | return Promise.resolve(false); 44 | } 45 | } 46 | 47 | export function nonRetriableErrors(...errors: any[]): ShouldRetry { 48 | return (_: number, err: Error): Promise => { 49 | for (const disallowed of errors) { 50 | if (err instanceof disallowed) { 51 | return Promise.resolve(false); 52 | } 53 | } 54 | 55 | return Promise.resolve(true); 56 | } 57 | } 58 | 59 | export function backoff(fn: DelayFunction, maxDelaySeconds: number): ShouldRetry { 60 | return async (attempt: number, _: Error): Promise => { 61 | const delay = Math.min(fn(attempt), maxDelaySeconds); 62 | 63 | return new Promise(resolve => setTimeout(resolve, delay * 1000)) 64 | .then(() => true); 65 | } 66 | } 67 | 68 | export function backoffWithJitter(fn: DelayFunction, maxDelaySeconds: number, jitter: number): ShouldRetry { 69 | if (jitter < 0.0 || jitter >= 0.25) { 70 | throw new Error("jitter should be [0, 0.25]"); 71 | } 72 | 73 | return async (attempt: number, _: Error): Promise => { 74 | const delay = Math.min(fn(attempt), maxDelaySeconds); 75 | 76 | // Center the jitter around the capped delay: 77 | // <------cappedDelay------> 78 | // jitter jitter 79 | const delayWithJitter = delay * (1 + Math.random() * jitter*2 - jitter); 80 | return new Promise(resolve => setTimeout(resolve, delayWithJitter * 1000)) 81 | .then(() => true); 82 | } 83 | } 84 | 85 | export type DelayFunction = (attempts: number) => number 86 | 87 | export function constantDelay(seconds: number): DelayFunction { 88 | return (_: number): number => { 89 | return seconds; 90 | } 91 | } 92 | 93 | export function linearDelay(baseDelaySeconds: number): DelayFunction { 94 | return (attempts: number): number => { 95 | return baseDelaySeconds * attempts; 96 | } 97 | } 98 | 99 | export function expontentialDelay(baseDelaySeconds: number, base: number): DelayFunction { 100 | return (attempts: number): number => { 101 | return baseDelaySeconds * Math.pow(base, attempts-1); 102 | } 103 | } 104 | 105 | export function binaryExpotentialDelay(baseDelaySeconds: number): DelayFunction { 106 | return expontentialDelay(baseDelaySeconds, 2); 107 | } 108 | -------------------------------------------------------------------------------- /src/solana/memo-program.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey as SolanaPublicKey, TransactionInstruction } from '@solana/web3.js'; 2 | 3 | 4 | export interface MemoParams { 5 | data: string, 6 | } 7 | 8 | export class MemoInstruction { 9 | /** 10 | * Decode a memo instruction and retrieve the instruction params. 11 | */ 12 | static decodeMemo(instruction: TransactionInstruction): MemoParams { 13 | this.checkProgramId(instruction.programId); 14 | 15 | return { 16 | data: instruction.data.toString(), 17 | }; 18 | } 19 | 20 | static checkProgramId(programId: SolanaPublicKey): void { 21 | if (!programId.equals(MemoProgram.programId)) { 22 | throw new Error('invalid instruction; programId is not MemoProgam'); 23 | } 24 | } 25 | } 26 | 27 | export class MemoProgram { 28 | /** 29 | * The address of the memo program that should be used. 30 | * todo: lock this in, or make configurable. 31 | */ 32 | static get programId(): SolanaPublicKey { 33 | return new SolanaPublicKey('Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo'); 34 | } 35 | 36 | static memo(params: MemoParams): TransactionInstruction { 37 | return new TransactionInstruction({ 38 | programId: this.programId, 39 | data: Buffer.from(params.data), 40 | keys: [] 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/solana/token-program.ts: -------------------------------------------------------------------------------- 1 | import { ASSOCIATED_TOKEN_PROGRAM_ID, AuthorityType, TOKEN_PROGRAM_ID } from '@solana/spl-token'; 2 | import { AccountMeta, PublicKey as SolanaPublicKey, SystemProgram, SYSVAR_RENT_PUBKEY, TransactionInstruction } from '@solana/web3.js'; 3 | 4 | // Reference: https://github.com/solana-labs/solana-program-library/blob/11b1e3eefdd4e523768d63f7c70a7aa391ea0d02/token/program/src/state.rs#L125 5 | export const AccountSize = 165; 6 | 7 | 8 | export enum Command { 9 | InitializeMint = 0, 10 | InitializeAccount = 1, 11 | InitializeMultisig = 2, 12 | Transfer = 3, 13 | Approve = 4, 14 | Revoke = 5, 15 | SetAuthority = 6, 16 | MintTo = 7, 17 | Burn = 8, 18 | CloseAccount = 9, 19 | FreezeAccount = 10, 20 | ThawAccount = 11, 21 | Transfer2 = 12, 22 | Approve2 = 13, 23 | MintTo2 = 14, 24 | Burn2 = 15, 25 | } 26 | 27 | export interface InitializeAccountParams { 28 | account: SolanaPublicKey, 29 | mint: SolanaPublicKey, 30 | owner: SolanaPublicKey 31 | } 32 | 33 | export interface TransferParams { 34 | source: SolanaPublicKey, 35 | dest: SolanaPublicKey, 36 | owner: SolanaPublicKey, 37 | amount: bigint, 38 | } 39 | 40 | export interface SetAuthorityParams { 41 | account: SolanaPublicKey, 42 | currentAuthority: SolanaPublicKey, 43 | newAuthority?: SolanaPublicKey, 44 | authorityType: AuthorityType, 45 | } 46 | 47 | export interface CreateAssociatedAccountParams { 48 | subsidizer: SolanaPublicKey 49 | address: SolanaPublicKey 50 | owner: SolanaPublicKey 51 | mint: SolanaPublicKey 52 | } 53 | 54 | export interface CloseAccountParams { 55 | account: SolanaPublicKey 56 | destination: SolanaPublicKey 57 | owner: SolanaPublicKey 58 | } 59 | 60 | // Use array index to map to the spl-token AuthorityType 61 | export const AuthorityTypes: AuthorityType[] = [ 62 | 'MintTokens', 63 | 'FreezeAccount', 64 | 'AccountOwner', 65 | 'CloseAccount', 66 | ]; 67 | 68 | export const AuthorityTypeToNumber: { [key in AuthorityType]: number; } = { 69 | 'MintTokens': 0, 70 | 'FreezeAccount': 1, 71 | 'AccountOwner': 2, 72 | 'CloseAccount': 3, 73 | }; 74 | 75 | export class TokenInstruction { 76 | /** 77 | * Decode a initialize account token instruction and retrieve the instruction params. 78 | */ 79 | static decodeInitializeAccount(instruction: TransactionInstruction): InitializeAccountParams { 80 | this.checkProgramId(instruction.programId, TOKEN_PROGRAM_ID); 81 | this.checkKeyLength(instruction.keys, 4); 82 | this.checkData(instruction.data, 1, Command.InitializeAccount); 83 | 84 | return { 85 | account: instruction.keys[0].pubkey, 86 | mint: instruction.keys[1].pubkey, 87 | owner: instruction.keys[2].pubkey, 88 | }; 89 | } 90 | 91 | /** 92 | * Decode a transfer token instruction and retrieve the instruction params. 93 | */ 94 | static decodeTransfer(instruction: TransactionInstruction): TransferParams { 95 | this.checkProgramId(instruction.programId, TOKEN_PROGRAM_ID); 96 | this.checkKeyLength(instruction.keys, 3); 97 | this.checkData(instruction.data, 9, Command.Transfer); 98 | 99 | return { 100 | source: instruction.keys[0].pubkey, 101 | dest: instruction.keys[1].pubkey, 102 | owner: instruction.keys[2].pubkey, 103 | amount: instruction.data.readBigUInt64LE(1) 104 | }; 105 | } 106 | 107 | /** 108 | * Decode a set authority transfer 109 | */ 110 | static decodeSetAuthority(instruction: TransactionInstruction): SetAuthorityParams { 111 | this.checkProgramId(instruction.programId, TOKEN_PROGRAM_ID); 112 | this.checkKeyLength(instruction.keys, 2); 113 | 114 | if (instruction.data.length < 3) { 115 | throw new Error(`invalid instruction data size: ${instruction.data.length}`); 116 | } 117 | 118 | if (instruction.data[2] == 0) { 119 | this.checkData(instruction.data, 3, Command.SetAuthority); 120 | } 121 | if (instruction.data[2] == 1) { 122 | this.checkData(instruction.data, 35, Command.SetAuthority); 123 | } 124 | 125 | return { 126 | account: instruction.keys[0].pubkey, 127 | currentAuthority: instruction.keys[1].pubkey, 128 | authorityType: AuthorityTypes[instruction.data[1]], 129 | newAuthority: instruction.data[2] == 1 ? new SolanaPublicKey(instruction.data.slice(3)) : undefined 130 | }; 131 | } 132 | 133 | static decodeCreateAssociatedAccount(instruction: TransactionInstruction): CreateAssociatedAccountParams { 134 | this.checkProgramId(instruction.programId, ASSOCIATED_TOKEN_PROGRAM_ID); 135 | this.checkKeyLength(instruction.keys, 7); 136 | 137 | if (instruction.data.length !== 0) { 138 | throw new Error(`invalid instruction data size: ${instruction.data.length}`); 139 | } 140 | if (!instruction.keys[4].pubkey.equals(SystemProgram.programId)) { 141 | throw new Error('system program key mismatch'); 142 | } 143 | if (!instruction.keys[5].pubkey.equals(TOKEN_PROGRAM_ID)) { 144 | throw new Error('token progrma key mismatch'); 145 | } 146 | if (!instruction.keys[6].pubkey.equals(SYSVAR_RENT_PUBKEY)) { 147 | throw new Error('rent sys var mismatch'); 148 | } 149 | 150 | return { 151 | subsidizer: instruction.keys[0].pubkey, 152 | address: instruction.keys[1].pubkey, 153 | owner: instruction.keys[2].pubkey, 154 | mint: instruction.keys[3].pubkey, 155 | }; 156 | } 157 | 158 | static decodeCloseAccount(instruction: TransactionInstruction): CloseAccountParams { 159 | this.checkProgramId(instruction.programId, TOKEN_PROGRAM_ID); 160 | this.checkData(instruction.data, 1, Command.CloseAccount); 161 | 162 | // note: we do < 3 instead of != 3 in order to support multisig cases 163 | if (instruction.keys.length < 3) { 164 | throw new Error(`invalid number of accounts: ${instruction.keys.length}`); 165 | } 166 | 167 | return { 168 | account: instruction.keys[0].pubkey, 169 | destination: instruction.keys[1].pubkey, 170 | owner: instruction.keys[2].pubkey, 171 | }; 172 | } 173 | 174 | private static checkProgramId(programId: SolanaPublicKey, expectedProgramId: SolanaPublicKey) { 175 | if (!programId.equals(expectedProgramId)) { 176 | throw new Error('invalid instruction; programId is not expected program id'); 177 | } 178 | } 179 | 180 | private static checkKeyLength(keys: AccountMeta[], expectedLength: number) { 181 | if (keys.length !== expectedLength) { 182 | throw new Error(`invalid instruction; found ${keys.length} keys, expected at least ${expectedLength}`); 183 | } 184 | } 185 | 186 | private static checkData(data: Buffer, expectedLength: number, expectedCommand: Command) { 187 | if (data.length < expectedLength) { 188 | throw new Error(`invalid instruction data size: ${data.length}`); 189 | } 190 | for (let i = expectedLength; i < data.length; i++) { 191 | if (data[i] != 0) { 192 | throw new Error(`invalid instruction data found at index ${i}`); 193 | } 194 | } 195 | 196 | if (data[0] !== expectedCommand) { 197 | throw new Error(`invalid instruction data: ${data}`); 198 | } 199 | } 200 | } 201 | 202 | export function getTokenCommand(instruction: TransactionInstruction): Command { 203 | if (!instruction.programId.equals(TOKEN_PROGRAM_ID)) { 204 | throw new Error('incorrect program'); 205 | } 206 | if (instruction.data.length === 0) { 207 | throw new Error('token instruction missing data'); 208 | } 209 | 210 | return instruction.data[0]; 211 | } 212 | -------------------------------------------------------------------------------- /src/webhook/index.ts: -------------------------------------------------------------------------------- 1 | import commonpb, { InvoiceList } from "@kinecosystem/agora-api/node/common/v3/model_pb"; 2 | import { Account, Transaction } from "@solana/web3.js"; 3 | import express from "express"; 4 | import { hmac, sha256 } from "hash.js"; 5 | import http from "http"; 6 | import { 7 | Creation, 8 | Environment, 9 | parseTransaction, 10 | PrivateKey, 11 | ReadOnlyPayment 12 | } from ".."; 13 | 14 | 15 | export const AGORA_HMAC_HEADER = "X-Agora-HMAC-SHA256".toLowerCase(); 16 | export const AGORA_USER_ID_HEADER = "X-Agora-User-Id".toLowerCase(); 17 | export const AGORA_USER_PASSKEY_HEADER = "X-Agora-User-Passkey".toLowerCase(); 18 | 19 | export interface Event { 20 | transaction_event: { 21 | tx_id: string, 22 | invoice_list?: commonpb.InvoiceList, 23 | solana_event?: { 24 | transaction: string, 25 | tx_error?: string, 26 | tx_error_raw?: string 27 | } 28 | } 29 | } 30 | 31 | export function EventsHandler(callback: (events: Event[]) => void, secret?: string): express.RequestHandler { 32 | return (req: express.Request, resp: express.Response, next: express.NextFunction) => { 33 | if (secret) { 34 | if (!verifySignature(req.headers, JSON.stringify(req.body), secret)) { 35 | resp.sendStatus(401); 36 | return; 37 | } 38 | } 39 | 40 | try { 41 | const events = req.body; 42 | if (events.length == undefined || events.length == 0) { 43 | resp.sendStatus(400); 44 | return; 45 | } 46 | 47 | callback(events); 48 | resp.sendStatus(200); 49 | } catch (err) { 50 | console.log(err); 51 | resp.sendStatus(500); 52 | } 53 | }; 54 | } 55 | 56 | export class CreateAccountRequest { 57 | userId?: string; 58 | userPassKey?: string; 59 | creation: Creation; 60 | transaction: Transaction; 61 | 62 | constructor( 63 | creation: Creation, transaction: Transaction, userId?: string, userPassKey?: string 64 | ) { 65 | this.userId = userId; 66 | this.userPassKey = userPassKey; 67 | this.creation = creation; 68 | this.transaction = transaction; 69 | } 70 | } 71 | 72 | export class CreateAccountResponse { 73 | transaction: Transaction; 74 | rejected: boolean; 75 | 76 | constructor(transaction: Transaction) { 77 | this.transaction = transaction; 78 | this.rejected = false; 79 | } 80 | 81 | isRejected(): boolean { 82 | return this.rejected; 83 | } 84 | 85 | sign(key: PrivateKey): void { 86 | if (this.transaction.signatures[0].publicKey.toBuffer().equals(key.publicKey().buffer)) { 87 | this.transaction.partialSign(new Account(key.secretKey())); 88 | } 89 | } 90 | 91 | reject(): void { 92 | this.rejected = true; 93 | } 94 | } 95 | 96 | export function CreateAccountHandler(env: Environment, callback: (req: CreateAccountRequest, resp: CreateAccountResponse) => void, secret?: string): express.RequestHandler { 97 | return (req: express.Request, resp: express.Response, next: express.NextFunction) => { 98 | if (secret) { 99 | if (!verifySignature(req.headers, JSON.stringify(req.body), secret)) { 100 | resp.sendStatus(401); 101 | return; 102 | } 103 | } 104 | 105 | let createRequest: CreateAccountRequest; 106 | let createResponse: CreateAccountResponse; 107 | 108 | try { 109 | interface requestBody { 110 | solana_transaction: string 111 | } 112 | 113 | const reqBody = req.body; 114 | 115 | let userId: string | undefined; 116 | if (req.headers[AGORA_USER_ID_HEADER] && req.headers[AGORA_USER_ID_HEADER]!.length > 0) { 117 | userId = req.headers[AGORA_USER_ID_HEADER]; 118 | } 119 | 120 | let userPassKey: string | undefined; 121 | if (req.headers[AGORA_USER_PASSKEY_HEADER] && req.headers[AGORA_USER_PASSKEY_HEADER]!.length > 0) { 122 | userPassKey = req.headers[AGORA_USER_PASSKEY_HEADER]; 123 | } 124 | 125 | if (!reqBody.solana_transaction || typeof reqBody.solana_transaction != "string") { 126 | resp.sendStatus(400); 127 | return; 128 | } 129 | 130 | const txBytes = Buffer.from(reqBody.solana_transaction, "base64"); 131 | const tx = Transaction.from(txBytes); 132 | const [creations, payments] = parseTransaction(tx); 133 | if (payments.length !== 0) { 134 | resp.sendStatus(400); 135 | return; 136 | } 137 | if (creations.length !== 1) { 138 | resp.sendStatus(400); 139 | return; 140 | } 141 | 142 | createRequest = new CreateAccountRequest(creations[0], tx, userId, userPassKey); 143 | createResponse = new CreateAccountResponse(tx); 144 | } catch (err) { 145 | resp.sendStatus(400); 146 | return; 147 | } 148 | 149 | try { 150 | callback(createRequest, createResponse); 151 | if (createResponse.isRejected()) { 152 | resp.sendStatus(403); 153 | return; 154 | } 155 | 156 | const sig = createResponse.transaction.signature; 157 | if (sig && sig != Buffer.alloc(64).fill(0)) { 158 | resp.status(200).send({ 159 | signature: sig.toString('base64') 160 | }); 161 | return; 162 | } 163 | 164 | return resp.sendStatus(200); 165 | } catch (err) { 166 | console.log(err); 167 | resp.sendStatus(500); 168 | } 169 | }; 170 | } 171 | 172 | export class SignTransactionRequest { 173 | userId?: string; 174 | userPassKey?: string; 175 | creations: Creation[]; 176 | payments: ReadOnlyPayment[]; 177 | transaction: Transaction; 178 | 179 | constructor( 180 | creations: Creation[], payments: ReadOnlyPayment[], transaction: Transaction, userId?: string, userPassKey?: string 181 | ) { 182 | this.userId = userId; 183 | this.userPassKey = userPassKey; 184 | this.creations = creations; 185 | this.payments = payments; 186 | 187 | this.transaction = transaction; 188 | } 189 | 190 | txId(): Buffer | undefined { 191 | return this.transaction.signature ? this.transaction.signature : undefined; 192 | } 193 | } 194 | 195 | export class SignTransactionResponse { 196 | transaction: Transaction; 197 | rejected: boolean; 198 | invoiceErrors: InvoiceError[]; 199 | 200 | constructor(transaction: Transaction) { 201 | this.transaction = transaction; 202 | this.rejected = false; 203 | this.invoiceErrors = []; 204 | } 205 | 206 | isRejected(): boolean { 207 | return this.rejected; 208 | } 209 | 210 | sign(key: PrivateKey): void { 211 | if (this.transaction.signatures[0].publicKey.toBuffer().equals(key.publicKey().buffer)) { 212 | this.transaction.partialSign(new Account(key.secretKey())); 213 | } 214 | } 215 | 216 | reject(): void { 217 | this.rejected = true; 218 | } 219 | 220 | markAlreadyPaid(idx: number): void { 221 | this.reject(); 222 | this.invoiceErrors.push({ 223 | operation_index: idx, 224 | reason: RejectionReason.AlreadyPaid, 225 | }); 226 | } 227 | 228 | markWrongDestination(idx: number): void { 229 | this.reject(); 230 | this.invoiceErrors.push({ 231 | operation_index: idx, 232 | reason: RejectionReason.WrongDestination, 233 | }); 234 | } 235 | 236 | markSkuNotFound(idx: number): void { 237 | this.reject(); 238 | this.invoiceErrors.push({ 239 | operation_index: idx, 240 | reason: RejectionReason.SkuNotFound, 241 | }); 242 | } 243 | } 244 | 245 | export enum RejectionReason { 246 | None = "", 247 | AlreadyPaid = "already_paid", 248 | WrongDestination = "wrong_destination", 249 | SkuNotFound = "sku_not_found", 250 | } 251 | 252 | export class InvoiceError { 253 | operation_index: number; 254 | reason: RejectionReason; 255 | 256 | constructor() { 257 | this.operation_index = 0; 258 | this.reason = RejectionReason.None; 259 | } 260 | } 261 | 262 | export function SignTransactionHandler(env: Environment, callback: (req: SignTransactionRequest, resp: SignTransactionResponse) => void, secret?: string): express.RequestHandler { 263 | return (req: express.Request, resp: express.Response, next: express.NextFunction) => { 264 | if (secret) { 265 | if (!verifySignature(req.headers, JSON.stringify(req.body), secret)) { 266 | resp.sendStatus(401); 267 | return; 268 | } 269 | } 270 | 271 | let signRequest: SignTransactionRequest; 272 | let signResponse: SignTransactionResponse; 273 | 274 | try { 275 | interface requestBody { 276 | solana_transaction: string 277 | invoice_list: string 278 | } 279 | 280 | const reqBody = req.body; 281 | 282 | let userId: string | undefined; 283 | if (req.headers[AGORA_USER_ID_HEADER] && req.headers[AGORA_USER_ID_HEADER]!.length > 0) { 284 | userId = req.headers[AGORA_USER_ID_HEADER]; 285 | } 286 | 287 | let userPassKey: string | undefined; 288 | if (req.headers[AGORA_USER_PASSKEY_HEADER] && req.headers[AGORA_USER_PASSKEY_HEADER]!.length > 0) { 289 | userPassKey = req.headers[AGORA_USER_PASSKEY_HEADER]; 290 | } 291 | 292 | let invoiceList: commonpb.InvoiceList | undefined; 293 | if (reqBody.invoice_list) { 294 | invoiceList = InvoiceList.deserializeBinary(Buffer.from(reqBody.invoice_list, "base64")); 295 | } 296 | 297 | if (!reqBody.solana_transaction || typeof reqBody.solana_transaction != "string") { 298 | resp.sendStatus(400); 299 | return; 300 | } 301 | 302 | const txBytes = Buffer.from(reqBody.solana_transaction, "base64"); 303 | const tx = Transaction.from(txBytes); 304 | const [creations, payments] = parseTransaction(tx, invoiceList); 305 | signRequest = new SignTransactionRequest(creations, payments, tx, userId, userPassKey); 306 | signResponse = new SignTransactionResponse(tx); 307 | } catch (err) { 308 | resp.sendStatus(400); 309 | return; 310 | } 311 | 312 | try { 313 | callback(signRequest, signResponse); 314 | if (signResponse.isRejected()) { 315 | resp.status(403).send({ 316 | invoice_errors: signResponse.invoiceErrors, 317 | }); 318 | return; 319 | } 320 | 321 | const sig = signResponse.transaction.signature; 322 | if (sig && sig != Buffer.alloc(64).fill(0)) { 323 | resp.status(200).send({ 324 | signature: sig.toString('base64') 325 | }); 326 | return; 327 | } 328 | 329 | return resp.sendStatus(200); 330 | } catch (err) { 331 | console.log(err); 332 | resp.sendStatus(500); 333 | } 334 | }; 335 | } 336 | 337 | function verifySignature(headers: http.IncomingHttpHeaders, body: any, secret: string): boolean { 338 | if (!headers[AGORA_HMAC_HEADER] || headers[AGORA_HMAC_HEADER]?.length == 0) { 339 | return false; 340 | } 341 | 342 | const rawSecret = Buffer.from(secret, "utf-8"); 343 | const actual = Buffer.from(headers[AGORA_HMAC_HEADER]!, 'base64').toString('hex'); 344 | const expected = hmac(sha256, rawSecret).update(body).digest('hex'); 345 | return actual == expected; 346 | } 347 | -------------------------------------------------------------------------------- /test/data/get_transaction_test_kin_2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "transaction_data": { 4 | "tx_id": "c29tZWhhc2g=", 5 | "payments": [ 6 | { 7 | "sender": "7ivi+RWXETJ30opPuYd1Uo0gTK6Cf0OS2OF9k7a9J3M=", 8 | "destination": "Vlp9vMrownPkQPnnIpJKCODT9bLzmTKulrW+5aYu5/Y=", 9 | "type": 1, 10 | "quarks": 15, 11 | "invoice": { 12 | "items": [ 13 | { 14 | "title": "t1", 15 | "amount": 15 16 | } 17 | ] 18 | }, 19 | "memo": "" 20 | } 21 | ] 22 | }, 23 | "transaction_state": 1, 24 | "response": "CAEiiwMKCgoIc29tZWhhc2gSCQoHY3Vyc29yMSKZAgosAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAS6AEAAAAA7ivi+RWXETJ30opPuYd1Uo0gTK6Cf0OS2OF9k7a9J3MAAAAAAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAMlBAB0vu/vKlOvK1ZKwXCiRW+roewkYEFHnnC+XipRAQAAAAEAAAAAAAAAAQAAAABWWn28yujCc+RA+ecikkoI4NP1svOZMq6Wtb7lpi7n9gAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAABdwAAAAAAAAAAXh4eHgAAAAJc2lnbmF0dXJlAAAAMkoKIgog7ivi+RWXETJ30opPuYd1Uo0gTK6Cf0OS2OF9k7a9J3MSIgogVlp9vMrownPkQPnnIpJKCODT9bLzmTKulrW+5aYu5/YYDzoKCggKBgoCdDEYDw==" 25 | } 26 | ] -------------------------------------------------------------------------------- /test/data/get_transaction_test_kin_3.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "transaction_data": { 4 | "tx_id": "c29tZWhhc2g=", 5 | "payments": [ 6 | { 7 | "sender": "CS/Bopgh+qgm3Q5G8j3rvBDAI19ChmuLp8Ertb8W308=", 8 | "destination": "Q8PsuzaT74H1YHKituqgeaLXYIZsbwZ56U0E+Rczd8M=", 9 | "type": 1, 10 | "quarks": 15, 11 | "invoice": { 12 | "items": [ 13 | { 14 | "title": "t1", 15 | "amount": 15 16 | } 17 | ] 18 | }, 19 | "memo": "" 20 | } 21 | ] 22 | }, 23 | "transaction_state": 1, 24 | "response": "CAEi4wIKCgoIc29tZWhhc2gSCQoHY3Vyc29yMSLxAQosAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAASwAEAAAAACS/Bopgh+qgm3Q5G8j3rvBDAI19ChmuLp8Ertb8W308AAAAAAAAAAAAAAAEAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAMlBAB0vu/vKlOvK1ZKwXCiRW+roewkYEFHnnC+XipRAQAAAAEAAAAAAAAAAQAAAABDw+y7NpPvgfVgcqK26qB5otdghmxvBnnpTQT5FzN3wwAAAAAAAAAAAAAADwAAAAAAAAABeHh4eAAAAAlzaWduYXR1cmUAAAAySgoiCiAJL8GimCH6qCbdDkbyPeu8EMAjX0KGa4unwSu1vxbfTxIiCiBDw+y7NpPvgfVgcqK26qB5otdghmxvBnnpTQT5FzN3wxgPOgoKCAoGCgJ0MRgP" 25 | } 26 | ] -------------------------------------------------------------------------------- /test/data/get_transaction_test_kin_4.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "transaction_data": { 4 | "tx_id": "c29tZWlk", 5 | "payments": [ 6 | { 7 | "sender": "qArFVAapjZiEjnBuo3wpKguMfsvL/L7mrJ2bVXYjAjs=", 8 | "destination": "68Js/IslaPUq5lNj9TK5+smYsb95WQlhnPVtSKKw9cU=", 9 | "type": 2, 10 | "quarks": 100, 11 | "invoice": { 12 | "items": [ 13 | { 14 | "title": "t1", 15 | "amount": 100, 16 | "sku": "c29tZXNrdQ==" 17 | } 18 | ] 19 | }, 20 | "memo": "" 21 | } 22 | ] 23 | }, 24 | "transaction_state": 1, 25 | "response": "CAEilQQKCAoGc29tZWlkGqcDCqQDAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgACBsY/b+zdVT2QbWbO2fUBijh1BACoenW6i2I/B2qmK3K5YKRzdaHWvlrouFLbNbQqs3l9H9cfB7KX/8ChtyEHFRaoCsVUBqmNmISOcG6jfCkqC4x+y8v8vuasnZtVdiMCO+vCbPyLJWj1KuZTY/UyufrJmLG/eVkJYZz1bUiisPXFBUpTUPhdyILWFKVWcniKKW3fHqur0KYGeIhJMvTu9qBLjZSqZfbhHF8VUgejr1cYwviKFTdhf9zXIO9kC1xZigAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgQALFJRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE9BQMCAwEJA2QAAAAAAAAAMkoKIgogqArFVAapjZiEjnBuo3wpKguMfsvL/L7mrJ2bVXYjAjsSIgog68Js/IslaPUq5lNj9TK5+smYsb95WQlhnPVtSKKw9cUYZDoTChEKDwoCdDEYZCIHc29tZXNrdQ==" 26 | } 27 | ] -------------------------------------------------------------------------------- /test/data/get_transaction_v3_test_kin_2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "response": "CAEawQcKIgog2mVyYoY+HSA4qoLSVodVGbmtwpGvWfx/PAi9G6iWHAUSXAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAGrwGAAAAAKym0Op0L01Bb2xCre0xMkoBv8DFUsGalNt3pIc4FGnLAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAGMS10ZXN0AAAAAAAGAAAAAQAAAACsptDqdC9NQW9sQq3tMTJKAb/AxVLBmpTbd6SHOBRpywAAAAAAAAAAgOAmYz0O1fUJl6dafXxhqb6+YCIjIQ6HszsFViHvYikAAAAAAAAAAAAAAAEAAAAArKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacsAAAABAAAAAHnQgyFXlfHnNMZ+vIe0dMVHHFKSocyI7ebJldksM9xzAAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAEAAAAArKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacsAAAABAAAAAB904EwuWlIEpa9xXnui8d03eE4BIiN7uSdUAJvok83XAAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAEAAAAArKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacsAAAABAAAAAAHWi8Wz/BDWPmXWqvRBDwCe0Q3fiPD03m6aAZiK9zu7AAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAEAAAAArKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacsAAAABAAAAABr97PnDWTwWYjXPPMpdi/bbizw8NRAw/BeRZkatWozpAAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAEAAAAArKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacsAAAABAAAAACfZxDUd/SObmTUYgLYpCqbK7NuyC7defgE67kf4MAXXAAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAAAAAAA", 4 | "transaction_data": { 5 | "payments": [ 6 | { 7 | "destination": "edCDIVeV8ec0xn68h7R0xUccUpKhzIjt5smV2Swz3HM=", 8 | "invoice": null, 9 | "memo": "1-test", 10 | "quarks": 10, 11 | "sender": "rKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacs=", 12 | "type": -1 13 | }, 14 | { 15 | "destination": "H3TgTC5aUgSlr3Fee6Lx3Td4TgEiI3u5J1QAm+iTzdc=", 16 | "invoice": null, 17 | "memo": "1-test", 18 | "quarks": 10, 19 | "sender": "rKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacs=", 20 | "type": -1 21 | }, 22 | { 23 | "destination": "AdaLxbP8ENY+Zdaq9EEPAJ7RDd+I8PTebpoBmIr3O7s=", 24 | "invoice": null, 25 | "memo": "1-test", 26 | "quarks": 10, 27 | "sender": "rKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacs=", 28 | "type": -1 29 | }, 30 | { 31 | "destination": "Gv3s+cNZPBZiNc88yl2L9tuLPDw1EDD8F5FmRq1ajOk=", 32 | "invoice": null, 33 | "memo": "1-test", 34 | "quarks": 10, 35 | "sender": "rKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacs=", 36 | "type": -1 37 | }, 38 | { 39 | "destination": "J9nENR39I5uZNRiAtikKpsrs27ILt15+ATruR/gwBdc=", 40 | "invoice": null, 41 | "memo": "1-test", 42 | "quarks": 10, 43 | "sender": "rKbQ6nQvTUFvbEKt7TEySgG/wMVSwZqU23ekhzgUacs=", 44 | "type": -1 45 | } 46 | ], 47 | "tx_hash": "2mVyYoY+HSA4qoLSVodVGbmtwpGvWfx/PAi9G6iWHAU=" 48 | } 49 | }, 50 | { 51 | "response": "CAEa6wcKIgog/55JNi55QJfJPzO9IKo6RQefmSHo/KvhhVeA7yPGT0cSUAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAGvgFAAAAAMaRSb9rLgI8OIISVVd3e8s8C6M4K3CEuVK+/iba9uVdAAAAAAAAAAAAAAABAAAAAAAAAANFBABMcDGOzs80tD3rEC6hGN/uT5ff08L59FH6K07xAgAAAAUAAAABAAAAAMaRSb9rLgI8OIISVVd3e8s8C6M4K3CEuVK+/iba9uVdAAAAAQAAAADG3Df1anMLxAX2P899VQznvYKuJHyv2WrKM22NE1d2OAAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAABAAAAAMaRSb9rLgI8OIISVVd3e8s8C6M4K3CEuVK+/iba9uVdAAAAAQAAAAC7vQggjnb2DvgrAQARwFMLsXKaZbfHb2xPzI+uGGoijgAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAABAAAAAMaRSb9rLgI8OIISVVd3e8s8C6M4K3CEuVK+/iba9uVdAAAAAQAAAADKPsE3yOijM2odTkEVfvdU7loDHPMH0WuYyX1xPPtPTAAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAABAAAAAMaRSb9rLgI8OIISVVd3e8s8C6M4K3CEuVK+/iba9uVdAAAAAQAAAABvIuA4gdRdIuZqr654O2rhjWWD8jUKIAlZAdOjIR0CGAAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAABAAAAAMaRSb9rLgI8OIISVVd3e8s8C6M4K3CEuVK+/iba9uVdAAAAAQAAAAA3yHLZZ9REKlaJA9HS2rRHxDMEwA+/ET31VffPy4eHoQAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAAAAAAAACp4ChYKFAoFVGVzdDAYCiIJcmFuZG9tc2t1ChYKFAoFVGVzdDEYCiIJcmFuZG9tc2t1ChYKFAoFVGVzdDIYCiIJcmFuZG9tc2t1ChYKFAoFVGVzdDMYCiIJcmFuZG9tc2t1ChYKFAoFVGVzdDQYCiIJcmFuZG9tc2t1", 52 | "transaction_data": { 53 | "payments": [ 54 | { 55 | "destination": "xtw39WpzC8QF9j/PfVUM572CriR8r9lqyjNtjRNXdjg=", 56 | "invoice": { 57 | "items": [ 58 | { 59 | "amount": 10, 60 | "sku": "cmFuZG9tc2t1", 61 | "title": "Test0" 62 | } 63 | ] 64 | }, 65 | "memo": "", 66 | "quarks": 10, 67 | "sender": "xpFJv2suAjw4ghJVV3d7yzwLozgrcIS5Ur7+Jtr25V0=", 68 | "type": 2 69 | }, 70 | { 71 | "destination": "u70III529g74KwEAEcBTC7FymmW3x29sT8yPrhhqIo4=", 72 | "invoice": { 73 | "items": [ 74 | { 75 | "amount": 10, 76 | "sku": "cmFuZG9tc2t1", 77 | "title": "Test1" 78 | } 79 | ] 80 | }, 81 | "memo": "", 82 | "quarks": 10, 83 | "sender": "xpFJv2suAjw4ghJVV3d7yzwLozgrcIS5Ur7+Jtr25V0=", 84 | "type": 2 85 | }, 86 | { 87 | "destination": "yj7BN8joozNqHU5BFX73VO5aAxzzB9FrmMl9cTz7T0w=", 88 | "invoice": { 89 | "items": [ 90 | { 91 | "amount": 10, 92 | "sku": "cmFuZG9tc2t1", 93 | "title": "Test2" 94 | } 95 | ] 96 | }, 97 | "memo": "", 98 | "quarks": 10, 99 | "sender": "xpFJv2suAjw4ghJVV3d7yzwLozgrcIS5Ur7+Jtr25V0=", 100 | "type": 2 101 | }, 102 | { 103 | "destination": "byLgOIHUXSLmaq+ueDtq4Y1lg/I1CiAJWQHToyEdAhg=", 104 | "invoice": { 105 | "items": [ 106 | { 107 | "amount": 10, 108 | "sku": "cmFuZG9tc2t1", 109 | "title": "Test3" 110 | } 111 | ] 112 | }, 113 | "memo": "", 114 | "quarks": 10, 115 | "sender": "xpFJv2suAjw4ghJVV3d7yzwLozgrcIS5Ur7+Jtr25V0=", 116 | "type": 2 117 | }, 118 | { 119 | "destination": "N8hy2WfURCpWiQPR0tq0R8QzBMAPvxE99VX3z8uHh6E=", 120 | "invoice": { 121 | "items": [ 122 | { 123 | "amount": 10, 124 | "sku": "cmFuZG9tc2t1", 125 | "title": "Test4" 126 | } 127 | ] 128 | }, 129 | "memo": "", 130 | "quarks": 10, 131 | "sender": "xpFJv2suAjw4ghJVV3d7yzwLozgrcIS5Ur7+Jtr25V0=", 132 | "type": 2 133 | } 134 | ], 135 | "tx_hash": "/55JNi55QJfJPzO9IKo6RQefmSHo/KvhhVeA7yPGT0c=" 136 | } 137 | }, 138 | { 139 | "response": "CAEawQcKIgogDuolGPri7BRU6i98YBR+U8gc/qNL1r4XrvL+pzrIO40SXAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAGrwGAAAAACLKRcWRv6UKlkxiF0nF/7Sro/ZRSK15t2vMVkX7cIZWAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAGMS10ZXN0AAAAAAAGAAAAAQAAAADQEJ4WRp3d0YbR1YWyZqXeimOWSEL+lK4FANe3+T+lFQAAAAAAAAAAGo/IVvCuw4p1kwHCnpdry0JQnlKGjU+gkJvywg7hgQwAAAAAAAAAAAAAAAEAAAAA0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRUAAAABAAAAAFNC+VtGwIm6JAXoEFfVsbzs0FKedMUbno67L+f4n9OXAAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAEAAAAA0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRUAAAABAAAAANhTVttOIwVNECXzquiz3MfSJ+no+6LU2MsaSWQIr734AAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAEAAAAA0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRUAAAABAAAAALIye1QCDbyr+t/gMzGbeb2VWOGLui2I8zypHGDETtKmAAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAEAAAAA0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRUAAAABAAAAAKSuv2iHSHA/6T0gZLi1EPigM5poTFDjLJecQAh42GDKAAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAEAAAAA0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRUAAAABAAAAAIggkQPjAZr/jdp7/406DcZqc3MMRyTtp1ol8w7ksQqJAAAAAUtJTgAAAAAARbkbzTTlnTmH2Upg/pWMLIXbfv04aTnF5MssubXmqJsAAAAAAAAD6AAAAAAAAAAA", 140 | "transaction_data": { 141 | "payments": [ 142 | { 143 | "destination": "U0L5W0bAibokBegQV9WxvOzQUp50xRuejrsv5/if05c=", 144 | "invoice": null, 145 | "memo": "1-test", 146 | "quarks": 10, 147 | "sender": "0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRU=", 148 | "type": -1 149 | }, 150 | { 151 | "destination": "2FNW204jBU0QJfOq6LPcx9In6ej7otTYyxpJZAivvfg=", 152 | "invoice": null, 153 | "memo": "1-test", 154 | "quarks": 10, 155 | "sender": "0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRU=", 156 | "type": -1 157 | }, 158 | { 159 | "destination": "sjJ7VAINvKv63+AzMZt5vZVY4Yu6LYjzPKkcYMRO0qY=", 160 | "invoice": null, 161 | "memo": "1-test", 162 | "quarks": 10, 163 | "sender": "0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRU=", 164 | "type": -1 165 | }, 166 | { 167 | "destination": "pK6/aIdIcD/pPSBkuLUQ+KAzmmhMUOMsl5xACHjYYMo=", 168 | "invoice": null, 169 | "memo": "1-test", 170 | "quarks": 10, 171 | "sender": "0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRU=", 172 | "type": -1 173 | }, 174 | { 175 | "destination": "iCCRA+MBmv+N2nv/jToNxmpzcwxHJO2nWiXzDuSxCok=", 176 | "invoice": null, 177 | "memo": "1-test", 178 | "quarks": 10, 179 | "sender": "0BCeFkad3dGG0dWFsmal3opjlkhC/pSuBQDXt/k/pRU=", 180 | "type": -1 181 | } 182 | ], 183 | "tx_hash": "DuolGPri7BRU6i98YBR+U8gc/qNL1r4XrvL+pzrIO40=" 184 | } 185 | }, 186 | { 187 | "response": "CAEa6wcKIgoghX0TU9Xldm93ALQeOdkimEoL0Ak6ITPLl5VYWuucAycSUAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAGvgFAAAAAEcdVsEg/WfYiR6VjsHtKaGACYYy77dMGmxcAZcwANnqAAAAAAAAAAAAAAABAAAAAAAAAANFBABMcDGOzs80tD3rEC6hGN/uT5ff08L59FH6K07xAgAAAAUAAAABAAAAAAWtdRYbjELLNs985o+o6BiT0m6jMz8gQMoFL9fhYjK0AAAAAQAAAAAdr1+fx06HvvpaHbAI2YmaxsYsSNofJnn/WmmeuD1dUAAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAABAAAAAAWtdRYbjELLNs985o+o6BiT0m6jMz8gQMoFL9fhYjK0AAAAAQAAAACocGpUoSCt9l4X1yOOMzJULjjmSyIegiiS6RN+KOY29gAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAABAAAAAAWtdRYbjELLNs985o+o6BiT0m6jMz8gQMoFL9fhYjK0AAAAAQAAAABxfkiponhoXykX95VAQdQ4cb7Qaq5pyRnipeeYYz8O8AAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAABAAAAAAWtdRYbjELLNs985o+o6BiT0m6jMz8gQMoFL9fhYjK0AAAAAQAAAABIeQ0HKur0bISMa2ItmP/QtXjFzed8HnCq/GEqbLQSpgAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAABAAAAAAWtdRYbjELLNs985o+o6BiT0m6jMz8gQMoFL9fhYjK0AAAAAQAAAABPoSxYXOCzCheX5LroGn0NXqGeySkbpN7AXteFQlN7jwAAAAFLSU4AAAAAAEW5G8005Z05h9lKYP6VjCyF2379OGk5xeTLLLm15qibAAAAAAAAA+gAAAAAAAAAACp4ChYKFAoFVGVzdDAYCiIJcmFuZG9tc2t1ChYKFAoFVGVzdDEYCiIJcmFuZG9tc2t1ChYKFAoFVGVzdDIYCiIJcmFuZG9tc2t1ChYKFAoFVGVzdDMYCiIJcmFuZG9tc2t1ChYKFAoFVGVzdDQYCiIJcmFuZG9tc2t1", 188 | "transaction_data": { 189 | "payments": [ 190 | { 191 | "destination": "Ha9fn8dOh776Wh2wCNmJmsbGLEjaHyZ5/1ppnrg9XVA=", 192 | "invoice": { 193 | "items": [ 194 | { 195 | "amount": 10, 196 | "sku": "cmFuZG9tc2t1", 197 | "title": "Test0" 198 | } 199 | ] 200 | }, 201 | "memo": "", 202 | "quarks": 10, 203 | "sender": "Ba11FhuMQss2z3zmj6joGJPSbqMzPyBAygUv1+FiMrQ=", 204 | "type": 2 205 | }, 206 | { 207 | "destination": "qHBqVKEgrfZeF9cjjjMyVC445ksiHoIokukTfijmNvY=", 208 | "invoice": { 209 | "items": [ 210 | { 211 | "amount": 10, 212 | "sku": "cmFuZG9tc2t1", 213 | "title": "Test1" 214 | } 215 | ] 216 | }, 217 | "memo": "", 218 | "quarks": 10, 219 | "sender": "Ba11FhuMQss2z3zmj6joGJPSbqMzPyBAygUv1+FiMrQ=", 220 | "type": 2 221 | }, 222 | { 223 | "destination": "cX5IqaJ4aF8pF/eVQEHUOHG+0GquackZ4qXnmGM/DvA=", 224 | "invoice": { 225 | "items": [ 226 | { 227 | "amount": 10, 228 | "sku": "cmFuZG9tc2t1", 229 | "title": "Test2" 230 | } 231 | ] 232 | }, 233 | "memo": "", 234 | "quarks": 10, 235 | "sender": "Ba11FhuMQss2z3zmj6joGJPSbqMzPyBAygUv1+FiMrQ=", 236 | "type": 2 237 | }, 238 | { 239 | "destination": "SHkNByrq9GyEjGtiLZj/0LV4xc3nfB5wqvxhKmy0EqY=", 240 | "invoice": { 241 | "items": [ 242 | { 243 | "amount": 10, 244 | "sku": "cmFuZG9tc2t1", 245 | "title": "Test3" 246 | } 247 | ] 248 | }, 249 | "memo": "", 250 | "quarks": 10, 251 | "sender": "Ba11FhuMQss2z3zmj6joGJPSbqMzPyBAygUv1+FiMrQ=", 252 | "type": 2 253 | }, 254 | { 255 | "destination": "T6EsWFzgswoXl+S66Bp9DV6hnskpG6TewF7XhUJTe48=", 256 | "invoice": { 257 | "items": [ 258 | { 259 | "amount": 10, 260 | "sku": "cmFuZG9tc2t1", 261 | "title": "Test4" 262 | } 263 | ] 264 | }, 265 | "memo": "", 266 | "quarks": 10, 267 | "sender": "Ba11FhuMQss2z3zmj6joGJPSbqMzPyBAygUv1+FiMrQ=", 268 | "type": 2 269 | } 270 | ], 271 | "tx_hash": "hX0TU9Xldm93ALQeOdkimEoL0Ak6ITPLl5VYWuucAyc=" 272 | } 273 | } 274 | ] -------------------------------------------------------------------------------- /test/data/get_transaction_v3_test_kin_3.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "transaction_data": { 4 | "tx_hash": "yWa3sbdChuPXw83km9ZI5H4jhhgvmBSmsuBq+nMpZVg=", 5 | "payments": [ 6 | { 7 | "sender": "bTee1/44oz+mSEj6vGV47fe9ESEzVoqm6E5/iNscH8w=", 8 | "destination": "lKWxfP3t+hGrMtmDM2A0GVMKnGSswwg8zInPmh6ncoc=", 9 | "type": -1, 10 | "quarks": 10, 11 | "invoice": null, 12 | "memo": "1-test" 13 | }, 14 | { 15 | "sender": "bTee1/44oz+mSEj6vGV47fe9ESEzVoqm6E5/iNscH8w=", 16 | "destination": "ePzc215zIKe+KF+bZ3BHIJRYTgl0T+4TMh7wzTCUxXA=", 17 | "type": -1, 18 | "quarks": 10, 19 | "invoice": null, 20 | "memo": "1-test" 21 | }, 22 | { 23 | "sender": "bTee1/44oz+mSEj6vGV47fe9ESEzVoqm6E5/iNscH8w=", 24 | "destination": "S7f3GY/2vtmFmL2bZqyeo93MHauDTLXPsF0i0O6aUjI=", 25 | "type": -1, 26 | "quarks": 10, 27 | "invoice": null, 28 | "memo": "1-test" 29 | }, 30 | { 31 | "sender": "bTee1/44oz+mSEj6vGV47fe9ESEzVoqm6E5/iNscH8w=", 32 | "destination": "B5tHh1AXZkX01gW4m8nHitIR3jCto39r97O/ScLtdNs=", 33 | "type": -1, 34 | "quarks": 10, 35 | "invoice": null, 36 | "memo": "1-test" 37 | }, 38 | { 39 | "sender": "bTee1/44oz+mSEj6vGV47fe9ESEzVoqm6E5/iNscH8w=", 40 | "destination": "jSat78jzu6cPStY26h/a4Ign52bt+blsZnrTg7c1PmQ=", 41 | "type": -1, 42 | "quarks": 10, 43 | "invoice": null, 44 | "memo": "1-test" 45 | } 46 | ] 47 | }, 48 | "response": "CAEa+QUKIgogyWa3sbdChuPXw83km9ZI5H4jhhgvmBSmsuBq+nMpZVgSXAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAGvQEAAAAAG03ntf+OKM/pkhI+rxleO33vREhM1aKpuhOf4jbHB/MAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAGMS10ZXN0AAAAAAAGAAAAAQAAAABtN57X/jijP6ZISPq8ZXjt970RITNWiqboTn+I2xwfzAAAAAAAAAAAkAavHmyaiXt2SnTaphQVb+jA30i3EVZTkdFXk40m8bUAAAAAAAAAAAAAAAEAAAAAbTee1/44oz+mSEj6vGV47fe9ESEzVoqm6E5/iNscH8wAAAABAAAAAJSlsXz97foRqzLZgzNgNBlTCpxkrMMIPMyJz5oep3KHAAAAAAAAAAAAAAAKAAAAAQAAAABtN57X/jijP6ZISPq8ZXjt970RITNWiqboTn+I2xwfzAAAAAEAAAAAePzc215zIKe+KF+bZ3BHIJRYTgl0T+4TMh7wzTCUxXAAAAAAAAAAAAAAAAoAAAABAAAAAG03ntf+OKM/pkhI+rxleO33vREhM1aKpuhOf4jbHB/MAAAAAQAAAABLt/cZj/a+2YWYvZtmrJ6j3cwdq4NMtc+wXSLQ7ppSMgAAAAAAAAAAAAAACgAAAAEAAAAAbTee1/44oz+mSEj6vGV47fe9ESEzVoqm6E5/iNscH8wAAAABAAAAAAebR4dQF2ZF9NYFuJvJx4rSEd4wraN/a/ezv0nC7XTbAAAAAAAAAAAAAAAKAAAAAQAAAABtN57X/jijP6ZISPq8ZXjt970RITNWiqboTn+I2xwfzAAAAAEAAAAAjSat78jzu6cPStY26h/a4Ign52bt+blsZnrTg7c1PmQAAAAAAAAAAAAAAAoAAAAAAAAAAA==" 49 | }, 50 | { 51 | "transaction_data": { 52 | "tx_hash": "gn8YbGuXcZbb6gz3PnE05NxklYnk4b5reyNvDta3i3o=", 53 | "payments": [ 54 | { 55 | "sender": "ezHe8arjLWjDAH17v3LVU+BfKkzEqqBfw0O5xlujHVw=", 56 | "destination": "CUrdR6KutaJD81z12BPDs66LNMwaITAZrlY743qqT30=", 57 | "type": 2, 58 | "quarks": 10, 59 | "invoice": { 60 | "items": [ 61 | { 62 | "title": "Test0", 63 | "amount": 10, 64 | "sku": "cmFuZG9tc2t1" 65 | } 66 | ] 67 | }, 68 | "memo": "" 69 | }, 70 | { 71 | "sender": "ezHe8arjLWjDAH17v3LVU+BfKkzEqqBfw0O5xlujHVw=", 72 | "destination": "f+JnliYccK1wZnIIh/SfbL9Bv1tLVuhAA/8h16GRWUI=", 73 | "type": 2, 74 | "quarks": 10, 75 | "invoice": { 76 | "items": [ 77 | { 78 | "title": "Test1", 79 | "amount": 10, 80 | "sku": "cmFuZG9tc2t1" 81 | } 82 | ] 83 | }, 84 | "memo": "" 85 | }, 86 | { 87 | "sender": "ezHe8arjLWjDAH17v3LVU+BfKkzEqqBfw0O5xlujHVw=", 88 | "destination": "K8oIS4+sBEjuhCkJavqbmYY1fSHuagzIum9b+71saRc=", 89 | "type": 2, 90 | "quarks": 10, 91 | "invoice": { 92 | "items": [ 93 | { 94 | "title": "Test2", 95 | "amount": 10, 96 | "sku": "cmFuZG9tc2t1" 97 | } 98 | ] 99 | }, 100 | "memo": "" 101 | }, 102 | { 103 | "sender": "ezHe8arjLWjDAH17v3LVU+BfKkzEqqBfw0O5xlujHVw=", 104 | "destination": "XIj0WrZf3nC29ZEzODRKN3M8ez/GYHZ8RiHxUePqzpM=", 105 | "type": 2, 106 | "quarks": 10, 107 | "invoice": { 108 | "items": [ 109 | { 110 | "title": "Test3", 111 | "amount": 10, 112 | "sku": "cmFuZG9tc2t1" 113 | } 114 | ] 115 | }, 116 | "memo": "" 117 | }, 118 | { 119 | "sender": "ezHe8arjLWjDAH17v3LVU+BfKkzEqqBfw0O5xlujHVw=", 120 | "destination": "g5GnNq9efBBfir/3wpoqHpLS3Fw73Yo4j+CtHEqRjek=", 121 | "type": 2, 122 | "quarks": 10, 123 | "invoice": { 124 | "items": [ 125 | { 126 | "title": "Test4", 127 | "amount": 10, 128 | "sku": "cmFuZG9tc2t1" 129 | } 130 | ] 131 | }, 132 | "memo": "" 133 | } 134 | ] 135 | }, 136 | "response": "CAEaowYKIgoggn8YbGuXcZbb6gz3PnE05NxklYnk4b5reyNvDta3i3oSUAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAGrAEAAAAAHsx3vGq4y1owwB9e79y1VPgXypMxKqgX8NDucZbox1cAAAAAAAAAAAAAAABAAAAAAAAAANFBABMcDGOzs80tD3rEC6hGN/uT5ff08L59FH6K07xAgAAAAUAAAABAAAAAHsx3vGq4y1owwB9e79y1VPgXypMxKqgX8NDucZbox1cAAAAAQAAAAAJSt1Hoq61okPzXPXYE8Ozros0zBohMBmuVjvjeqpPfQAAAAAAAAAAAAAACgAAAAEAAAAAezHe8arjLWjDAH17v3LVU+BfKkzEqqBfw0O5xlujHVwAAAABAAAAAH/iZ5YmHHCtcGZyCIf0n2y/Qb9bS1boQAP/IdehkVlCAAAAAAAAAAAAAAAKAAAAAQAAAAB7Md7xquMtaMMAfXu/ctVT4F8qTMSqoF/DQ7nGW6MdXAAAAAEAAAAAK8oIS4+sBEjuhCkJavqbmYY1fSHuagzIum9b+71saRcAAAAAAAAAAAAAAAoAAAABAAAAAHsx3vGq4y1owwB9e79y1VPgXypMxKqgX8NDucZbox1cAAAAAQAAAABciPRatl/ecLb1kTM4NEo3czx7P8ZgdnxGIfFR4+rOkwAAAAAAAAAAAAAACgAAAAEAAAAAezHe8arjLWjDAH17v3LVU+BfKkzEqqBfw0O5xlujHVwAAAABAAAAAIORpzavXnwQX4q/98KaKh6S0txcO92KOI/grRxKkY3pAAAAAAAAAAAAAAAKAAAAAAAAAAAqeAoWChQKBVRlc3QwGAoiCXJhbmRvbXNrdQoWChQKBVRlc3QxGAoiCXJhbmRvbXNrdQoWChQKBVRlc3QyGAoiCXJhbmRvbXNrdQoWChQKBVRlc3QzGAoiCXJhbmRvbXNrdQoWChQKBVRlc3Q0GAoiCXJhbmRvbXNrdQ==" 137 | }, 138 | { 139 | "transaction_data": { 140 | "tx_hash": "qRMnPrhn8F3WDhg08ijCb3cLxSbbN8EoOpmtgeZp1tE=", 141 | "payments": [ 142 | { 143 | "sender": "uaMbxt5LB8E3w6+tGjxi4dbUFiIrrnijc44U2KevTKI=", 144 | "destination": "1NLKca5puNNIx2hdJy6GXwtfkAxE+iYQx5uE54+rQpY=", 145 | "type": -1, 146 | "quarks": 10, 147 | "invoice": null, 148 | "memo": "1-test" 149 | }, 150 | { 151 | "sender": "uaMbxt5LB8E3w6+tGjxi4dbUFiIrrnijc44U2KevTKI=", 152 | "destination": "rVDV6IJ+UzfHNbOsWILCijeXykZHIxxchVnLJlf5pMw=", 153 | "type": -1, 154 | "quarks": 10, 155 | "invoice": null, 156 | "memo": "1-test" 157 | }, 158 | { 159 | "sender": "uaMbxt5LB8E3w6+tGjxi4dbUFiIrrnijc44U2KevTKI=", 160 | "destination": "vLLRQ1aoSnMWWAQmCKJ+lts8SIUG18niOvSl13g1+eo=", 161 | "type": -1, 162 | "quarks": 10, 163 | "invoice": null, 164 | "memo": "1-test" 165 | }, 166 | { 167 | "sender": "uaMbxt5LB8E3w6+tGjxi4dbUFiIrrnijc44U2KevTKI=", 168 | "destination": "SXkDkIQxXI5sejM3SnD6zw8We803ucdPM8/EWn2r4mg=", 169 | "type": -1, 170 | "quarks": 10, 171 | "invoice": null, 172 | "memo": "1-test" 173 | }, 174 | { 175 | "sender": "uaMbxt5LB8E3w6+tGjxi4dbUFiIrrnijc44U2KevTKI=", 176 | "destination": "ZNz0rIIo2LdvHWpXiq9CmFmzRs4Gaa5WSOCpHroAHlk=", 177 | "type": -1, 178 | "quarks": 10, 179 | "invoice": null, 180 | "memo": "1-test" 181 | } 182 | ] 183 | }, 184 | "response": "CAEa+QUKIgogqRMnPrhn8F3WDhg08ijCb3cLxSbbN8EoOpmtgeZp1tESXAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAGvQEAAAAAHGPV/X2mvQO06bZbizd5fjnNPS8xSmP9MYKacqjJ3H4AAAAAAAAAAAAAAABAAAAAAAAAAEAAAAGMS10ZXN0AAAAAAAGAAAAAQAAAAC5oxvG3ksHwTfDr60aPGLh1tQWIiuueKNzjhTYp69MogAAAAAAAAAAftoYuDp1nYg3djdPR1zNVg/vHsyVJYtQi07T7yZ3wMcAAAAAAAAAAAAAAAEAAAAAuaMbxt5LB8E3w6+tGjxi4dbUFiIrrnijc44U2KevTKIAAAABAAAAANTSynGuabjTSMdoXScuhl8LX5AMRPomEMebhOePq0KWAAAAAAAAAAAAAAAKAAAAAQAAAAC5oxvG3ksHwTfDr60aPGLh1tQWIiuueKNzjhTYp69MogAAAAEAAAAArVDV6IJ+UzfHNbOsWILCijeXykZHIxxchVnLJlf5pMwAAAAAAAAAAAAAAAoAAAABAAAAALmjG8beSwfBN8OvrRo8YuHW1BYiK654o3OOFNinr0yiAAAAAQAAAAC8stFDVqhKcxZYBCYIon6W2zxIhQbXyeI69KXXeDX56gAAAAAAAAAAAAAACgAAAAEAAAAAuaMbxt5LB8E3w6+tGjxi4dbUFiIrrnijc44U2KevTKIAAAABAAAAAEl5A5CEMVyObHozN0pw+s8PFnvNN7nHTzPPxFp9q+JoAAAAAAAAAAAAAAAKAAAAAQAAAAC5oxvG3ksHwTfDr60aPGLh1tQWIiuueKNzjhTYp69MogAAAAEAAAAAZNz0rIIo2LdvHWpXiq9CmFmzRs4Gaa5WSOCpHroAHlkAAAAAAAAAAAAAAAoAAAAAAAAAAA==" 185 | }, 186 | { 187 | "transaction_data": { 188 | "tx_hash": "mQd/3eww1wd9ao6C8yQTkhyVHnIW+DZ3cE9Ni3wQwFQ=", 189 | "payments": [ 190 | { 191 | "sender": "rcd/Mka+N4x4hbkp81/mJWjbkJRHErlABPlUH0Ra4JA=", 192 | "destination": "QsAYzdJEgDPQVi7bxhbc+ajdln1W5F+k/+40J66lh8A=", 193 | "type": 2, 194 | "quarks": 10, 195 | "invoice": { 196 | "items": [ 197 | { 198 | "title": "Test0", 199 | "amount": 10, 200 | "sku": "cmFuZG9tc2t1" 201 | } 202 | ] 203 | }, 204 | "memo": "" 205 | }, 206 | { 207 | "sender": "rcd/Mka+N4x4hbkp81/mJWjbkJRHErlABPlUH0Ra4JA=", 208 | "destination": "Vf4JmNXtBBriK0GI+6Th6y05XCqejSXCPRSD7KbaeSs=", 209 | "type": 2, 210 | "quarks": 10, 211 | "invoice": { 212 | "items": [ 213 | { 214 | "title": "Test1", 215 | "amount": 10, 216 | "sku": "cmFuZG9tc2t1" 217 | } 218 | ] 219 | }, 220 | "memo": "" 221 | }, 222 | { 223 | "sender": "rcd/Mka+N4x4hbkp81/mJWjbkJRHErlABPlUH0Ra4JA=", 224 | "destination": "pPopfGwf5HD5nF42YYq9HCceZtT08/wjHKJ+SfbX1UU=", 225 | "type": 2, 226 | "quarks": 10, 227 | "invoice": { 228 | "items": [ 229 | { 230 | "title": "Test2", 231 | "amount": 10, 232 | "sku": "cmFuZG9tc2t1" 233 | } 234 | ] 235 | }, 236 | "memo": "" 237 | }, 238 | { 239 | "sender": "rcd/Mka+N4x4hbkp81/mJWjbkJRHErlABPlUH0Ra4JA=", 240 | "destination": "pg6vH4nSttFnv3XiAYFGlRGvgZXl5XlAI/XSqTRcbdk=", 241 | "type": 2, 242 | "quarks": 10, 243 | "invoice": { 244 | "items": [ 245 | { 246 | "title": "Test3", 247 | "amount": 10, 248 | "sku": "cmFuZG9tc2t1" 249 | } 250 | ] 251 | }, 252 | "memo": "" 253 | }, 254 | { 255 | "sender": "rcd/Mka+N4x4hbkp81/mJWjbkJRHErlABPlUH0Ra4JA=", 256 | "destination": "ZZ9MFpBrtLmgaqeL6OF/nG9rVFTXBjOyygnoj94wJIo=", 257 | "type": 2, 258 | "quarks": 10, 259 | "invoice": { 260 | "items": [ 261 | { 262 | "title": "Test4", 263 | "amount": 10, 264 | "sku": "cmFuZG9tc2t1" 265 | } 266 | ] 267 | }, 268 | "memo": "" 269 | } 270 | ] 271 | }, 272 | "response": "CAEaowYKIgogmQd/3eww1wd9ao6C8yQTkhyVHnIW+DZ3cE9Ni3wQwFQSUAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAGrAEAAAAAJNKNKZ3vamNgH2mO8kBuOVJYiy7lriZjqoIZRccwe3rAAAAAAAAAAAAAAABAAAAAAAAAANFBABMcDGOzs80tD3rEC6hGN/uT5ff08L59FH6K07xAgAAAAUAAAABAAAAAK3HfzJGvjeMeIW5KfNf5iVo25CURxK5QAT5VB9EWuCQAAAAAQAAAABCwBjN0kSAM9BWLtvGFtz5qN2WfVbkX6T/7jQnrqWHwAAAAAAAAAAAAAAACgAAAAEAAAAArcd/Mka+N4x4hbkp81/mJWjbkJRHErlABPlUH0Ra4JAAAAABAAAAAFX+CZjV7QQa4itBiPuk4estOVwqno0lwj0Ug+ym2nkrAAAAAAAAAAAAAAAKAAAAAQAAAACtx38yRr43jHiFuSnzX+YlaNuQlEcSuUAE+VQfRFrgkAAAAAEAAAAApPopfGwf5HD5nF42YYq9HCceZtT08/wjHKJ+SfbX1UUAAAAAAAAAAAAAAAoAAAABAAAAAK3HfzJGvjeMeIW5KfNf5iVo25CURxK5QAT5VB9EWuCQAAAAAQAAAACmDq8fidK20We/deIBgUaVEa+BleXleUAj9dKpNFxt2QAAAAAAAAAAAAAACgAAAAEAAAAArcd/Mka+N4x4hbkp81/mJWjbkJRHErlABPlUH0Ra4JAAAAABAAAAAGWfTBaQa7S5oGqni+jhf5xva1RU1wYzssoJ6I/eMCSKAAAAAAAAAAAAAAAKAAAAAAAAAAAqeAoWChQKBVRlc3QwGAoiCXJhbmRvbXNrdQoWChQKBVRlc3QxGAoiCXJhbmRvbXNrdQoWChQKBVRlc3QyGAoiCXJhbmRvbXNrdQoWChQKBVRlc3QzGAoiCXJhbmRvbXNrdQoWChQKBVRlc3Q0GAoiCXJhbmRvbXNrdQ==" 273 | } 274 | ] -------------------------------------------------------------------------------- /test/errors.spec.ts: -------------------------------------------------------------------------------- 1 | import commonpb from "@kinecosystem/agora-api/node/common/v3/model_pb"; 2 | import commonpbv4 from "@kinecosystem/agora-api/node/common/v4/model_pb"; 3 | import { Token, TOKEN_PROGRAM_ID } from "@solana/spl-token"; 4 | import { Transaction as SolanaTransaction } from "@solana/web3.js"; 5 | import { xdr } from "stellar-base"; 6 | import { PrivateKey } from "../src"; 7 | import { 8 | AccountDoesNotExist, 9 | AccountExists, 10 | AlreadyPaid, 11 | BadNonce, 12 | DestinationDoesNotExist, 13 | errorFromProto, 14 | errorsFromSolanaTx, 15 | errorsFromStellarTx, 16 | errorsFromXdr, 17 | InsufficientBalance, 18 | InsufficientFee, 19 | InvalidSignature, 20 | invoiceErrorFromProto, 21 | Malformed, 22 | SenderDoesNotExist, 23 | SkuNotFound, 24 | TransactionFailed, 25 | WrongDestination 26 | } from "../src/errors"; 27 | import { MemoProgram } from "../src/solana/memo-program"; 28 | 29 | test("parse no errors", () => { 30 | const resultResult = xdr.TransactionResultResult.txSuccess([ 31 | xdr.OperationResult.opInner(xdr.OperationResultTr.payment(xdr.PaymentResult.paymentSuccess())) 32 | ]); 33 | const result = new xdr.TransactionResult({ 34 | feeCharged: new xdr.Int64(0, 0), 35 | result: resultResult, 36 | ext: xdr.TransactionResultExt.fromXDR(Buffer.alloc(4)), 37 | }); 38 | 39 | const errors = errorsFromXdr(result); 40 | expect(errors.TxError).toBeUndefined(); 41 | expect(errors.OpErrors).toBeUndefined(); 42 | }); 43 | 44 | test("unsupported transaction errors", () => { 45 | const testCases = [ 46 | { 47 | // "code": "TransactionResultCodeTxTooEarly" 48 | xdr: "AAAAAAAAAAD////+AAAAAA==", 49 | }, 50 | { 51 | // "code": "TransactionResultCodeTxTooLate" 52 | xdr: "AAAAAAAAAAD////9AAAAAA==", 53 | }, 54 | { 55 | // "code": "TransactionResultCodeTxInternalError" 56 | xdr: "AAAAAAAAAAD////1AAAAAA==", 57 | }, 58 | ]; 59 | 60 | for (const tc of testCases) { 61 | const result = xdr.TransactionResult.fromXDR(Buffer.from(tc.xdr, "base64")); 62 | const errorResult = errorsFromXdr(result); 63 | expect(errorResult.TxError?.message).toContain("unknown transaction result code"); 64 | expect(errorResult.OpErrors).toBeUndefined(); 65 | } 66 | }); 67 | 68 | test("unsupported operation errors", () => { 69 | const testCases = [ 70 | { 71 | // OpCodeNotSupported 72 | xdr: "AAAAAAAAAAD/////AAAAAf////0AAAAA", 73 | }, 74 | { 75 | // Inner -> CreateAccount::LowReserve 76 | xdr: "AAAAAAAAAAD/////AAAAAQAAAAAAAAAA/////QAAAAA=", 77 | }, 78 | { 79 | // Inner -> Payment::LineFull 80 | xdr: "AAAAAAAAAAD/////AAAAAQAAAAAAAAAB////+AAAAAA=", 81 | }, 82 | { 83 | // Inner -> AccountMerge::Malformed 84 | xdr: "AAAAAAAAAAD/////AAAAAQAAAAAAAAAI/////wAAAAA=", 85 | }, 86 | ]; 87 | 88 | for (const tc of testCases) { 89 | const result = xdr.TransactionResult.fromXDR(Buffer.from(tc.xdr, "base64")); 90 | const errorResult = errorsFromXdr(result); 91 | expect(errorResult.TxError).toBeInstanceOf(TransactionFailed); 92 | expect(errorResult.OpErrors).toHaveLength(1); 93 | expect(errorResult.OpErrors![0].message).toContain("unknown"); 94 | expect(errorResult.OpErrors![0].message).toContain("operation"); 95 | } 96 | 97 | // All of the above, but combined 98 | const xdrBytes = "AAAAAAAAAAD/////AAAABP////0AAAAAAAAAAP////0AAAAAAAAAAf////gAAAAAAAAACP////8AAAAA"; 99 | const result = xdr.TransactionResult.fromXDR(Buffer.from(xdrBytes, "base64")); 100 | const errorResult = errorsFromXdr(result); 101 | expect(errorResult.TxError).toBeInstanceOf(TransactionFailed); 102 | expect(errorResult.OpErrors).toHaveLength(4); 103 | for (let i = 0; i < 4; i++) { 104 | expect(errorResult.OpErrors![i].message).toContain("unknown"); 105 | expect(errorResult.OpErrors![i].message).toContain("operation"); 106 | } 107 | }); 108 | 109 | test("transaction errors", () => { 110 | const testCases = [ 111 | { 112 | // "code": "TransactionResultCodeTxMissingOperation" 113 | expected: Malformed, 114 | xdr: "AAAAAAAAAAD////8AAAAAA==", 115 | }, 116 | { 117 | // "code": "TransactionResultCodeTxBadSeq" 118 | expected: BadNonce, 119 | xdr: "AAAAAAAAAAD////7AAAAAA==", 120 | }, 121 | { 122 | // "code": "TransactionResultCodeTxBadAuth" 123 | expected: InvalidSignature, 124 | xdr: "AAAAAAAAAAD////6AAAAAA==", 125 | }, 126 | { 127 | // "code": "TransactionResultCodeTxInsufficientBalance" 128 | expected: InsufficientBalance, 129 | xdr: "AAAAAAAAAAD////5AAAAAA==", 130 | }, 131 | { 132 | // "code": "TransactionResultCodeTxNoAccount" 133 | expected: SenderDoesNotExist, 134 | xdr: "AAAAAAAAAAD////4AAAAAA==", 135 | }, 136 | { 137 | // "code": "TransactionResultCodeTxInsufficientFee" 138 | expected: InsufficientFee, 139 | xdr: "AAAAAAAAAAD////3AAAAAA==", 140 | } 141 | ]; 142 | 143 | for (const tc of testCases) { 144 | const result = xdr.TransactionResult.fromXDR(Buffer.from(tc.xdr, "base64")); 145 | const errorResult = errorsFromXdr(result); 146 | 147 | expect(errorResult.TxError).toBeInstanceOf(tc.expected); 148 | expect(errorResult.OpErrors).toBeUndefined(); 149 | } 150 | }); 151 | 152 | test("operation errors", () => { 153 | const xdrBytes = "AAAAAAAAAAD/////AAAADf/////////+AAAAAAAAAAD/////AAAAAAAAAAD////+AAAAAAAAAAD////8AAAAAAAAAAH/////AAAAAAAAAAH////+AAAAAAAAAAH////9AAAAAAAAAAH////8AAAAAAAAAAH////7AAAAAAAAAAH////6AAAAAAAAAAH////5AAAAAAAAAAEAAAAAAAAAAA=="; 154 | const result = xdr.TransactionResult.fromXDR(Buffer.from(xdrBytes, "base64")); 155 | 156 | const errorResult = errorsFromXdr(result); 157 | expect(errorResult.TxError).toBeInstanceOf(TransactionFailed); 158 | 159 | const expected = [ 160 | InvalidSignature, 161 | SenderDoesNotExist, 162 | Malformed, 163 | InsufficientBalance, 164 | AccountExists, 165 | Malformed, 166 | InsufficientBalance, 167 | Malformed, 168 | InvalidSignature, 169 | DestinationDoesNotExist, 170 | Malformed, 171 | InvalidSignature, 172 | // Last operation should not have an error. 173 | ]; 174 | 175 | expect(errorResult.OpErrors).toHaveLength(expected.length + 1); 176 | for (let i = 0; i < expected.length; i++) { 177 | expect(errorResult.OpErrors![i]).toBeInstanceOf(expected[i]); 178 | } 179 | 180 | expect(errorResult.OpErrors![expected.length + 1]).toBeUndefined(); 181 | }); 182 | 183 | test("errorFromProto", () => { 184 | const testCases = [ 185 | { 186 | reason: commonpbv4.TransactionError.Reason.NONE, 187 | expected: undefined, 188 | }, 189 | { 190 | reason: commonpbv4.TransactionError.Reason.UNAUTHORIZED, 191 | expected: InvalidSignature, 192 | }, 193 | { 194 | reason: commonpbv4.TransactionError.Reason.BAD_NONCE, 195 | expected: BadNonce, 196 | }, 197 | { 198 | reason: commonpbv4.TransactionError.Reason.INSUFFICIENT_FUNDS, 199 | expected: InsufficientBalance, 200 | }, 201 | { 202 | reason: commonpbv4.TransactionError.Reason.INVALID_ACCOUNT, 203 | expected: AccountDoesNotExist, 204 | }, 205 | ]; 206 | 207 | testCases.forEach((tc) => { 208 | const protoError = new commonpbv4.TransactionError(); 209 | protoError.setReason(tc.reason); 210 | const error = errorFromProto(protoError); 211 | if (tc.expected) { 212 | expect(error).toBeInstanceOf(tc.expected); 213 | } else { 214 | expect(error).toBeUndefined(); 215 | } 216 | }); 217 | }); 218 | 219 | test("invoiceErrorFromProto", () => { 220 | const testCases = [ 221 | { 222 | reason: commonpb.InvoiceError.Reason.UNKNOWN, 223 | expected: Error, 224 | }, 225 | { 226 | reason: commonpb.InvoiceError.Reason.ALREADY_PAID, 227 | expected: AlreadyPaid, 228 | }, 229 | { 230 | reason: commonpb.InvoiceError.Reason.WRONG_DESTINATION, 231 | expected: WrongDestination, 232 | }, 233 | { 234 | reason: commonpb.InvoiceError.Reason.SKU_NOT_FOUND, 235 | expected: SkuNotFound, 236 | }, 237 | ]; 238 | 239 | testCases.forEach((tc) => { 240 | const protoError = new commonpb.InvoiceError(); 241 | protoError.setReason(tc.reason); 242 | const error = invoiceErrorFromProto(protoError); 243 | expect(error).toBeInstanceOf(tc.expected); 244 | }); 245 | }); 246 | 247 | test("errorFromSolanaTx", () => { 248 | const [sender, destination] = [PrivateKey.random().publicKey(), PrivateKey.random().publicKey()]; 249 | const tx = new SolanaTransaction({ 250 | feePayer: sender.solanaKey(), 251 | }).add( 252 | MemoProgram.memo({data: "data"}), 253 | Token.createTransferInstruction( 254 | TOKEN_PROGRAM_ID, 255 | sender.solanaKey(), 256 | destination.solanaKey(), 257 | sender.solanaKey(), 258 | [], 259 | 100, 260 | ), 261 | Token.createSetAuthorityInstruction( 262 | TOKEN_PROGRAM_ID, 263 | sender.solanaKey(), 264 | destination.solanaKey(), 265 | 'AccountOwner', 266 | sender.solanaKey(), 267 | [], 268 | ), 269 | ); 270 | 271 | const testCases = [ 272 | { 273 | index: 1, 274 | expectedOpIndex: 1, 275 | expectedPaymentIndex: 0 276 | }, 277 | { 278 | index: 0, 279 | expectedOpIndex: 0, 280 | expectedPaymentIndex: -1 281 | }, 282 | ]; 283 | testCases.forEach(tc => { 284 | const protoError = new commonpbv4.TransactionError(); 285 | protoError.setReason(commonpbv4.TransactionError.Reason.INVALID_ACCOUNT); 286 | protoError.setInstructionIndex(tc.index); 287 | 288 | const errors = errorsFromSolanaTx(tx, protoError); 289 | expect(errors.TxError).toBeInstanceOf(AccountDoesNotExist); 290 | 291 | expect(errors.OpErrors).toBeDefined(); 292 | expect(errors.OpErrors!.length).toEqual(3); 293 | for (let i = 0; i < errors.OpErrors!.length; i++) { 294 | if (i == tc.expectedOpIndex) { 295 | expect(errors.OpErrors![i]).toBeInstanceOf(AccountDoesNotExist); 296 | } else { 297 | expect(errors.OpErrors![i]).toBeUndefined(); 298 | } 299 | } 300 | 301 | if (tc.expectedPaymentIndex > -1) { 302 | expect(errors.PaymentErrors).toBeDefined(); 303 | expect(errors.PaymentErrors!.length).toEqual(1); // exclude memo instruction + auth instruction 304 | for (let i = 0; i < errors.PaymentErrors!.length; i++) { 305 | if (i == tc.expectedPaymentIndex) { 306 | expect(errors.PaymentErrors![i]).toBeInstanceOf(AccountDoesNotExist); 307 | } else { 308 | expect(errors.OpErrors![i]).toBeUndefined(); 309 | } 310 | } 311 | } else { 312 | expect(errors.PaymentErrors).toBeUndefined(); 313 | } 314 | }); 315 | }); 316 | 317 | test("errorFromStellarTx", () => { 318 | // create, payment, payment, create 319 | const xdrBytes = "AAAAAMrPQ1diKVqce6E5xOuL76CmGyd/hDnbxB5NdvHkCD+/AAAAAAAAAAAAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAADaGV5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAQAAAADKz0NXYilanHuhOcTri++gphsnf4Q528QeTXbx5Ag/vwAAAAAAAAAAK+sG64+oMh1NtRMr5w8B8LotsAwIdka6k1dzgATUL4oAAAAAAAAACgAAAAAAAAABAAAAACvrBuuPqDIdTbUTK+cPAfC6LbAMCHZGupNXc4AE1C+KAAAAAAAAAAAAAAAPAAAAAAAAAAEAAAAAK+sG64+oMh1NtRMr5w8B8LotsAwIdka6k1dzgATUL4oAAAAAAAAAAAAAAA8AAAABAAAAAMrPQ1diKVqce6E5xOuL76CmGyd/hDnbxB5NdvHkCD+/AAAAAAAAAAAr6wbrj6gyHU21EyvnDwHwui2wDAh2RrqTV3OABNQvigAAAAAAAAAKAAAAAAAAAAF4eHh4AAAACXNpZ25hdHVyZQAAAA=="; 320 | const env = xdr.TransactionEnvelope.fromXDR(Buffer.from(xdrBytes, "base64")); 321 | 322 | const testCases = [ 323 | { 324 | index: 2, 325 | expectedOpIndex: 2, 326 | expectedPaymentIndex: 1, 327 | }, 328 | { 329 | index: 3, 330 | expectedOpIndex: 3, 331 | expectedPaymentIndex: -1, 332 | } 333 | ]; 334 | 335 | testCases.forEach(tc => { 336 | const protoError = new commonpbv4.TransactionError(); 337 | protoError.setReason(commonpbv4.TransactionError.Reason.INVALID_ACCOUNT); 338 | protoError.setInstructionIndex(tc.index); 339 | 340 | const errors = errorsFromStellarTx(env, protoError); 341 | expect(errors.TxError).toBeInstanceOf(AccountDoesNotExist); 342 | 343 | expect(errors.OpErrors).toBeDefined(); 344 | expect(errors.OpErrors!.length).toEqual(4); 345 | for (let i = 0; i < errors.OpErrors!.length; i++) { 346 | if (i == tc.expectedOpIndex) { 347 | expect(errors.OpErrors![i]).toBeInstanceOf(AccountDoesNotExist); 348 | } else { 349 | expect(errors.OpErrors![i]).toBeUndefined(); 350 | } 351 | } 352 | 353 | if (tc.expectedPaymentIndex > -1) { 354 | expect(errors.PaymentErrors).toBeDefined(); 355 | expect(errors.PaymentErrors!.length).toEqual(2); // exclude memo instruction 356 | for (let i = 0; i < errors.PaymentErrors!.length; i++) { 357 | if (i == tc.expectedPaymentIndex) { 358 | expect(errors.PaymentErrors![i]).toBeInstanceOf(AccountDoesNotExist); 359 | } else { 360 | expect(errors.OpErrors![i]).toBeUndefined(); 361 | } 362 | } 363 | } else { 364 | expect(errors.PaymentErrors).toBeUndefined(); 365 | } 366 | }); 367 | }); 368 | -------------------------------------------------------------------------------- /test/jest-env.js: -------------------------------------------------------------------------------- 1 | // Addresses this issue: https://github.com/facebook/jest/issues/4422 2 | // Custom environment stolen from: https://github.com/facebook/jest/issues/7780#issuecomment-615890410 3 | "use strict"; 4 | 5 | const NodeEnvironment = require("jest-environment-node"); 6 | 7 | class MyEnvironment extends NodeEnvironment { 8 | constructor(config) { 9 | super( 10 | Object.assign({}, config, { 11 | globals: Object.assign({}, config.globals, { 12 | Uint32Array: Uint32Array, 13 | Uint8Array: Uint8Array, 14 | ArrayBuffer: ArrayBuffer, 15 | }), 16 | }), 17 | ); 18 | } 19 | 20 | async setup() {} 21 | 22 | async teardown() {} 23 | 24 | } 25 | 26 | module.exports = MyEnvironment; 27 | -------------------------------------------------------------------------------- /test/keys.spec.ts: -------------------------------------------------------------------------------- 1 | import { PublicKey, PrivateKey } from "../src/keys"; 2 | 3 | test("stellar_keys", () => { 4 | const pub = "GCABWU4FHL3RGOIWCX5TOVLIAMLEU2YXXLCMHVXLDOFHKLNLGCSBRJYP"; 5 | const priv = "SCZ4KGTCMAFIJQCCJDMMKDFUB7NYV56VBNEU7BKMR4PQFUETJCWLV6GN"; 6 | 7 | const pubKey = PublicKey.fromString(pub); 8 | const privKey = PrivateKey.fromString(priv); 9 | 10 | expect(privKey.equals(PrivateKey.fromString(priv))).toBeTruthy(); 11 | expect(privKey.publicKey()).toStrictEqual(pubKey); 12 | expect(privKey.publicKey().equals(pubKey)).toBeTruthy(); 13 | expect(privKey.stellarSeed()).toBe(priv); 14 | expect(privKey.publicKey().stellarAddress()).toBe(pub); 15 | }); 16 | 17 | test("invalid stellar keys", () => { 18 | const invalidPublicKeys = [ 19 | "", 20 | "G", 21 | "GCABWU4FHL3RGOIWCX5TOVLIAMLEU", 22 | "GCABWU4FHL3RGOIWCX5TOVLIAMLEU2YXXLCMHVXLDOFHKLNLGCSBRJYPXXXXXXXXXXXXXXXX", 23 | ]; 24 | for (const k of invalidPublicKeys) { 25 | expect(() => { PublicKey.fromString(k); }).toThrow(); 26 | } 27 | 28 | const invalidPrivateKeys = [ 29 | "", 30 | "SCZ4KGTCMAFIJQCCJDMMKD", 31 | "SCZ4KGTCMAFIJQCCJDMMKDFUB7NYV56VBNEU7BKMR4PQFUETJCWLV6GNXXXXXXX", 32 | ]; 33 | for (const k of invalidPrivateKeys) { 34 | expect(() => { PrivateKey.fromString(k); }).toThrow(); 35 | } 36 | }); 37 | 38 | test("base58 round trip", () => { 39 | const pk = PrivateKey.random(); 40 | expect(PrivateKey.fromBase58(pk.toBase58())).toEqual(pk); 41 | 42 | const pubkey = pk.publicKey(); 43 | expect(PublicKey.fromBase58(pubkey.toBase58())).toEqual(pubkey); 44 | }); 45 | -------------------------------------------------------------------------------- /test/memo.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MAX_TRANSACTION_TYPE, Memo, 3 | TransactionType 4 | } from "../src" 5 | import { MAX_APP_INDEX } from "../src/memo" 6 | 7 | test('TestMemo_Valid', () => { 8 | const emptyFK = Buffer.alloc(29) 9 | 10 | for (let v = 0; v <= 7; v++) { 11 | const m = Memo.new(v, TransactionType.Spend, 1, emptyFK) 12 | 13 | expect(m.Version()).toBe(v) 14 | expect(m.TransactionType()).toBe(TransactionType.Spend) 15 | expect(m.AppIndex()).toBe(1) 16 | expect(emptyFK.equals(m.ForeignKey())).toBe(true) 17 | } 18 | 19 | for (let t = TransactionType.None; t < MAX_TRANSACTION_TYPE; t++) { 20 | const m = Memo.new(1, t, 1, emptyFK) 21 | 22 | expect(m.Version()).toBe(1) 23 | expect(m.TransactionType()).toBe(t) 24 | expect(m.AppIndex()).toBe(1) 25 | expect(emptyFK.equals(m.ForeignKey())).toBe(true) 26 | } 27 | 28 | // We increment by 0xf instead of 1 since iterating over the total space 29 | // is far too slow in Javascript (unlike some other languages). 30 | for (let i = 0; i < MAX_APP_INDEX; i += 0xf) { 31 | const m = Memo.new(1, TransactionType.Spend, i, emptyFK) 32 | 33 | expect(m.Version()).toBe(1) 34 | expect(m.TransactionType()).toBe(TransactionType.Spend) 35 | expect(m.AppIndex()).toBe(i) 36 | expect(emptyFK.equals(m.ForeignKey())).toBe(true) 37 | } 38 | 39 | // Test a short foreign key 40 | const fk = Buffer.alloc(29) 41 | fk[0] = 1 42 | const m = Memo.new(1, TransactionType.Earn, 2, fk) 43 | expect(fk.equals(m.ForeignKey())).toBe(true) 44 | 45 | // Test range of foreign keys 46 | for (let i = 0; i < 256; i += 29) { 47 | for (let j = 0; j < 29; j++) { 48 | fk[j] = i + j 49 | } 50 | 51 | const m = Memo.new(1, TransactionType.Earn, 2, fk) 52 | for (let j = 0; j < 28; j++) { 53 | expect(fk[j]).toBe(m.ForeignKey()[j]) 54 | } 55 | 56 | // Note: because we only have 230 bits, the last byte in the memo fk 57 | // only has the first 6 bits of the last byte in the original fk. 58 | expect(fk[28]&0x3f).toBe(m.ForeignKey()[28]) 59 | } 60 | }) 61 | 62 | test('TestMemo_Invalid', () => { 63 | const fk = Buffer.alloc(29) 64 | 65 | expect(() => Memo.new(8, TransactionType.Earn, 1, fk)).toThrow("invalid version") 66 | 67 | let m = Memo.new(1, TransactionType.Earn, 1, fk) 68 | m.buffer[0] = 0xfc 69 | expect(Memo.IsValid(m)).toBeFalsy() 70 | expect(Memo.IsValid(m, false)).toBeFalsy() 71 | expect(Memo.IsValid(m, true)).toBeFalsy() 72 | 73 | // invalid tx type 74 | expect(() => Memo.new(1, TransactionType.Unknown, 1, fk)).toThrow("cannot use unknown transaction type") 75 | 76 | // Version higher than configured 77 | m = Memo.new(2, TransactionType.Earn, 1, fk) 78 | expect(Memo.IsValid(m)).toBeTruthy() 79 | expect(Memo.IsValid(m, false)).toBeTruthy() 80 | expect(Memo.IsValid(m, true)).toBeFalsy() 81 | 82 | // Transaction type higher than configured 83 | m = Memo.new(1, MAX_TRANSACTION_TYPE+1, 1, fk) 84 | expect(Memo.IsValid(m)).toBeTruthy() 85 | expect(Memo.IsValid(m, false)).toBeTruthy() 86 | expect(Memo.IsValid(m, true)).toBeFalsy() 87 | 88 | }) 89 | 90 | test('TestMemo_from', () => { 91 | // Reference strings from Go implementation. 92 | const valid = Memo.from(Buffer.from("KQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 'base64')) 93 | expect(Memo.IsValid(valid)).toBeTruthy() 94 | expect(Memo.IsValid(valid, true)).toBeFalsy() 95 | 96 | const strictlyValid = Memo.from(Buffer.from("JQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 'base64')) 97 | expect(Memo.IsValid(strictlyValid)).toBeTruthy() 98 | expect(Memo.IsValid(strictlyValid, true)).toBeTruthy() 99 | 100 | // Test deserialization with an unknown tx type 101 | const unknownTxType = Memo.from(Buffer.from("RQUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 'base64')) 102 | expect(Memo.IsValid(unknownTxType)).toBeTruthy() 103 | expect(Memo.IsValid(unknownTxType, false)).toBeTruthy() 104 | expect(Memo.IsValid(unknownTxType, true)).toBeFalsy() 105 | expect(unknownTxType.TransactionType()).toBe(TransactionType.Unknown) 106 | expect(unknownTxType.TransactionTypeRaw()).toBe(10) 107 | }) 108 | -------------------------------------------------------------------------------- /test/retry/retry.spec.ts: -------------------------------------------------------------------------------- 1 | import { constantDelay, linearDelay, expontentialDelay, binaryExpotentialDelay, limit, retriableErrors, nonRetriableErrors, backoff, retryAsync, backoffWithJitter} from "../../src/retry"; 2 | import { AccountDoesNotExist, BadNonce } from "../../src/errors"; 3 | import { retry } from "../../src/retry"; 4 | 5 | test("delayFunctions", () => { 6 | for (let i = 0; i < 5; i++) { 7 | expect(constantDelay(1.5)(i)).toBe(1.5); 8 | expect(linearDelay(1.5)(i)).toBe(i*1.5); 9 | } 10 | 11 | for (let i = 1; i <= 4; i++) { 12 | expect(expontentialDelay(2, 3.0)(i)).toBe(2 * Math.pow(3, i-1)) 13 | } 14 | 15 | for (let i = 1; i <= 4; i++) { 16 | expect(binaryExpotentialDelay(1.5)(i)).toBe(expontentialDelay(1.5, 2)(i)) 17 | } 18 | }) 19 | 20 | test("strategies", async () => { 21 | // We perform an action, then check retry logic. 22 | // As a result, if our max attempts is 2, we should 23 | // only retry once. 24 | // 25 | // This is since we're measuring "max attemts", not 26 | // "max retries". 27 | expect(await limit(2)(1, new Error())).toBeTruthy(); 28 | expect(await limit(2)(2, new Error())).toBeFalsy(); 29 | 30 | const errors: any[] = [ 31 | AccountDoesNotExist, 32 | BadNonce, 33 | ]; 34 | 35 | for (const e of errors) { 36 | expect(await retriableErrors(...errors)(1, new e())).toBeTruthy(); 37 | expect(await nonRetriableErrors(...errors)(1, new e())).toBeFalsy(); 38 | } 39 | 40 | expect(await retriableErrors(...errors)(1, new Error())).toBeFalsy(); 41 | expect(await nonRetriableErrors(...errors)(1, new Error())).toBeTruthy(); 42 | 43 | let start = new Date().getTime(); 44 | expect(await backoff(constantDelay(0.5), 0.5)(10, new Error())).toBeTruthy(); 45 | let elapsed = new Date().getTime() - start; 46 | 47 | expect(elapsed).toBeGreaterThanOrEqual(500); 48 | expect(elapsed).toBeLessThan(1000); 49 | 50 | start = new Date().getTime(); 51 | expect(await backoffWithJitter(constantDelay(0.5), 0.5, 0.1)(10, new Error())).toBeTruthy(); 52 | elapsed = new Date().getTime() - start; 53 | 54 | expect(elapsed).toBeGreaterThanOrEqual(450); 55 | expect(elapsed).toBeLessThan(1000); 56 | 57 | try { 58 | await backoffWithJitter(constantDelay(0.5), 0.5, 0.3); 59 | fail(); 60 | } catch (err) { 61 | expect(err).toBeInstanceOf(Error); 62 | expect((err).message).toContain("jitter should be [0, 0.25]"); 63 | } 64 | 65 | }) 66 | 67 | test("retry", async () => { 68 | // Happy path, works on first try. 69 | let fn = (): void => { }; 70 | expect(await retry(fn, limit(3))).toBeUndefined(); 71 | 72 | // Test eventual success 73 | let i = 0; 74 | fn = () => { 75 | i++; 76 | if (i < 2) { 77 | throw new AccountDoesNotExist(""); 78 | } 79 | }; 80 | expect(await retry(fn, limit(3))).toBeUndefined(); 81 | 82 | // Ensure multiple strategies are evaluated. 83 | let called = 0; 84 | fn = () => { 85 | called++; 86 | throw new AccountDoesNotExist(""); 87 | }; 88 | 89 | // For some reason, `expectThrowError()` doesn't properly handle errors. 90 | await retry(fn, limit(3), nonRetriableErrors(new AccountDoesNotExist(""))) 91 | .then(_ => { 92 | fail(); 93 | }) 94 | .catch(err => { 95 | expect(err).toBe(err); 96 | }); 97 | expect(called).toBe(1); 98 | }) 99 | 100 | test("retry_async", async () => { 101 | const fn = (): Promise => { 102 | return new Promise(resolve => { 103 | setTimeout(resolve, 500, 1); 104 | }) 105 | } 106 | const start = new Date().getTime(); 107 | expect(await retryAsync(fn)).toBe(1); 108 | const duration = new Date().getTime() - start; 109 | expect(duration).toBeGreaterThanOrEqual(500); 110 | expect(duration).toBeLessThan(1000); 111 | }) 112 | -------------------------------------------------------------------------------- /test/solana/memo-program.spec.ts: -------------------------------------------------------------------------------- 1 | import { MemoParams, MemoInstruction, MemoProgram, } from "../../src/solana/memo-program" 2 | 3 | test('TestMemoProgram', () => { 4 | const params: MemoParams = { 5 | data: 'somedata', 6 | } 7 | const instruction = MemoProgram.memo(params) 8 | expect(params).toEqual( 9 | MemoInstruction.decodeMemo(instruction) 10 | ) 11 | }) 12 | -------------------------------------------------------------------------------- /test/solana/token-program.spec.ts: -------------------------------------------------------------------------------- 1 | import { Token, TOKEN_PROGRAM_ID } from "@solana/spl-token"; 2 | import BigNumber from "bignumber.js"; 3 | import { bigNumberToU64 } from "../../src"; 4 | import { PrivateKey } from "../../src/keys"; 5 | import { InitializeAccountParams, SetAuthorityParams, TokenInstruction, TransferParams } from "../../src/solana/token-program"; 6 | 7 | test('TestTokenProgram_InitializeAccount', () => { 8 | const params: InitializeAccountParams = { 9 | account: PrivateKey.random().publicKey().solanaKey(), 10 | mint: PrivateKey.random().publicKey().solanaKey(), 11 | owner: PrivateKey.random().publicKey().solanaKey(), 12 | }; 13 | const instruction = Token.createInitAccountInstruction( 14 | TOKEN_PROGRAM_ID, 15 | params.mint, 16 | params.account, 17 | params.owner, 18 | ); 19 | 20 | expect(params).toEqual(TokenInstruction.decodeInitializeAccount(instruction)); 21 | }); 22 | 23 | test('TestTokenProgram_Transfer', () => { 24 | const params: TransferParams = { 25 | source: PrivateKey.random().publicKey().solanaKey(), 26 | dest: PrivateKey.random().publicKey().solanaKey(), 27 | owner: PrivateKey.random().publicKey().solanaKey(), 28 | amount: BigInt(123456789), 29 | }; 30 | const instruction = Token.createTransferInstruction( 31 | TOKEN_PROGRAM_ID, 32 | params.source, 33 | params.dest, 34 | params.owner, 35 | [], 36 | bigNumberToU64(new BigNumber(123456789)), 37 | ); 38 | 39 | expect(params).toEqual(TokenInstruction.decodeTransfer(instruction)); 40 | }); 41 | 42 | 43 | test('TestTokenProgram_SetAuthority', () => { 44 | let params: SetAuthorityParams = { 45 | account: PrivateKey.random().publicKey().solanaKey(), 46 | currentAuthority: PrivateKey.random().publicKey().solanaKey(), 47 | authorityType: 'AccountOwner', 48 | }; 49 | let instruction = Token.createSetAuthorityInstruction( 50 | TOKEN_PROGRAM_ID, 51 | params.account, 52 | null, 53 | params.authorityType, 54 | params.currentAuthority, 55 | [] 56 | ); 57 | 58 | expect(params).toEqual(TokenInstruction.decodeSetAuthority(instruction)); 59 | 60 | params = { 61 | account: PrivateKey.random().publicKey().solanaKey(), 62 | currentAuthority: PrivateKey.random().publicKey().solanaKey(), 63 | newAuthority: PrivateKey.random().publicKey().solanaKey(), 64 | authorityType: 'CloseAccount', 65 | }; 66 | instruction = Token.createSetAuthorityInstruction( 67 | TOKEN_PROGRAM_ID, 68 | params.account, 69 | params.newAuthority!, 70 | params.authorityType, 71 | params.currentAuthority, 72 | [], 73 | ); 74 | 75 | expect(params).toEqual(TokenInstruction.decodeSetAuthority(instruction)); 76 | }); 77 | -------------------------------------------------------------------------------- /test/webhook/webhook.spec.ts: -------------------------------------------------------------------------------- 1 | import { ASSOCIATED_TOKEN_PROGRAM_ID, Token, TOKEN_PROGRAM_ID } from "@solana/spl-token"; 2 | import { Account, Transaction } from "@solana/web3.js"; 3 | import base58 from "bs58"; 4 | import express from "express"; 5 | import hash, { hmac, sha256 } from "hash.js"; 6 | import request from "supertest"; 7 | import { 8 | Environment, 9 | Memo, 10 | PrivateKey, 11 | TransactionType 12 | } from "../../src"; 13 | import { MemoProgram } from "../../src/solana/memo-program"; 14 | import { 15 | AGORA_HMAC_HEADER, 16 | AGORA_USER_ID_HEADER, 17 | AGORA_USER_PASSKEY_HEADER, CreateAccountHandler, CreateAccountRequest, CreateAccountResponse, Event, 18 | EventsHandler, 19 | InvoiceError, 20 | RejectionReason, SignTransactionHandler, SignTransactionRequest, 21 | SignTransactionResponse 22 | } from "../../src/webhook"; 23 | 24 | 25 | const WEBHOOK_SECRET = "super_secret"; 26 | const subsidizer = PrivateKey.random(); 27 | 28 | const app = express(); 29 | app.use("/events", express.json()); 30 | app.use("/events", EventsHandler((events: Event[]) => {}, WEBHOOK_SECRET)); 31 | 32 | app.use("/sign_transaction", express.json()); 33 | app.use("/sign_transaction", SignTransactionHandler(Environment.Test, (req: SignTransactionRequest, resp: SignTransactionResponse) => { 34 | }, WEBHOOK_SECRET)); 35 | 36 | app.use("/create_account", express.json()); 37 | app.use("/create_account", CreateAccountHandler(Environment.Test, (req: CreateAccountRequest, resp: CreateAccountResponse) => { 38 | }, WEBHOOK_SECRET)); 39 | 40 | function getHmacHeader(body: any): string { 41 | const hex = hmac(sha256, WEBHOOK_SECRET).update(JSON.stringify(body)).digest('hex'); 42 | return Buffer.from(hex, "hex").toString("base64"); 43 | } 44 | 45 | test("hmac header validation", async () => { 46 | await request(app) 47 | .post("/events") 48 | .set('Accept', 'application/json') 49 | .send([]) 50 | .expect(401); 51 | 52 | const events: Event[] = [ 53 | { 54 | transaction_event: { 55 | tx_id: Buffer.from(base58.decode('2nBhEBYYvfaAe16UMNqRHre4YNSskvuYgx3M6E4JP1oDYvZEJHvoPzyUidNgNX5r9sTyN1J9UxtbCXy2rqYcuyuv')).toString('base64'), 56 | solana_event: { 57 | transaction: 'AVj7dxHlQ9IrvdYVIjuiRFs1jLaDMHixgrv+qtHBwz51L4/ImLZhszwiyEJDIp7xeBSpm/TX5B7mYzxa+fPOMw0BAAMFJMJVqLw+hJYheizSoYlLm53KzgT82cDVmazarqQKG2GQsLgiqktA+a+FDR4/7xnDX7rsusMwryYVUdixfz1B1Qan1RcZLwqvxvJl4/t3zHragsUp0L47E24tAFUgAAAABqfVFxjHdMkoVmOYaR1etoteuKObS21cc1VbIQAAAAAHYUgdNXR0u3xNdiTr072z2DVec9EQQ/wNo1OAAAAAAAtxOUhPBp2WSjUNJEgfvy70BbxI00fZyEPvFHNfxrtEAQQEAQIDADUCAAAAAQAAAAAAAACtAQAAAAAAAAdUE18R96XTJCe+YfRfUp6WP+YKCy/72ucOL8AoBFSpAA==', 58 | } 59 | } 60 | } 61 | ]; 62 | 63 | await request(app) 64 | .post("/events") 65 | .set('Accept', 'application/json') 66 | .set(AGORA_HMAC_HEADER, "blah") 67 | .send(events) 68 | .expect(401); 69 | 70 | await request(app) 71 | .post("/sign_transaction") 72 | .set('Accept', 'application/json') 73 | .set(AGORA_HMAC_HEADER, "blah") 74 | .send(events) 75 | .expect(401); 76 | 77 | await request(app) 78 | .post("/events") 79 | .set('Accept', 'application/json') 80 | .set(AGORA_HMAC_HEADER, getHmacHeader(events)) 81 | .send(events) 82 | .expect(200); 83 | 84 | const sender = PrivateKey.random().publicKey(); 85 | const destination = PrivateKey.random().publicKey(); 86 | const recentBlockhash = PrivateKey.random().publicKey(); 87 | 88 | const tx = new Transaction({ 89 | feePayer: subsidizer.publicKey().solanaKey(), 90 | recentBlockhash: recentBlockhash.toBase58(), 91 | }).add( 92 | Token.createTransferInstruction( 93 | TOKEN_PROGRAM_ID, 94 | sender.solanaKey(), 95 | destination.solanaKey(), 96 | sender.solanaKey(), 97 | [], 98 | 100, 99 | ) 100 | ); 101 | 102 | const signRequest = { 103 | solana_transaction: tx.serialize({ 104 | verifySignatures: false, 105 | requireAllSignatures: false, 106 | }).toString('base64'), 107 | }; 108 | 109 | await request(app) 110 | .post("/sign_transaction") 111 | .set('Accept', 'application/json') 112 | .set(AGORA_HMAC_HEADER, getHmacHeader(signRequest)) 113 | .send(signRequest) 114 | .expect(200); 115 | 116 | const mint = PrivateKey.random().publicKey().solanaKey(); 117 | const assoc = await Token.getAssociatedTokenAddress( 118 | ASSOCIATED_TOKEN_PROGRAM_ID, 119 | TOKEN_PROGRAM_ID, 120 | mint, 121 | sender.solanaKey(), 122 | ); 123 | const createTx = new Transaction({ 124 | feePayer: subsidizer.publicKey().solanaKey(), 125 | recentBlockhash: recentBlockhash.toBase58(), 126 | }).add( 127 | Token.createAssociatedTokenAccountInstruction( 128 | ASSOCIATED_TOKEN_PROGRAM_ID, 129 | TOKEN_PROGRAM_ID, 130 | mint, 131 | assoc, 132 | sender.solanaKey(), 133 | subsidizer.publicKey().solanaKey(), 134 | ), 135 | Token.createSetAuthorityInstruction( 136 | TOKEN_PROGRAM_ID, 137 | assoc, 138 | subsidizer.publicKey().solanaKey(), 139 | 'CloseAccount', 140 | sender.solanaKey(), 141 | [], 142 | ), 143 | ); 144 | 145 | const createRequest = { 146 | solana_transaction: createTx.serialize({ 147 | verifySignatures: false, 148 | requireAllSignatures: false, 149 | }).toString('base64'), 150 | }; 151 | 152 | await request(app) 153 | .post("/create_account") 154 | .set('Accept', 'application/json') 155 | .set(AGORA_HMAC_HEADER, getHmacHeader(createRequest)) 156 | .send(createRequest) 157 | .expect(200); 158 | }); 159 | 160 | test("invalid requests", async () => { 161 | const garbage = { 162 | hello: "world" 163 | }; 164 | 165 | await request(app) 166 | .post("/events") 167 | .set('Accept', 'application/json') 168 | .set(AGORA_HMAC_HEADER, getHmacHeader(garbage)) 169 | .send(garbage) 170 | .expect(400); 171 | 172 | await request(app) 173 | .post("/sign_transaction") 174 | .set('Accept', 'application/json') 175 | .set(AGORA_HMAC_HEADER, getHmacHeader(garbage)) 176 | .send(garbage) 177 | .expect(400); 178 | 179 | const garbageTx = { 180 | envelope_xdr: "notproperbase64", 181 | }; 182 | await request(app) 183 | .post("/sign_transaction") 184 | .set('Accept', 'application/json') 185 | .set(AGORA_HMAC_HEADER, getHmacHeader(garbageTx)) 186 | .send(garbageTx) 187 | .expect(400); 188 | 189 | await request(app) 190 | .post("/create_account") 191 | .set('Accept', 'application/json') 192 | .set(AGORA_HMAC_HEADER, getHmacHeader(garbageTx)) 193 | .send(garbageTx) 194 | .expect(400); 195 | 196 | }); 197 | 198 | test("eventsHandler", async () => { 199 | const app = express(); 200 | let received = new Array(); 201 | 202 | app.use("/events", express.json()); 203 | app.use("/events", EventsHandler((events: Event[]) => { 204 | received = events; 205 | })); 206 | 207 | const sent: Event[] = [ 208 | { 209 | transaction_event: { 210 | tx_id: Buffer.from(base58.decode('2nBhEBYYvfaAe16UMNqRHre4YNSskvuYgx3M6E4JP1oDYvZEJHvoPzyUidNgNX5r9sTyN1J9UxtbCXy2rqYcuyuv')).toString('base64'), 211 | solana_event: { 212 | transaction: 'AVj7dxHlQ9IrvdYVIjuiRFs1jLaDMHixgrv+qtHBwz51L4/ImLZhszwiyEJDIp7xeBSpm/TX5B7mYzxa+fPOMw0BAAMFJMJVqLw+hJYheizSoYlLm53KzgT82cDVmazarqQKG2GQsLgiqktA+a+FDR4/7xnDX7rsusMwryYVUdixfz1B1Qan1RcZLwqvxvJl4/t3zHragsUp0L47E24tAFUgAAAABqfVFxjHdMkoVmOYaR1etoteuKObS21cc1VbIQAAAAAHYUgdNXR0u3xNdiTr072z2DVec9EQQ/wNo1OAAAAAAAtxOUhPBp2WSjUNJEgfvy70BbxI00fZyEPvFHNfxrtEAQQEAQIDADUCAAAAAQAAAAAAAACtAQAAAAAAAAdUE18R96XTJCe+YfRfUp6WP+YKCy/72ucOL8AoBFSpAA==', 213 | tx_error: 'none', 214 | tx_error_raw: 'rawerror', 215 | } 216 | } 217 | }, 218 | ]; 219 | await request(app) 220 | .post("/events") 221 | .set('Accept', 'application/json') 222 | .send(sent) 223 | .expect(200); 224 | 225 | expect(received).toStrictEqual(sent); 226 | }); 227 | 228 | test("createAccountHandler", async () => { 229 | const app = express(); 230 | interface createResponse { 231 | signature: string 232 | } 233 | 234 | let actualUserId: string | undefined; 235 | let actualUserPasskey: string | undefined; 236 | 237 | app.use("/create_account", express.json()); 238 | app.use("/create_account", CreateAccountHandler(Environment.Test, (req: CreateAccountRequest, resp: CreateAccountResponse) => { 239 | actualUserId = req.userId; 240 | actualUserPasskey = req.userPassKey; 241 | 242 | resp.sign(subsidizer); 243 | }, WEBHOOK_SECRET)); 244 | 245 | const recentBlockhash = PrivateKey.random().publicKey().solanaKey(); 246 | const owner = PrivateKey.random().publicKey().solanaKey(); 247 | const mint = PrivateKey.random().publicKey().solanaKey(); 248 | const assoc = await Token.getAssociatedTokenAddress( 249 | ASSOCIATED_TOKEN_PROGRAM_ID, 250 | TOKEN_PROGRAM_ID, 251 | mint, 252 | owner, 253 | ); 254 | const createTx = new Transaction({ 255 | feePayer: subsidizer.publicKey().solanaKey(), 256 | recentBlockhash: recentBlockhash.toBase58(), 257 | }).add( 258 | Token.createAssociatedTokenAccountInstruction( 259 | ASSOCIATED_TOKEN_PROGRAM_ID, 260 | TOKEN_PROGRAM_ID, 261 | mint, 262 | assoc, 263 | owner, 264 | subsidizer.publicKey().solanaKey(), 265 | ), 266 | Token.createSetAuthorityInstruction( 267 | TOKEN_PROGRAM_ID, 268 | assoc, 269 | subsidizer.publicKey().solanaKey(), 270 | 'CloseAccount', 271 | owner, 272 | [], 273 | ), 274 | ); 275 | 276 | const req = { 277 | solana_transaction: createTx.serialize({ 278 | verifySignatures: false, 279 | requireAllSignatures: false, 280 | }).toString('base64'), 281 | }; 282 | 283 | const resp = await request(app) 284 | .post("/create_account") 285 | .set('Accept', 'application/json') 286 | .set(AGORA_HMAC_HEADER, getHmacHeader(req)) 287 | .set(AGORA_USER_ID_HEADER, "user_id") 288 | .set(AGORA_USER_PASSKEY_HEADER, "user_pass_key") 289 | .send(req) 290 | .expect(200); 291 | 292 | expect((resp.body).signature).toBeDefined(); 293 | expect(actualUserId).toBe("user_id"); 294 | expect(actualUserPasskey).toBe("user_pass_key"); 295 | }); 296 | test("createAccountHandler rejection", async () => { 297 | const app = express(); 298 | interface createResponse { 299 | signature: string 300 | } 301 | 302 | app.use("/create_account", express.json()); 303 | app.use("/create_account", CreateAccountHandler(Environment.Test, (req: CreateAccountRequest, resp: CreateAccountResponse) => { 304 | resp.reject(); 305 | }, WEBHOOK_SECRET)); 306 | 307 | const recentBlockhash = PrivateKey.random().publicKey().solanaKey(); 308 | const owner = PrivateKey.random().publicKey().solanaKey(); 309 | const mint = PrivateKey.random().publicKey().solanaKey(); 310 | const assoc = await Token.getAssociatedTokenAddress( 311 | ASSOCIATED_TOKEN_PROGRAM_ID, 312 | TOKEN_PROGRAM_ID, 313 | mint, 314 | owner, 315 | ); 316 | const createTx = new Transaction({ 317 | feePayer: subsidizer.publicKey().solanaKey(), 318 | recentBlockhash: recentBlockhash.toBase58(), 319 | }).add( 320 | Token.createAssociatedTokenAccountInstruction( 321 | ASSOCIATED_TOKEN_PROGRAM_ID, 322 | TOKEN_PROGRAM_ID, 323 | mint, 324 | assoc, 325 | owner, 326 | subsidizer.publicKey().solanaKey(), 327 | ), 328 | Token.createSetAuthorityInstruction( 329 | TOKEN_PROGRAM_ID, 330 | assoc, 331 | subsidizer.publicKey().solanaKey(), 332 | 'CloseAccount', 333 | owner, 334 | [], 335 | ), 336 | ); 337 | 338 | const req = { 339 | solana_transaction: createTx.serialize({ 340 | verifySignatures: false, 341 | requireAllSignatures: false, 342 | }).toString('base64'), 343 | }; 344 | 345 | const resp = await request(app) 346 | .post("/create_account") 347 | .set('Accept', 'application/json') 348 | .set(AGORA_HMAC_HEADER, getHmacHeader(req)) 349 | .send(req) 350 | .expect(403); 351 | 352 | expect((resp.body).signature).toBeUndefined(); 353 | }); 354 | 355 | test("signtransactionHandler", async () => { 356 | const app = express(); 357 | interface signResponse { 358 | signature: string 359 | } 360 | 361 | let actualUserId: string | undefined; 362 | let actualUserPasskey: string | undefined; 363 | 364 | app.use("/sign_transaction", express.json()); 365 | app.use("/sign_transaction", SignTransactionHandler(Environment.Test, (req: SignTransactionRequest, resp: SignTransactionResponse) => { 366 | actualUserId = req.userId; 367 | actualUserPasskey = req.userPassKey; 368 | 369 | resp.sign(subsidizer); 370 | }, WEBHOOK_SECRET)); 371 | 372 | const sender = PrivateKey.random().publicKey(); 373 | const destination = PrivateKey.random().publicKey(); 374 | const recentBlockhash = PrivateKey.random().publicKey(); 375 | 376 | const transaction = new Transaction({ 377 | feePayer: subsidizer.publicKey().solanaKey(), 378 | recentBlockhash: recentBlockhash.toBase58(), 379 | }).add( 380 | Token.createTransferInstruction( 381 | TOKEN_PROGRAM_ID, 382 | sender.solanaKey(), 383 | destination.solanaKey(), 384 | sender.solanaKey(), 385 | [], 386 | 100, 387 | ) 388 | ); 389 | 390 | const req = { 391 | solana_transaction: transaction.serialize({ 392 | verifySignatures: false, 393 | requireAllSignatures: false, 394 | }).toString("base64"), 395 | kin_version: 4, 396 | }; 397 | 398 | const resp = await request(app) 399 | .post("/sign_transaction") 400 | .set('Accept', 'application/json') 401 | .set(AGORA_HMAC_HEADER, getHmacHeader(req)) 402 | .set(AGORA_USER_ID_HEADER, "user_id") 403 | .set(AGORA_USER_PASSKEY_HEADER, "user_pass_key") 404 | .send(req) 405 | .expect(200); 406 | 407 | expect((resp.body).signature).toBeDefined(); 408 | expect(actualUserId).toBe("user_id"); 409 | expect(actualUserPasskey).toBe("user_pass_key"); 410 | }); 411 | 412 | test("signTransactionHandler rejection", async () => { 413 | const app = express(); 414 | 415 | interface signResponse { 416 | signature: string 417 | invoice_errors: InvoiceError[] 418 | } 419 | 420 | app.use("/sign_transaction", express.json()); 421 | app.use("/sign_transaction", SignTransactionHandler(Environment.Test, (req: SignTransactionRequest, resp: SignTransactionResponse) => { 422 | resp.markSkuNotFound(0); 423 | resp.markWrongDestination(1); 424 | resp.markAlreadyPaid(2); 425 | })); 426 | 427 | const sender = PrivateKey.random().publicKey(); 428 | const destination = PrivateKey.random().publicKey(); 429 | const recentBlockhash = PrivateKey.random().publicKey(); 430 | const b64Invoicelist = "CggKBgoEdGVzdAoKCggKBHRlc3QYAQoKCggKBHRlc3QYAgoKCggKBHRlc3QYAwoKCggKBHRlc3QYBAoKCggKBHRlc3QYBQoKCggKBHRlc3QYBgoKCggKBHRlc3QYBwoKCggKBHRlc3QYCAoKCggKBHRlc3QYCQ=="; 431 | const ilHash = Buffer.from(hash.sha224().update(Buffer.from(b64Invoicelist, 'base64')).digest('hex'), "hex"); 432 | 433 | const kinMemo = Memo.new(1, TransactionType.Spend, 1, ilHash); 434 | 435 | const transaction = new Transaction({ 436 | feePayer: subsidizer.publicKey().solanaKey(), 437 | recentBlockhash: recentBlockhash.toBase58(), 438 | }).add(MemoProgram.memo({data: kinMemo.buffer.toString("base64")})); 439 | 440 | // There are 10 invoices in the invoice list 441 | for (let i = 0; i < 10; i++) { 442 | transaction.add( 443 | Token.createTransferInstruction( 444 | TOKEN_PROGRAM_ID, 445 | sender.solanaKey(), 446 | destination.solanaKey(), 447 | sender.solanaKey(), 448 | [], 449 | 100, 450 | ) 451 | ); 452 | } 453 | 454 | const req = { 455 | solana_transaction: transaction.serialize({ 456 | verifySignatures: false, 457 | requireAllSignatures: false, 458 | }).toString("base64"), 459 | kin_version: 4, 460 | invoice_list: b64Invoicelist, 461 | }; 462 | 463 | const resp = await request(app) 464 | .post("/sign_transaction") 465 | .set('Accept', 'application/json') 466 | .set(AGORA_HMAC_HEADER, getHmacHeader(req)) 467 | .send(req) 468 | .expect(403); 469 | 470 | expect((resp.body).signature).toBeUndefined(); 471 | 472 | const expectedReasons = [ 473 | RejectionReason.SkuNotFound, 474 | RejectionReason.WrongDestination, 475 | RejectionReason.AlreadyPaid, 476 | ]; 477 | const invoiceErrors = (resp.body).invoice_errors; 478 | expect(invoiceErrors).toHaveLength(3); 479 | for (let i = 0; i < 3; i++) { 480 | expect(invoiceErrors[i].operation_index).toBe(i); 481 | expect(invoiceErrors[i].reason).toBe(expectedReasons[i]); 482 | } 483 | }); 484 | 485 | test("signTransactionRequest getTxId", async () => { 486 | const owner = PrivateKey.random(); 487 | const sender = PrivateKey.random().publicKey(); 488 | const destination = PrivateKey.random().publicKey(); 489 | const recentBlockhash = PrivateKey.random().publicKey(); 490 | 491 | const transaction = new Transaction({ 492 | feePayer: subsidizer.publicKey().solanaKey(), 493 | recentBlockhash: recentBlockhash.toBase58(), 494 | }).add( 495 | Token.createTransferInstruction( 496 | TOKEN_PROGRAM_ID, 497 | sender.solanaKey(), 498 | destination.solanaKey(), 499 | owner.publicKey().solanaKey(), 500 | [], 501 | 100, 502 | ) 503 | ); 504 | transaction.sign(new Account(owner.secretKey())); 505 | transaction.sign(new Account(subsidizer.secretKey())); 506 | 507 | const req = new SignTransactionRequest([],[], transaction); 508 | expect(req.txId()).toEqual(transaction.signature!); 509 | }); 510 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "skipLibCheck": true, 5 | "module": "commonjs", 6 | "declaration": true, 7 | "rootDir": "src/", 8 | "outDir": "dist/", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "moduleResolution": "node" 12 | }, 13 | "exclude": [ 14 | "dist", 15 | "node_modules", 16 | "examples", 17 | "**/*.spec.ts" 18 | ] 19 | } 20 | --------------------------------------------------------------------------------