├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierrc ├── LICENSE ├── Makefile ├── README.md ├── examples ├── README.md ├── login │ └── index.html └── transact │ └── index.html ├── package.json ├── protocol.md ├── rollup.config.js ├── src ├── errors.ts ├── index-bundle.ts ├── index-module.ts ├── index.ts ├── link-callback.ts ├── link-options.ts ├── link-session.ts ├── link-storage.ts ├── link-transport.ts ├── link-types.ts ├── link.ts └── utils.ts ├── test ├── abis │ └── eosio.token.json ├── aes.ts ├── session.ts └── tsconfig.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "ignorePatterns": ["lib/*", "node_modules/**"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:prettier/recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "rules": { 13 | "prettier/prettier": "warn", 14 | "no-console": "warn", 15 | "sort-imports": [ 16 | "warn", 17 | { 18 | "ignoreCase": true, 19 | "ignoreDeclarationSort": true 20 | } 21 | ], 22 | "@typescript-eslint/explicit-module-boundary-types": "off", 23 | "@typescript-eslint/no-explicit-any": "off", 24 | "@typescript-eslint/no-namespace": "off", 25 | "@typescript-eslint/no-non-null-assertion": "off", 26 | "@typescript-eslint/no-empty-function": "warn", 27 | "no-inner-declarations": "off", 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | docs/ 4 | .nyc_output 5 | coverage 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | arrowParens: "always" 2 | bracketSpacing: false 3 | endOfLine: "lf" 4 | printWidth: 100 5 | semi: false 6 | singleQuote: true 7 | tabWidth: 4 8 | trailingComma: "es5" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Greymass Inc. All Rights Reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistribution of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistribution in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 20 | IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 21 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 25 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 26 | OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | YOU ACKNOWLEDGE THAT THIS SOFTWARE IS NOT DESIGNED, LICENSED OR INTENDED FOR USE 29 | IN THE DESIGN, CONSTRUCTION, OPERATION OR MAINTENANCE OF ANY MILITARY FACILITY. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SRC_FILES := $(shell find src -name '*.ts') 2 | 3 | lib: ${SRC_FILES} package.json tsconfig.json node_modules rollup.config.js 4 | @./node_modules/.bin/rollup -c && touch lib 5 | 6 | .PHONY: test 7 | test: node_modules 8 | @TS_NODE_PROJECT='./test/tsconfig.json' ./node_modules/.bin/mocha -u tdd -r ts-node/register --extension ts test/*.ts --grep '$(grep)' 9 | 10 | .PHONY: coverage 11 | coverage: node_modules 12 | @TS_NODE_PROJECT='./test/tsconfig.json' ./node_modules/.bin/nyc --reporter=html ./node_modules/.bin/mocha -u tdd -r ts-node/register --extension ts test/*.ts -R nyan && open coverage/index.html 13 | 14 | .PHONY: lint 15 | lint: node_modules 16 | @./node_modules/.bin/eslint src --ext .ts --fix 17 | 18 | .PHONY: ci-test 19 | ci-test: node_modules 20 | @TS_NODE_PROJECT='./test/tsconfig.json' ./node_modules/.bin/nyc --reporter=text ./node_modules/.bin/mocha -u tdd -r ts-node/register --extension ts test/*.ts -R list 21 | 22 | .PHONY: ci-lint 23 | ci-lint: node_modules 24 | @./node_modules/.bin/eslint src --ext .ts --max-warnings 0 --format unix && echo "Ok" 25 | 26 | docs: $(SRC_FILES) node_modules 27 | ./node_modules/.bin/typedoc \ 28 | --excludeInternal \ 29 | --excludePrivate --excludeProtected \ 30 | --name "Anchor Link" --includeVersion --readme none \ 31 | --out docs \ 32 | src/index-module.ts 33 | 34 | .PHONY: deploy-site 35 | deploy-site: docs 36 | cp -r ./examples ./docs/examples/ 37 | ./node_modules/.bin/gh-pages -d docs 38 | 39 | node_modules: 40 | yarn install --non-interactive --frozen-lockfile --ignore-scripts 41 | 42 | .PHONY: publish 43 | publish: | distclean node_modules 44 | @git diff-index --quiet HEAD || (echo "Uncommitted changes, please commit first" && exit 1) 45 | @git fetch origin && git diff origin/master --quiet || (echo "Changes not pushed to origin, please push first" && exit 1) 46 | @yarn config set version-tag-prefix "" && yarn config set version-git-message "Version %s" 47 | @yarn publish && git push && git push --tags 48 | 49 | .PHONY: clean 50 | clean: 51 | rm -rf lib/ coverage/ docs/ 52 | 53 | .PHONY: distclean 54 | distclean: clean 55 | rm -rf node_modules/ 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Anchor Link [![Package Version](https://img.shields.io/npm/v/anchor-link.svg?style=flat-square)](https://www.npmjs.com/package/anchor-link) ![License](https://img.shields.io/npm/l/anchor-link.svg?style=flat-square) 2 | 3 | Persistent, fast and secure signature provider for EOSIO chains built on top of [EOSIO Signing Requests (EEP-7)](https://github.com/greymass/eosio-signing-request) 4 | 5 | Key features: 6 | 7 | - Persistent account sessions 8 | - End-to-end encryption (E2EE) 9 | - Account-based identity proofs 10 | - Cross-device signing 11 | - Network resource management 12 | - Open standard 13 | 14 | Resources: 15 | 16 | - [API Documentation](https://greymass.github.io/anchor-link) 17 | - [Protocol Specification](./protocol.md) 18 | - [Developer Chat (Telegram)](https://t.me/anchor_link) 19 | 20 | Guides: 21 | 22 | - [Integrating an app with Anchor using anchor-link](https://forums.eoscommunity.org/t/integrating-an-app-with-anchor-using-anchor-link/165) 23 | 24 | Examples: 25 | 26 | - [Simple Examples](./examples) 27 | - [VueJS Demo Application](https://github.com/greymass/anchor-link-demo) 28 | - [ReactJS Demo Application](https://github.com/greymass/anchor-link-demo-multipass) 29 | 30 | ## Installation 31 | 32 | The `anchor-link` package is distributed both as a module on [npm](https://www.npmjs.com/package/anchor-link) and a standalone bundle on [unpkg](http://unpkg.com/anchor-link). 33 | 34 | ### Browser using a bundler (recommended) 35 | 36 | Install Anchor Link and a [transport](#transports): 37 | 38 | ``` 39 | yarn add anchor-link anchor-link-browser-transport 40 | # or 41 | npm install --save anchor-link anchor-link-browser-transport 42 | ``` 43 | 44 | Import them into your project: 45 | 46 | ```js 47 | import AnchorLink from 'anchor-link' 48 | import AnchorLinkBrowserTransport from 'anchor-link-browser-transport' 49 | ``` 50 | 51 | ### Browser using a pre-built bundle 52 | 53 | Include the scripts in your `` tag. 54 | 55 | ```html 56 | 57 | 58 | ``` 59 | 60 | `AnchorLink` and `AnchorLinkBrowserTransport` are now available in the global scope of your document. 61 | 62 | ### Using node.js 63 | 64 | Using node.js 65 | 66 | ``` 67 | yarn add anchor-link anchor-link-console-transport 68 | # or 69 | npm install --save anchor-link anchor-link-console-transport 70 | ``` 71 | 72 | Import them into your project: 73 | 74 | ```js 75 | const AnchorLink = require('anchor-link') 76 | const AnchorLinkConsoleTransport = require('anchor-link-console-transport') 77 | ``` 78 | 79 | ## Usage 80 | 81 | First you need to instantiate your transport and the link. 82 | 83 | ```ts 84 | const transport = new AnchorLinkBrowserTransport() 85 | const link = new AnchorLink({ 86 | transport, 87 | chains: [ 88 | { 89 | chainId: 'aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906', 90 | nodeUrl: 'https://eos.greymass.com', 91 | }, 92 | ], 93 | }) 94 | ``` 95 | 96 | Now you have a link instance that can be used in the browser to login and/or transact. See [options](https://greymass.github.io/anchor-link/interfaces/LinkOptions.html) for a full list of available options. Also refer to the [anchor-link-browser-transport](https://github.com/greymass/anchor-link-browser-transport/tree/master#basic-usage) README for a list of available options within the transport. 97 | 98 | ### Create a user session 99 | 100 | To create a persistent session where you can push multiple transaction to a users wallet you need to call the [login](https://greymass.github.io/anchor-link/classes/Link.html#login) method on your link instance and pass your application name. 101 | 102 | ```ts 103 | // Perform the login, which returns the users identity 104 | const identity = await link.login('mydapp') 105 | 106 | // Save the session within your application for future use 107 | const {session} = identity 108 | console.log(`Logged in as ${session.auth}`) 109 | ``` 110 | 111 | ### Perform a transaction with a user session 112 | 113 | Using the session you have persisted within your applications state from the user login, you can now send transactions through the session to the users wallet using the [transact](https://greymass.github.io/anchor-link/classes/Link.html#transact) method. 114 | 115 | ```ts 116 | const action = { 117 | account: 'eosio', 118 | name: 'voteproducer', 119 | authorization: [session.auth], 120 | data: { 121 | voter: session.auth.actor, 122 | proxy: 'greymassvote', 123 | producers: [], 124 | }, 125 | } 126 | 127 | session.transact({action}).then(({transaction}) => { 128 | console.log(`Transaction broadcast! Id: ${transaction.id}`) 129 | }) 130 | ``` 131 | 132 | ### Restoring a session 133 | 134 | If a user has previously logged in to your application, you can restore that previous session by calling the [restoreSession](https://greymass.github.io/anchor-link/classes/Link.html#restoresession) method on your link instance. 135 | 136 | ```ts 137 | link.restoreSession('mydapp').then((session) => { 138 | console.log(`Session for ${session.auth} restored`) 139 | const action = { 140 | account: 'eosio', 141 | name: 'voteproducer', 142 | authorization: [session.auth], 143 | data: { 144 | voter: session.auth.actor, 145 | proxy: 'greymassvote', 146 | producers: [], 147 | }, 148 | } 149 | session.transact({action}).then(({transaction}) => { 150 | console.log(`Transaction broadcast! Id: ${transaction.id}`) 151 | }) 152 | }) 153 | ``` 154 | 155 | ### Additional Methods 156 | 157 | A full list of all methods can be found in the [Link class documentation](https://greymass.github.io/anchor-link/classes/Link.html). 158 | 159 | - List all available sessions: [listSessions](https://greymass.github.io/anchor-link/classes/Link.html#listsessions) 160 | - Removing a session: [removeSession](https://greymass.github.io/anchor-link/classes/Link.html#removesession) 161 | 162 | ### One-shot transact 163 | 164 | To sign action(s) or a transaction using the link without logging in you can call the [transact](https://greymass.github.io/anchor-link/classes/Link.html#transact) method on your link instance. 165 | 166 | ```ts 167 | const action = { 168 | account: 'eosio', 169 | name: 'voteproducer', 170 | authorization: [ 171 | { 172 | actor: '............1', // ............1 will be resolved to the signing accounts name 173 | permission: '............2', // ............2 will be resolved to the signing accounts authority (e.g. 'active') 174 | }, 175 | ], 176 | data: { 177 | voter: '............1', // same as above, resolved to the signers account name 178 | proxy: 'greymassvote', 179 | producers: [], 180 | }, 181 | } 182 | link.transact({action}).then(({signer, transaction}) => { 183 | console.log( 184 | `Success! Transaction signed by ${signer} and bradcast with transaction id: ${transaction.id}` 185 | ) 186 | }) 187 | ``` 188 | 189 | You can find more examples in the [examples directory](./examples) at the root of this repository and don't forget to look at the [API documentation](https://greymass.github.io/anchor-link/classes/Link.html). 190 | 191 | ## Transports 192 | 193 | Transports in Anchor Link are responsible for getting signature requests to the users wallet when establishing a session or when using anchor link without logging in. 194 | 195 | Available transports: 196 | 197 | | Package | Description | 198 | | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | 199 | | [anchor-link-browser-transport](https://github.com/greymass/anchor-link-browser-transport) | Browser overlay that generates QR codes or triggers local URI handler if available | 200 | | [anchor-link-console-transport](https://github.com/greymass/anchor-link-console-transport) | Transport that prints ASCII QR codes and esr:// links to the JavaScript console | 201 | 202 | See the [`LinkTransport` documentation](https://greymass.github.io/anchor-link/interfaces/LinkTransport.html) for details on how to implement custom transports. 203 | 204 | ## Protocol 205 | 206 | The Anchor Link protocol uses EEP-7 identity requests to establish a channel to compatible wallets using an untrusted HTTP POST to WebSocket forwarder (see [buoy node.js](https://github.com/greymass/buoy-nodejs)). 207 | 208 | A session key and unique channel URL is generated by the client which is attached to the identity request and sent to the wallet (see [transports](#transports)). The wallet signs the identity proof and sends it back along with its own channel URL and session key. Subsequent signature requests can now be encrypted to a shared secret derived from the two keys and pushed directly to the wallet channel. 209 | 210 | [📘 Protocol specification](./protocol.md) 211 | 212 | ## Developing 213 | 214 | You need [Make](https://www.gnu.org/software/make/), [node.js](https://nodejs.org/en/) and [yarn](https://classic.yarnpkg.com/en/docs/install) installed. 215 | 216 | Clone the repository and run `make` to checkout all dependencies and build the project. See the [Makefile](./Makefile) for other useful targets. Before submitting a pull request make sure to run `make lint`. 217 | 218 | --- 219 | 220 | Made with ☕️ & ❤️ by [Greymass](https://greymass.com), if you find this useful please consider [supporting us](https://greymass.com/support-us). 221 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Anchor Link usage examples 2 | 3 | - Once-off transaction on custom chain - [Source](./transact) | [Live demo](https://greymass.github.io/anchor-link/examples/transact/) 4 | - Login and transact with sessions - [Source](./login) | [Live demo](https://greymass.github.io/anchor-link/examples/login/) 5 | 6 | Note that these examples uses the pre-built bundles from [unpkg](https://unpkg.com/anchor-link/) for convenience and portability, we recommend that you use a bundler like [parcel](https://parceljs.org), [rollup](https://rollupjs.org) or [webpack](https://webpack.js.org) to include Anchor Link in your project instead. 7 | -------------------------------------------------------------------------------- /examples/login/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Anchor Link - Login 7 | 8 | 9 | 80 | 91 | 92 | 93 |
94 |

Welcome foo!

95 | 99 |

Press F to pay respects

100 |

101 |     
102 |
103 | 104 |
105 | 106 | 107 | -------------------------------------------------------------------------------- /examples/transact/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Anchor Link - Transact 7 | 8 | 9 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anchor-link", 3 | "version": "3.5.1", 4 | "description": "Library for authenticating and signing transactions using the Anchor Link protocol", 5 | "license": "BSD-3-Clause", 6 | "main": "lib/anchor-link.js", 7 | "module": "lib/anchor-link.m.js", 8 | "types": "lib/anchor-link.d.ts", 9 | "unpkg": "lib/anchor-link.bundle.js", 10 | "scripts": { 11 | "prepare": "make" 12 | }, 13 | "directories": { 14 | "lib": "lib" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/greymass/anchor-link.git" 19 | }, 20 | "authors": [ 21 | "Johan Nordberg ", 22 | "Aaron Cox " 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/greymass/anchor-link/issues" 26 | }, 27 | "homepage": "https://github.com/greymass/anchor-link", 28 | "files": [ 29 | "lib/*", 30 | "src/*" 31 | ], 32 | "browser": { 33 | "buffer": false, 34 | "crypto": false 35 | }, 36 | "dependencies": { 37 | "@greymass/miniaes": "^1.0.0", 38 | "@wharfkit/antelope": "^1.0.7", 39 | "@wharfkit/signing-request": "^3.2.0", 40 | "fetch-ponyfill": "^7.1.0", 41 | "isomorphic-ws": "^4.0.1", 42 | "pako": "^2.0.3", 43 | "tslib": "^2.1.0", 44 | "uuid": "^8.3.2", 45 | "ws": "^8.5.0" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.12.16", 49 | "@babel/preset-env": "^7.12.16", 50 | "@rollup/plugin-babel": "^5.2.3", 51 | "@rollup/plugin-commonjs": "^21.0.3", 52 | "@rollup/plugin-json": "^4.1.0", 53 | "@rollup/plugin-node-resolve": "^13.0.0", 54 | "@rollup/plugin-replace": "^4.0.0", 55 | "@rollup/plugin-typescript": "^8.1.1", 56 | "@types/bn.js": "^5.1.0", 57 | "@types/mocha": "^9.1.0", 58 | "@types/node": "^17.0.23", 59 | "@types/pako": "^1.0.1", 60 | "@types/uuid": "^8.0.0", 61 | "@types/ws": "^8.5.3", 62 | "@typescript-eslint/eslint-plugin": "^5.17.0", 63 | "@typescript-eslint/parser": "^5.17.0", 64 | "core-js": "^3.8.3", 65 | "eslint": "^8.12.0", 66 | "eslint-config-prettier": "^8.1.0", 67 | "eslint-plugin-prettier": "^4.0.0", 68 | "gh-pages": "^3.1.0", 69 | "mocha": "^9.0.2", 70 | "nyc": "^15.1.0", 71 | "prettier": "^2.2.1", 72 | "rollup": "^2.38.5", 73 | "rollup-plugin-dts": "^4.2.0", 74 | "rollup-plugin-terser": "^7.0.2", 75 | "ts-node": "^10.0.0", 76 | "typedoc": "^0.22.13", 77 | "typescript": "^4.1.5" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /protocol.md: -------------------------------------------------------------------------------- 1 | # Anchor Link Protocol 2 | 3 | Persistent sessions that allows applications to setup a persistent and secure channel for pushing signature requests (ESR/EEP-7) to a wallet. 4 | 5 | ## Definitions 6 | 7 | - dApp - EOSIO application using Anchor Link its signature provider 8 | - Wallet - Application holding the private keys for the users EOSIO account(s) 9 | - Forwarder - Untrusted POST -> WebSocket data forwarder routed with UUIDs 10 | - Channel - One-way push channel via the forwarder 11 | - Request - A EOSIO Signing Request (ESR/EEP-7) 12 | - Callback - A request response sent on a one-time channel from Wallet -> dApp 13 | - Session - Persistent dApp <-> Wallet session 14 | 15 | ## Wallet initialization 16 | 17 | Wallet generates a new key-pair (hence referred to as the "receive key") and a UUID that will be used to setup a persistent channel for receiving requests. 18 | 19 | ## Creating a Session 20 | 21 | 1. dApp generates a key-pair that will be used to encrypt subsequent request, hence referred to as the "request key". It also generates a UUID that is used to create a one-time channel 22 | 2. dApp creates an Identity Request using the public request key and sends it directly to the Wallet (QR code/NFC reader/local URI handler) 23 | 3. Wallet stores the request key and constructs a callback payload with the identity proof (as per the ESR spec) along with the extra fields `link_ch` which is the persistent channel wallet channel url and `link_key` which is the wallet receive key. 24 | 4. dApp validates the identity proof and stores the `link_ch` and `link_key` along with the request key. 25 | 5. dApp can now push encrypted requests to the wallet's receive channel with the shared secret derived from its own request key and the wallets receive key. 26 | 27 | In pseudo-code: 28 | 29 | ```python 30 | # dApp 31 | forwarder_address = "https://forward.example.com" 32 | private_request_key = secp256k1_random_key() 33 | public_request_key = secp256k1_get_public(private_request_key) 34 | callback_ch = forwarder_address + "/" + gen_uuidv4() 35 | request = esr_make_id_request(callback_ch, metadata={req_key=public_request_key}) 36 | ui_show_qr_code(request) 37 | response = wait_for_callback(callback_ch) 38 | assert(verify_id_proof(response["id_proof"])) 39 | save_session(private_request_key, response["link_ch"], response["link_key"]) 40 | 41 | # Wallet 42 | forwarder_address = "https://forward.example.com" # does not have to be the same as dApp 43 | private_receive_key = secp256k1_random_key() 44 | public_receive_key = secp256k1_get_public(private_receive_key) 45 | receive_ch = forwarder_address + "/" + gen_uuidv4() 46 | def handle_id_request(request): 47 | assert(present_to_user(request) == ACCEPTED) 48 | proof = sign_id_proof(request) 49 | response = esr_make_id_response(request, proof) 50 | response.metadata["link_ch"] = receive_ch 51 | response.metadata["link_key"] = public_receive_key 52 | push_channel(response, request.get_callback()) 53 | ``` 54 | 55 | ## Transacting using a Session 56 | 57 | 1. dApp creates a request with the transaction that should be signed along with a new UUID for the callback. 58 | 2. dApp encrypts the request using the shared secret derived from its own request key and the wallet receive key and pushes it to the wallet receive channel. 59 | 3. Wallet decrypts the request received on the channel and presents it to the user, if accepted the request is signed and the response is sent to the callback 60 | 4. dApp reconstructs the transaction, attaches the signature received from the wallet and broadcasts it to the network 61 | 62 | In pseudo-code: 63 | 64 | ```python 65 | # dApp 66 | forwarder_address = "https://forward.example.com" 67 | session = load_session() 68 | callback_ch = forwarder_address + "/" + gen_uuidv4() 69 | request = esr_make_request(transaction, callback_ch) 70 | request.metadata["expiry"] = date_now() + 60 71 | encrypted = aes_encrypt(request, shared_secret(session["public_receive_key"], session["private_request_key"]) 72 | encrypted_envelope = {key: session["public_request_key"], ciphertext: encrypted, checksum: sha256(request)} 73 | push_channel(encrypted_envelope, session["link_ch"]) 74 | response = wait_for_callback(callback_ch) 75 | push_transaction(MY_RPC_NODE, response.get_signed_transaction()) 76 | 77 | # Wallet 78 | def handle_channel_push(encrypted): 79 | assert(is_active_session_key(encrypted.key)) 80 | request = aes_decrypt(encrypted.ciphertext, shared_secret(session["private_receive_key"], encrypted.key) 81 | assert(verify_checksum(request, encrypted.checksum)) 82 | assert(request.metadata["expiry"] > date_now()) 83 | assert(present_to_user(request) == ACCEPTED) 84 | signature = sign_request(request) 85 | response = esr_make_response(request, signature) 86 | send_callback(response, request.get_callback()) 87 | ``` 88 | 89 | ## Security considerations 90 | 91 | For the Forwarder to remain untrusted several security measures has to be taken. 92 | 93 | ### MITM / Request modification 94 | 95 | The Forwarder could intercept signing requests from the dApp and modify them before passing them on to the Wallet. 96 | 97 | #### Mitigation 98 | 99 | The identity request that establishes the channel always goes directly to the wallet via QR code, NFC tag or local URI handler. 100 | 101 | The request contains a public key that the dApp holds the private key for. All subsequent requests over the channel are encrypted to a shared secret known only by the dApp and Wallet. 102 | 103 | ### Replay attacks 104 | 105 | Signing requests can be configured to always resolve to a unique transaction making replay attacks possible. The Forwarder could save all requests passing on the channel and selectively re-send them in an attempt to trick the Wallet user. 106 | 107 | #### Mitigation 108 | 109 | Each request contains an expiry time. Wallets can additionally keep track of the request callback urls and reject any request with a re-used UUID. 110 | 111 | ### Denial of Service 112 | 113 | The Forwarder could refuse to deliver requests or callbacks. It could also publish all channel UUIDs that has seen more than one use for allowing someone to push a large amount of data to a wallet, possibly preventing it from receiving legitimate requests. 114 | 115 | #### Mitigation 116 | 117 | There are no protocol level mitigation for this type of attack except for the encryption that prevents the forwarder from selectively targeting users based on what data is being sent. 118 | 119 | The forwarder service is easy to run and open-source, wallets can use multiple service providers and re-negotiate channels if the currently used forwarder becomes malicious or unreliable. 120 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import dts from 'rollup-plugin-dts' 3 | import babel from '@rollup/plugin-babel' 4 | import resolve from '@rollup/plugin-node-resolve' 5 | import commonjs from '@rollup/plugin-commonjs' 6 | import typescript from '@rollup/plugin-typescript' 7 | import {terser} from 'rollup-plugin-terser' 8 | import json from '@rollup/plugin-json' 9 | import replace from '@rollup/plugin-replace' 10 | 11 | import pkg from './package.json' 12 | 13 | const license = fs.readFileSync('LICENSE').toString('utf-8').trim() 14 | const banner = ` 15 | /** 16 | * Anchor Link v${pkg.version} 17 | * ${pkg.homepage} 18 | * 19 | * @license 20 | * ${license.replace(/\n/g, '\n * ')} 21 | */ 22 | `.trim() 23 | 24 | const exportFix = ` 25 | (function () { 26 | var pkg = AnchorLink; 27 | AnchorLink = pkg.default; 28 | for (var key in pkg) { 29 | if (key === 'default') continue; 30 | AnchorLink[key] = pkg[key]; 31 | } 32 | })() 33 | ` 34 | 35 | const replaceVersion = replace({ 36 | preventAssignment: true, 37 | __ver: pkg.version, 38 | }) 39 | 40 | export default [ 41 | { 42 | input: 'src/index-bundle.ts', 43 | output: { 44 | banner, 45 | file: pkg.main, 46 | format: 'cjs', 47 | sourcemap: true, 48 | exports: 'default', 49 | }, 50 | plugins: [replaceVersion, typescript({target: 'es6'})], 51 | external: Object.keys({...pkg.dependencies, ...pkg.peerDependencies}), 52 | onwarn, 53 | }, 54 | { 55 | input: 'src/index.ts', 56 | output: { 57 | banner, 58 | file: pkg.module, 59 | format: 'esm', 60 | sourcemap: true, 61 | }, 62 | plugins: [replaceVersion, typescript({target: 'es2020'})], 63 | external: Object.keys({...pkg.dependencies, ...pkg.peerDependencies}), 64 | onwarn, 65 | }, 66 | { 67 | input: 'src/index.ts', 68 | output: {banner, file: pkg.types, format: 'esm'}, 69 | onwarn, 70 | plugins: [dts()], 71 | }, 72 | { 73 | input: pkg.module, 74 | output: { 75 | banner, 76 | footer: exportFix, 77 | name: 'AnchorLink', 78 | file: pkg.unpkg, 79 | format: 'iife', 80 | sourcemap: true, 81 | exports: 'named', 82 | }, 83 | plugins: [ 84 | replaceVersion, 85 | resolve({browser: true}), 86 | commonjs(), 87 | json(), 88 | babel({ 89 | babelHelpers: 'bundled', 90 | exclude: /node_modules\/core-js.*/, 91 | presets: [ 92 | [ 93 | '@babel/preset-env', 94 | { 95 | targets: '>0.25%, not dead', 96 | useBuiltIns: 'usage', 97 | corejs: '3', 98 | }, 99 | ], 100 | ], 101 | }), 102 | terser({ 103 | format: { 104 | comments(_, comment) { 105 | return comment.type === 'comment2' && /@license/.test(comment.value) 106 | }, 107 | max_line_len: 500, 108 | }, 109 | }), 110 | ], 111 | external: Object.keys({...pkg.peerDependencies}), 112 | onwarn, 113 | }, 114 | ] 115 | 116 | function onwarn(warning, rollupWarn) { 117 | if (warning.code === 'CIRCULAR_DEPENDENCY') { 118 | // unnecessary warning 119 | return 120 | } 121 | if ( 122 | warning.code === 'UNUSED_EXTERNAL_IMPORT' && 123 | warning.source === 'tslib' && 124 | warning.names[0] === '__read' 125 | ) { 126 | // when using ts with importHelpers: true rollup complains about this 127 | // seems safe to ignore since __read is not actually imported or used anywhere in the resulting bundles 128 | return 129 | } 130 | rollupWarn(warning) 131 | } 132 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import type {LinkSession} from './link-session' 2 | 3 | /** 4 | * Error codes. Accessible using the `code` property on errors thrown by [[Link]] and [[LinkSession]]. 5 | * - `E_DELIVERY`: Unable to route message to wallet. 6 | * - `E_TIMEOUT`: Request was delivered but user/wallet didn't respond in time. 7 | * - `E_CANCEL`: The [[LinkTransport]] canceled the request. 8 | * - `E_IDENTITY`: Identity proof failed to verify. 9 | */ 10 | export type LinkErrorCode = 'E_DELIVERY' | 'E_TIMEOUT' | 'E_CANCEL' | 'E_IDENTITY' 11 | 12 | /** 13 | * Error that is thrown if a [[LinkTransport]] cancels a request. 14 | * @internal 15 | */ 16 | export class CancelError extends Error { 17 | public code = 'E_CANCEL' 18 | constructor(reason?: string) { 19 | super(`User canceled request ${reason ? '(' + reason + ')' : ''}`) 20 | } 21 | } 22 | 23 | /** 24 | * Error that is thrown if an identity request fails to verify. 25 | * @internal 26 | */ 27 | export class IdentityError extends Error { 28 | public code = 'E_IDENTITY' 29 | constructor(reason?: string) { 30 | super(`Unable to verify identity ${reason ? '(' + reason + ')' : ''}`) 31 | } 32 | } 33 | 34 | /** 35 | * Error originating from a [[LinkSession]]. 36 | * @internal 37 | */ 38 | export class SessionError extends Error { 39 | public code: 'E_DELIVERY' | 'E_TIMEOUT' 40 | public session: LinkSession 41 | constructor(reason: string, code: 'E_DELIVERY' | 'E_TIMEOUT', session: LinkSession) { 42 | super(reason) 43 | this.code = code 44 | this.session = session 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/index-bundle.ts: -------------------------------------------------------------------------------- 1 | import * as pkg from './index' 2 | const AnchorLink = pkg.default 3 | for (const key of Object.keys(pkg)) { 4 | if (key === 'default') continue 5 | AnchorLink[key] = pkg[key] 6 | } 7 | export default AnchorLink 8 | -------------------------------------------------------------------------------- /src/index-module.ts: -------------------------------------------------------------------------------- 1 | export * from './link' 2 | export * from './link-session' 3 | export type {LinkOptions, LinkChainConfig} from './link-options' 4 | export type {LinkTransport} from './link-transport' 5 | export type {LinkStorage} from './link-storage' 6 | export type { 7 | LinkCallback, 8 | LinkCallbackService, 9 | LinkCallbackRejection, 10 | LinkCallbackResponse, 11 | } from './link-callback' 12 | export * from './errors' 13 | export { 14 | IdentityProof, 15 | IdentityProofType, 16 | CallbackPayload, 17 | ChainId, 18 | ChainIdType, 19 | ChainName, 20 | } from '@wharfkit/signing-request' 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // export library 2 | export * from './index-module' 3 | 4 | // default export is Link class for convenience 5 | import {Link} from './index-module' 6 | export default Link 7 | 8 | // expose dependencies 9 | export * from '@wharfkit/signing-request' 10 | export * from '@wharfkit/antelope' 11 | -------------------------------------------------------------------------------- /src/link-callback.ts: -------------------------------------------------------------------------------- 1 | import {v4 as uuid} from 'uuid' 2 | import {CallbackPayload} from '@wharfkit/signing-request' 3 | import WebSocket from 'isomorphic-ws' 4 | 5 | import {fetch, logWarn} from './utils' 6 | 7 | /** Service that handles waiting for a ESR callback to be sent to an url. */ 8 | export interface LinkCallbackService { 9 | create(): LinkCallback 10 | } 11 | 12 | /** Can be returned by callback services if the user explicitly rejects the request. */ 13 | export interface LinkCallbackRejection { 14 | /** Rejection message. */ 15 | rejected: string 16 | } 17 | 18 | /** Callback response, can either be a ESR callback payload or a rejection message. */ 19 | export type LinkCallbackResponse = CallbackPayload | LinkCallbackRejection 20 | 21 | /** Callback that can be waited for. */ 22 | export interface LinkCallback { 23 | /** Url that should be hit to trigger the callback. */ 24 | url: string 25 | /** Wait for the callback to resolve. */ 26 | wait(): Promise 27 | /** Cancel a pending callback. */ 28 | cancel(): void 29 | } 30 | 31 | /** @internal */ 32 | export class BuoyCallbackService implements LinkCallbackService { 33 | readonly address: string 34 | constructor(address: string) { 35 | this.address = address.trim().replace(/\/$/, '') 36 | } 37 | 38 | create() { 39 | const url = `${this.address}/${uuid()}` 40 | return new BuoyCallback(url) 41 | } 42 | } 43 | 44 | /** @internal */ 45 | class BuoyCallback implements LinkCallback { 46 | constructor(readonly url: string) {} 47 | private ctx: {cancel?: () => void} = {} 48 | wait() { 49 | if (this.url.includes('hyperbuoy')) { 50 | return pollForCallback(this.url, this.ctx) 51 | } else { 52 | return waitForCallback(this.url, this.ctx) 53 | } 54 | } 55 | cancel() { 56 | if (this.ctx.cancel) { 57 | this.ctx.cancel() 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * Connect to a WebSocket channel and wait for a message. 64 | * @internal 65 | */ 66 | function waitForCallback(url: string, ctx: {cancel?: () => void}) { 67 | return new Promise((resolve, reject) => { 68 | let active = true 69 | let retries = 0 70 | const socketUrl = url.replace(/^http/, 'ws') 71 | const handleResponse = (response: string) => { 72 | try { 73 | resolve(JSON.parse(response)) 74 | } catch (error) { 75 | error.message = 'Unable to parse callback JSON: ' + error.message 76 | reject(error) 77 | } 78 | } 79 | const connect = () => { 80 | const socket = new WebSocket(socketUrl) 81 | ctx.cancel = () => { 82 | active = false 83 | if ( 84 | socket.readyState === WebSocket.OPEN || 85 | socket.readyState === WebSocket.CONNECTING 86 | ) { 87 | socket.close() 88 | } 89 | } 90 | socket.onmessage = (event) => { 91 | active = false 92 | if (socket.readyState === WebSocket.OPEN) { 93 | socket.close() 94 | } 95 | if (typeof Blob !== 'undefined' && event.data instanceof Blob) { 96 | const reader = new FileReader() 97 | reader.onload = () => { 98 | handleResponse(reader.result as string) 99 | } 100 | reader.onerror = (error) => { 101 | reject(error) 102 | } 103 | reader.readAsText(event.data) 104 | } else { 105 | if (typeof event.data === 'string') { 106 | handleResponse(event.data) 107 | } else { 108 | handleResponse(event.data.toString()) 109 | } 110 | } 111 | } 112 | socket.onopen = () => { 113 | retries = 0 114 | } 115 | socket.onclose = () => { 116 | if (active) { 117 | setTimeout(connect, backoff(retries++)) 118 | } 119 | } 120 | } 121 | connect() 122 | }) 123 | } 124 | 125 | /** 126 | * Long-poll for message. 127 | * @internal 128 | */ 129 | async function pollForCallback( 130 | url: string, 131 | ctx: {cancel?: () => void} 132 | ): Promise { 133 | let active = true 134 | ctx.cancel = () => { 135 | active = false 136 | } 137 | while (active) { 138 | try { 139 | const res = await fetch(url) 140 | if (res.status === 408) { 141 | continue 142 | } else if (res.status === 200) { 143 | return await res.json() 144 | } else { 145 | throw new Error(`HTTP ${res.status}: ${res.statusText}`) 146 | } 147 | } catch (error) { 148 | logWarn('Unexpected hyperbuoy error', error) 149 | } 150 | await sleep(1000) 151 | } 152 | return null as unknown as CallbackPayload 153 | } 154 | 155 | /** 156 | * Exponential backoff function that caps off at 10s after 10 tries. 157 | * https://i.imgur.com/IrUDcJp.png 158 | * @internal 159 | */ 160 | function backoff(tries: number): number { 161 | return Math.min(Math.pow(tries * 10, 2), 10 * 1000) 162 | } 163 | 164 | /** 165 | * Return promise that resolves after given milliseconds. 166 | * @internal 167 | */ 168 | function sleep(ms: number) { 169 | return new Promise((resolve) => { 170 | setTimeout(resolve, ms) 171 | }) 172 | } 173 | -------------------------------------------------------------------------------- /src/link-options.ts: -------------------------------------------------------------------------------- 1 | import type {APIClient} from '@wharfkit/antelope' 2 | import type {ChainIdType} from '@wharfkit/signing-request' 3 | 4 | import type {LinkCallbackService} from './link-callback' 5 | import type {LinkChain} from './link' 6 | import type {LinkStorage} from './link-storage' 7 | import type {LinkTransport} from './link-transport' 8 | 9 | /** 10 | * Type describing a EOSIO chain. 11 | */ 12 | export interface LinkChainConfig { 13 | /** 14 | * The chains unique 32-byte id. 15 | */ 16 | chainId: ChainIdType 17 | /** 18 | * URL to EOSIO node to communicate with (or a @wharfkit/antelope APIClient instance). 19 | */ 20 | nodeUrl: string | APIClient 21 | } 22 | 23 | /** 24 | * Available options when creating a new [[Link]] instance. 25 | */ 26 | export interface LinkOptions { 27 | /** 28 | * Link transport responsible for presenting signing requests to user. 29 | */ 30 | transport: LinkTransport 31 | /** 32 | * Chain configurations to support. 33 | * For example for a link that can login and transact on EOS and WAX: 34 | * ```ts 35 | * [ 36 | * { 37 | * chainId: 'aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906', 38 | * nodeUrl: 'https://eos.greymass.com', 39 | * }, 40 | * { 41 | * chainId: '1064487b3cd1a897ce03ae5b6a865651747e2e152090f99c1d19d44e01aea5a4', 42 | * nodeUrl: 'https://wax.greymass.com', 43 | * }, 44 | * ] 45 | * ``` 46 | */ 47 | chains: (LinkChainConfig | LinkChain)[] 48 | /** 49 | * ChainID or esr chain name alias for which the link is valid. 50 | * @deprecated Use [[chains]] instead. 51 | */ 52 | chainId?: ChainIdType 53 | /** 54 | * URL to EOSIO node to communicate with or a `@wharfkit/antelope` APIClient instance. 55 | * @deprecated Use [[chains]] instead. 56 | */ 57 | client?: string | APIClient 58 | /** 59 | * URL to callback forwarder service or an object implementing [[LinkCallbackService]]. 60 | * See [buoy-nodejs](https://github.com/greymass/buoy-nodejs) and (buoy-golang)[https://github.com/greymass/buoy-golang] 61 | * for reference implementations. 62 | * @default `https://cb.anchor.link` 63 | */ 64 | service?: string | LinkCallbackService 65 | /** 66 | * Optional storage adapter that will be used to persist sessions. If not set will use the transport storage 67 | * if available, explicitly set this to `null` to force no storage. 68 | * @default Use transport storage. 69 | */ 70 | storage?: LinkStorage | null 71 | /** 72 | * Whether to verify identity proofs submitted by the signer, if this is disabled the 73 | * [[Link.login | login]] and [[Link.identify | identify]] methods will not return an account object. 74 | * @default `false` 75 | */ 76 | verifyProofs?: boolean 77 | /** 78 | * Whether to encode the chain ids with the identity request that establishes a session. 79 | * Only applicable when using multiple chain configurations, can be set to false to 80 | * decrease QR code sizes when supporting many chains. 81 | * @default `true` 82 | */ 83 | encodeChainIds?: boolean 84 | } 85 | 86 | /** @internal */ 87 | export namespace LinkOptions { 88 | /** @internal */ 89 | export const defaults = { 90 | service: 'https://cb.anchor.link', 91 | verifyProofs: false, 92 | encodeChainIds: true, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/link-session.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Name, 3 | NameType, 4 | PermissionLevel, 5 | PermissionLevelType, 6 | PrivateKey, 7 | PrivateKeyType, 8 | PublicKey, 9 | PublicKeyType, 10 | Serializer, 11 | } from '@wharfkit/antelope' 12 | 13 | import {ChainId, ChainIdType, SigningRequest} from '@wharfkit/signing-request' 14 | 15 | import {SessionError} from './errors' 16 | import {Link, TransactArgs, TransactOptions, TransactResult} from './link' 17 | import {LinkTransport} from './link-transport' 18 | import {LinkCreate, LinkInfo, SealedMessage} from './link-types' 19 | import {fetch, logWarn, sealMessage, sessionMetadata} from './utils' 20 | 21 | /** 22 | * Type describing a link session that can create a eosjs compatible 23 | * signature provider and transact for a specific auth. 24 | */ 25 | export abstract class LinkSession { 26 | /** @internal */ 27 | constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function 28 | /** The underlying link instance used by the session. */ 29 | abstract link: Link 30 | /** App identifier that owns the session. */ 31 | abstract identifier: Name 32 | /** Id of the chain where the session is valid. */ 33 | abstract chainId: ChainId 34 | /** The public key the session can sign for. */ 35 | abstract publicKey: PublicKey 36 | /** The EOSIO auth (a.k.a. permission level) the session can sign for. */ 37 | abstract auth: PermissionLevel 38 | /** Session type, e.g. 'channel'. */ 39 | abstract type: string 40 | /** Arbitrary metadata that will be serialized with the session. */ 41 | abstract metadata: {[key: string]: any} 42 | /** Creates a eosjs compatible signature provider that can sign for the session public key. */ 43 | abstract makeSignatureProvider(): any 44 | /** 45 | * Transact using this session. See [[Link.transact]]. 46 | */ 47 | abstract transact(args: TransactArgs, options?: TransactOptions): Promise 48 | /** Returns a JSON-encodable object that can be used recreate the session. */ 49 | abstract serialize(): SerializedLinkSession 50 | /** 51 | * Convenience, remove this session from associated [[Link]] storage if set. 52 | * Equivalent to: 53 | * ```ts 54 | * session.link.removeSession(session.identifier, session.auth, session.chainId) 55 | * ``` 56 | */ 57 | async remove() { 58 | if (this.link.storage) { 59 | await this.link.removeSession(this.identifier, this.auth, this.chainId) 60 | } 61 | } 62 | /** API client for the chain this session is valid on. */ 63 | get client() { 64 | return this.link.getChain(this.chainId).client 65 | } 66 | /** Restore a previously serialized session. */ 67 | static restore(link: Link, data: SerializedLinkSession): LinkSession { 68 | switch (data.type) { 69 | case 'channel': 70 | return new LinkChannelSession(link, data.data, data.metadata) 71 | case 'fallback': 72 | return new LinkFallbackSession(link, data.data, data.metadata) 73 | default: 74 | throw new Error('Unable to restore, session data invalid') 75 | } 76 | } 77 | } 78 | 79 | /** @internal */ 80 | export interface SerializedLinkSession { 81 | type: string 82 | metadata: {[key: string]: any} 83 | data: any 84 | } 85 | 86 | /** @internal */ 87 | interface ChannelInfo { 88 | /** Public key requests are encrypted to. */ 89 | key: PublicKeyType 90 | /** The wallet given channel name, usually the device name. */ 91 | name: string 92 | /** The channel push url. */ 93 | url: string 94 | } 95 | 96 | /** @internal */ 97 | export interface LinkChannelSessionData { 98 | /** App identifier that owns the session. */ 99 | identifier: NameType 100 | /** Authenticated user permission. */ 101 | auth: PermissionLevelType 102 | /** Public key of authenticated user */ 103 | publicKey: PublicKeyType 104 | /** The wallet channel url. */ 105 | channel: ChannelInfo 106 | /** The private request key. */ 107 | requestKey: PrivateKeyType 108 | /** The session chain id. */ 109 | chainId: ChainIdType 110 | } 111 | 112 | /** 113 | * Link session that pushes requests over a channel. 114 | * @internal 115 | */ 116 | export class LinkChannelSession extends LinkSession implements LinkTransport { 117 | readonly link: Link 118 | readonly chainId: ChainId 119 | readonly auth: PermissionLevel 120 | readonly identifier: Name 121 | readonly type = 'channel' 122 | public metadata 123 | readonly publicKey: PublicKey 124 | serialize: () => SerializedLinkSession 125 | private timeout = 2 * 60 * 1000 // ms 126 | private encrypt: (request: SigningRequest) => SealedMessage 127 | private channelKey: PublicKey 128 | private channelUrl: string 129 | private channelName: string 130 | 131 | constructor(link: Link, data: LinkChannelSessionData, metadata: any) { 132 | super() 133 | this.link = link 134 | this.chainId = ChainId.from(data.chainId) 135 | this.auth = PermissionLevel.from(data.auth) 136 | this.publicKey = PublicKey.from(data.publicKey) 137 | this.identifier = Name.from(data.identifier) 138 | const privateKey = PrivateKey.from(data.requestKey) 139 | this.channelKey = PublicKey.from(data.channel.key) 140 | this.channelUrl = data.channel.url 141 | this.channelName = data.channel.name 142 | this.encrypt = (request) => { 143 | return sealMessage(request.encode(true, false), privateKey, this.channelKey) 144 | } 145 | this.metadata = { 146 | ...(metadata || {}), 147 | timeout: this.timeout, 148 | name: this.channelName, 149 | request_key: privateKey.toPublic(), 150 | } 151 | this.serialize = () => ({ 152 | type: 'channel', 153 | data: { 154 | ...data, 155 | channel: { 156 | url: this.channelUrl, 157 | key: this.channelKey, 158 | name: this.channelName, 159 | }, 160 | }, 161 | metadata: this.metadata, 162 | }) 163 | } 164 | 165 | onSuccess(request, result) { 166 | if (this.link.transport.onSuccess) { 167 | this.link.transport.onSuccess(request, result) 168 | } 169 | } 170 | 171 | onFailure(request, error) { 172 | if (this.link.transport.onFailure) { 173 | this.link.transport.onFailure(request, error) 174 | } 175 | } 176 | 177 | onRequest(request: SigningRequest, cancel) { 178 | const info = LinkInfo.from({ 179 | expiration: new Date(Date.now() + this.timeout), 180 | }) 181 | if (this.link.transport.onSessionRequest) { 182 | this.link.transport.onSessionRequest(this, request, cancel) 183 | } 184 | const timer = setTimeout(() => { 185 | cancel(new SessionError('Wallet did not respond in time', 'E_TIMEOUT', this)) 186 | }, this.timeout) 187 | request.setInfoKey('link', info) 188 | let payloadSent = false 189 | const payload = Serializer.encode({object: this.encrypt(request)}) 190 | if (this.link.transport.sendSessionPayload) { 191 | try { 192 | payloadSent = this.link.transport.sendSessionPayload(payload, this) 193 | } catch (error) { 194 | logWarn('Unexpected error when transport tried to send session payload', error) 195 | } 196 | } 197 | if (payloadSent) { 198 | return 199 | } 200 | fetch(this.channelUrl, { 201 | method: 'POST', 202 | headers: { 203 | 'X-Buoy-Soft-Wait': '10', 204 | }, 205 | body: payload.array, 206 | }) 207 | .then((response) => { 208 | if (Math.floor(response.status / 100) !== 2) { 209 | clearTimeout(timer) 210 | if (response.status === 202) { 211 | logWarn('Missing delivery ack from session channel') 212 | } 213 | cancel(new SessionError('Unable to push message', 'E_DELIVERY', this)) 214 | } else { 215 | // request delivered 216 | } 217 | }) 218 | .catch((error) => { 219 | clearTimeout(timer) 220 | cancel( 221 | new SessionError( 222 | `Unable to reach link service (${error.message || String(error)})`, 223 | 'E_DELIVERY', 224 | this 225 | ) 226 | ) 227 | }) 228 | } 229 | 230 | addLinkInfo(request: SigningRequest) { 231 | const createInfo = LinkCreate.from({ 232 | session_name: this.identifier, 233 | request_key: this.metadata.request_key, 234 | user_agent: this.link.getUserAgent(), 235 | }) 236 | request.setInfoKey('link', createInfo) 237 | } 238 | 239 | prepare(request) { 240 | if (this.link.transport.prepare) { 241 | return this.link.transport.prepare(request, this) 242 | } 243 | return Promise.resolve(request) 244 | } 245 | 246 | showLoading() { 247 | if (this.link.transport.showLoading) { 248 | return this.link.transport.showLoading() 249 | } 250 | } 251 | 252 | recoverError(error: Error, request: SigningRequest) { 253 | if (this.link.transport.recoverError) { 254 | return this.link.transport.recoverError(error, request) 255 | } 256 | return false 257 | } 258 | 259 | public makeSignatureProvider(): any { 260 | return this.link.makeSignatureProvider([this.publicKey.toString()], this.chainId, this) 261 | } 262 | 263 | async transact(args: TransactArgs, options?: TransactOptions) { 264 | const res: TransactResult = await this.link.transact( 265 | args, 266 | {...options, chain: this.chainId}, 267 | this 268 | ) 269 | // update session if callback payload contains new channel info 270 | if (res.payload.link_ch && res.payload.link_key && res.payload.link_name) { 271 | try { 272 | const metadata = { 273 | ...this.metadata, 274 | ...sessionMetadata(res.payload, res.resolved.request), 275 | } 276 | this.channelUrl = res.payload.link_ch 277 | this.channelKey = PublicKey.from(res.payload.link_key) 278 | this.channelName = res.payload.link_name 279 | metadata.name = res.payload.link_name 280 | this.metadata = metadata 281 | } catch (error) { 282 | logWarn('Unable to recover link session', error) 283 | } 284 | } 285 | return res 286 | } 287 | } 288 | 289 | /** @internal */ 290 | export interface LinkFallbackSessionData { 291 | auth: PermissionLevelType 292 | publicKey: PublicKeyType 293 | identifier: NameType 294 | chainId: ChainIdType 295 | } 296 | 297 | /** 298 | * Link session that sends every request over the transport. 299 | * @internal 300 | */ 301 | export class LinkFallbackSession extends LinkSession implements LinkTransport { 302 | readonly link: Link 303 | readonly chainId: ChainId 304 | readonly auth: PermissionLevel 305 | readonly type = 'fallback' 306 | readonly identifier: Name 307 | readonly metadata: {[key: string]: any} 308 | readonly publicKey: PublicKey 309 | serialize: () => SerializedLinkSession 310 | 311 | constructor(link: Link, data: LinkFallbackSessionData, metadata: any) { 312 | super() 313 | this.link = link 314 | this.auth = PermissionLevel.from(data.auth) 315 | this.publicKey = PublicKey.from(data.publicKey) 316 | this.chainId = ChainId.from(data.chainId) 317 | this.metadata = metadata || {} 318 | this.identifier = Name.from(data.identifier) 319 | this.serialize = () => ({ 320 | type: this.type, 321 | data, 322 | metadata: this.metadata, 323 | }) 324 | } 325 | 326 | onSuccess(request, result) { 327 | if (this.link.transport.onSuccess) { 328 | this.link.transport.onSuccess(request, result) 329 | } 330 | } 331 | 332 | onFailure(request, error) { 333 | if (this.link.transport.onFailure) { 334 | this.link.transport.onFailure(request, error) 335 | } 336 | } 337 | 338 | onRequest(request, cancel) { 339 | if (this.link.transport.onSessionRequest) { 340 | this.link.transport.onSessionRequest(this, request, cancel) 341 | } else { 342 | this.link.transport.onRequest(request, cancel) 343 | } 344 | } 345 | 346 | prepare(request) { 347 | if (this.link.transport.prepare) { 348 | return this.link.transport.prepare(request, this) 349 | } 350 | return Promise.resolve(request) 351 | } 352 | 353 | showLoading() { 354 | if (this.link.transport.showLoading) { 355 | return this.link.transport.showLoading() 356 | } 357 | } 358 | 359 | public makeSignatureProvider(): any { 360 | return this.link.makeSignatureProvider([this.publicKey.toString()], this.chainId, this) 361 | } 362 | 363 | transact(args: TransactArgs, options?: TransactOptions) { 364 | return this.link.transact(args, {...options, chain: this.chainId}, this) 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /src/link-storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface storage adapters should implement. 3 | * 4 | * Storage adapters are responsible for persisting [[LinkSession]]s and can optionally be 5 | * passed to the [[Link]] constructor to auto-persist sessions. 6 | */ 7 | export interface LinkStorage { 8 | /** Write string to storage at key. Should overwrite existing values without error. */ 9 | write(key: string, data: string): Promise 10 | /** Read key from storage. Should return `null` if key can not be found. */ 11 | read(key: string): Promise 12 | /** Delete key from storage. Should not error if deleting non-existing key. */ 13 | remove(key: string): Promise 14 | } 15 | -------------------------------------------------------------------------------- /src/link-transport.ts: -------------------------------------------------------------------------------- 1 | import type {Bytes} from '@wharfkit/antelope' 2 | import type {SigningRequest} from '@wharfkit/signing-request' 3 | 4 | import type {TransactResult} from './link' 5 | import type {LinkSession} from './link-session' 6 | import type {LinkStorage} from './link-storage' 7 | 8 | /** 9 | * Protocol link transports need to implement. 10 | * 11 | * A transport is responsible for getting the request to the 12 | * user, e.g. by opening request URIs or displaying QR codes. 13 | */ 14 | export interface LinkTransport { 15 | /** 16 | * Present a signing request to the user. 17 | * @param request The signing request. 18 | * @param cancel Can be called to abort the request. 19 | */ 20 | onRequest(request: SigningRequest, cancel: (reason: string | Error) => void): void 21 | /** Called if the request was successful. */ 22 | onSuccess?(request: SigningRequest, result: TransactResult): void 23 | /** Called if the request failed. */ 24 | onFailure?(request: SigningRequest, error: Error): void 25 | /** 26 | * Called when a session request is initiated. 27 | * @param session Session where the request originated. 28 | * @param request Signing request that will be sent over the session. 29 | */ 30 | onSessionRequest?( 31 | session: LinkSession, 32 | request: SigningRequest, 33 | cancel: (reason: string | Error) => void 34 | ): void 35 | /** Can be implemented if transport provides a storage as well. */ 36 | storage?: LinkStorage 37 | /** Can be implemented to modify request just after it has been created. */ 38 | prepare?(request: SigningRequest, session?: LinkSession): Promise 39 | /** Called immediately when the transaction starts */ 40 | showLoading?(): void 41 | /** User agent reported to the signer. */ 42 | userAgent?(): string 43 | /** Send session request payload, optional. Can return false to indicate it has to be sent over the socket. */ 44 | sendSessionPayload?(payload: Bytes, session: LinkSession): boolean 45 | /** 46 | * Can be implemented to recover from certain errors, if the function returns true the error will 47 | * not bubble up to the caller of .transact or .login and the link will continue waiting for the callback. 48 | */ 49 | recoverError?(error: Error, request: SigningRequest): boolean 50 | } 51 | -------------------------------------------------------------------------------- /src/link-types.ts: -------------------------------------------------------------------------------- 1 | import {Bytes, Name, PublicKey, Struct, TimePointSec, UInt32, UInt64} from '@wharfkit/antelope' 2 | 3 | @Struct.type('sealed_message') 4 | export class SealedMessage extends Struct { 5 | @Struct.field('public_key') from!: PublicKey 6 | @Struct.field('uint64') nonce!: UInt64 7 | @Struct.field('bytes') ciphertext!: Bytes 8 | @Struct.field('uint32') checksum!: UInt32 9 | } 10 | 11 | @Struct.type('link_create') 12 | export class LinkCreate extends Struct { 13 | @Struct.field('name') session_name!: Name 14 | @Struct.field('public_key') request_key!: PublicKey 15 | @Struct.field('string', {extension: true}) user_agent?: string 16 | } 17 | 18 | @Struct.type('link_info') 19 | export class LinkInfo extends Struct { 20 | @Struct.field('time_point_sec') expiration!: TimePointSec 21 | } 22 | -------------------------------------------------------------------------------- /src/link.ts: -------------------------------------------------------------------------------- 1 | import zlib from 'pako' 2 | 3 | import { 4 | ABIDef, 5 | ABISerializable, 6 | AnyAction, 7 | AnyTransaction, 8 | API, 9 | APIClient, 10 | Bytes, 11 | Name, 12 | NameType, 13 | PermissionLevel, 14 | PermissionLevelType, 15 | PrivateKey, 16 | Serializer, 17 | Signature, 18 | SignedTransaction, 19 | Transaction, 20 | } from '@wharfkit/antelope' 21 | 22 | import { 23 | AbiProvider, 24 | CallbackPayload, 25 | ChainId, 26 | ChainIdType, 27 | IdentityProof, 28 | PlaceholderName, 29 | PlaceholderPermission, 30 | ResolvedSigningRequest, 31 | ResolvedTransaction, 32 | SigningRequest, 33 | SigningRequestCreateArguments, 34 | } from '@wharfkit/signing-request' 35 | 36 | import {CancelError, IdentityError} from './errors' 37 | import {LinkOptions} from './link-options' 38 | import {LinkChannelSession, LinkFallbackSession, LinkSession} from './link-session' 39 | import {LinkStorage} from './link-storage' 40 | import {LinkTransport} from './link-transport' 41 | import {LinkCreate} from './link-types' 42 | import {BuoyCallbackService, LinkCallback, LinkCallbackService} from './link-callback' 43 | import {sessionMetadata} from './utils' 44 | 45 | /** 46 | * Payload accepted by the [[Link.transact]] method. 47 | * Note that one of `action`, `actions` or `transaction` must be set. 48 | */ 49 | export interface TransactArgs { 50 | /** Full transaction to sign. */ 51 | transaction?: AnyTransaction 52 | /** Action to sign. */ 53 | action?: AnyAction 54 | /** Actions to sign. */ 55 | actions?: AnyAction[] 56 | } 57 | 58 | /** 59 | * Options for the [[Link.transact]] method. 60 | */ 61 | export interface TransactOptions { 62 | /** 63 | * Whether to broadcast the transaction or just return the signature. 64 | * Defaults to true. 65 | */ 66 | broadcast?: boolean 67 | /** 68 | * Chain to use when configured with multiple chains. 69 | */ 70 | chain?: LinkChainType 71 | /** 72 | * Whether the signer can make modifications to the request 73 | * (e.g. applying a cosigner action to pay for resources). 74 | * 75 | * Defaults to false if [[broadcast]] is true or unspecified; otherwise true. 76 | */ 77 | noModify?: boolean 78 | } 79 | 80 | /** 81 | * The result of a [[Link.transact]] call. 82 | */ 83 | export interface TransactResult { 84 | /** The resolved signing request. */ 85 | resolved: ResolvedSigningRequest 86 | /** The chain that was used. */ 87 | chain: LinkChain 88 | /** The transaction signatures. */ 89 | signatures: Signature[] 90 | /** The callback payload. */ 91 | payload: CallbackPayload 92 | /** The signer authority. */ 93 | signer: PermissionLevel 94 | /** The resulting transaction. */ 95 | transaction: Transaction 96 | /** Resolved version of transaction, with the action data decoded. */ 97 | resolvedTransaction: ResolvedTransaction 98 | /** Push transaction response from api node, only present if transaction was broadcast. */ 99 | processed?: {[key: string]: any} 100 | } 101 | 102 | /** 103 | * The result of a [[Link.identify]] call. 104 | */ 105 | export interface IdentifyResult extends TransactResult { 106 | /** The identified account, not present unless [[LinkOptions.verifyProofs]] is set to true. */ 107 | account?: API.v1.AccountObject 108 | /** The identity proof. */ 109 | proof: IdentityProof 110 | } 111 | 112 | /** 113 | * The result of a [[Link.login]] call. 114 | */ 115 | export interface LoginResult extends IdentifyResult { 116 | /** The session created by the login. */ 117 | session: LinkSession 118 | } 119 | 120 | /** 121 | * Link chain, can be a [[LinkChain]] instance, a chain id or a index in [[Link.chains]]. 122 | * @internal 123 | */ 124 | export type LinkChainType = LinkChain | ChainIdType | number 125 | 126 | /** 127 | * Class representing a EOSIO chain. 128 | */ 129 | export class LinkChain implements AbiProvider { 130 | /** EOSIO ChainID for which requests are valid. */ 131 | public chainId: ChainId 132 | /** API client instance used to communicate with the chain. */ 133 | public client: APIClient 134 | 135 | private abiCache = new Map() 136 | private pendingAbis = new Map>() 137 | 138 | /** @internal */ 139 | constructor(chainId: ChainIdType, clientOrUrl: APIClient | string) { 140 | this.chainId = ChainId.from(chainId) 141 | this.client = 142 | typeof clientOrUrl === 'string' ? new APIClient({url: clientOrUrl}) : clientOrUrl 143 | } 144 | 145 | /** 146 | * Fetch the ABI for given account, cached. 147 | * @internal 148 | */ 149 | public async getAbi(account: Name) { 150 | const key = String(account) 151 | let rv = this.abiCache.get(key) 152 | if (!rv) { 153 | let getAbi = this.pendingAbis.get(key) 154 | if (!getAbi) { 155 | getAbi = this.client.v1.chain.get_abi(account) 156 | this.pendingAbis.set(key, getAbi) 157 | } 158 | rv = (await getAbi).abi 159 | this.pendingAbis.delete(key) 160 | if (rv) { 161 | this.abiCache.set(key, rv) 162 | } 163 | } 164 | return rv as ABIDef 165 | } 166 | } 167 | 168 | /** 169 | * Anchor Link main class. 170 | * 171 | * @example 172 | * 173 | * ```ts 174 | * import AnchorLink from 'anchor-link' 175 | * import ConsoleTransport from 'anchor-link-console-transport' 176 | * 177 | * const link = new AnchorLink({ 178 | * transport: new ConsoleTransport(), 179 | * chains: [ 180 | * { 181 | * chainId: 'aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906', 182 | * nodeUrl: 'https://eos.greymass.com', 183 | * }, 184 | * ], 185 | * }) 186 | * 187 | * const result = await link.transact({actions: myActions}) 188 | * ``` 189 | */ 190 | export class Link { 191 | /** Package version. */ 192 | static version: string = '__ver' // eslint-disable-line @typescript-eslint/no-inferrable-types 193 | 194 | /** Chains this instance is configured with. */ 195 | public readonly chains: LinkChain[] 196 | /** Transport used to deliver requests to the users wallet. */ 197 | public readonly transport: LinkTransport 198 | /** Storage adapter used to persist sessions. */ 199 | public readonly storage?: LinkStorage 200 | 201 | private callbackService: LinkCallbackService 202 | private verifyProofs: boolean 203 | private encodeChainIds: boolean 204 | 205 | /** Create a new link instance. */ 206 | constructor(options: LinkOptions) { 207 | if (typeof options !== 'object') { 208 | throw new TypeError('Missing options object') 209 | } 210 | if (!options.transport) { 211 | throw new TypeError('options.transport is required') 212 | } 213 | let chains = options.chains || [] 214 | if (options.chainId && options.client) { 215 | if (options.chains.length > 0) { 216 | throw new TypeError( 217 | 'options.chainId and options.client are deprecated and cannot be used together with options.chains' 218 | ) 219 | } 220 | chains = [{chainId: options.chainId, nodeUrl: options.client}] 221 | } 222 | if (chains.length === 0) { 223 | throw new TypeError('options.chains is required') 224 | } 225 | this.chains = chains.map((chain) => { 226 | if (chain instanceof LinkChain) { 227 | return chain 228 | } 229 | if (!chain.chainId) { 230 | throw new TypeError('options.chains[].chainId is required') 231 | } 232 | if (!chain.nodeUrl) { 233 | throw new TypeError('options.chains[].nodeUrl is required') 234 | } 235 | return new LinkChain(chain.chainId, chain.nodeUrl) 236 | }) 237 | if (options.service === undefined || typeof options.service === 'string') { 238 | this.callbackService = new BuoyCallbackService( 239 | options.service || LinkOptions.defaults.service 240 | ) 241 | } else { 242 | this.callbackService = options.service 243 | } 244 | this.transport = options.transport 245 | if (options.storage !== null) { 246 | this.storage = options.storage || this.transport.storage 247 | } 248 | this.verifyProofs = 249 | options.verifyProofs !== undefined 250 | ? options.verifyProofs 251 | : LinkOptions.defaults.verifyProofs 252 | this.encodeChainIds = 253 | options.encodeChainIds !== undefined 254 | ? options.encodeChainIds 255 | : LinkOptions.defaults.encodeChainIds 256 | } 257 | 258 | /** 259 | * The APIClient instance for communicating with the node. 260 | * @note This returns the first APIClient when link is configured with multiple chains. 261 | */ 262 | public get client() { 263 | return this.chains[0].client 264 | } 265 | 266 | /** 267 | * Return a [[LinkChain]] object for given chainId or chain reference. 268 | * @throws If this link instance has no configured chain for given reference. 269 | * @internal 270 | */ 271 | public getChain(chain: LinkChainType) { 272 | if (chain instanceof LinkChain) { 273 | return chain 274 | } 275 | if (typeof chain === 'number') { 276 | const rv = this.chains[chain] 277 | if (!rv) { 278 | throw new Error(`Invalid chain index: ${chain}`) 279 | } 280 | return rv 281 | } 282 | const id = ChainId.from(chain) 283 | const rv = this.chains.find((c) => c.chainId.equals(id)) 284 | if (!rv) { 285 | throw new Error(`Unsupported chain: ${id}`) 286 | } 287 | return rv 288 | } 289 | 290 | /** 291 | * Create a SigningRequest instance configured for this link. 292 | * @internal 293 | */ 294 | public async createRequest( 295 | args: SigningRequestCreateArguments, 296 | chain?: LinkChain, 297 | transport?: LinkTransport 298 | ) { 299 | const t = transport || this.transport 300 | let request: SigningRequest 301 | if (chain || this.chains.length === 1) { 302 | const c = chain || this.chains[0] 303 | request = await SigningRequest.create( 304 | { 305 | ...args, 306 | chainId: c.chainId, 307 | broadcast: false, 308 | }, 309 | {abiProvider: c, zlib} 310 | ) 311 | } else { 312 | // multi-chain request 313 | request = await SigningRequest.create( 314 | { 315 | ...args, 316 | chainId: null, 317 | chainIds: this.encodeChainIds ? this.chains.map((c) => c.chainId) : undefined, 318 | broadcast: false, 319 | }, 320 | // abi's will be pulled from the first chain and assumed to be identical on all chains 321 | {abiProvider: this.chains[0], zlib} 322 | ) 323 | } 324 | if (t.prepare) { 325 | request = await t.prepare(request) 326 | } 327 | const callback = this.callbackService.create() 328 | request.setCallback(callback.url, true) 329 | return {request, callback} 330 | } 331 | 332 | /** 333 | * Send a SigningRequest instance using this link. 334 | * @internal 335 | */ 336 | public async sendRequest( 337 | request: SigningRequest, 338 | callback: LinkCallback, 339 | chain?: LinkChain, 340 | transport?: LinkTransport, 341 | broadcast = false 342 | ) { 343 | const t = transport || this.transport 344 | try { 345 | const linkUrl = request.data.callback 346 | if (linkUrl !== callback.url) { 347 | throw new Error('Invalid request callback') 348 | } 349 | if (request.data.flags.broadcast === true || request.data.flags.background === false) { 350 | throw new Error('Invalid request flags') 351 | } 352 | // wait for callback or user cancel 353 | let done = false 354 | const cancel = new Promise((resolve, reject) => { 355 | t.onRequest(request, (reason) => { 356 | if (done) { 357 | // ignore any cancel calls once callbackResponse below has resolved 358 | return 359 | } 360 | const error = typeof reason === 'string' ? new CancelError(reason) : reason 361 | if (t.recoverError && t.recoverError(error, request) === true) { 362 | // transport was able to recover from the error 363 | return 364 | } 365 | callback.cancel() 366 | reject(error) 367 | }) 368 | }) 369 | const callbackResponse = await Promise.race([callback.wait(), cancel]) 370 | done = true 371 | if (typeof callbackResponse.rejected === 'string') { 372 | throw new CancelError(callbackResponse.rejected) 373 | } 374 | const payload = callbackResponse as CallbackPayload 375 | const signer = PermissionLevel.from({ 376 | actor: payload.sa, 377 | permission: payload.sp, 378 | }) 379 | const signatures: Signature[] = Object.keys(payload) 380 | .filter((key) => key.startsWith('sig') && key !== 'sig0') 381 | .map((key) => Signature.from(payload[key]!)) 382 | let c: LinkChain 383 | if (!chain && this.chains.length > 1) { 384 | if (!payload.cid) { 385 | throw new Error( 386 | 'Multi chain response payload must specify resolved chain id (cid)' 387 | ) 388 | } 389 | c = this.getChain(payload.cid) 390 | } else { 391 | c = chain || this.getChain(0) 392 | if (payload.cid && !c.chainId.equals(payload.cid)) { 393 | throw new Error('Got response for wrong chain id') 394 | } 395 | } 396 | // recreate transaction from request response 397 | const resolved = await ResolvedSigningRequest.fromPayload(payload, { 398 | zlib, 399 | abiProvider: c, 400 | }) 401 | // prepend cosigner signature if present 402 | const cosignerSig = resolved.request.getInfoKey('cosig', { 403 | type: Signature, 404 | array: true, 405 | }) as Signature[] | undefined 406 | if (cosignerSig) { 407 | signatures.unshift(...cosignerSig) 408 | } 409 | const result: TransactResult = { 410 | resolved, 411 | chain: c, 412 | transaction: resolved.transaction, 413 | resolvedTransaction: resolved.resolvedTransaction, 414 | signatures, 415 | payload, 416 | signer, 417 | } 418 | if (broadcast) { 419 | const signedTx = SignedTransaction.from({ 420 | ...resolved.transaction, 421 | signatures, 422 | }) 423 | const res = await c.client.v1.chain.push_transaction(signedTx) 424 | result.processed = res.processed 425 | } 426 | if (t.onSuccess) { 427 | t.onSuccess(request, result) 428 | } 429 | return result 430 | } catch (error) { 431 | if (t.onFailure) { 432 | t.onFailure(request, error) 433 | } 434 | throw error 435 | } 436 | } 437 | 438 | /** 439 | * Sign and optionally broadcast a EOSIO transaction, action or actions. 440 | * 441 | * Example: 442 | * 443 | * ```ts 444 | * let result = await myLink.transact({transaction: myTx}) 445 | * ``` 446 | * 447 | * @param args The action, actions or transaction to use. 448 | * @param options Options for this transact call. 449 | * @param transport Transport override, for internal use. 450 | */ 451 | public async transact( 452 | args: TransactArgs, 453 | options?: TransactOptions, 454 | transport?: LinkTransport 455 | ): Promise { 456 | const o = options || {} 457 | const t = transport || this.transport 458 | const c = o.chain !== undefined ? this.getChain(o.chain) : undefined 459 | const broadcast = o.broadcast !== false 460 | const noModify = o.noModify !== undefined ? o.noModify : !broadcast 461 | // Initialize the loading state of the transport 462 | if (t && t.showLoading) { 463 | t.showLoading() 464 | } 465 | // eosjs transact compat: upgrade to transaction if args have any header fields 466 | const anyArgs = args as any 467 | if ( 468 | args.actions && 469 | (anyArgs.expiration || 470 | anyArgs.ref_block_num || 471 | anyArgs.ref_block_prefix || 472 | anyArgs.max_net_usage_words || 473 | anyArgs.max_cpu_usage_ms || 474 | anyArgs.delay_sec) 475 | ) { 476 | args = { 477 | transaction: { 478 | expiration: '1970-01-01T00:00:00', 479 | ref_block_num: 0, 480 | ref_block_prefix: 0, 481 | max_net_usage_words: 0, 482 | max_cpu_usage_ms: 0, 483 | delay_sec: 0, 484 | ...anyArgs, 485 | }, 486 | } 487 | } 488 | const {request, callback} = await this.createRequest(args, c, t) 489 | if (noModify) { 490 | request.setInfoKey('no_modify', true, 'bool') 491 | } 492 | const result = await this.sendRequest(request, callback, c, t, broadcast) 493 | return result 494 | } 495 | 496 | /** 497 | * Send an identity request and verify the identity proof if [[LinkOptions.verifyProofs]] is true. 498 | * @param args.scope The scope of the identity request. 499 | * @param args.requestPermission Optional request permission if the request is for a specific account or permission. 500 | * @param args.info Metadata to add to the request. 501 | * @note This is for advanced use-cases, you probably want to use [[Link.login]] instead. 502 | */ 503 | public async identify(args: { 504 | scope: NameType 505 | requestPermission?: PermissionLevelType 506 | info?: {[key: string]: ABISerializable | Bytes} 507 | }): Promise { 508 | const {request, callback} = await this.createRequest({ 509 | identity: {permission: args.requestPermission, scope: args.scope}, 510 | info: args.info, 511 | }) 512 | const res = await this.sendRequest(request, callback) 513 | if (!res.resolved.request.isIdentity()) { 514 | throw new IdentityError('Unexpected response') 515 | } 516 | 517 | let account: API.v1.AccountObject | undefined 518 | const proof = res.resolved.getIdentityProof(res.signatures[0]) 519 | if (this.verifyProofs) { 520 | account = await res.chain.client.v1.chain.get_account(res.signer.actor) 521 | if (!account) { 522 | throw new IdentityError(`Signature from unknown account: ${proof.signer.actor}`) 523 | } 524 | const accountPermission = account.permissions.find(({perm_name}) => 525 | proof.signer.permission.equals(perm_name) 526 | ) 527 | if (!accountPermission) { 528 | throw new IdentityError( 529 | `${proof.signer.actor} signed for unknown permission: ${proof.signer.permission}` 530 | ) 531 | } 532 | const proofValid = proof.verify( 533 | accountPermission.required_auth, 534 | account.head_block_time 535 | ) 536 | if (!proofValid) { 537 | throw new IdentityError(`Invalid identify proof for: ${proof.signer}`) 538 | } 539 | } 540 | 541 | if (args.requestPermission) { 542 | const perm = PermissionLevel.from(args.requestPermission) 543 | if ( 544 | (!perm.actor.equals(PlaceholderName) && !perm.actor.equals(proof.signer.actor)) || 545 | (!perm.permission.equals(PlaceholderPermission) && 546 | !perm.permission.equals(proof.signer.permission)) 547 | ) { 548 | throw new IdentityError( 549 | `Identity proof singed by ${proof.signer}, expected: ${formatAuth(perm)} ` 550 | ) 551 | } 552 | } 553 | return { 554 | ...res, 555 | account, 556 | proof, 557 | } 558 | } 559 | 560 | /** 561 | * Login and create a persistent session. 562 | * @param identifier The session identifier, an EOSIO name (`[a-z1-5]{1,12}`). 563 | * Should be set to the contract account if applicable. 564 | */ 565 | public async login(identifier: NameType): Promise { 566 | const privateKey = PrivateKey.generate('K1') 567 | const requestKey = privateKey.toPublic() 568 | const createInfo = LinkCreate.from({ 569 | session_name: identifier, 570 | request_key: requestKey, 571 | user_agent: this.getUserAgent(), 572 | }) 573 | const res = await this.identify({ 574 | scope: identifier, 575 | info: { 576 | link: createInfo, 577 | scope: identifier, 578 | }, 579 | }) 580 | const metadata = sessionMetadata(res.payload, res.resolved.request) 581 | const signerKey = res.proof.recover() 582 | let session: LinkSession 583 | if (res.payload.link_ch && res.payload.link_key && res.payload.link_name) { 584 | session = new LinkChannelSession( 585 | this, 586 | { 587 | identifier, 588 | chainId: res.chain.chainId, 589 | auth: res.signer, 590 | publicKey: signerKey, 591 | channel: { 592 | url: res.payload.link_ch, 593 | key: res.payload.link_key, 594 | name: res.payload.link_name, 595 | }, 596 | requestKey: privateKey, 597 | }, 598 | metadata 599 | ) 600 | } else { 601 | session = new LinkFallbackSession( 602 | this, 603 | { 604 | identifier, 605 | chainId: res.chain.chainId, 606 | auth: res.signer, 607 | publicKey: signerKey, 608 | }, 609 | metadata 610 | ) 611 | } 612 | await this.storeSession(session) 613 | return { 614 | ...res, 615 | session, 616 | } 617 | } 618 | 619 | /** 620 | * Restore previous session, use [[login]] to create a new session. 621 | * @param identifier The session identifier, must be same as what was used when creating the session with [[login]]. 622 | * @param auth A specific session auth to restore, if omitted the most recently used session will be restored. 623 | * @param chainId If given function will only consider that specific chain when restoring session. 624 | * @returns A [[LinkSession]] instance or null if no session can be found. 625 | * @throws If no [[LinkStorage]] adapter is configured or there was an error retrieving the session data. 626 | **/ 627 | public async restoreSession( 628 | identifier: NameType, 629 | auth?: PermissionLevelType, 630 | chainId?: ChainIdType 631 | ) { 632 | if (!this.storage) { 633 | throw new Error('Unable to restore session: No storage adapter configured') 634 | } 635 | let key: string 636 | if (auth && chainId) { 637 | // both auth and chain id given, we can look up on specific key 638 | key = this.sessionKey( 639 | identifier, 640 | formatAuth(PermissionLevel.from(auth)), 641 | String(ChainId.from(chainId)) 642 | ) 643 | } else { 644 | // otherwise we use the session list to filter down to most recently used matching given params 645 | let list = await this.listSessions(identifier) 646 | if (auth) { 647 | list = list.filter((item) => item.auth.equals(auth)) 648 | } 649 | if (chainId) { 650 | const id = ChainId.from(chainId) 651 | list = list.filter((item) => item.chainId.equals(id)) 652 | } 653 | const latest = list[0] 654 | if (!latest) { 655 | return null 656 | } 657 | key = this.sessionKey(identifier, formatAuth(latest.auth), String(latest.chainId)) 658 | } 659 | const data = await this.storage.read(key) 660 | if (!data) { 661 | return null 662 | } 663 | let sessionData: any 664 | try { 665 | sessionData = JSON.parse(data) 666 | } catch (error) { 667 | throw new Error( 668 | `Unable to restore session: Stored JSON invalid (${error.message || String(error)})` 669 | ) 670 | } 671 | const session = LinkSession.restore(this, sessionData) 672 | if (auth || chainId) { 673 | // update latest used 674 | await this.touchSession(identifier, session.auth, session.chainId) 675 | } 676 | return session 677 | } 678 | 679 | /** 680 | * List stored session auths for given identifier. 681 | * The most recently used session is at the top (index 0). 682 | * @throws If no [[LinkStorage]] adapter is configured or there was an error retrieving the session list. 683 | **/ 684 | public async listSessions(identifier: NameType) { 685 | if (!this.storage) { 686 | throw new Error('Unable to list sessions: No storage adapter configured') 687 | } 688 | const key = this.sessionKey(identifier, 'list') 689 | let list: {auth: PermissionLevelType; chainId: ChainIdType}[] 690 | try { 691 | list = JSON.parse((await this.storage.read(key)) || '[]') 692 | } catch (error) { 693 | throw new Error(`Unable to list sessions: ${error.message || String(error)}`) 694 | } 695 | return list.map(({auth, chainId}) => ({ 696 | auth: PermissionLevel.from(auth), 697 | chainId: ChainId.from(chainId), 698 | })) 699 | } 700 | 701 | /** 702 | * Remove stored session for given identifier and auth. 703 | * @throws If no [[LinkStorage]] adapter is configured or there was an error removing the session data. 704 | */ 705 | public async removeSession(identifier: NameType, auth: PermissionLevel, chainId: ChainId) { 706 | if (!this.storage) { 707 | throw new Error('Unable to remove session: No storage adapter configured') 708 | } 709 | const key = this.sessionKey(identifier, formatAuth(auth), String(chainId)) 710 | await this.storage.remove(key) 711 | await this.touchSession(identifier, auth, chainId, true) 712 | } 713 | 714 | /** 715 | * Remove all stored sessions for given identifier. 716 | * @throws If no [[LinkStorage]] adapter is configured or there was an error removing the session data. 717 | */ 718 | public async clearSessions(identifier: string) { 719 | if (!this.storage) { 720 | throw new Error('Unable to clear sessions: No storage adapter configured') 721 | } 722 | for (const {auth, chainId} of await this.listSessions(identifier)) { 723 | await this.removeSession(identifier, auth, chainId) 724 | } 725 | } 726 | 727 | /** 728 | * Create an eosjs compatible signature provider using this link. 729 | * @param availableKeys Keys the created provider will claim to be able to sign for. 730 | * @param chain Chain to use when configured with multiple chains. 731 | * @param transport (internal) Transport override for this call. 732 | * @note We don't know what keys are available so those have to be provided, 733 | * to avoid this use [[LinkSession.makeSignatureProvider]] instead. Sessions can be created with [[Link.login]]. 734 | */ 735 | public makeSignatureProvider( 736 | availableKeys: string[], 737 | chain?: LinkChainType, 738 | transport?: LinkTransport 739 | ): any { 740 | return { 741 | getAvailableKeys: async () => availableKeys, 742 | sign: async (args) => { 743 | const t = transport || this.transport 744 | const c = chain ? this.getChain(chain) : this.chains[0] 745 | let request = SigningRequest.fromTransaction( 746 | args.chainId, 747 | args.serializedTransaction, 748 | {abiProvider: c, zlib} 749 | ) 750 | const callback = this.callbackService.create() 751 | request.setCallback(callback.url, true) 752 | request.setBroadcast(false) 753 | if (t.prepare) { 754 | request = await t.prepare(request) 755 | } 756 | const {transaction, signatures} = await this.sendRequest(request, callback, c, t) 757 | const serializedTransaction = Serializer.encode({object: transaction}) 758 | return { 759 | ...args, 760 | serializedTransaction, 761 | signatures, 762 | } 763 | }, 764 | } 765 | } 766 | 767 | /** Makes sure session is in storage list of sessions and moves it to top (most recently used). */ 768 | private async touchSession( 769 | identifier: NameType, 770 | auth: PermissionLevel, 771 | chainId: ChainId, 772 | remove = false 773 | ) { 774 | const list = await this.listSessions(identifier) 775 | const existing = list.findIndex( 776 | (item) => item.auth.equals(auth) && item.chainId.equals(chainId) 777 | ) 778 | if (existing >= 0) { 779 | list.splice(existing, 1) 780 | } 781 | if (remove === false) { 782 | list.unshift({auth, chainId}) 783 | } 784 | const key = this.sessionKey(identifier, 'list') 785 | await this.storage!.write(key, JSON.stringify(list)) 786 | } 787 | 788 | /** 789 | * Makes sure session is in storage list of sessions and moves it to top (most recently used). 790 | * @internal 791 | */ 792 | async storeSession(session: LinkSession) { 793 | if (this.storage) { 794 | const key = this.sessionKey( 795 | session.identifier, 796 | formatAuth(session.auth), 797 | String(session.chainId) 798 | ) 799 | const data = JSON.stringify(session.serialize()) 800 | await this.storage.write(key, data) 801 | await this.touchSession(session.identifier, session.auth, session.chainId) 802 | } 803 | } 804 | 805 | /** Session storage key for identifier and suffix. */ 806 | private sessionKey(identifier: NameType, ...suffix: string[]) { 807 | return [String(Name.from(identifier)), ...suffix].join('-') 808 | } 809 | 810 | /** 811 | * Return user agent of this link. 812 | * @internal 813 | */ 814 | getUserAgent() { 815 | let rv = `AnchorLink/${Link.version}` 816 | if (this.transport.userAgent) { 817 | rv += ' ' + this.transport.userAgent() 818 | } 819 | return rv 820 | } 821 | } 822 | 823 | /** 824 | * Format a EOSIO permission level in the format `actor@permission` taking placeholders into consideration. 825 | * @internal 826 | */ 827 | function formatAuth(auth: PermissionLevelType): string { 828 | const a = PermissionLevel.from(auth) 829 | const actor = a.actor.equals(PlaceholderName) ? '' : String(a.actor) 830 | let permission: string 831 | if (a.permission.equals(PlaceholderName) || a.permission.equals(PlaceholderPermission)) { 832 | permission = '' 833 | } else { 834 | permission = String(a.permission) 835 | } 836 | return `${actor}@${permission}` 837 | } 838 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import makeFetch from 'fetch-ponyfill' 2 | import {AES_CBC} from '@greymass/miniaes' 3 | import { 4 | Bytes, 5 | Checksum256, 6 | Checksum512, 7 | PrivateKey, 8 | PublicKey, 9 | Serializer, 10 | UInt64, 11 | } from '@wharfkit/antelope' 12 | import {CallbackPayload, SigningRequest} from '@wharfkit/signing-request' 13 | 14 | import {SealedMessage} from './link-types' 15 | 16 | /** @internal */ 17 | export const fetch = makeFetch().fetch 18 | 19 | /** 20 | * Encrypt a message using AES and shared secret derived from given keys. 21 | * @internal 22 | */ 23 | export function sealMessage( 24 | message: string, 25 | privateKey: PrivateKey, 26 | publicKey: PublicKey, 27 | nonce?: UInt64 28 | ): SealedMessage { 29 | const secret = privateKey.sharedSecret(publicKey) 30 | if (!nonce) { 31 | nonce = UInt64.random() 32 | } 33 | const key = Checksum512.hash(Serializer.encode({object: nonce}).appending(secret.array)) 34 | const cbc = new AES_CBC(key.array.slice(0, 32), key.array.slice(32, 48)) 35 | const ciphertext = Bytes.from(cbc.encrypt(Bytes.from(message, 'utf8').array)) 36 | const checksumView = new DataView(Checksum256.hash(key.array).array.buffer) 37 | const checksum = checksumView.getUint32(0, true) 38 | return SealedMessage.from({ 39 | from: privateKey.toPublic(), 40 | nonce, 41 | ciphertext, 42 | checksum, 43 | }) 44 | } 45 | 46 | /** 47 | * Extract session metadata from a callback payload and request. 48 | * @internal 49 | */ 50 | export function sessionMetadata(payload: CallbackPayload, request: SigningRequest) { 51 | const metadata: Record = { 52 | // backwards compat, can be removed next major release 53 | sameDevice: request.getRawInfo()['return_path'] !== undefined, 54 | } 55 | // append extra metadata from the signer 56 | if (payload.link_meta) { 57 | try { 58 | const parsed = JSON.parse(payload.link_meta) 59 | for (const key of Object.keys(parsed)) { 60 | // normalize key names to camelCase 61 | metadata[snakeToCamel(key)] = parsed[key] 62 | } 63 | } catch (error) { 64 | logWarn('Unable to parse link metadata', error, payload.link_meta) 65 | } 66 | } 67 | return metadata 68 | } 69 | 70 | /** 71 | * Return PascalCase version of snake_case string. 72 | * @internal 73 | */ 74 | function snakeToPascal(name: string): string { 75 | return name 76 | .split('_') 77 | .map((v) => (v[0] ? v[0].toUpperCase() : '_') + v.slice(1)) 78 | .join('') 79 | } 80 | 81 | /** 82 | * Return camelCase version of snake_case string. 83 | * @internal 84 | */ 85 | function snakeToCamel(name: string): string { 86 | const pascal = snakeToPascal(name) 87 | return pascal[0].toLowerCase() + pascal.slice(1) 88 | } 89 | 90 | /** 91 | * Print a warning message to console. 92 | * @internal 93 | **/ 94 | export function logWarn(...args: any[]) { 95 | // eslint-disable-next-line no-console 96 | console.warn('[anchor-link]', ...args) 97 | } 98 | -------------------------------------------------------------------------------- /test/abis/eosio.token.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "eosio::abi/1.1", 3 | "types": [], 4 | "structs": [{ 5 | "name": "account", 6 | "base": "", 7 | "fields": [{ 8 | "name": "balance", 9 | "type": "asset" 10 | } 11 | ] 12 | },{ 13 | "name": "close", 14 | "base": "", 15 | "fields": [{ 16 | "name": "owner", 17 | "type": "name" 18 | },{ 19 | "name": "symbol", 20 | "type": "symbol" 21 | } 22 | ] 23 | },{ 24 | "name": "create", 25 | "base": "", 26 | "fields": [{ 27 | "name": "issuer", 28 | "type": "name" 29 | },{ 30 | "name": "maximum_supply", 31 | "type": "asset" 32 | } 33 | ] 34 | },{ 35 | "name": "currency_stats", 36 | "base": "", 37 | "fields": [{ 38 | "name": "supply", 39 | "type": "asset" 40 | },{ 41 | "name": "max_supply", 42 | "type": "asset" 43 | },{ 44 | "name": "issuer", 45 | "type": "name" 46 | } 47 | ] 48 | },{ 49 | "name": "issue", 50 | "base": "", 51 | "fields": [{ 52 | "name": "to", 53 | "type": "name" 54 | },{ 55 | "name": "quantity", 56 | "type": "asset" 57 | },{ 58 | "name": "memo", 59 | "type": "string" 60 | } 61 | ] 62 | },{ 63 | "name": "open", 64 | "base": "", 65 | "fields": [{ 66 | "name": "owner", 67 | "type": "name" 68 | },{ 69 | "name": "symbol", 70 | "type": "symbol" 71 | },{ 72 | "name": "ram_payer", 73 | "type": "name" 74 | } 75 | ] 76 | },{ 77 | "name": "retire", 78 | "base": "", 79 | "fields": [{ 80 | "name": "quantity", 81 | "type": "asset" 82 | },{ 83 | "name": "memo", 84 | "type": "string" 85 | } 86 | ] 87 | },{ 88 | "name": "transfer", 89 | "base": "", 90 | "fields": [{ 91 | "name": "from", 92 | "type": "name" 93 | },{ 94 | "name": "to", 95 | "type": "name" 96 | },{ 97 | "name": "quantity", 98 | "type": "asset" 99 | },{ 100 | "name": "memo", 101 | "type": "string" 102 | } 103 | ] 104 | } 105 | ], 106 | "actions": [{ 107 | "name": "close", 108 | "type": "close", 109 | "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Close Token Balance\nsummary: 'Close {{nowrap owner}}’s zero quantity balance'\nicon: https://raw.githubusercontent.com/cryptokylin/eosio.contracts/v1.7.0/contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\n{{owner}} agrees to close their zero quantity balance for the {{symbol_to_symbol_code symbol}} token.\n\nRAM will be refunded to the RAM payer of the {{symbol_to_symbol_code symbol}} token balance for {{owner}}." 110 | },{ 111 | "name": "create", 112 | "type": "create", 113 | "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Create New Token\nsummary: 'Create a new token'\nicon: https://raw.githubusercontent.com/cryptokylin/eosio.contracts/v1.7.0/contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\n{{$action.account}} agrees to create a new token with symbol {{asset_to_symbol_code maximum_supply}} to be managed by {{issuer}}.\n\nThis action will not result any any tokens being issued into circulation.\n\n{{issuer}} will be allowed to issue tokens into circulation, up to a maximum supply of {{maximum_supply}}.\n\nRAM will deducted from {{$action.account}}’s resources to create the necessary records." 114 | },{ 115 | "name": "issue", 116 | "type": "issue", 117 | "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Issue Tokens into Circulation\nsummary: 'Issue {{nowrap quantity}} into circulation and transfer into {{nowrap to}}’s account'\nicon: https://raw.githubusercontent.com/cryptokylin/eosio.contracts/v1.7.0/contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nThe token manager agrees to issue {{quantity}} into circulation, and transfer it into {{to}}’s account.\n\n{{#if memo}}There is a memo attached to the transfer stating:\n{{memo}}\n{{/if}}\n\nIf {{to}} does not have a balance for {{asset_to_symbol_code quantity}}, or the token manager does not have a balance for {{asset_to_symbol_code quantity}}, the token manager will be designated as the RAM payer of the {{asset_to_symbol_code quantity}} token balance for {{to}}. As a result, RAM will be deducted from the token manager’s resources to create the necessary records.\n\nThis action does not allow the total quantity to exceed the max allowed supply of the token." 118 | },{ 119 | "name": "open", 120 | "type": "open", 121 | "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Open Token Balance\nsummary: 'Open a zero quantity balance for {{nowrap owner}}'\nicon: https://raw.githubusercontent.com/cryptokylin/eosio.contracts/v1.7.0/contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\n{{ram_payer}} agrees to establish a zero quantity balance for {{owner}} for the {{symbol_to_symbol_code symbol}} token.\n\nIf {{owner}} does not have a balance for {{symbol_to_symbol_code symbol}}, {{ram_payer}} will be designated as the RAM payer of the {{symbol_to_symbol_code symbol}} token balance for {{owner}}. As a result, RAM will be deducted from {{ram_payer}}’s resources to create the necessary records." 122 | },{ 123 | "name": "retire", 124 | "type": "retire", 125 | "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Remove Tokens from Circulation\nsummary: 'Remove {{nowrap quantity}} from circulation'\nicon: https://raw.githubusercontent.com/cryptokylin/eosio.contracts/v1.7.0/contracts/icons/token.png#207ff68b0406eaa56618b08bda81d6a0954543f36adc328ab3065f31a5c5d654\n---\n\nThe token manager agrees to remove {{quantity}} from circulation, taken from their own account.\n\n{{#if memo}} There is a memo attached to the action stating:\n{{memo}}\n{{/if}}" 126 | },{ 127 | "name": "transfer", 128 | "type": "transfer", 129 | "ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Transfer Tokens\nsummary: 'Send {{nowrap quantity}} from {{nowrap from}} to {{nowrap to}}'\nicon: https://raw.githubusercontent.com/cryptokylin/eosio.contracts/v1.7.0/contracts/icons/transfer.png#5dfad0df72772ee1ccc155e670c1d124f5c5122f1d5027565df38b418042d1dd\n---\n\n{{from}} agrees to send {{quantity}} to {{to}}.\n\n{{#if memo}}There is a memo attached to the transfer stating:\n{{memo}}\n{{/if}}\n\nIf {{from}} is not already the RAM payer of their {{asset_to_symbol_code quantity}} token balance, {{from}} will be designated as such. As a result, RAM will be deducted from {{from}}’s resources to refund the original RAM payer.\n\nIf {{to}} does not have a balance for {{asset_to_symbol_code quantity}}, {{from}} will be designated as the RAM payer of the {{asset_to_symbol_code quantity}} token balance for {{to}}. As a result, RAM will be deducted from {{from}}’s resources to create the necessary records." 130 | } 131 | ], 132 | "tables": [{ 133 | "name": "accounts", 134 | "index_type": "i64", 135 | "key_names": [], 136 | "key_types": [], 137 | "type": "account" 138 | },{ 139 | "name": "stat", 140 | "index_type": "i64", 141 | "key_names": [], 142 | "key_types": [], 143 | "type": "currency_stats" 144 | } 145 | ], 146 | "ricardian_clauses": [], 147 | "error_messages": [], 148 | "abi_extensions": [], 149 | "variants": [] 150 | } 151 | -------------------------------------------------------------------------------- /test/aes.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import 'mocha' 3 | 4 | import {sealMessage} from '../src/utils' 5 | import {PrivateKey, UInt64} from '@wharfkit/antelope' 6 | 7 | suite('aes', function () { 8 | test('seal message', function () { 9 | const k1 = PrivateKey.from('5KGNiwTYdDWVBc9RCC28hsi7tqHGUsikn9Gs8Yii93fXbkYzxGi') 10 | const k2 = PrivateKey.from('5Kik3tbLSn24ScHFsj6GwLkgd1H4Wecxkzt1VX7PBBRDQUCdGFa') 11 | const sealed = sealMessage( 12 | 'The hovercraft is full of eels', 13 | k1, 14 | k2.toPublic(), 15 | UInt64.from(42) 16 | ) 17 | assert.equal( 18 | sealed.ciphertext.hexString, 19 | 'a26b34e0fe70e2d624da9fddf3ba574c5b827d729d0edc172641f44ea3739ab0' 20 | ) 21 | assert.equal(sealed.checksum.toString(), '2660735416') 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /test/session.ts: -------------------------------------------------------------------------------- 1 | import {strict as assert} from 'assert' 2 | import 'mocha' 3 | 4 | import {Link, LinkTransport} from '../src' 5 | import {SigningRequest} from '@wharfkit/signing-request' 6 | import { 7 | API, 8 | APIClient, 9 | APIMethods, 10 | APIProvider, 11 | APIResponse, 12 | PermissionLevel, 13 | PrivateKey, 14 | TimePointSec, 15 | } from '@wharfkit/antelope' 16 | import {LinkCallback, LinkCallbackResponse, LinkCallbackService} from '../src/link-callback' 17 | import {readFileSync} from 'fs' 18 | import {join as pathJoin} from 'path' 19 | import {LinkCreate} from '../src/link-types' 20 | 21 | const now = TimePointSec.fromMilliseconds(Date.now()) 22 | const expiration = TimePointSec.fromMilliseconds(Date.now() + 60 * 1000) 23 | 24 | class TestManager implements LinkTransport, APIProvider, LinkCallbackService, LinkCallback { 25 | key = PrivateKey.generate('K1') 26 | signer = PermissionLevel.from({actor: 'foobar', permission: 'active'}) 27 | account = API.v1.AccountObject.from({ 28 | account_name: this.signer.actor, 29 | head_block_num: 123456789, 30 | head_block_time: now, 31 | privileged: false, 32 | last_code_update: '1970-01-01T00:00:00.000', 33 | created: '2001-01-01T00:00:00.000', 34 | core_liquid_balance: '42.0000 EOS', 35 | ram_quota: 10000, 36 | net_weight: 200000, 37 | cpu_weight: 2000000, 38 | net_limit: { 39 | used: 500, 40 | available: 21323091, 41 | max: 21323567, 42 | }, 43 | cpu_limit: { 44 | used: 3222, 45 | available: 12522, 46 | max: 15744, 47 | }, 48 | ram_usage: 5394, 49 | permissions: [ 50 | { 51 | perm_name: 'active', 52 | parent: 'owner', 53 | required_auth: { 54 | threshold: 1, 55 | keys: [ 56 | { 57 | key: this.key.toPublic(), 58 | weight: 1, 59 | }, 60 | ], 61 | accounts: [], 62 | waits: [], 63 | }, 64 | }, 65 | { 66 | perm_name: 'owner', 67 | parent: '', 68 | required_auth: { 69 | threshold: 1, 70 | keys: [ 71 | { 72 | key: this.key.toPublic(), 73 | weight: 1, 74 | }, 75 | ], 76 | accounts: [], 77 | waits: [], 78 | }, 79 | }, 80 | ], 81 | total_resources: { 82 | owner: this.signer.actor, 83 | net_weight: '20.5000 EOS', 84 | cpu_weight: '174.4929 EOS', 85 | ram_bytes: 7849, 86 | }, 87 | }) 88 | 89 | morph(json): APIResponse { 90 | return { 91 | json, 92 | text: JSON.stringify(json), 93 | status: 200, 94 | headers: {}, 95 | } 96 | } 97 | 98 | // api 99 | async call(args: { 100 | path: string 101 | params?: any 102 | method?: APIMethods | undefined 103 | }): Promise { 104 | switch (args.path) { 105 | case '/v1/chain/get_account': 106 | return this.morph(this.account) 107 | case '/v1/chain/get_abi': { 108 | const account = String(args.params.account_name) 109 | const data = readFileSync(pathJoin(__dirname, 'abis', `${account}.json`)) 110 | return this.morph({account_name: account, abi: JSON.parse(data.toString('utf-8'))}) 111 | } 112 | case '/v1/chain/push_transaction': { 113 | return this.morph({}) 114 | } 115 | default: 116 | throw new Error(`Unexpected request to ${args.path}`) 117 | } 118 | } 119 | 120 | // callback 121 | url = 'test://' 122 | create() { 123 | return this 124 | } 125 | async wait(): Promise { 126 | const request = this.lastRequest 127 | if (!request) { 128 | throw new Error('No request') 129 | } 130 | const info: LinkCreate | undefined = request.getInfoKey('link', LinkCreate) 131 | if (info && String(info.session_name) === 'abort') { 132 | return {rejected: 'no thanks'} 133 | } 134 | const abis = await request.fetchAbis() 135 | const resolved = request.resolve(abis, this.signer, { 136 | expiration, 137 | ref_block_num: 0, 138 | ref_block_prefix: 0, 139 | }) 140 | const digest = resolved.transaction.signingDigest(request.getChainId()) 141 | const signature = this.key.signDigest(digest) 142 | const callback = resolved.getCallback([signature]) 143 | return callback!.payload 144 | } 145 | cancel() {} 146 | 147 | // transport 148 | lastRequest?: SigningRequest 149 | lastCancel?: (reason: string | Error) => void 150 | onRequest(request: SigningRequest, cancel: (reason: string | Error) => void): void { 151 | this.lastRequest = request 152 | this.lastCancel = cancel 153 | } 154 | } 155 | 156 | const manager = new TestManager() 157 | const client = new APIClient({provider: manager}) 158 | const link = new Link({ 159 | chains: [ 160 | { 161 | nodeUrl: client, 162 | chainId: 'beefface00000000000000000000000000000000000000000000000000000000', 163 | }, 164 | ], 165 | transport: manager, 166 | service: manager, 167 | verifyProofs: true, 168 | }) 169 | 170 | suite('session', function () { 171 | test('login & transact', async function () { 172 | const {account, session, transaction, resolvedTransaction} = await link.login('test') 173 | assert.equal(String(account!.account_name), 'foobar') 174 | assert.equal(String(transaction.expiration), expiration.toString()) 175 | assert.equal(String(resolvedTransaction.expiration), expiration.toString()) 176 | assert.equal(String(resolvedTransaction.actions[0].data.scope), 'test') 177 | await session.transact({ 178 | action: { 179 | account: 'eosio.token', 180 | name: 'transfer', 181 | authorization: [session.auth], 182 | data: { 183 | from: session.auth.actor, 184 | to: 'teamgreymass', 185 | quantity: '100000.0000 EOS', 186 | memo: 'lol', 187 | }, 188 | }, 189 | }) 190 | }) 191 | test('abort from wallet', async function () { 192 | try { 193 | await link.login('abort') 194 | assert.fail() 195 | } catch (error) { 196 | assert.equal(error.message, 'User canceled request (no thanks)') 197 | } 198 | }) 199 | }) 200 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "isolatedModules": false, 5 | "module": "commonjs", 6 | "target": "es2015" 7 | }, 8 | "include": ["./**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "downlevelIteration": true, 4 | "esModuleInterop": true, 5 | "experimentalDecorators": true, 6 | "importHelpers": true, 7 | "lib": ["esnext", "dom"], 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "noImplicitAny": false, 11 | "sourceMap": true, 12 | "strict": true, 13 | "target": "es2020", 14 | "useUnknownInCatchVariables": false 15 | }, 16 | "include": ["src/*"] 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-plugin-prettier", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "prettier": true, 8 | "triple-equals": [ 9 | true, 10 | "allow-null-check" 11 | ], 12 | "ordered-imports": true 13 | } 14 | } --------------------------------------------------------------------------------