├── .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 [](https://www.npmjs.com/package/anchor-link)  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 |Welcome foo!
95 |Press F to pay respects
100 | 101 |",
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 | }
--------------------------------------------------------------------------------