diff --git a/.eslintrc.js b/.eslintrc.js index 1ca45b4a..f4967c59 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,6 +10,7 @@ module.exports = { dappPage: true, extensionPage: true, AlgoSigner: true, + algorand: true, }, parser: '@typescript-eslint/parser', parserOptions: { diff --git a/README.md b/README.md index 95de4601..93a6ffe7 100644 --- a/README.md +++ b/README.md @@ -10,25 +10,17 @@ _This is the preferred solution for end-users, updates will be automatically ins Developers working with dApps may also install directly from the release package, or by downloading the project and building it. -## `AlgoSigner.sign()` and `AlgoSigner.signMultisig()` have been officially deprecated. - -An interactive transition guide is available [here](https://purestake.github.io/algosigner-dapp-example/v1v2TransitionGuide.html). - -## 1.9.6 Release +## 1.10.0 Release ### Main updates -This update adds supports for easier transfers with the new autocomplete feature. Start typing an account, contact name or name service alias on the destination field when sending Algos or ASAs and you'll be able to select the desired address from a dropdown. This also marks the end of the support of the older signing methods that were previously available. +As part of the process of supporting the [Algorand Foundations ARCs](https://arc.algorand.foundation/), in 1.10.0 a number of non-breaking additions have been made to support how dApps will work with AlgoSigner. In time, the existing legacy features will be deprecated. -- Autocomplete feature for UI-made transfers, supporting: - - Accounts and Contact names - - External name services (NFDomains and Algorand Namespace Service) -- `AlgoSigner.sign()` and `AlgoSigner.signMultisig()` have been deprecated -- New Account creation now occurs in the browser, improving ease of use when saving the mnemonic -- Improved dApp support with the new [`stxn`](docs/dApp-integration.md#providing-signed-reference-transactions) field, as well as new and more descriptive error types +- A new top level object, `window.algorand` is made available and can be accessed by the dapp to make calls to AlgoSigner. The existing `window.AlgoSigner` object remains but will be deprecated over the next year. +- An updated connection flow and address discovery process for dApps is in place, using `algorand.enable()`. **The existing connection flow persists but will be deprecated over the next 3-6 months.** +- Dapps may also now request for AlgoSigner to directly post signed transactions to the network and not return the signed blob to the dApp for handling. +- Additional documentation regarding the use of `authAddr` for signing transactions with rekeyed accounts. -### Other updates -- Improved Account Importing and Cache Clearing -- Get to your Contacts more easily with dedicated access +An interactive transition guide will be available soon to aid in the migration of existing functionalities. ## New Users @@ -44,6 +36,9 @@ For teams looking to integrate AlgoSigner into a project: - [dApp Development guide](docs/dApp-guide.md) - [AlgoSigner dApp Integration Guide](docs/dApp-integration.md) +**NOTE:** `AlgoSigner.sign()` and `AlgoSigner.signMultisig()` have been officially deprecated. An interactive transition guide is available [here](https://purestake.github.io/algosigner-dapp-example/v1v2TransitionGuide.html) if you still need to migrate existing functionalities. + + ## AlgoSigner development For developers interested in working with AlgoSigner, an [Extension Development Guide](docs/extension-developers.md) is available. diff --git a/docs/dApp-integration.md b/docs/dApp-integration.md index f093e23d..48a55436 100644 --- a/docs/dApp-integration.md +++ b/docs/dApp-integration.md @@ -2,208 +2,193 @@ # Integrating AlgoSigner to add Transaction Capabilities for dApps on Algorand -AlgoSigner injects a JavaScript library into every web page the browser user visits, which allows the site to interact with the extension. The dApp can use the injected library to connect to the user's Wallet, discover account addresses it holds, query the Network (make calls to AlgoD v2 or the Indexer) and request AlgoSigner to request for the user to sign a transaction initiated by the application. **All methods of the injected library return a Promise that needs to be handled by the dApp.** +AlgoSigner injects a JavaScript library into every web page the browser user visits, which allows the site to interact with the extension. The dApp can use the injected library to connect to the user's Wallet, request account addresses it holds, ask AlgoSigner to request the user to sign a transaction initiated by the application, and to post signed transactions to the network. -## Methods +## AlgoSigner 1.10.0 Update -- [AlgoSigner.connect()](#algosignerconnect) -- [AlgoSigner.accounts({ ledger: ‘MainNet|TestNet’ })](#algosigneraccounts-ledger-mainnettestnet-) -- [AlgoSigner.algod({ ledger: ‘MainNet|TestNet’, path: ‘algod v2 path’, ... })](#algosigneralgod-ledger-mainnettestnet-path-algod-v2-path--) -- [AlgoSigner.indexer({ ledger: ‘MainNet|TestNet’, path: ‘indexer v2 path’ })](#algosignerindexer-ledger-mainnettestnet-path-indexer-v2-path-) -- [AlgoSigner.signTxn([txnObjects,...])](#algosignersigntxntxnobjects) -- [AlgoSigner.send({ ledger: ‘MainNet|TestNet’, txBlob })](#algosignersend-ledger-mainnettestnet-txblob-) +As part of the process of supporting the [Algorand Foundations ARCs](https://arc.algorand.foundation/), in 1.10.0 a number of non-breaking additions have been made to support how dApps will work with AlgoSigner. In time, the existing legacy features will be deprecated. -## Misc +### Additions -[Rejection Messages](#rejection-messages) +- A new top level object, `window.algorand` is made available and can be accessed by the dapp to make calls to AlgoSigner. The existing `window.AlgoSigner` object remains but will be deprecated over the next year. -[Working with Custom Networks](#custom-networks) +- An updated connection flow and address discovery process for dApps is in place, using `algorand.enable()`. The existing connection flow persists but will be deprecated over the next 3-6 months. -[Helper Functions](#helper-functions) +- Dapps may also now request for AlgoSigner to directly post signed transactions to the network and not return the signed blob to the dApp for handling. -## Method Detail +- Additional documentation regarding the use of `authAddr` for signing transactions with rekeyed accounts. -### AlgoSigner.connect() +**NOTE: This guide refers only to the post-1.10.0 features and the `window.algorand` object. If you're looking for information on the `window.AlgoSigner` object, please refer to the [legacy Integration Guide](legacy-dApp-integration.md)** -Requests access to the Wallet for the dApp, may be rejected or approved. Every access to the extension begins with a connect request, which if approved by the user, allows the dApp to follow-up with other requests. - - +## Methods -### AlgoSigner.accounts({ ledger: ‘MainNet|TestNet’ }) +- [algorand.enable(enableOpts)](#algorandenableenableopts-enableopts) +- [algorand.signTxns([txnObjects, ...])](#algorandsigntxnstxnobjects-txnobject--txnobject) +- [algorand.postTxns([signedTxns, ...])](#algorandposttxnsstxns-signedtxn--signedtxn) +- [algorand.signAndPostTxns([txnObjects, ...])](#algorandsignandposttxnstxnobjects-txnobject--txnobject) -Returns an array of accounts present in the Wallet for the given Network. +## Misc -**Request** +[Rejection Messages](#rejection-messages) -```js -AlgoSigner.accounts({ ledger: 'TestNet' }); -``` +[Working with Custom Networks](#custom-networks) -**Response** +[Helper Encoding Functions](#encoding-functions) -```json -[ - { - "address": "U2VHSZL3LNGATL3IBCXFCPBTYSXYZBW2J4OGMPLTA4NA2CB4PR7AW7C77E" - }, - { - "address": "MTHFSNXBMBD4U46Z2HAYAOLGD2EV6GQBPXVTL727RR3G44AJ3WVFMZGSBE" - } -] -``` +## Method Detail -## [API Calls](#api-calls) +### algorand.enable(enableOpts: EnableOpts) -Developers may use AlgoSigner's connection to Algorand and Indexer API services. This is an optional convienance, dApps may establish their own connections to retrieve parameters, query the Indexer, and send transactions to the chain. +In order for dApps to interact with AlgoSigner, they must first request access by calling `algorand.enable()`. This will prompt the user to select which accounts they which to share with the dApp as well as the network they wish to operate on. -Proxied requests are passed through to an API service - currently set to the PureStake API service. Endpoints available are limited to what the service exposes. The API backend may be configured by advanced users and is not guaranteed to respond as expected. Only text responses are supported for this service (not message packed). +After the user selects which network and accounts to share with the dApp, they'll be returned by AlgoSigner as the response to the `algorand.enable()` call. -### AlgoSigner.algod({ ledger: ‘MainNet|TestNet’, path: ‘algod v2 path’, ... }) -Proxies the requested path to the Algod v2 API. Is limited to endpoints made available by the API server. By default, all calls to the AlgoSigner.algod method are GET. +``` +export type EnableResponse = { + genesisID: specific genesis ID shared by the user, + genesisHash: specific genesis hash shared by the user, + accounts: array of specific accounts shared by the user +}; +``` -**Request (GET)** +**Request** ```js -AlgoSigner.algod({ - ledger: 'TestNet', - path: '/v2/transactions/params', -}); +await algorand.enable(); ``` **Response** ```json { - "consensus-version": "https://github.com/algorandfoundation/specs/tree/e5f565421d720c6f75cdd186f7098495caf9101f", - "fee": 1, - "genesis-hash": "SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=", - "genesis-id": "testnet-v1.0", - "last-round": 8271323, - "min-fee": 1000 + "genesisID": "NETWORK_ID", + "genesisHash": "NETWORK_HASH", + "accounts": [ + "ACCOUNT_1", + "ACCOUNT_2", + ], } ``` -To make a POST requests, more details need to be included in as input. More information can be found [here](https://developer.algorand.org/docs/reference/rest-apis/algod/v2/). - -**Request (POST)** +In cases were the dApp wishes to request specific accounts, a specific network or both; they will be able to do so by providing additional parameters to the `algorand.enable()` call. -```js -AlgoSigner.algod({ - ledger: 'TestNet', - path: '/v2/teal/compile', - body: 'int 0', - method: 'POST', - contentType: 'text/plain', -}); ``` - -**Response** - -```json -{ - "hash": "KI4DJG2OOFJGUERJGSWCYGFZWDNEU2KWTU56VRJHITP62PLJ5VYMBFDBFE", - "result": "ASABACI=" -} +export type EnableOpts = { + genesisID?: [optional] specific genesis ID requested by the dApp, + genesisHash?: [optional] specific genesis hash requested by the dApp, + accounts?: [optional] array of specific accounts requested by the dApp, +}; ``` +If either `EnableOpts.genesisID` or `EnableOpts.genesisHash` are provided, they must match one of the networks available in AlgoSigner. In cases were there's ambiguity, such as to networks having a common `genesisID`, the user will be prompted to choose between the available matching networks. -### AlgoSigner.indexer({ ledger: ‘MainNet|TestNet’, path: ‘indexer v2 path’ }) +If neither `EnableOpts.genesisID` nor `EnableOpts.genesisHash` are provided, the user will be able to select which network they want to grant access to for the dApp. -Proxies the requested path to the Indexer v2 API. Is limited to endpoints made available by the API server. The API backend may be configured by advanced users and is not guaranteed to respond as expected. More information can be found [here](https://developer.algorand.org/docs/reference/rest-apis/algod/v2/). +If `EnableOpts.accounts` is provided, the requested accounts will appear as required for the user and the user will be prompted to grant control over the specified accounts; these accounts will be positioned at the start of the array in the response. The user may share additional accounts than those requested by the dApp, in which case the accounts will be appended at the end of the returning account array. The user may also choose to share fewer accounts than those requested, in which case the promise will be rejected and the rejected accounts will be found inside the `data.accounts` property of the error. + +If `EnableOpts.accounts` is not provided, the user will be prompted to select which accounts they wish to share with the dApp. **Request** ```js -AlgoSigner.indexer({ - ledger: 'TestNet', - path: '/v2/assets/150821', +await algorand.enable({ + genesisID: 'mainnet-v1.0', + accounts: ['REQUESTED_ACCOUNT'], }); ``` **Response** +User granting access to an additional account: ```json { - "asset": { - "index": 150821, - "params": { - "clawback": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", - "creator": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", - "decimals": 15, - "default-frozen": false, - "freeze": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", - "manager": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", - "name": "decimal Test", - "reserve": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", - "total": 1000, - "unit-name": "dectest" - } + "genesisID": "mainnet-v1.0", + "genesisHash": "USER_SELECTED_HASH", + "accounts": [ + "REQUESTED_ACCOUNT", + "ADDITIONAL_USER_SELECTED_ACCOUNT", + ], +} +``` + +User not granting access to an account requested by the dApp: +```json +{ + "code": 4400, + "message": "...", + "data": { + "accounts": [ + "REQUESTED_ACCOUNT", + ], }, - "current-round": 8271410 } ``` -## Working with Transactions +#### Regarding multiple calls to `algorand.enable()` -Sent in transactions will be validated against the Algorand JS SDK transaction types - field names must match, and the whole transaction will be rejected otherwise. +In cases where an enable call is made after a user has already authorized the dApp there will be additional checks before prompting the user. There is an ephemeral single network cached history and if the requested network from the dApp matches the previous one and the accounts are the same or a subset of the cached approved accounts the cache will be returned instead of prompting the user. -This section covers the new v2 Signing. Additional end-to-end examples will be included in the next weeks. +If the network is different or there are additional accounts requested compared to the cache the user will be prompted to re-authorize the request. -### AlgoSigner.signTxn([txnObjects,...]) +## Working with Transactions -Send transaction objects, conforming to the Algorand JS SDK, to AlgoSigner for approval. The Network is determined from the 'genesis-id' property. If approved, the response is an array of signed transaction objects, with the binary blob field base64 encoded to prevent transmission issues. +Transactions provided to AlgoSigner will be validated against the Algorand JS SDK transaction types. Field names must match and the whole transaction will be rejected otherwise. #### Transaction Requirements Transactions objects need to be presented with the following structure: ``` -{ - txn: Base64-encoded string of a transaction binary, - signers?: [optional] array of addresses to sign with (defaults to the sender), - stxn?: [optional] Base64-encoded string of a signed transaction binary - multisig?: [optional] extra metadata needed for multisig transactions, +export type TxnObject = { + txn: Base64-encoded string of a transaction binary, + signers?: [optional] array of addresses to sign with (defaults to the sender), + stxn?: [optional] Base64-encoded string of a signed transaction binary, + multisig?: [optional] extra metadata needed for multisig transactions, + authAddr?: [optional] used to specify which account is doing the signing when dealing with rekeyed accounts, }; ``` -In order to facilitate conversion between different formats and encodings, [helper functions](#helper-functions) are available on the `AlgoSigner.encoding.*` namespace. +In order to facilitate conversion between different formats and encodings, [helper encoding functions](#encoding-functions) are available on the `algorand.encoding.*` namespace. Also available on transactions built with the JS SDK is the `.toByte()` method that converts the SDK transaction object into it's binary format. +### algorand.signTxns(txnObjects: TxnObject[] | TxnObject[][]) + +Send transaction objects, conforming to the Algorand JS SDK, to AlgoSigner for approval. The *network* is determined from the `genesis-id` property. +If approved, the response is an array of base64-encoded signed transaction objects. + **Request** ```js -AlgoSigner.signTxn([ +algorand.signTxns([ { txn: 'iqNhbXRko2ZlZc0D6KJmds4A259Go2dlbqx0ZXN0bmV0LXYxLjCiZ2jEIEhjtRiks8hOyBDyLU8QgcsPcfBZp6wg3sYvf3DlCToio2dycMQgdsLAGqgrtwqqQS4UEN7O8CZHjfhPTwLHrB1A2pXwvKGibHbOANujLqNyY3bEIK0TEDcptY0uFvk2V5LDVzRfdz7O4freYHEuZbpI+6hMo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaNwYXk=', }, ]); ``` -**NOTE:** Even though the method accepts an array of transactions, it requires atomic transactions that have a groupId and will error on non-atomic groups. +**NOTE:** Even though the method accepts an array of transactions, it requires atomic transactions that share a groupId and will error on non-atomic groups. **Response** ```json [ - { - "txID": "4F6GE5EBTBJ7DOTWKA3GK4JYARFDCVR5CYEXP6O27FUCE5SGFDYQ", - "blob": "gqNzaWfEQL6mW/7ss2HKAqsuHN/7ePx11wKSAvFocw5QEDvzSvrvJdzWYvT7ua8Lc0SS0zOmUDDaHQC/pGJ0PNqnu7W3qQKjdHhuiaNhbXQGo2ZlZc4AA7U4omZ2zgB+OrujZ2VurHRlc3RuZXQtdjEuMKJnaMQgSGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiKibHbOAH4+o6NyY3bEIHhydylNDQQhpD9QdKWejLCMBgb5UYJTGCfDW3KgLsI+o3NuZMQgZM5ZNuFgR8pz2dHBgDlmHolfGgF96zX/X4x2bnAJ3aqkdHlwZaNwYXk=" - } + "gqNzaWfEQL6mW/7ss2HKAqsuHN/7ePx11wKSAvFocw5QEDvzSvrvJdzWYvT7ua8Lc0SS0zOmUDDaHQC/pGJ0PNqnu7W3qQKjdHhuiaNhbXQGo2ZlZc4AA7U4omZ2zgB+OrujZ2VurHRlc3RuZXQtdjEuMKJnaMQgSGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiKibHbOAH4+o6NyY3bEIHhydylNDQQhpD9QdKWejLCMBgb5UYJTGCfDW3KgLsI+o3NuZMQgZM5ZNuFgR8pz2dHBgDlmHolfGgF96zX/X4x2bnAJ3aqkdHlwZaNwYXk=", ] ``` **Example** ```js -await AlgoSigner.connect(); +// Connect to AlgoSigner +await algorand.enable(); // Create an Algod client to get suggested transaction params let client = new algosdk.Algodv2(token, server, port, headers); let suggestedParams = await client.getTransactionParams().do(); // Use the JS SDK to build a Transaction -let sdkTx = new algosdk.Transaction({ +let sdkTxn = new algosdk.Transaction({ to: 'RECEIVER_ADDRESS', from: 'SENDER_ADDRESS', amount: 100, @@ -211,12 +196,12 @@ let sdkTx = new algosdk.Transaction({ }); // Get the binary and base64 encode it -let binaryTx = sdkTx.toByte(); -let base64Tx = AlgoSigner.encoding.msgpackToBase64(binaryTx); +let binaryTxn = sdkTxn.toByte(); +let base64Txn = algorand.encoding.msgpackToBase64(binaryTxn); -let signedTxs = await AlgoSigner.signTxn([ +let signedTxns = await algorand.signTxns([ { - txn: base64Tx, + txn: base64Txn, }, ]); ``` @@ -226,7 +211,7 @@ Alternatively, you can provide multiple arrays of transactions at once. Same rul **Request** ```js -AlgoSigner.signTxn([ +algorand.signTxns([ [ { txn: 'iqNhbXRko2ZlZc0D6KJmds4A259Go2dlbqx0ZXN0bmV0LXYxLjCiZ2jEIEhjtRiks8hOyBDyLU8QgcsPcfBZp6wg3sYvf3DlCToio2dycMQgdsLAGqgrtwqqQS4UEN7O8CZHjfhPTwLHrB1A2pXwvKGibHbOANujLqNyY3bEIK0TEDcptY0uFvk2V5LDVzRfdz7O4freYHEuZbpI+6hMo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaNwYXk=', @@ -245,28 +230,22 @@ AlgoSigner.signTxn([ ```json [ [ - { - "txID": "4F6GE5EBTBJ7DOTWKA3GK4JYARFDCVR5CYEXP6O27FUCE5SGFDYQ", - "blob": "gqNzaWfEQL6mW/7ss2HKAqsuHN/7ePx11wKSAvFocw5QEDvzSvrvJdzWYvT7ua8Lc0SS0zOmUDDaHQC/pGJ0PNqnu7W3qQKjdHhuiaNhbXQGo2ZlZc4AA7U4omZ2zgB+OrujZ2VurHRlc3RuZXQtdjEuMKJnaMQgSGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiKibHbOAH4+o6NyY3bEIHhydylNDQQhpD9QdKWejLCMBgb5UYJTGCfDW3KgLsI+o3NuZMQgZM5ZNuFgR8pz2dHBgDlmHolfGgF96zX/X4x2bnAJ3aqkdHlwZaNwYXk=" - } + "gqNzaWfEQL6mW/7ss2HKAqsuHN/7ePx11wKSAvFocw5QEDvzSvrvJdzWYvT7ua8Lc0SS0zOmUDDaHQC/pGJ0PNqnu7W3qQKjdHhuiaNhbXQGo2ZlZc4AA7U4omZ2zgB+OrujZ2VurHRlc3RuZXQtdjEuMKJnaMQgSGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiKibHbOAH4+o6NyY3bEIHhydylNDQQhpD9QdKWejLCMBgb5UYJTGCfDW3KgLsI+o3NuZMQgZM5ZNuFgR8pz2dHBgDlmHolfGgF96zX/X4x2bnAJ3aqkdHlwZaNwYXk=", ], [ - { - "txID": "QK4XYJYGN7CLER25SKT3DV4UWNI5DVYXRBJRZEUWYA523EU5ZB7A", - "blob": "gqNzaWfEQC8ZIPYimAypJD2TmEQjuWxEEk8/gJbBegEHdtyKr6TuA78otKIEB9PYQimgMLGn87YOEB6GgRe5vjWRTuWGsAqjdHhuiaRhZnJ6w6RmYWRkxCCWbKEpQ95TYoAvO38lyzQZDnmTa+ypnikZ42XOmdgtu6RmYWlkzgDITmiiZnbOAQNjO6NnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4BA2cjo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaRhZnJ6" - } + "gqNzaWfEQC8ZIPYimAypJD2TmEQjuWxEEk8/gJbBegEHdtyKr6TuA78otKIEB9PYQimgMLGn87YOEB6GgRe5vjWRTuWGsAqjdHhuiaRhZnJ6w6RmYWRkxCCWbKEpQ95TYoAvO38lyzQZDnmTa+ypnikZ42XOmdgtu6RmYWlkzgDITmiiZnbOAQNjO6NnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4BA2cjo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaRhZnJ6", ] ] ``` -The signed transactions can then be sent using the SDK (example below) or using the [AlgoSigner.send()](#algosignersend-ledger-mainnettestnet-txblob-) method. +The signed transactions can then be sent using the SDK (example below) or using the [algorand.postTxns()](#algorandposttxnsstxns-signedtxn--signedtxn) method. ```js // Get the base64 encoded signed transaction and convert it to binary -let binarySignedTx = AlgoSigner.encoding.base64ToMsgpack(signedTxs[0].blob); +let binarySignedTxn = algorand.encoding.base64ToMsgpack(signedTxns[0]); // Send the transaction through the SDK client -await client.sendRawTransaction(binarySignedTx).do(); +await client.sendRawTransaction(binarySignedTxn).do(); ``` ### Atomic Transactions @@ -276,13 +255,13 @@ For Atomic transactions, provide an array of transaction objects with the same g **Example** ```js -let tx1 = new algosdk.Transaction({ +let txn1 = new algosdk.Transaction({ to: 'SECOND_ADDRESS', from: 'FIRST_ADDRESS', amount: 100, ...suggestedParams, }); -let tx2 = new algosdk.Transaction({ +let txn2 = new algosdk.Transaction({ to: 'FIRST_ADDRESS', from: 'SECOND_ADDRESS', amount: 100, @@ -290,17 +269,17 @@ let tx2 = new algosdk.Transaction({ }); // Assign a Group ID to the transactions using the SDK -algosdk.assignGroupID([tx1, tx2]); +algosdk.assignGroupID([txn1, txn2]); -let binaryTxs = [tx1.toByte(), tx2.toByte()]; -let base64Txs = binaryTxs.map((binary) => AlgoSigner.encoding.msgpackToBase64(binary)); +let binaryTxns = [txn1.toByte(), txn2.toByte()]; +let base64Txns = binaryTxns.map((binary) => algorand.encoding.msgpackToBase64(binary)); -let signedTxs = await AlgoSigner.signTxn([ +let signedTxns = await algorand.signTxns([ { - txn: base64Txs[0], + txn: base64Txns[0], }, { - txn: base64Txs[1], + txn: base64Txns[1], }, ]); ``` @@ -308,8 +287,8 @@ let signedTxs = await AlgoSigner.signTxn([ The signed transaction array can then be sent using the SDK. ```js -let binarySignedTxs = signedTxs.map((tx) => AlgoSigner.encoding.base64ToMsgpack(tx.blob)); -await client.sendRawTransaction(binarySignedTxs).do(); +let binarySignedTxns = signedTxns.map((stxn) => algorand.encoding.base64ToMsgpack(stxn)); +await client.sendRawTransaction(binarySignedTxns).do(); ``` #### Reference Atomic transactions @@ -324,38 +303,38 @@ In case not all group transactions belong to accounts on AlgoSigner, you can set } ``` -`AlgoSigner.signTxn()` will return `null` in the position(s) where reference transactions were provided. In these instances, you'd have to sign the missing transaction(s) by your own means before they can be sent. This is useful for transactions that require external signing, like `lsig` transactions. +`algorand.signTxns()` will return `null` in the position(s) where reference transactions were provided. In these instances, you'd have to sign the missing transaction(s) by your own means before they can be sent. This is useful for transactions that require external signing, like `lsig` transactions. #### Providing Signed reference transaction(s) -You can provide a signed reference transaction to AlgoSigner via the `stxn` field of the transaction object for it to be validated and returned as part of the group. For the transaction(s) where `stxn` was provided, `AlgoSigner.signTxn()` will return the `stxn` string in the same position of the response array as the corresponding reference transaction(s) instead of `null`. +You can provide a signed reference transaction to AlgoSigner via the `stxn` field of the transaction object for it to be validated and returned as part of the group. For the transaction(s) where `stxn` was provided, `algorand.signTxns()` will return the `stxn` string in the same position of the response array as the corresponding reference transaction(s) instead of `null`. **Example** ```js -let tx1 = new algosdk.Transaction({ +let txn1 = new algosdk.Transaction({ to: 'EXTERNAL_ACCOUNT', from: 'ACCOUNT_IN_ALGOSIGNER', amount: 100, ...suggestedParams, }); -let tx2 = new algosdk.Transaction({ +let txn2 = new algosdk.Transaction({ to: 'ACCOUNT_IN_ALGOSIGNER', from: 'EXTERNAL_ACCOUNT', amount: 100, ...suggestedParams, }); -algosdk.assignGroupID([tx1, tx2]); -let binaryTxs = [tx1.toByte(), tx2.toByte()]; -let base64Txs = binaryTxs.map((binary) => AlgoSigner.encoding.msgpackToBase64(binary)); +algosdk.assignGroupID([txn1, txn2]); +let binaryTxns = [txn1.toByte(), txn2.toByte()]; +let base64Txns = binaryTxns.map((binary) => algorand.encoding.msgpackToBase64(binary)); -let signedTxs = await AlgoSigner.signTxn([ +let signedTxns = await algorand.signTxns([ { - txn: base64Txs[0], + txn: base64Txns[0], }, { - txn: base64Txs[1], + txn: base64Txns[1], signers: [], stxn: 'MANUALLY_SIGNED_SECOND_TXN_B64', }, @@ -366,30 +345,24 @@ let signedTxs = await AlgoSigner.signTxn([ ```js [ - { - txID: '...', - blob: 'ALGOSIGNER_SIGNED_B64', - }, - { - txID: '...', - blob: 'MANUALLY_SIGNED_SECOND_TXN_B64', - }, + 'ALGOSIGNER_SIGNED_B64', + 'MANUALLY_SIGNED_SECOND_TXN_B64', ]; ``` #### Signing reference transactions manually - + In case you can't or don't want to provide the `stxn`, the provided transaction should look like this: **Example** ```js -let signedTxs = await AlgoSigner.signTxn([ +let signedTxns = await algorand.signTxns([ { - txn: base64Txs[0], + txn: base64Txns[0], }, { - txn: base64Txs[1], + txn: base64Txns[1], signers: [], }, ]); @@ -399,10 +372,7 @@ let signedTxs = await AlgoSigner.signTxn([ ```js [ - { - txID: '...', - blob: 'ALGOSIGNER_SIGNED_B64', - }, + 'ALGOSIGNER_SIGNED_B64', null, ]; ``` @@ -411,32 +381,15 @@ Afterwards, you can sign and send the remaining transaction(s) with the SDK: ```js // Convert first transaction to binary from the response -let signedTx1Binary = AlgoSigner.encoding.base64ToMsgpack(signedTxs[0].blob); +let signedTxn1Binary = algorand.encoding.base64ToMsgpack(signedTxns[0]); // Sign leftover transaction with the SDK let externalAccount = algosdk.mnemonicToSecretKey('EXTERNAL_ACCOUNT_MNEMONIC'); -let signedTx2Binary = tx2.signTxn(externalAccount.sk); - -await client.sendRawTransaction([signedTx1Binary, signedTx2Binary]).do(); -``` - -Alternatively, if you're using the [AlgoSigner.send()](#algosignersend-ledger-mainnettestnet-txblob-) to send the transaction(s), you have to merge the binaries before converting to a single base64 encoded string. - -```js -// Merge transaction binaries into a single Uint8Array -let combinedBinaryTxns = new Uint8Array(signedTx1Binary.byteLength + signedTx2Binary.byteLength); -combinedBinaryTxns.set(signedTx1Binary, 0); -combinedBinaryTxns.set(signedTx2Binary, signedTx1Binary.byteLength); - -// Convert the combined array values back to base64 -let combinedBase64Txns = AlgoSigner.encoding.msgpackToBase64(combinedBinaryTxns); +let signedTxn2Binary = txn2.signTxn(externalAccount.sk); -await AlgoSigner.send({ - ledger: 'TestNet', - tx: combinedBase64Txns, -}); +await client.sendRawTransaction([signedTxn1Binary, signedTxn2Binary]).do(); ``` -### Multisig Transactions +#### Multisig Transactions For Multisig transactions, an additional metadata object is required that adheres to the [Algorand multisig parameters](https://developer.algorand.org/docs/features/accounts/create/#how-to-generate-a-multisignature-account) structure when creating a new multisig account: @@ -448,9 +401,9 @@ For Multisig transactions, an additional metadata object is required that adhere } ``` -`AlgoSigner.signTxn()` will validate that the resulting multisig address made from the provided parameters matches the sender address and try to sign with every account on the `addrs` array that is also on AlgoSigner. +`algorand.signTxns()` will validate that the resulting multisig address made from the provided parameters matches the sender address and try to sign with every account on the `addrs` array that is also on AlgoSigner. -**NOTE:** `AlgoSigner.signTxn()` only accepts unsigned multisig transactions. In case you need to add more signatures to partially signed multisig transactions, please use the SDK. +**NOTE:** `algorand.signTxns()` only accepts unsigned multisig transactions. In case you need to add more signatures to partially signed multisig transactions, please use the SDK. **Example** @@ -463,7 +416,7 @@ let multisigParams = { let multisigAddress = algosdk.multisigAddress(multisigParams); -let multisigTx = new algosdk.Transaction({ +let multisigTxn = new algosdk.Transaction({ to: 'RECEIVER_ADDRESS', from: multisigAddress, amount: 100, @@ -471,13 +424,13 @@ let multisigTx = new algosdk.Transaction({ }); // Get the binary and base64 encode it -let binaryMultisigTx = multisigTx.toByte(); -let base64MultisigTx = AlgoSigner.encoding.msgpackToBase64(binaryMultisigTx); +let binaryMultisigTxn = multisigTxn.toByte(); +let base64MultisigTxn = algorand.encoding.msgpackToBase64(binaryMultisigTxn); // This returns a partially signed Multisig Transaction with signatures for FIRST_ADDRESS and SECOND_ADDRESS -let signedTxs = await AlgoSigner.signTxn([ +let signedTxns = await algorand.signTxns([ { - txn: base64MultisigTx, + txn: base64MultisigTxn, msig: multisigParams, }, ]); @@ -487,47 +440,142 @@ In case you want to specify a subset of addresses to sign with, you can add them ```js // This returns a partially signed Multisig Transaction with signatures for SECOND_ADDRESS -let signedTxs = await AlgoSigner.signTxn([ +let signedTxns = await algorand.signTxns([ { - txn: base64MultisigTx, + txn: base64MultisigTxn, msig: multisigParams, signers: ['SECOND_ADDRESS'], }, ]); ``` +#### Authorized Addresses + +When dealing with rekeyed accounts, the authorized address to be used to sign the transaction differs from the rekeyed account. The `TxnObject.authAddr` field allows to specify a different sender address in those cases. + +**NOTE:** If specified, AlgoSigner will sign the transaction using this authorized address even if it sees the sender address was not rekeyed to authAddr. This is because the sender may be rekeyed before the transaction is committed. + +**Example** + +```js +let txn = new algosdk.Transaction({ + to: 'REKEYED_ACCOUNT', + from: 'REKEYED_ACCOUNT', + amount: 100, + ...suggestedParams, +}); + +let base64Txn = algorand.encoding.msgpackToBase64(txn.toByte()); + +let signedTxns = await algorand.signTxns([ + { + txn: base64Txn, + authAddr: 'AUTHORIZED_ADDRESS_FOR_REKEY', + }, +]); +``` + +**Special Considerations** -### AlgoSigner.send({ ledger: ‘MainNet|TestNet’, txBlob }) +In cases where both `TxnObject.authAddr` and `TxnObject.msig` are provided, both addresses need to match or the transaction will be rejected. + +In cases where both `TxnObject.authAddr` and a `TxnObject.signers` array with a single address are provided, but no `TxnObject.msig` was provided; `authAddr` needs to match the `signer` or the transaction will be rejected. + +### algorand.postTxns(stxns: SignedTxn[] | SignedTxn[][]) Send a base64 encoded signed transaction blob to AlgoSigner to transmit to the Network. **Request** ```js -AlgoSigner.send({ - ledger: 'TestNet', - tx: signedTx.blob, -}); +algorand.postTxns([signedTx]); +``` + +**Response** + +``` +export type PostResult = { + txnIDs: string[] | string[][], +}; +``` + +```json +{ + "txnIDs": [ + "OKU6A2QYMRSZAUEJUZL3PW5XKLTA6TKWQHIIBXDCO3KT5OHCULBA" + ] +} +``` + +When providing more than one group of transactions at the same time, all transactions will be sent to the network simultaneously. In case some of the groups were unable to be commited to the network, the returning error will provide information on which ones were successful, as well as the reason behind the failed ones. + +``` +export type PartialPostError = { + successTxnIDs: (string | null)[][], + data: (string | null)[][], + code: number, + message: string, +}; +``` +The `successTxnIDs` property includes the IDs of the transactions that were succesfully commited to the network while the `data` property includes the reasons behind the failing ones. Both arrays are *ordered* respecting the original positions of the provided groups of transactions. + +**Request** + +```js +algorand.postTxns([successfulTxn], [rejectedTxn]); ``` **Response** ```json -{ "txId": "OKU6A2QYMRSZAUEJUZL3PW5XKLTA6TKWQHIIBXDCO3KT5OHCULBA" } +{ + "successTxnIDs": [ + "OKU6A2QYMRSZAUEJUZL3PW5XKLTA6TKWQHIIBXDCO3KT5OHCULBA", + null, + ], + "data": [ + null, + "Reason behind network refusal", + ], + "code": 4400, + "message": "...", +} +``` + +### algorand.signAndPostTxns(txnObjects: TxnObject[] | TxnObject[][]) + +This methods takes the input from [algorand.signTxns()](#algorandsigntxnstxnobjects-txnobject--txnobject) and internally posts the signed transactions to the network before returning the response of [algorand.postTxns()](#algorandposttxnsstxns-signedtxn--signedtxn) to the dApp. + +**Request** + +```js +algorand.signTxns([ + { + txn: 'iqNhbXRko2ZlZc0D6KJmds4A259Go2dlbqx0ZXN0bmV0LXYxLjCiZ2jEIEhjtRiks8hOyBDyLU8QgcsPcfBZp6wg3sYvf3DlCToio2dycMQgdsLAGqgrtwqqQS4UEN7O8CZHjfhPTwLHrB1A2pXwvKGibHbOANujLqNyY3bEIK0TEDcptY0uFvk2V5LDVzRfdz7O4freYHEuZbpI+6hMo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaNwYXk=', + }, +]); +``` + +**Response** + +```json +{ + "txnIDs": [ + "OKU6A2QYMRSZAUEJUZL3PW5XKLTA6TKWQHIIBXDCO3KT5OHCULBA" + ] +} ``` ## Custom Networks -- Custom networks beta support is now in AlgoSigner. [Setup Guide](add-network.md) -- AlgoSigner.accounts(ledger) has changed such that calls now accept names that have been added to the user's custom network list as valid ledger names. - - A non-matching ledger name will result in a error: - - The provided ledger is not supported (Code: 4200). - - An empty request will result with an error: - - Ledger not provided. Please use a base ledger: [TestNet,MainNet] or an available custom one [{"name":"Theta","genesisId":"thetanet-v1.0"}]. -- Transaction requests will require a valid matching "genesisId", even for custom networks. +Custom networks beta support is now in AlgoSigner. A setup Guide can be found [here](add-network.md). + +- [algorand.enable()](#algorandenableenableopts-enableopts) calls accept genesis IDs/hashes that have been added to the user's custom network list as valid networks. + - Non-matching `genesisID` or `genesisHash` will result in a error. +- Transaction requests will require a valid matching `genesisID`, even for custom networks. -## Signature Rejection Messages +## Rejection Messages -AlgoSigner may return some of the following error codes when requesting signatures: +Some of the following error codes may be returned when interacting with AlgoSigner. When available, any additional info regarding the error will be found on the `data` property of the error. | Error Code | Description | Additional notes | | ---------- | ----------- | ---------------- | @@ -535,9 +583,10 @@ AlgoSigner may return some of the following error codes when requesting signatur | 4001 | The user rejected the signature request. | N/A | | 4100 | The requested operation and/or account has not been authorized by the user. | This is usually due to the connection between the dApp and the wallet becoming stale and the user [needing to reconnect](connection-issues.md). Otherwise, it may signal that you are trying to sign with private keys not found on AlgoSigner. | | 4200 | The wallet does not support the requested operation. | N/A | -| 4201 | The wallet does not support signing that many transactions at a time. | The max number of transactions per group is 16. For Ledger devices, they can't sign more than one transaction at the same time. | -| 4202 | The wallet was not initialized properly beforehand. | Users need to have imported or created an account on AlgoSigner before connecting to dApps | +| 4201 | The wallet does not support signing that many transactions at a time. | The max number of transactions per group is 16. For Ledger devices, they currently can't sign more than one transaction at the same time. | +| 4202 | The wallet was not initialized properly beforehand. | Users need to have imported or created an account on AlgoSigner before connecting to dApps. | | 4300 | The input provided is invalid. | AlgoSigner rejected some of the transactions due to invalid fields. | +| 4400 | Some transactions were not sent properly. | Some, but not all of the transactions were able to be posted to the network. The IDs of the succesfully posted transactions as well as information on the failing ones are provided on the error. Additional information, if available, would be provided in the `data` field of the error object. @@ -554,13 +603,13 @@ Returned errors have the following object structure: Errors may be passed back to the dApp from the Algorand JS SDK if a transaction is valid, but has some other issue - for example, insufficient funds in the sending account. -## Helper Functions +## Encoding Functions -`AlgoSigner.enconding.*` contains a few different methods in order to help with the different formats and encodings that are needed when working with dApps and the SDK. +`algorand.enconding.*` contains a few different methods in order to help with the different formats and encodings that are needed when working with dApps and the SDK. ``` - AlgoSigner.encoding.msgpackToBase64(): receives a binary object (as a Uint8Array) and returns the corresponding base64 encoded string, - AlgoSigner.encoding.base64ToMsgpack(): receives a base64 encoded string and returns the corresponding binary object (as a Uint8Array), - AlgoSigner.encoding.stringToByteArray(): receives a plain unencoded string and returns the corresponding binary object (as a Uint8Array), - AlgoSigner.encoding.byteArrayToString(): receives a binary object (as a Uint8Array) and returns the corresponding plain unencoded string, + algorand.encoding.msgpackToBase64(): receives a binary object (as a Uint8Array) and returns the corresponding base64 encoded string, + algorand.encoding.base64ToMsgpack(): receives a base64 encoded string and returns the corresponding binary object (as a Uint8Array), + algorand.encoding.stringToByteArray(): receives a plain unencoded string and returns the corresponding binary object (as a Uint8Array), + algorand.encoding.byteArrayToString(): receives a binary object (as a Uint8Array) and returns the corresponding plain unencoded string, ``` diff --git a/docs/legacy-dApp-integration.md b/docs/legacy-dApp-integration.md new file mode 100644 index 00000000..e7c90304 --- /dev/null +++ b/docs/legacy-dApp-integration.md @@ -0,0 +1,568 @@ +# ![AlgoSigner](/media/algosigner-wallet-banner-3.png) + +# Integrating AlgoSigner to dApps using the `AlgoSigner` object + +**NOTE:** As of 1.10.0, a new top level object `window.algorand` is made available for integration with AlgoSigner. This page refers to integrating AlgoSigner using the previous `window.AlgoSigner` object; if you need to migrate existing functionalities, an interactive transition guide will be available soon. + +AlgoSigner injects a JavaScript library into every web page the browser user visits, which allows the site to interact with the extension. The dApp can use the injected library to connect to the user's Wallet, discover account addresses it holds, query the Network (make calls to AlgoD v2 or the Indexer) and request AlgoSigner to request for the user to sign a transaction initiated by the application. **All methods of the injected library return a Promise that needs to be handled by the dApp.** + +## Methods + +- [AlgoSigner.connect()](#algosignerconnect) +- [AlgoSigner.accounts({ ledger: ‘MainNet|TestNet’ })](#algosigneraccounts-ledger-mainnettestnet-) +- [AlgoSigner.algod({ ledger: ‘MainNet|TestNet’, path: ‘algod v2 path’, ... })](#algosigneralgod-ledger-mainnettestnet-path-algod-v2-path--) +- [AlgoSigner.indexer({ ledger: ‘MainNet|TestNet’, path: ‘indexer v2 path’ })](#algosignerindexer-ledger-mainnettestnet-path-indexer-v2-path-) +- [AlgoSigner.signTxn([txnObjects,...])](#algosignersigntxntxnobjects) +- [AlgoSigner.send({ ledger: ‘MainNet|TestNet’, txBlob })](#algosignersend-ledger-mainnettestnet-txblob-) + +## Misc + +[Rejection Messages](#rejection-messages) + +[Working with Custom Networks](#custom-networks) + +[Helper Functions](#helper-functions) + +## Method Detail + +### AlgoSigner.connect() + +Requests access to the Wallet for the dApp, may be rejected or approved. Every access to the extension begins with a connect request, which if approved by the user, allows the dApp to follow-up with other requests. + + + +### AlgoSigner.accounts({ ledger: ‘MainNet|TestNet’ }) + +Returns an array of accounts present in the Wallet for the given Network. + +**Request** + +```js +AlgoSigner.accounts({ ledger: 'TestNet' }); +``` + +**Response** + +```json +[ + { + "address": "U2VHSZL3LNGATL3IBCXFCPBTYSXYZBW2J4OGMPLTA4NA2CB4PR7AW7C77E" + }, + { + "address": "MTHFSNXBMBD4U46Z2HAYAOLGD2EV6GQBPXVTL727RR3G44AJ3WVFMZGSBE" + } +] +``` + +## [API Calls](#api-calls) + +Developers may use AlgoSigner's connection to Algorand and Indexer API services. This is an optional convienance, dApps may establish their own connections to retrieve parameters, query the Indexer, and send transactions to the chain. + +Proxied requests are passed through to an API service - currently set to the PureStake API service. Endpoints available are limited to what the service exposes. The API backend may be configured by advanced users and is not guaranteed to respond as expected. Only text responses are supported for this service (not message packed). + +### AlgoSigner.algod({ ledger: ‘MainNet|TestNet’, path: ‘algod v2 path’, ... }) + +Proxies the requested path to the Algod v2 API. Is limited to endpoints made available by the API server. By default, all calls to the AlgoSigner.algod method are GET. + +**Request (GET)** + +```js +AlgoSigner.algod({ + ledger: 'TestNet', + path: '/v2/transactions/params', +}); +``` + +**Response** + +```json +{ + "consensus-version": "https://github.com/algorandfoundation/specs/tree/e5f565421d720c6f75cdd186f7098495caf9101f", + "fee": 1, + "genesis-hash": "SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=", + "genesis-id": "testnet-v1.0", + "last-round": 8271323, + "min-fee": 1000 +} +``` + +To make a POST requests, more details need to be included in as input. More information can be found [here](https://developer.algorand.org/docs/reference/rest-apis/algod/v2/). + +**Request (POST)** + +```js +AlgoSigner.algod({ + ledger: 'TestNet', + path: '/v2/teal/compile', + body: 'int 0', + method: 'POST', + contentType: 'text/plain', +}); +``` + +**Response** + +```json +{ + "hash": "KI4DJG2OOFJGUERJGSWCYGFZWDNEU2KWTU56VRJHITP62PLJ5VYMBFDBFE", + "result": "ASABACI=" +} +``` + +### AlgoSigner.indexer({ ledger: ‘MainNet|TestNet’, path: ‘indexer v2 path’ }) + +Proxies the requested path to the Indexer v2 API. Is limited to endpoints made available by the API server. The API backend may be configured by advanced users and is not guaranteed to respond as expected. More information can be found [here](https://developer.algorand.org/docs/reference/rest-apis/algod/v2/). + +**Request** + +```js +AlgoSigner.indexer({ + ledger: 'TestNet', + path: '/v2/assets/150821', +}); +``` + +**Response** + +```json +{ + "asset": { + "index": 150821, + "params": { + "clawback": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", + "creator": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", + "decimals": 15, + "default-frozen": false, + "freeze": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", + "manager": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", + "name": "decimal Test", + "reserve": "Q2SLSQTBMVJYVT2AANUAXY4A5G7A3Y6L2M6L3WIXKNYBTMMQFGUOQGKSRQ", + "total": 1000, + "unit-name": "dectest" + } + }, + "current-round": 8271410 +} +``` + +## Working with Transactions + +Sent in transactions will be validated against the Algorand JS SDK transaction types - field names must match, and the whole transaction will be rejected otherwise. + +This section covers the new v2 Signing. Additional end-to-end examples will be included in the next weeks. + +### AlgoSigner.signTxn([txnObjects,...]) + +Send transaction objects, conforming to the Algorand JS SDK, to AlgoSigner for approval. The Network is determined from the 'genesis-id' property. If approved, the response is an array of signed transaction objects, with the binary blob field base64 encoded to prevent transmission issues. + +#### Transaction Requirements + +Transactions objects need to be presented with the following structure: + +``` +{ + txn: Base64-encoded string of a transaction binary, + signers?: [optional] array of addresses to sign with (defaults to the sender), + stxn?: [optional] Base64-encoded string of a signed transaction binary + multisig?: [optional] extra metadata needed for multisig transactions, +}; +``` + +In order to facilitate conversion between different formats and encodings, [helper functions](#helper-functions) are available on the `AlgoSigner.encoding.*` namespace. + +Also available on transactions built with the JS SDK is the `.toByte()` method that converts the SDK transaction object into it's binary format. + +**Request** + +```js +AlgoSigner.signTxn([ + { + txn: 'iqNhbXRko2ZlZc0D6KJmds4A259Go2dlbqx0ZXN0bmV0LXYxLjCiZ2jEIEhjtRiks8hOyBDyLU8QgcsPcfBZp6wg3sYvf3DlCToio2dycMQgdsLAGqgrtwqqQS4UEN7O8CZHjfhPTwLHrB1A2pXwvKGibHbOANujLqNyY3bEIK0TEDcptY0uFvk2V5LDVzRfdz7O4freYHEuZbpI+6hMo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaNwYXk=', + }, +]); +``` + +**NOTE:** Even though the method accepts an array of transactions, it requires atomic transactions that have a groupId and will error on non-atomic groups. + +**Response** + +```json +[ + { + "txID": "4F6GE5EBTBJ7DOTWKA3GK4JYARFDCVR5CYEXP6O27FUCE5SGFDYQ", + "blob": "gqNzaWfEQL6mW/7ss2HKAqsuHN/7ePx11wKSAvFocw5QEDvzSvrvJdzWYvT7ua8Lc0SS0zOmUDDaHQC/pGJ0PNqnu7W3qQKjdHhuiaNhbXQGo2ZlZc4AA7U4omZ2zgB+OrujZ2VurHRlc3RuZXQtdjEuMKJnaMQgSGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiKibHbOAH4+o6NyY3bEIHhydylNDQQhpD9QdKWejLCMBgb5UYJTGCfDW3KgLsI+o3NuZMQgZM5ZNuFgR8pz2dHBgDlmHolfGgF96zX/X4x2bnAJ3aqkdHlwZaNwYXk=" + } +] +``` + +**Example** + +```js +await AlgoSigner.connect(); + +// Create an Algod client to get suggested transaction params +let client = new algosdk.Algodv2(token, server, port, headers); +let suggestedParams = await client.getTransactionParams().do(); + +// Use the JS SDK to build a Transaction +let sdkTx = new algosdk.Transaction({ + to: 'RECEIVER_ADDRESS', + from: 'SENDER_ADDRESS', + amount: 100, + ...suggestedParams, +}); + +// Get the binary and base64 encode it +let binaryTx = sdkTx.toByte(); +let base64Tx = AlgoSigner.encoding.msgpackToBase64(binaryTx); + +let signedTxs = await AlgoSigner.signTxn([ + { + txn: base64Tx, + }, +]); +``` + +Alternatively, you can provide multiple arrays of transactions at once. Same rules regarding the contents of the groups apply. + +**Request** + +```js +AlgoSigner.signTxn([ + [ + { + txn: 'iqNhbXRko2ZlZc0D6KJmds4A259Go2dlbqx0ZXN0bmV0LXYxLjCiZ2jEIEhjtRiks8hOyBDyLU8QgcsPcfBZp6wg3sYvf3DlCToio2dycMQgdsLAGqgrtwqqQS4UEN7O8CZHjfhPTwLHrB1A2pXwvKGibHbOANujLqNyY3bEIK0TEDcptY0uFvk2V5LDVzRfdz7O4freYHEuZbpI+6hMo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaNwYXk=', + }, + ], + [ + { + txn: 'iaRhZnJ6w6RmYWRkxCCWbKEpQ95TYoAvO38lyzQZDnmTa+ypnikZ42XOmdgtu6RmYWlkzgDITmiiZnbOAQNjIKNnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4BA2cIo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaRhZnJ6', + }, + ], +]); +``` + +**Response** + +```json +[ + [ + { + "txID": "4F6GE5EBTBJ7DOTWKA3GK4JYARFDCVR5CYEXP6O27FUCE5SGFDYQ", + "blob": "gqNzaWfEQL6mW/7ss2HKAqsuHN/7ePx11wKSAvFocw5QEDvzSvrvJdzWYvT7ua8Lc0SS0zOmUDDaHQC/pGJ0PNqnu7W3qQKjdHhuiaNhbXQGo2ZlZc4AA7U4omZ2zgB+OrujZ2VurHRlc3RuZXQtdjEuMKJnaMQgSGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiKibHbOAH4+o6NyY3bEIHhydylNDQQhpD9QdKWejLCMBgb5UYJTGCfDW3KgLsI+o3NuZMQgZM5ZNuFgR8pz2dHBgDlmHolfGgF96zX/X4x2bnAJ3aqkdHlwZaNwYXk=" + } + ], + [ + { + "txID": "QK4XYJYGN7CLER25SKT3DV4UWNI5DVYXRBJRZEUWYA523EU5ZB7A", + "blob": "gqNzaWfEQC8ZIPYimAypJD2TmEQjuWxEEk8/gJbBegEHdtyKr6TuA78otKIEB9PYQimgMLGn87YOEB6GgRe5vjWRTuWGsAqjdHhuiaRhZnJ6w6RmYWRkxCCWbKEpQ95TYoAvO38lyzQZDnmTa+ypnikZ42XOmdgtu6RmYWlkzgDITmiiZnbOAQNjO6NnZW6sdGVzdG5ldC12MS4womdoxCBIY7UYpLPITsgQ8i1PEIHLD3HwWaesIN7GL39w5Qk6IqJsds4BA2cjo3NuZMQglmyhKUPeU2KALzt/Jcs0GQ55k2vsqZ4pGeNlzpnYLbukdHlwZaRhZnJ6" + } + ] +] +``` + +The signed transactions can then be sent using the SDK (example below) or using the [AlgoSigner.send()](#algosignersend-ledger-mainnettestnet-txblob-) method. + +```js +// Get the base64 encoded signed transaction and convert it to binary +let binarySignedTx = AlgoSigner.encoding.base64ToMsgpack(signedTxs[0].blob); + +// Send the transaction through the SDK client +await client.sendRawTransaction(binarySignedTx).do(); +``` + +### Atomic Transactions + +For Atomic transactions, provide an array of transaction objects with the same group ID, _provided in the same order as when the group was assigned_. + +**Example** + +```js +let tx1 = new algosdk.Transaction({ + to: 'SECOND_ADDRESS', + from: 'FIRST_ADDRESS', + amount: 100, + ...suggestedParams, +}); +let tx2 = new algosdk.Transaction({ + to: 'FIRST_ADDRESS', + from: 'SECOND_ADDRESS', + amount: 100, + ...suggestedParams, +}); + +// Assign a Group ID to the transactions using the SDK +algosdk.assignGroupID([tx1, tx2]); + +let binaryTxs = [tx1.toByte(), tx2.toByte()]; +let base64Txs = binaryTxs.map((binary) => AlgoSigner.encoding.msgpackToBase64(binary)); + +let signedTxs = await AlgoSigner.signTxn([ + { + txn: base64Txs[0], + }, + { + txn: base64Txs[1], + }, +]); +``` + +The signed transaction array can then be sent using the SDK. + +```js +let binarySignedTxs = signedTxs.map((tx) => AlgoSigner.encoding.base64ToMsgpack(tx.blob)); +await client.sendRawTransaction(binarySignedTxs).do(); +``` + +#### Reference Atomic transactions + +In case not all group transactions belong to accounts on AlgoSigner, you can set the `signers` field of the transaction object as an empty array to specify that it's only being sent to AlgoSigner for reference and group validation, not for signing. Reference transactions should look like this: + +```js +{ + txn: 'B64_TXN', + signers: [], + // This tells AlgoSigner that this transaction is not meant to be signed +} +``` + +`AlgoSigner.signTxn()` will return `null` in the position(s) where reference transactions were provided. In these instances, you'd have to sign the missing transaction(s) by your own means before they can be sent. This is useful for transactions that require external signing, like `lsig` transactions. + +#### Providing Signed reference transaction(s) + +You can provide a signed reference transaction to AlgoSigner via the `stxn` field of the transaction object for it to be validated and returned as part of the group. For the transaction(s) where `stxn` was provided, `AlgoSigner.signTxn()` will return the `stxn` string in the same position of the response array as the corresponding reference transaction(s) instead of `null`. + +**Example** + +```js +let tx1 = new algosdk.Transaction({ + to: 'EXTERNAL_ACCOUNT', + from: 'ACCOUNT_IN_ALGOSIGNER', + amount: 100, + ...suggestedParams, +}); +let tx2 = new algosdk.Transaction({ + to: 'ACCOUNT_IN_ALGOSIGNER', + from: 'EXTERNAL_ACCOUNT', + amount: 100, + ...suggestedParams, +}); + +algosdk.assignGroupID([tx1, tx2]); +let binaryTxs = [tx1.toByte(), tx2.toByte()]; +let base64Txs = binaryTxs.map((binary) => AlgoSigner.encoding.msgpackToBase64(binary)); + +let signedTxs = await AlgoSigner.signTxn([ + { + txn: base64Txs[0], + }, + { + txn: base64Txs[1], + signers: [], + stxn: 'MANUALLY_SIGNED_SECOND_TXN_B64', + }, +]); +``` + +**Response** + +```js +[ + { + txID: '...', + blob: 'ALGOSIGNER_SIGNED_B64', + }, + { + txID: '...', + blob: 'MANUALLY_SIGNED_SECOND_TXN_B64', + }, +]; +``` + +#### Signing reference transactions manually + +In case you can't or don't want to provide the `stxn`, the provided transaction should look like this: + +**Example** + +```js +let signedTxs = await AlgoSigner.signTxn([ + { + txn: base64Txs[0], + }, + { + txn: base64Txs[1], + signers: [], + }, +]); +``` + +**Response** + +```js +[ + { + txID: '...', + blob: 'ALGOSIGNER_SIGNED_B64', + }, + null, +]; +``` + +Afterwards, you can sign and send the remaining transaction(s) with the SDK: + +```js +// Convert first transaction to binary from the response +let signedTx1Binary = AlgoSigner.encoding.base64ToMsgpack(signedTxs[0].blob); +// Sign leftover transaction with the SDK +let externalAccount = algosdk.mnemonicToSecretKey('EXTERNAL_ACCOUNT_MNEMONIC'); +let signedTx2Binary = tx2.signTxn(externalAccount.sk); + +await client.sendRawTransaction([signedTx1Binary, signedTx2Binary]).do(); +``` + +Alternatively, if you're using the [AlgoSigner.send()](#algosignersend-ledger-mainnettestnet-txblob-) to send the transaction(s), you have to merge the binaries before converting to a single base64 encoded string. + +```js +// Merge transaction binaries into a single Uint8Array +let combinedBinaryTxns = new Uint8Array(signedTx1Binary.byteLength + signedTx2Binary.byteLength); +combinedBinaryTxns.set(signedTx1Binary, 0); +combinedBinaryTxns.set(signedTx2Binary, signedTx1Binary.byteLength); + +// Convert the combined array values back to base64 +let combinedBase64Txns = AlgoSigner.encoding.msgpackToBase64(combinedBinaryTxns); + +await AlgoSigner.send({ + ledger: 'TestNet', + tx: combinedBase64Txns, +}); +``` + +### Multisig Transactions + +For Multisig transactions, an additional metadata object is required that adheres to the [Algorand multisig parameters](https://developer.algorand.org/docs/features/accounts/create/#how-to-generate-a-multisignature-account) structure when creating a new multisig account: + +```js +{ + version: number, + threshold: number, + addrs: string[] +} +``` + +`AlgoSigner.signTxn()` will validate that the resulting multisig address made from the provided parameters matches the sender address and try to sign with every account on the `addrs` array that is also on AlgoSigner. + +**NOTE:** `AlgoSigner.signTxn()` only accepts unsigned multisig transactions. In case you need to add more signatures to partially signed multisig transactions, please use the SDK. + +**Example** + +```js +let multisigParams = { + version: 1, + threshold: 1, + addrs: ['FIRST_ADDRESS', 'SECOND_ADDRESS', 'ADDRESS_NOT_IN_ALGOSIGNER'], +}; + +let multisigAddress = algosdk.multisigAddress(multisigParams); + +let multisigTx = new algosdk.Transaction({ + to: 'RECEIVER_ADDRESS', + from: multisigAddress, + amount: 100, + ...suggestedParams, +}); + +// Get the binary and base64 encode it +let binaryMultisigTx = multisigTx.toByte(); +let base64MultisigTx = AlgoSigner.encoding.msgpackToBase64(binaryMultisigTx); + +// This returns a partially signed Multisig Transaction with signatures for FIRST_ADDRESS and SECOND_ADDRESS +let signedTxs = await AlgoSigner.signTxn([ + { + txn: base64MultisigTx, + msig: multisigParams, + }, +]); +``` + +In case you want to specify a subset of addresses to sign with, you can add them to the `signers` list on the transaction object, like so: + +```js +// This returns a partially signed Multisig Transaction with signatures for SECOND_ADDRESS +let signedTxs = await AlgoSigner.signTxn([ + { + txn: base64MultisigTx, + msig: multisigParams, + signers: ['SECOND_ADDRESS'], + }, +]); +``` + +### AlgoSigner.send({ ledger: ‘MainNet|TestNet’, txBlob }) + +Send a base64 encoded signed transaction blob to AlgoSigner to transmit to the Network. + +**Request** + +```js +AlgoSigner.send({ + ledger: 'TestNet', + tx: signedTx.blob, +}); +``` + +**Response** + +```json +{ "txId": "OKU6A2QYMRSZAUEJUZL3PW5XKLTA6TKWQHIIBXDCO3KT5OHCULBA" } +``` + +## Custom Networks + +- Custom networks beta support is now in AlgoSigner. [Setup Guide](add-network.md) +- AlgoSigner.accounts(ledger) has changed such that calls now accept names that have been added to the user's custom network list as valid ledger names. + - A non-matching ledger name will result in a error: + - The provided ledger is not supported (Code: 4200). + - An empty request will result with an error: + - Ledger not provided. Please use a base ledger: [TestNet,MainNet] or an available custom one [{"name":"Theta","genesisId":"thetanet-v1.0"}]. +- Transaction requests will require a valid matching "genesisId", even for custom networks. + +## Signature Rejection Messages + +AlgoSigner may return some of the following error codes when requesting signatures: + +| Error Code | Description | Additional notes | +| ---------- | ----------- | ---------------- | +| 4000 | An unknown error occured. | N/A | +| 4001 | The user rejected the signature request. | N/A | +| 4100 | The requested operation and/or account has not been authorized by the user. | This is usually due to the connection between the dApp and the wallet becoming stale and the user [needing to reconnect](connection-issues.md). Otherwise, it may signal that you are trying to sign with private keys not found on AlgoSigner. | +| 4200 | The wallet does not support the requested operation. | N/A | +| 4201 | The wallet does not support signing that many transactions at a time. | The max number of transactions per group is 16. For Ledger devices, they can't sign more than one transaction at the same time. | +| 4202 | The wallet was not initialized properly beforehand. | Users need to have imported or created an account on AlgoSigner before connecting to dApps | +| 4300 | The input provided is invalid. | AlgoSigner rejected some of the transactions due to invalid fields. | + +Additional information, if available, would be provided in the `data` field of the error object. + +Returned errors have the following object structure: + +``` +{ + message: string; + code: number; + name: string; + data?: any; +} +``` + +Errors may be passed back to the dApp from the Algorand JS SDK if a transaction is valid, but has some other issue - for example, insufficient funds in the sending account. + +## Helper Functions + +`AlgoSigner.enconding.*` contains a few different methods in order to help with the different formats and encodings that are needed when working with dApps and the SDK. + +``` + AlgoSigner.encoding.msgpackToBase64(): receives a binary object (as a Uint8Array) and returns the corresponding base64 encoded string, + AlgoSigner.encoding.base64ToMsgpack(): receives a base64 encoded string and returns the corresponding binary object (as a Uint8Array), + AlgoSigner.encoding.stringToByteArray(): receives a plain unencoded string and returns the corresponding binary object (as a Uint8Array), + AlgoSigner.encoding.byteArrayToString(): receives a binary object (as a Uint8Array) and returns the corresponding plain unencoded string, +``` diff --git a/package.json b/package.json index d9833a52..4070cc11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "algosigner", - "version": "1.9.6", + "version": "1.10.0", "author": "https://developer.purestake.io", "description": "Sign Algorand transactions in your browser with PureStake.", "repository": "https://github.com/PureStake/algosigner", diff --git a/packages/common/package.json b/packages/common/package.json index 03ed3769..fd2ebcfc 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@algosigner/common", - "version": "1.9.6", + "version": "1.10.0", "author": "https://developer.purestake.io", "description": "Common library functions for AlgoSigner.", "repository": "https://github.com/PureStake/algosigner", diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts index 85640897..25b71e8e 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -8,31 +8,31 @@ export class RequestError { data?: any; static None = new RequestError('', 0); - static Undefined = new RequestError( - 'An undefined error occurred.', - 4000 - ); + static Undefined = new RequestError('An undefined error occurred.', 4000); static UserRejected = new RequestError( 'The extension user does not authorize the request.', 4001 ); + static EnableRejected = (data: object): RequestError => new RequestError( + 'The extension user does not authorize the request.', + 4001, + data + ); static SiteNotAuthorizedByUser = new RequestError( 'The extension user has not authorized requests from this website.', 4100 ); - static NoMnemonicAvailable = (address: string): RequestError => new RequestError( - `The user does not possess the required private key to sign with for address: "${address}".`, - 4100 - ); + static NoMnemonicAvailable = (address: string): RequestError => + new RequestError( + `The user does not possess the required private key to sign with for address: "${address}".`, + 4100 + ); static NoAccountMatch = (address: string, ledger: string): RequestError => new RequestError( `No matching account found on AlgoSigner for address "${address}" on network ${ledger}.`, 4100 ); - static UnsupportedLedger = new RequestError( - 'The provided ledger is not supported.', - 4200 - ); + static UnsupportedLedger = new RequestError('The provided ledger is not supported.', 4200); static PendingTransaction = new RequestError('Another query processing', 4201); static LedgerMultipleTransactions = new RequestError( 'Ledger hardware device signing is only available for one transaction at a time.', @@ -50,14 +50,24 @@ export class RequestError { new RequestError('Validation failed for transaction due to invalid properties.', 4300, data); static InvalidTransactionStructure = (data?: any): RequestError => new RequestError('Validation failed for transaction due to invalid structure.', 4300, data); - static InvalidFormat = new RequestError( - '[RequestError.InvalidFormat] Please provide an array of either valid transaction objects or nested arrays of valid transaction objects.', + static InvalidSignTxnsFormat = new RequestError( + 'Please provide an array of either valid transaction objects or nested arrays of valid transaction objects.', + 4300 + ); + static InvalidPostTxnsFormat = new RequestError( + 'Please provide either an array of signed transactions or an array of groups of signed transactions.', 4300 ); + static PostValidationFailed = (reason: any): RequestError => + new RequestError( + "There was a problem validating the transaction(s). The reasons are provided in the 'data' property.", + 4300, + reason + ); static CantMatchMsigSigners = (info: string): RequestError => new RequestError( `AlgoSigner does not currently possess one of the requested signers for this multisig transaction: ${info}.`, - 4300, + 4300 ); static InvalidSignerAddress = (address: string): RequestError => new RequestError(`Signers array contains the invalid address "${address}"`, 4300); @@ -108,21 +118,32 @@ export class RequestError { static InvalidAuthAddress = (address: string): RequestError => new RequestError(`'authAddr' contains the invalid address "${address}"`, 4300); static IncompleteOrDisorderedGroup = new RequestError( - 'The transaction group is incomplete or presented in a different order than when it was created.', + 'The provided group ID does not match the provided transactions. This usually means the transaction group is incomplete or presented in a different order than when it was created.', 4300 ); static MultipleTxsRequireGroup = new RequestError( 'If signing multiple transactions, they need to belong to a same group.', 4300 ); - static NonMatchingGroup = new RequestError( - 'All transactions need to belong to the same group.', + static MismatchingGroup = new RequestError( + 'All transactions provided in a same group need to have matching group IDs.', 4300 ); static NoDifferentLedgers = new RequestError( 'All transactions need to belong to the same ledger.', 4300 ); + static PartiallySuccessfulPost = (successTxnIDs: string[], data: any): PostError => + new PostError( + successTxnIDs, + "Some of the groups of transactions were unable to be posted. The reason for each unsuccessful group is in it's corresponding position inside the 'data' array.", + 4400, + data + ); + static PostConfirmationFailed = new RequestError( + 'The transaction(s) were succesfully sent to the network, but there was an issue while waiting for confirmation. Please verify that they were commited to the network before trying again.', + 4400 + ); static SigningError = (code: number, data?: any): RequestError => new RequestError('There was a problem signing the transaction(s).', code, data); @@ -135,3 +156,12 @@ export class RequestError { Error.captureStackTrace(this, RequestError); } } + +class PostError extends RequestError { + successTxnIDs: string[]; + + public constructor(successTxnIDs: string[], message: string, code: number, data?: any) { + super(message, code, data); + this.successTxnIDs = successTxnIDs; + } +} diff --git a/packages/common/src/messaging/types.ts b/packages/common/src/messaging/types.ts index ef95804c..201c6cf4 100644 --- a/packages/common/src/messaging/types.ts +++ b/packages/common/src/messaging/types.ts @@ -1,5 +1,3 @@ -import { WalletTransaction } from '../types'; - export const JSONRPC_VERSION: string = '2.0'; /* eslint-disable no-unused-vars */ @@ -7,13 +5,14 @@ export enum JsonRpcMethod { // dApp methods Heartbeat = 'heartbeat', Authorization = 'authorization', + EnableAuthorization = 'enable-authorization', AuthorizationAllow = 'authorization-allow', AuthorizationDeny = 'authorization-deny', - SignAllow = 'sign-allow', SignAllowWalletTx = 'sign-allow-wallet-tx', SignDeny = 'sign-deny', SignWalletTransaction = 'sign-wallet-transaction', SendTransaction = 'send-transaction', + PostTransactions = 'post-txns', Algod = 'algod', Indexer = 'indexer', Accounts = 'accounts', @@ -47,6 +46,7 @@ export enum JsonRpcMethod { GetNamespaceConfigs = 'get-namespace-configs', ToggleNamespaceConfig = 'toggle-namespace-config', GetGovernanceAddresses = 'get-governance-addresses', + GetEnableAccounts = 'get-enable-accounts', // Ledger Device Methods LedgerSaveAccount = 'ledger-save-account', @@ -57,7 +57,7 @@ export enum JsonRpcMethod { } export type JsonPayload = { - [key: string]: string | number | Array | JsonPayload | undefined; + [key: string]: string | number | boolean | Array | JsonPayload | undefined; }; export type JsonRpcBody = { diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 1179f892..b0736341 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -32,6 +32,15 @@ export type WalletTransaction = { readonly authAddr?: string; }; +export type SignTxnsOpts = { + [key: string]: string | boolean, +}; + +export enum OptsKeys { + ARC01Return = 'AlgoSigner_arc01', + sendTxns = 'AlgoSigner_send' +} + export type Alias = { readonly name: string; readonly address: string; diff --git a/packages/common/src/types/ledgers.ts b/packages/common/src/types/ledgers.ts index 4d367c63..b27939bb 100644 --- a/packages/common/src/types/ledgers.ts +++ b/packages/common/src/types/ledgers.ts @@ -50,12 +50,12 @@ export function getBaseSupportedLedgers(): Array { new LedgerTemplate({ name: 'MainNet', genesisId: 'mainnet-v1.0', - genesisHash: '', + genesisHash: 'wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=', }), new LedgerTemplate({ name: 'TestNet', genesisId: 'testnet-v1.0', - genesisHash: '', + genesisHash: 'SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=', }), ]; } diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index f6475619..00090d29 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -35,6 +35,17 @@ export function isLedgerBaseSupported(ledger: string): boolean { * @param address * @returns string */ -export function obfuscateAddress(address: string): string { - return `${address.slice(0, 10)}.....${address.slice(-10)}`; +export function obfuscateAddress(address: string, range: number = 10): string { + return `${address.slice(0, range)}.....${address.slice(-range)}`; +} + +/** + * Compares to buffers to make sure it's contents match + * Useful for validating groups or other msgpack encoded data + * @param b1 first buffer to compare + * @param b2 second buffer to compare + * @returns boolean + */ +export function areBuffersEqual(b1: Uint8Array, b2: Uint8Array): boolean { + return b1.every((value, index) => b2[index] === value); } \ No newline at end of file diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 96d41b21..b4038d3a 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -1,6 +1,6 @@ { "name": "algosigner-crypto", - "version": "1.9.6", + "version": "1.10.0", "author": "https://developer.purestake.io", "description": "Cryptographic wrapper for saving and retrieving extention information in AlgoSigner.", "repository": { diff --git a/packages/dapp/package.json b/packages/dapp/package.json index 143f3f23..3aa94dd7 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -1,6 +1,6 @@ { "name": "@algosigner/dapp", - "version": "1.9.6", + "version": "1.10.0", "author": "https://developer.purestake.io", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", diff --git a/packages/dapp/src/algorand.ts b/packages/dapp/src/algorand.ts new file mode 100644 index 00000000..f270accf --- /dev/null +++ b/packages/dapp/src/algorand.ts @@ -0,0 +1,37 @@ +import { Task } from './fn/task'; +import { Router } from './fn/router'; +import { + base64ToByteArray, + byteArrayToBase64, + stringToByteArray, + byteArrayToString, +} from '@algosigner/common/encoding'; + +class Wrapper { + private static instance: Wrapper; + private task: Task = new Task(); + private router: Router = new Router(); + + public encoding: object = { + msgpackToBase64: byteArrayToBase64, + base64ToMsgpack: base64ToByteArray, + stringToByteArray, + byteArrayToString, + }; + + public enable: Function = this.task.enable; + public signAndPostTxns: Function = this.task.signAndPostTxns; + // public getAlgodv2Client: Function = this.task.algod; + // public getIndexerClient: Function = this.task.indexer; + public signTxns: Function = this.task.signTxns; + public postTxns: Function = this.task.postTxns; + + public static getInstance(): Wrapper { + if (!Wrapper.instance) { + Wrapper.instance = new Wrapper(); + } + return Wrapper.instance; + } +} + +export const algorand = Wrapper.getInstance(); diff --git a/packages/dapp/src/fn/task.ts b/packages/dapp/src/fn/task.ts index ae3be41e..731439d8 100644 --- a/packages/dapp/src/fn/task.ts +++ b/packages/dapp/src/fn/task.ts @@ -2,7 +2,7 @@ import { ITask } from './interfaces'; import { MessageBuilder } from '../messaging/builder'; -import { Transaction, WalletTransaction } from '@algosigner/common/types'; +import { SignTxnsOpts, OptsKeys, WalletTransaction } from '@algosigner/common/types'; import { RequestError } from '@algosigner/common/errors'; import { JsonRpcMethod, JsonPayload } from '@algosigner/common/messaging/types'; import { Runtime } from '@algosigner/common/runtime/runtime'; @@ -10,15 +10,39 @@ import { Runtime } from '@algosigner/common/runtime/runtime'; export class Task extends Runtime implements ITask { static subscriptions: { [key: string]: Function } = {}; + private static validateFormat(transactionsOrGroups: WalletTransaction[]): void { + const formatError = RequestError.InvalidSignTxnsFormat; + // We check for empty arrays + if (!Array.isArray(transactionsOrGroups) || !transactionsOrGroups.length) throw formatError; + transactionsOrGroups.forEach((txOrGroup) => { + // We check for no null values and no empty nested arrays + if ( + txOrGroup === null || + txOrGroup === undefined || + (!Array.isArray(txOrGroup) && + typeof txOrGroup === 'object' && + (!txOrGroup.txn || (txOrGroup.txn && !txOrGroup.txn.length))) || + (Array.isArray(txOrGroup) && + (!txOrGroup.length || (txOrGroup.length && !txOrGroup.every((tx) => tx !== null)))) + ) + throw formatError; + }); + } + connect(): Promise { return MessageBuilder.promise(JsonRpcMethod.Authorization, {}); } + enable(opts: JsonPayload, error: RequestError = RequestError.None): Promise { + const params = {...opts} + return MessageBuilder.promise(JsonRpcMethod.EnableAuthorization, params, error); + } + accounts(params: JsonPayload, error: RequestError = RequestError.None): Promise { - return MessageBuilder.promise(JsonRpcMethod.Accounts, params as JsonPayload, error); + return MessageBuilder.promise(JsonRpcMethod.Accounts, params, error); } - send(params: Transaction, error: RequestError = RequestError.None): Promise { + send(params: JsonPayload, error: RequestError = RequestError.None): Promise { return MessageBuilder.promise(JsonRpcMethod.SendTransaction, params, error); } @@ -39,29 +63,67 @@ export class Task extends Runtime implements ITask { * @returns array or nested array of signed transactions */ signTxn( - transactionsOrGroups: Array, + transactionsOrGroups: WalletTransaction[], error: RequestError = RequestError.None ): Promise { - const formatError = RequestError.InvalidFormat; - // We check for empty arrays - if (!Array.isArray(transactionsOrGroups) || !transactionsOrGroups.length) throw formatError; - transactionsOrGroups.forEach((txOrGroup) => { - // We check for no null values and no empty nested arrays - if ( - txOrGroup === null || - txOrGroup === undefined || - (!Array.isArray(txOrGroup) && - typeof txOrGroup === 'object' && - (!txOrGroup.txn || (txOrGroup.txn && !txOrGroup.txn.length))) || - (Array.isArray(txOrGroup) && - (!txOrGroup.length || (txOrGroup.length && !txOrGroup.every((tx) => tx !== null)))) - ) - throw formatError; - }); + Task.validateFormat(transactionsOrGroups); + + const params = { + transactionsOrGroups: transactionsOrGroups, + }; + return MessageBuilder.promise(JsonRpcMethod.SignWalletTransaction, params, error); + } + + /** + * @param transactionsOrGroups array or nested array of grouped transaction objects + * @param opts optional parameters passed to the function + * @returns array or nested array of signed transactions + */ + signTxns( + transactionsOrGroups: WalletTransaction[], + opts?: SignTxnsOpts, + error: RequestError = RequestError.None + ): Promise { + Task.validateFormat(transactionsOrGroups); const params = { transactionsOrGroups: transactionsOrGroups, + opts: { + [OptsKeys.ARC01Return]: true, + ...opts, + }, }; return MessageBuilder.promise(JsonRpcMethod.SignWalletTransaction, params, error); } + + /** + * @param transactionsOrGroups array or nested array of grouped transaction objects + * @param opts optional parameters passed to the function + * @returns array or nested array of confirmed transactions sent to the network + */ + signAndPostTxns( + transactionsOrGroups: WalletTransaction[], + opts?: SignTxnsOpts, + error: RequestError = RequestError.None + ): Promise { + Task.validateFormat(transactionsOrGroups); + + const params = { + transactionsOrGroups: transactionsOrGroups, + opts: { + [OptsKeys.ARC01Return]: true, + [OptsKeys.sendTxns]: true, + ...opts, + }, + }; + return MessageBuilder.promise(JsonRpcMethod.SignWalletTransaction, params, error); + } + + /** + * @param stxns array or nested array of grouped signed transaction + * @returns array or nested array of confirmed transactions sent to the network + */ + postTxns(stxns: string[] | string[][], error: RequestError = RequestError.None): Promise { + return MessageBuilder.promise(JsonRpcMethod.PostTransactions, { stxns }, error); + } } diff --git a/packages/dapp/src/index.ts b/packages/dapp/src/index.ts index b4bc0d7a..cbb4d867 100644 --- a/packages/dapp/src/index.ts +++ b/packages/dapp/src/index.ts @@ -1 +1,2 @@ export { AlgoSigner } from './algosigner'; +export { algorand } from './algorand'; diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 231f6621..68d30072 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "AlgoSigner", "author": "https://developer.purestake.io", - "version": "1.9.6", + "version": "1.10.0", "description": "Algorand Wallet Extension | Send & Receive ALGOs | Sign dApp Transactions", "icons": { "48": "icon.png" diff --git a/packages/extension/package.json b/packages/extension/package.json index b0db3e3e..4c30bdc1 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "algosigner-extension", - "version": "1.9.6", + "version": "1.10.0", "author": "https://developer.purestake.io", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", @@ -20,7 +20,7 @@ "webpack-cli": "^4.9.0" }, "dependencies": { - "algosdk": "1.19.0", + "algosdk": "1.22.0", "buffer": "^6.0.3" }, "scripts": { diff --git a/packages/extension/src/background/messaging/handler.ts b/packages/extension/src/background/messaging/handler.ts index 0a150e0c..e9a5ebd4 100644 --- a/packages/extension/src/background/messaging/handler.ts +++ b/packages/extension/src/background/messaging/handler.ts @@ -10,6 +10,7 @@ const auth_methods = [ JsonRpcMethod.Authorization, JsonRpcMethod.AuthorizationAllow, JsonRpcMethod.AuthorizationDeny, + JsonRpcMethod.EnableAuthorization, ]; class RequestValidation { diff --git a/packages/extension/src/background/messaging/task.ts b/packages/extension/src/background/messaging/task.ts index c201629d..e15346d1 100644 --- a/packages/extension/src/background/messaging/task.ts +++ b/packages/extension/src/background/messaging/task.ts @@ -1,6 +1,6 @@ import algosdk, { MultisigMetadata, Transaction } from 'algosdk'; -import { WalletTransaction } from '@algosigner/common/types'; +import { OptsKeys, WalletTransaction } from '@algosigner/common/types'; import { RequestError } from '@algosigner/common/errors'; import { JsonRpcMethod } from '@algosigner/common/messaging/types'; import { Ledger } from '@algosigner/common/types'; @@ -8,6 +8,7 @@ import { API } from './types'; import { getValidatedTxnWrap, getLedgerFromGenesisId, + getLedgerFromMixedGenesis, } from '../transaction/actions'; import { BaseValidatedTxnWrap } from '../transaction/baseValidatedTxnWrap'; import { ValidationResponse, ValidationStatus } from '../utils/validator'; @@ -17,21 +18,12 @@ import encryptionWrap from '../encryptionWrap'; import { Settings } from '../config'; import { extensionBrowser } from '@algosigner/common/chrome'; import { logging } from '@algosigner/common/logging'; -import { buildTransaction } from '../utils/transactionBuilder'; import { base64ToByteArray, byteArrayToBase64 } from '@algosigner/common/encoding'; -import { removeEmptyFields } from '@algosigner/common/utils'; +import { areBuffersEqual } from '@algosigner/common/utils'; -// Additional space for the title bar +// Popup properties accounts for additional space needed for the title bar const titleBarHeight = 28; - -const authPopupProperties = { - type: 'popup', - focused: true, - width: 400, - height: 550 + titleBarHeight, -}; - -const signPopupProperties = { +const popupProperties = { type: 'popup', focused: true, width: 400, @@ -41,11 +33,49 @@ const signPopupProperties = { export class Task { private static requests: { [key: string]: any } = {}; private static authorized_pool: Array = []; + private static authorized_pool_details: any = {} public static isAuthorized(origin: string): boolean { return Task.authorized_pool.indexOf(origin) > -1; } + public static isPreAuthorized(origin: string, genesisID: string, requestedAccounts: Array): boolean { + // Validate the origin is in the authorized pool + if (Task.authorized_pool.indexOf(origin) === -1) { + return false; + } + + // Validate the genesisID is the authorized one + // Note: The arc-0006 requires "genesisID" which matches the transaction, but we use "genesisId" internally in some places + if (!Task.authorized_pool_details[origin] || !(Task.authorized_pool_details[origin]['genesisID'] === genesisID)) { + return false; + } + + // Validate the requested accounts exist in the pool detail + for (let i = 0; i < requestedAccounts.length; i++) { + if (!(Task.authorized_pool_details[origin].accounts.includes(requestedAccounts[i]))) { + return false; + } + } + + // We made it through negative checks to accounts are currently authroized + return true; + } + + // Checks for the originId authorization in details then call to make sure the account exists in Algosigner. + private static checkAccountIsImportedAndAuthorized(genesisID: string, address: string, originId: string): void { + // Legacy authorized and internal calls will not have an originId in authorized pool details + if (Task.authorized_pool_details[originId]) { + // This must be a dApp using enable - verify the ledger and address are authorized + if ((Task.authorized_pool_details[originId]['genesisID'] !== genesisID) + || (!Task.authorized_pool_details[originId]['accounts'].includes(address))) { + throw RequestError.NoAccountMatch(address, genesisID); + } + } + // Call the normal account check + InternalMethods.checkAccountIsImported(genesisID, address); + } + public static build(request: any) { const body = request.body; const method = body.method; @@ -67,6 +97,7 @@ export class Task { public static clearPool() { Task.authorized_pool = []; + Task.authorized_pool_details = {}; } public static getChainAuthAddress = async (transaction: any): Promise => { @@ -269,8 +300,11 @@ export class Task { wrap.signers = signers; // We validate the authAddress if available - if (authAddr && !algosdk.isValidAddress(authAddr)) { - throw RequestError.InvalidAuthAddress(authAddr); + if (authAddr) { + if (!algosdk.isValidAddress(authAddr)) { + throw RequestError.InvalidAuthAddress(authAddr); + } + Task.checkAccountIsImportedAndAuthorized(genesisID, authAddr, request.originTabID); } // If we have msigData, we validate the addresses and fetch the resulting msig address @@ -335,7 +369,7 @@ export class Task { // We make sure we have the available accounts for signing signers.forEach((address) => { try { - InternalMethods.checkAccountIsImported(genesisID, address); + Task.checkAccountIsImportedAndAuthorized(genesisID, address, request.originTabID); } catch (e) { throw RequestError.CantMatchMsigSigners(e.message); } @@ -355,7 +389,7 @@ export class Task { // We reject if we can't convert from b64 to a valid txn throw RequestError.InvalidSignedTxn; } - if (!signedTxnBytes.every((value, index) => unsignedTxnBytes[index] === value)) { + if (!areBuffersEqual(signedTxnBytes, unsignedTxnBytes)) { // We reject if the transactions don't match throw RequestError.NonMatchingSignedTxn; } @@ -364,7 +398,7 @@ export class Task { } else { // There's no signers field, we validate the sender if there's no msig if (!msigData) { - InternalMethods.checkAccountIsImported(genesisID, wrap.transaction.from); + Task.checkAccountIsImportedAndAuthorized(genesisID, wrap.transaction.from, request.originTabID); } } @@ -443,7 +477,7 @@ export class Task { /** * Group validations */ - const groupId = transactionWraps[0].transaction.group; + const providedGroupId: string = transactionWraps[0].transaction.group; if (transactionWraps.length > 1) { if ( @@ -454,16 +488,16 @@ export class Task { throw RequestError.NoDifferentLedgers; } - if (!groupId) { + if (!providedGroupId || !transactionWraps.every((wrap) => wrap.transaction.group)) { throw RequestError.MultipleTxsRequireGroup; } - if (!transactionWraps.every((wrap) => groupId === wrap.transaction.group)) { - throw RequestError.NonMatchingGroup; + if (!transactionWraps.every((wrap) => providedGroupId === wrap.transaction.group)) { + throw RequestError.MismatchingGroup; } } - if (groupId) { + if (providedGroupId) { // Verify group is presented as a whole const recreatedGroupTxs = algosdk.assignGroupID( rawTxArray.slice().map((tx) => { @@ -472,7 +506,7 @@ export class Task { }) ); const recalculatedGroupID = byteArrayToBase64(recreatedGroupTxs[0].group); - if (groupId !== recalculatedGroupID) { + if (providedGroupId !== recalculatedGroupID) { throw RequestError.IncompleteOrDisorderedGroup; } } @@ -492,7 +526,7 @@ export class Task { extensionBrowser.windows.create( { url: extensionBrowser.runtime.getURL('index.html#/sign-v2-transaction'), - ...signPopupProperties, + ...popupProperties, }, function (w) { if (w) { @@ -548,7 +582,7 @@ export class Task { extensionBrowser.windows.create( { url: extensionBrowser.runtime.getURL('index.html#/authorize'), - ...authPopupProperties, + ...popupProperties, }, function (w: any) { if (w) { @@ -564,7 +598,181 @@ export class Task { ); } }, - // handle-wallet-transactions + // Enable function as defined in ARC-006 + [JsonRpcMethod.EnableAuthorization]: (d: any) => { + const { accounts } = d.body.params; + let { genesisID, genesisHash, ledger } = d.body.params; + + // Delete any previous request made from the Tab that it's trying to connect. + delete Task.requests[d.originTabID]; + + // Get an internal session - if unavailable then we will connect and deny + const session = InternalMethods.getHelperSession(); + + // Set a flag for a specified network + let networkSpecifiedType = 0; + if (genesisID && genesisHash) { + networkSpecifiedType = 1; + } + else if (genesisID || genesisHash) { + networkSpecifiedType = 2; + } + d.body.params.networkSpecifiedType = networkSpecifiedType; + + // If session is missing then we should throw an error, but still popup the login screen + if (session.availableLedgers.length === 0) { + // No ledgers are available. The user is logged out so just prompt them to login. + extensionBrowser.windows.create({ + url: extensionBrowser.runtime.getURL('index.html#/close'), + ...popupProperties, + }); + + // Let the dApp know there was an issue with a generic unauthorized + d.error = RequestError.SiteNotAuthorizedByUser; + + // Set the timeout higher to allow for the previous popup before responding + setTimeout(() => { + MessageApi.send(d); + }, 2000); + } + else { + // Get ledger/hash/id from the genesisID and/or hash + const ledgerTemplate = getLedgerFromMixedGenesis(genesisID, genesisHash); + + // Validate that the genesis id and hash if provided match the resulting one + // This is because a dapp may request an id and hash from different ledgers + if ((genesisID && genesisID !== ledgerTemplate.genesisId) + || (genesisHash && genesisHash !== ledgerTemplate.genesisHash)) { + d.error = RequestError.UnsupportedLedger; + setTimeout(() => { + MessageApi.send(d); + }, 500); + return; + } + + // We've validated the ledger information + // So we can set the ledger, genesisID, and genesisHash + ledger = ledgerTemplate.name; + genesisID = ledgerTemplate.genesisId; + genesisHash = ledgerTemplate.genesisHash; + // Then reflect those changes for the page + d.body.params.ledger = ledger; // For legacy name use + d.body.params.genesisID = genesisID; + d.body.params.genesisHash = genesisHash; + + // If we already have the ledger authorized for this origin then check the shared accounts + if (Task.isAuthorized(d.origin)) { + // First check that we actually still have the addresses requested + try { + accounts.forEach(account => { + InternalMethods.checkAccountIsImported(genesisID, account); + }); + + // If the ledger and ALL accounts are available then respond with the cached data + if (Task.isPreAuthorized(d.origin, genesisID, accounts)) { + // We have the accounts and may include additional, but just make sure the order is maintained + const sharedAccounts = []; + accounts.forEach(account => { + // Make sure we don't include accounts that have been deleted + InternalMethods.checkAccountIsImported(genesisID, account); + sharedAccounts.push(account); + }); + Task.authorized_pool_details[d.origin]['accounts'].forEach(account => { + if (!(sharedAccounts.includes(account))) { + // Make sure we don't include accounts that have been deleted + InternalMethods.checkAccountIsImported(genesisID, account); + sharedAccounts.push(account); + } + }); + + // Now we can set the response, but don't need to update the cache + d.response = { + 'genesisID': genesisID, + 'genesisHash': genesisHash, + accounts: sharedAccounts + }; + MessageApi.send(d); + return; + } + } + catch { + // Failure means we won't auto authorize, but we can sink the error as we are re-prompting + } + } + // We haven't immediately failed and don't have preAuthorization so we need to prompt accounts. + const promptedAccounts = []; + + // Add any requested accounts so they can be in the proper order to start + if (accounts) { + for (let i = 0; i < accounts.length; i++) { + // We initially push accounts as missing and don't have them selected + // If we also own the address it will be modified to not missing and selected by default + const requestedAddress = accounts[i]; + promptedAccounts.push({ + address: requestedAddress, + missing: true, + requested: true, + }); + } + } + + // Get wallet accounts for the specified ledger + const walletAccounts = session.wallet[ledger]; + + // If we need a requested a ledger but don't have it, respond with an error + if (walletAccounts === undefined) { + d.error = RequestError.UnsupportedLedger; + + setTimeout(() => { + MessageApi.send(d); + }, 500); + return; + } + + // Add all the walletAccounts we have for the ledger + for (let i = 0; i < walletAccounts.length; i++) { + const walletAccount = walletAccounts[i].address; + const accountIndex = promptedAccounts.findIndex(e => e.address === walletAccount); + + if (accountIndex > -1) { + // If we have the account then mark it as valid + promptedAccounts[accountIndex]['missing'] = false; + promptedAccounts[accountIndex]['selected'] = true; + } + else { + // If we are missing the address then this is an account that the dApp did not request + // but we can push the value an the additional choices from the user before returning + promptedAccounts.push({ + address: walletAccount, + requested: false, + selected: false + }); + } + } + + // Add the prompted accounts to the params that will go to the page + d.body.params['promptedAccounts'] = promptedAccounts; + + extensionBrowser.windows.create( + { + url: extensionBrowser.runtime.getURL('index.html#/enable'), + ...popupProperties, + }, + function (w: any) { + if (w) { + Task.requests[d.originTabID] = { + window_id: w.id, + message: d, + }; + setTimeout(function () { + extensionBrowser.runtime.sendMessage(d); + }, 500); + } + } + ); + } + }, + // sign-wallet-transaction [JsonRpcMethod.SignWalletTransaction]: async ( d: any, resolve: Function, @@ -601,8 +809,8 @@ export class Task { // If none of the formats match up, we throw an error if (!transactionsOrGroups || (!hasSingleGroup && !hasMultipleGroups)) { - logging.log(RequestError.InvalidFormat.message); - d.error = RequestError.InvalidFormat; + logging.log(RequestError.InvalidSignTxnsFormat.message); + d.error = RequestError.InvalidSignTxnsFormat; reject(d); return; } @@ -615,7 +823,7 @@ export class Task { d.body.params.currentGroup = 0; d.body.params.signedGroups = []; - Task.signIndividualGroup(d); + await Task.signIndividualGroup(d); }, // send-transaction [JsonRpcMethod.SendTransaction]: (d: any, resolve: Function, reject: Function) => { @@ -629,9 +837,7 @@ export class Task { }, method: 'POST', }; - const tx = atob(params.tx) - .split('') - .map((x) => x.charCodeAt(0)); + const tx = base64ToByteArray(params.tx); fetchParams.body = new Uint8Array(tx); let url = conn.url; @@ -647,6 +853,152 @@ export class Task { reject(d); }); }, + // post-txns + [JsonRpcMethod.PostTransactions]: (d: any, resolve: Function, reject: Function) => { + // Separate stxns by groups + const { stxns } = d.body.params; + let groupsToSend: string[][]; + // These arrays are nested, reflecting the content of the groups + const evaluationErrors = [], + responseIDs = [], + fetchBodies = [], + fetchPromises = [], + fetchErrors = []; + let fetchResponses = []; + // We check if it's a valid array and if it's nested + if (Array.isArray(stxns) && stxns.length) { + groupsToSend = Array.isArray(stxns[0]) ? stxns : [stxns]; + } else { + throw RequestError.InvalidPostTxnsFormat; + } + + groupsToSend.forEach((group, index) => { + let txnBinaries: Array, + decodedTxns: Array, + txnIDs: Array = []; + try { + // Decode txns and validate group matches + txnBinaries = group.map((txn) => base64ToByteArray(txn)); + decodedTxns = txnBinaries.map((bin) => algosdk.decodeSignedTransaction(bin).txn); + if (decodedTxns.length > 1 || decodedTxns[0].group) { + // Fetch group ID from provided txns + let providedGroupID: Uint8Array; + for (const dtx of decodedTxns) { + if (!dtx.group) { + throw RequestError.MultipleTxsRequireGroup; + } else { + if (providedGroupID) { + if (!areBuffersEqual(dtx.group, providedGroupID)) { + throw RequestError.MismatchingGroup; + } + } else { + providedGroupID = dtx.group; + } + } + } + // Recalculate Group ID based on transactions provided to make sure they match + const cleanTxns = decodedTxns.slice().map((tx) => { + delete tx.group; + return tx; + }); + const computedGroupID: Buffer = algosdk.computeGroupID(cleanTxns); + if (!areBuffersEqual(providedGroupID, computedGroupID)) { + throw RequestError.IncompleteOrDisorderedGroup; + } + } + + // Calculate txIDs for response and create merged binaries + txnIDs = decodedTxns.map((tx) => tx.txID()); + const totalLength = txnBinaries + .map((bin) => bin.byteLength) + .reduce((pv, cv) => pv + cv, 0); + const mergedBinaries: Uint8Array = new Uint8Array(totalLength); + for (let i = 0; i < txnBinaries.length; i++) { + const bin = txnBinaries[i]; + const position = i > 0 ? txnBinaries[i - 1].byteLength : 0; + mergedBinaries.set(bin, position); + } + fetchBodies[index] = new Uint8Array(mergedBinaries); + responseIDs[index] = txnIDs; + } catch (e) { + evaluationErrors[index] = e; + } + }); + + if (evaluationErrors.length) { + let reasons = new Array(groupsToSend.length); + evaluationErrors.forEach((e, i) => (reasons[i] = e ? e.message : null)); + if (groupsToSend.length === 1) { + reasons = reasons[0]; + } + d.error = RequestError.PostValidationFailed(reasons); + resolve(d); + } else { + const genesisID = Task.authorized_pool_details[d.origin]['genesisID']; + const genesisHash = Task.authorized_pool_details[d.origin]['genesisHash']; + const ledger = getLedgerFromMixedGenesis(genesisID, genesisHash).name; + const conn = Settings.getBackendParams(ledger, API.Algod); + const sendPath = '/v2/transactions'; + const fetchParams: any = { + headers: { + ...conn.headers, + 'Content-Type': 'application/x-binary', + }, + method: 'POST', + }; + + let url = conn.url; + if (conn.port.length > 0) url += ':' + conn.port; + + fetchBodies.forEach((body, index) => { + fetchParams.body = body; + fetchPromises[index] = Task.fetchAPI(`${url}${sendPath}`, fetchParams); + }); + + Promise.allSettled(fetchPromises).then((results) => { + const algod = InternalMethods.getAlgod(ledger); + const confirmationPromises = []; + results.forEach((res, index) => { + if (res.status === 'fulfilled') { + const txID = res.value.txId; + confirmationPromises[index] = algosdk.waitForConfirmation(algod, txID, 2); + } else { + fetchErrors[index] = res.reason.message; + fetchResponses[index] = null; + } + }); + + Promise.all(confirmationPromises).then((confirmations) => { + // We add values to both arrays to preserve indexes across all possible return arrays + confirmations.forEach((c, index) => { + if (c && 'confirmed-round' in c) { + fetchResponses[index] = responseIDs[index]; + fetchErrors[index] = null; + } else { + fetchErrors[index] = RequestError.PostConfirmationFailed; + fetchResponses[index] = null; + } + }); + + if (groupsToSend.length === 1) { + fetchResponses = fetchResponses.length ? fetchResponses[0] : []; + } + + // Check if there's any non-null errors + if (fetchErrors.filter(Boolean).length) { + const successTxnIDs = fetchResponses; + const reasons = new Array(groupsToSend.length); + fetchErrors.forEach((e, i) => (reasons[i] = e ? e.message : null)); + d.error = RequestError.PartiallySuccessfulPost(successTxnIDs, reasons); + resolve(d); + } else { + d.response = { txnIDs: fetchResponses }; + resolve(d); + } + }); + }); + } + }, // algod [JsonRpcMethod.Algod]: (d: any, resolve: Function, reject: Function) => { const { params } = d.body; @@ -743,7 +1095,7 @@ export class Task { private: { // authorization-allow [JsonRpcMethod.AuthorizationAllow]: (d) => { - const { responseOriginTabID } = d.body.params; + const { responseOriginTabID, isEnable, accounts, genesisID, genesisHash } = d.body.params; const auth = Task.requests[responseOriginTabID]; const message = auth.message; @@ -754,6 +1106,34 @@ export class Task { setTimeout(() => { // Response needed message.response = {}; + // We need to send the authorized accounts and genesis info back if this is an enable call + if (isEnable) { + // Check that the requested accounts have been approved + const rejectedAccounts = []; + const sharedAccounts = []; + for (const i in accounts) { + if ((accounts[i]['requested'] && !accounts[i]['selected']) || accounts[i]['missing']) { + rejectedAccounts.push(accounts[i]['address']); + } + else if(accounts[i]['selected']) { + sharedAccounts.push(accounts[i]['address']); + } + } + if (rejectedAccounts.length > 0) { + message.error = RequestError.EnableRejected({ 'accounts': rejectedAccounts }); + } + else { + message.response = { + 'genesisID': genesisID, + 'genesisHash': genesisHash, + accounts: sharedAccounts + } + + // Add to the authorized pool details. + // This will be checked to restrict access for enable function users + Task.authorized_pool_details[`${message.origin}`] = message.response; + } + } MessageApi.send(message); }, 100); }, @@ -773,166 +1153,12 @@ export class Task { }, }, extension: { - // sign-allow - [JsonRpcMethod.SignAllow]: (request: any, sendResponse: Function) => { - const { passphrase, responseOriginTabID } = request.body.params; - const auth = Task.requests[responseOriginTabID]; - const message = auth.message; - let holdResponse = false; - - const { - from, - // to, - // fee, - // amount, - // firstRound, - // lastRound, - genesisID, - // genesisHash, - // note, - } = message.body.params.transaction; - - try { - const ledger = getLedgerFromGenesisId(genesisID); - - const context = new encryptionWrap(passphrase); - context.unlock(async (unlockedValue: any) => { - if ('error' in unlockedValue) { - sendResponse(unlockedValue); - return false; - } - - extensionBrowser.windows.remove(auth.window_id); - - let account; - - if (unlockedValue[ledger] === undefined) { - message.error = RequestError.UnsupportedLedger; - MessageApi.send(message); - } - // Find address to send algos from - for (let i = unlockedValue[ledger].length - 1; i >= 0; i--) { - if (unlockedValue[ledger][i].address === from) { - account = unlockedValue[ledger][i]; - break; - } - } - - // If the account is not a hardware account we need to get the mnemonic - let recoveredAccount; - if (!account.isHardware) { - recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); - } - - const txn = { ...message.body.params.transaction }; - removeEmptyFields(txn); - - // Modify base64 encoded fields - if ('note' in txn && txn.note !== undefined) { - txn.note = new Uint8Array(Buffer.from(txn.note)); - } - // Application transactions only - if (txn && txn.type == 'appl') { - if ('appApprovalProgram' in txn) { - try { - txn.appApprovalProgram = Uint8Array.from( - Buffer.from(txn.appApprovalProgram, 'base64') - ); - } catch { - message.error = - 'Error trying to parse appApprovalProgram into a Uint8Array value.'; - } - } - if ('appClearProgram' in txn) { - try { - txn.appClearProgram = Uint8Array.from( - Buffer.from(txn.appClearProgram, 'base64') - ); - } catch { - message.error = - 'Error trying to parse appClearProgram into a Uint8Array value.'; - } - } - if ('appArgs' in txn) { - try { - const tempArgs = []; - txn.appArgs.forEach((element) => { - logging.log(element); - tempArgs.push(Uint8Array.from(Buffer.from(element, 'base64'))); - }); - txn.appArgs = tempArgs; - } catch { - message.error = 'Error trying to parse appArgs into Uint8Array values.'; - } - } - } - - try { - // This step transitions a raw object into a transaction style object - const builtTx = buildTransaction(txn); - - if (recoveredAccount) { - // We recovered an account from within the saved extension data and can sign with it - const txblob = builtTx.signTxn(recoveredAccount.sk); - - // We are combining the tx id get and sign into one step/object because of legacy, - // this may not need to be the case any longer. - const signedTxn = { - txID: builtTx.txID().toString(), - blob: txblob, - }; - - const b64Obj = Buffer.from(signedTxn.blob).toString('base64'); - - message.response = { - txID: signedTxn.txID, - blob: b64Obj, - }; - } else if (account.isHardware) { - // The account is hardware based. We need to open the extension in tab to connect. - // We will need to hold the response to dApps - holdResponse = true; - - // Create an encoded transaction for the ledger sign - const encodedTxn = Buffer.from( - algosdk.encodeUnsignedTransaction(builtTx) - ).toString('base64'); - message.body.params.encodedTxn = encodedTxn; - - InternalMethods[JsonRpcMethod.LedgerSignTransaction](message, (response) => { - // We only have to worry about possible errors here - if ('error' in response) { - // Cancel the hold response since errors needs to be returned - holdResponse = false; - message.error = response.error; - } - }); - } - } catch (e) { - message.error = e.message; - } - - // Clean class saved request - delete Task.requests[responseOriginTabID]; - - // Hardware signing will defer the response - if (!holdResponse) { - MessageApi.send(message); - } - }); - } catch { - // On error we should remove the task - delete Task.requests[responseOriginTabID]; - return false; - } - return true; - }, // sign-allow-wallet-tx [JsonRpcMethod.SignAllowWalletTx]: (request: any, sendResponse: Function) => { const { passphrase, responseOriginTabID } = request.body.params; const auth = Task.requests[responseOriginTabID]; const message = auth.message; - const { groupsToSign, currentGroup, signedGroups } = message.body.params; + const { groupsToSign, currentGroup, signedGroups, opts } = message.body.params; const singleGroup = groupsToSign.length === 1; const walletTransactions: Array = groupsToSign[currentGroup]; const transactionsWraps: Array = @@ -1119,6 +1345,13 @@ export class Task { } }); + if (opts && opts[OptsKeys.ARC01Return]) { + signedTxs.forEach( + (maybeTxn: any, index: number) => + (signedTxs[index] = maybeTxn !== null ? maybeTxn.blob : null) + ); + } + // We check if there were errors signing this group if (signErrors.length) { let data = ''; @@ -1150,8 +1383,9 @@ export class Task { message.body.params.currentGroup = currentGroup + 1; message.body.params.signedGroups = signedGroups; if (message.body.params.currentGroup < groupsToSign.length) { + // More groups to sign, continue prompting user try { - Task.signIndividualGroup(message); + await Task.signIndividualGroup(message); } catch (e) { let errorMessage = 'There was a problem validating the transaction(s). '; @@ -1166,19 +1400,23 @@ export class Task { return; } } else { - let response; - if (signedGroups.length === 1) { - response = signedGroups[0]; + // No more groups to sign, build final user-facing response + if (opts && opts[OptsKeys.sendTxns]) { + message.body.params.stxns = message.body.params.signedGroups; } else { - response = signedGroups; + message.response = signedGroups.length === 1 ? signedGroups[0] : signedGroups; } - message.response = response; // Clean class saved request delete Task.requests[responseOriginTabID]; - + // Hardware signing will defer the response if (!holdResponse) { - MessageApi.send(message); + if (opts && opts[OptsKeys.sendTxns]) { + Task.methods().public[JsonRpcMethod.PostTransactions](message, MessageApi.send); + } else { + MessageApi.send(message); + return; + } } } }); @@ -1316,21 +1554,38 @@ export class Task { return InternalMethods[JsonRpcMethod.LedgerGetSessionTxn](request, sendResponse); }, [JsonRpcMethod.LedgerSendTxnResponse]: (request: any, sendResponse: Function) => { - InternalMethods[JsonRpcMethod.LedgerSendTxnResponse](request, function (response) { + InternalMethods[JsonRpcMethod.LedgerSendTxnResponse](request, function (internalResponse) { logging.log( - `Task method - LedgerSendTxnResponse - Returning: ${JSON.stringify(response)}`, + `Task method - LedgerSendTxnResponse - Received: ${JSON.stringify(internalResponse)}`, 2 ); // Message indicates that this response will go to the DApp - if ('message' in response) { - // Send the response back to the origniating page - MessageApi.send(response.message); - // Also pass back the blob response to the caller - sendResponse(response.message.response); - } else { - // Send repsonse to the calling function + if ('message' in internalResponse) { + const message = internalResponse.message; + const opts = message.body.params.opts; + const response = message.response; + + if (opts && opts[OptsKeys.ARC01Return]) { + response.forEach( + (maybeTxn: any, index: number) => + (response[index] = maybeTxn !== null ? maybeTxn.blob : null) + ); + } + // We pass back the blob response to the UI sendResponse(response); + + if (opts && opts[OptsKeys.sendTxns]) { + message.body.params.stxns = response; + Task.methods().public[JsonRpcMethod.PostTransactions](message, MessageApi.send); + } else { + // Send the response back to the originating page + MessageApi.send(message); + return; + } + } else { + // Send response to the calling function + sendResponse(internalResponse); } }); return true; @@ -1364,6 +1619,60 @@ export class Task { [JsonRpcMethod.GetGovernanceAddresses]: (request: any, sendResponse: Function) => { return InternalMethods[JsonRpcMethod.GetGovernanceAddresses](request, sendResponse); }, + [JsonRpcMethod.GetEnableAccounts]: (request: any, sendResponse: Function) => { + const { promptedAccounts, ledger } = request.body.params; + + // Setup new prompted accounts which will be the return values + const newPromptedAccounts = []; + + // Add any requested accounts so they can be in the proper order to start + if (promptedAccounts) { + for (let i = 0; i < promptedAccounts.length; i++) { + // This call is a ledger change so we already have requested values included in the accounts + if (promptedAccounts[i].requested) { + newPromptedAccounts.push({ + address: promptedAccounts[i]['address'], + missing: true, + requested: true, + }); + } + } + } + + // Get an internal session and get wallet accounts for the new chosen ledger + const session = InternalMethods.getHelperSession(); + const walletAccounts = session.wallet[ledger]; + + // We only need to add accounts if we actually have them + if (walletAccounts) { + // Add all the walletAccounts we have for the ledger + for (let i = 0; i < walletAccounts.length; i++) { + const walletAccount = walletAccounts[i].address; + const accountIndex = newPromptedAccounts.findIndex(e => e.address === walletAccount); + + if (accountIndex > -1) { + // If we have the account then mark it as valid + newPromptedAccounts[accountIndex]['missing'] = false; + newPromptedAccounts[accountIndex]['selected'] = true; + } + else { + // If we are missing the address then this is an account that the dApp did not request + // but we can push the value an the additional choices from the user before returning + newPromptedAccounts.push({ + address: walletAccount, + requested: false, + selected: false + }); + } + } + } + + // Replace the prompted accounts on params that will go back to the page + request.body.params['promptedAccounts'] = newPromptedAccounts; + + // Respond with the new params + sendResponse(request.body.params); + }, }, }; } diff --git a/packages/extension/src/background/transaction/actions.ts b/packages/extension/src/background/transaction/actions.ts index 67e6bff0..63c62a2e 100644 --- a/packages/extension/src/background/transaction/actions.ts +++ b/packages/extension/src/background/transaction/actions.ts @@ -24,7 +24,7 @@ import { TransactionType } from '@algosigner/common/types/transaction'; import { RequestError } from '@algosigner/common/errors'; import { BaseValidatedTxnWrap } from './baseValidatedTxnWrap'; import { Settings } from '../config'; -import { getBaseSupportedLedgers } from '@algosigner/common/types/ledgers'; +import { getBaseSupportedLedgers, LedgerTemplate } from '@algosigner/common/types/ledgers'; import { removeEmptyFields } from '@algosigner/common/utils'; import algosdk from 'algosdk'; @@ -144,6 +144,61 @@ export function getLedgerFromGenesisId(genesisId: string) { return defaultLedger; } +export function getLedgerFromMixedGenesis(genesisId: string, genesisHash: string): LedgerTemplate { + // Default the ledger to mainnet + const defaultLedger = 'MainNet'; + + // Check Genesis Id and Hash for base supported ledgers first + const defaultLedgers = getBaseSupportedLedgers(); + let ledger; + if (genesisId) { + ledger = defaultLedgers.find((l) => genesisId === l['genesisId']); + if (ledger !== undefined) { + // Found genesisId, make sure the hash matches + if (!genesisHash || genesisHash === ledger.genesisHash) { + return ledger; + } + } + + // Injected networks may have additional information, multiples, or additional checks + // so we will check them separately + const injectedNetworks = Settings.getCleansedInjectedNetworks(); + injectedNetworks.forEach(network => { + if (network['genesisId'] === genesisId) { + // Found genesisId, make sure the hash matches + if (!genesisHash || genesisHash === network.genesisHash) { + return network; + } + } + }); + } + + // We didn't match on the genesis id so check the hashes + if (genesisHash) { + ledger = defaultLedgers.find((l) => genesisHash === l['genesisHash']); + if (ledger !== undefined) { + // Found genesisHash, make sure the id matches + if (!genesisId || genesisId === ledger.genesisId) { + return ledger; + } + } + + // Injected networks may have additional information, multiples, or additional checks + // so we will check them separately + const injectedNetworks = Settings.getCleansedInjectedNetworks(); + injectedNetworks.forEach(network => { + if (network['genesisHash'] === genesisHash) { + // Found genesisHash, make sure the id matches + if (!genesisId || genesisId === ledger.genesisId) { + return network; + } + } + }); + } + + return defaultLedgers.find((l) => defaultLedger === l['name']); +} + export function calculateEstimatedFee(transactionWrap: BaseValidatedTxnWrap, params: any): void { const transaction = transactionWrap.transaction; const minFee = +params['min-fee']; diff --git a/packages/storage/package.json b/packages/storage/package.json index 56172ecd..4930682a 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "algosigner-storage", - "version": "1.9.6", + "version": "1.10.0", "author": "https://developer.purestake.io", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", diff --git a/packages/test-project/package.json b/packages/test-project/package.json index 1cbf1e0d..8da0d0c3 100644 --- a/packages/test-project/package.json +++ b/packages/test-project/package.json @@ -1,11 +1,11 @@ { "name": "algorand-test-project", - "version": "1.9.6", + "version": "1.10.0", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", "description": "Repository for tests", "devDependencies": { - "algosdk": "1.19.0", + "algosdk": "1.22.0", "jest": "^28.1.0", "jest-runner-groups": "^2.2.0", "puppeteer": "^13.7.0", @@ -14,14 +14,17 @@ }, "scripts": { "github": "jest -i --group=github", - "ui": "jest --group=ui", + "ui": "jest -i --group=ui", "ui/accounts": "jest --group=ui/accounts", "ui/networks": "jest --group=ui/networks", "ui/transactions": "jest --group=ui/transactions", - "dapp": "jest --group=dapp", - "dapp/signtxn": "jest --group=dapp/signtxn", + "dapp": "jest -i --group=dapp", + "dapp/legacy/signtxn": "jest --group=dapp/legacy/signtxn", "dapp/groups": "jest --group=dapp/groups", - "ledger": "jest --group=ledger", + "dapp/arcs": "jest -i --group=dapp/arcs", + "dapp/arcs/signtxns": "jest --group=dapp/arcs/signtxns", + "dapp/arcs/misc": "jest --group=dapp/arcs/misc", + "ledger": "jest -i --group=ledger", "coveragetest": "jest --coverage=true --coverageDirectory ../test-project/coverage --projects ../crypto ../extension ../storage ../common ../dapp --runInBand && bash -c \"start chrome \"$(realpath ./coverage/lcov-report/index.html\"\")", "test": "jest -i --group=-github --group=-ledger" } diff --git a/packages/test-project/tests/common/helpers.js b/packages/test-project/tests/common/helpers.js index dc132073..667fa176 100644 --- a/packages/test-project/tests/common/helpers.js +++ b/packages/test-project/tests/common/helpers.js @@ -77,7 +77,8 @@ async function inputPassword() { await extensionPage.waitForSelector('#enterPassword'); await extensionPage.type('#enterPassword', wallet.password); await extensionPage.click('#authButton'); - await extensionPage.waitForFunction(() => !document.querySelector('#authButton')); + await extensionPage.waitForFunction(() => document.querySelector('#authButton') === null); + await expect(extensionPage.select('#authButton')).rejects.toThrow(); } async function getOpenedTab() { @@ -133,7 +134,7 @@ async function getLedgerSuggestedParams(ledger = 'TestNet') { }; } -async function signDappTxns(transactionsToSign, testFunction) { +async function signDappTxnsWAlgoSigner(transactionsToSign, testFunction) { const timestampedName = `popupTest-${new Date().getTime().toString()}`; if (testFunction) { await dappPage.exposeFunction(timestampedName, async () => { @@ -177,6 +178,49 @@ async function signDappTxns(transactionsToSign, testFunction) { return signedTransactions; } +async function signDappTxnsWAlgorand(transactionsToSign, testFunction) { + const timestampedName = `popupTest-${new Date().getTime().toString()}`; + if (testFunction) { + await dappPage.exposeFunction(timestampedName, async () => { + try { + await testFunction(); + } catch (e) { + console.log(e); + } + }); + } + + await dappPage.waitForTimeout(2000); + const signedTransactions = await dappPage.evaluate( + async (transactionsToSign, testFunction, testTimestamp) => { + const signPromise = algorand.signTxns(transactionsToSign) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + + if (testFunction) { + await window[testTimestamp](); + } + + await window['authorizeSignTxn'](); + return await Promise.resolve(signPromise); + }, + transactionsToSign, + !!testFunction, + timestampedName + ); + for (let i = 0; i < signedTransactions.length; i++) { + const signedTx = signedTransactions[i]; + if (signedTx) { + await expect(signedTx).toHaveProperty('length'); + } + } + return signedTransactions; +} + async function sendTransaction(blob) { const sendBody = { ledger: 'TestNet', @@ -265,7 +309,8 @@ module.exports = { getOpenedTab, getPopup, getLedgerSuggestedParams, - signDappTxns, + signDappTxnsWAlgoSigner, + signDappTxnsWAlgorand, sendTransaction, base64ToByteArray, byteArrayToBase64, diff --git a/packages/test-project/tests/common/tests.js b/packages/test-project/tests/common/tests.js index 5df79faf..89c0c3b4 100644 --- a/packages/test-project/tests/common/tests.js +++ b/packages/test-project/tests/common/tests.js @@ -1,5 +1,5 @@ const { wallet, extension } = require('./constants'); -const { openAccountDetails, goBack, inputPassword, getPopup, closeModal } = require('./helpers'); +const { openAccountDetails, goBack, inputPassword, getPopup } = require('./helpers'); // Common Tests function WelcomePage() { @@ -94,7 +94,128 @@ function DeleteAccount(account) { } // Dapp Tests -function ConnectAlgoSigner() { +function ConnectWithAlgorandObject() { + test('Expose Authorize Functions', async () => { + async function authorizeDapp() { + const popup = await getPopup(); + await popup.waitForSelector('#grantAccess'); + await popup.click('#grantAccess'); + } + await dappPage.exposeFunction('authorizeDapp', authorizeDapp); + + async function authorizeSign() { + const popup = await getPopup(); + await popup.waitForSelector('#approveTx'); + await popup.click('#approveTx'); + await popup.waitForSelector('#enterPassword'); + await popup.type('#enterPassword', wallet.password); + await popup.waitForSelector('#authButton'); + await popup.click('#authButton'); + } + await dappPage.exposeFunction('authorizeSign', authorizeSign); + + async function rejectDapp() { + const popup = await getPopup(); + await popup.waitForSelector('#denyAccess'); + await popup.click('#denyAccess'); + } + await dappPage.exposeFunction('rejectDapp', rejectDapp); + + async function rejectSign() { + const popup = await getPopup(); + await popup.waitForSelector('#rejectTx'); + await popup.click('#rejectTx'); + } + await dappPage.exposeFunction('rejectSign', rejectSign); + + async function authorizeSignTxn() { + const popup = await getPopup(); + + // Atomic txs Approval + try { + await popup.waitForTimeout(500); + const txAmount = await popup.$eval('.dropdown-trigger span', (e) => +e.innerText.slice(-1)); + + for (let i = 0; i < txAmount; i++) { + await popup.click('#toggleApproval'); + await popup.waitForTimeout(250); + } + } catch (e) { + // Maybe a Single transaction + } + + await popup.waitForSelector('#approveTx'); + await popup.click('#approveTx'); + await popup.waitForSelector('#enterPassword'); + await popup.type('#enterPassword', wallet.password); + await popup.waitForSelector('#authButton'); + await popup.click('#authButton'); + } + await dappPage.exposeFunction('authorizeSignTxn', authorizeSignTxn); + + // Groups of Groups Approvals + async function authorizeSignTxnGroups(amount) { + const popup = await getPopup(); + for (let i = 0; i < amount; i++) { + try { + await authorizeSignTxn(); + await popup.waitForTimeout(2000); + } catch (e) { + console.log('Error:'); + console.log(e); + } + } + } + await dappPage.exposeFunction('authorizeSignTxnGroups', authorizeSignTxnGroups); + }); + + test('SiteNotAuthorizedByUser error before connecting', async () => { + await expect( + dappPage.evaluate(() => { + return Promise.resolve(algorand.postTxns()) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }) + ).resolves.toMatchObject({ + message: expect.stringContaining('The extension user has not'), + code: 4100, + }); + }); + + test('UserRejected error upon connection refusal', async () => { + await expect( + dappPage.evaluate(async () => { + const connectPromise = algorand.enable(); + await window.rejectDapp(); + return Promise.resolve(connectPromise) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }) + ).resolves.toMatchObject({ + message: expect.stringContaining('The extension user does not'), + code: 4001, + }); + }); + + test('Enable Dapp through content.js', async () => { + const connected = await dappPage.evaluate(async () => { + const connectPromise = algorand.enable(); + await window.authorizeDapp(); + return await connectPromise; + }); + await expect(connected).toEqual({}); + }); +} + +function ConnectWithAlgoSignerObject() { test('Expose Authorize Functions', async () => { async function authorizeDapp() { const popup = await getPopup(); @@ -223,5 +344,6 @@ module.exports = { ImportAccount, VerifyAccount, DeleteAccount, - ConnectAlgoSigner, + ConnectWithAlgorandObject, + ConnectWithAlgoSignerObject, }; diff --git a/packages/test-project/tests/dapp-arcs-misc.test.js b/packages/test-project/tests/dapp-arcs-misc.test.js new file mode 100644 index 00000000..43107bce --- /dev/null +++ b/packages/test-project/tests/dapp-arcs-misc.test.js @@ -0,0 +1,201 @@ +/** + * dapp e2e tests for the AlgoSigner V2 Signing functionality + * + * @group dapp/arcs/misc + */ + +const algosdk = require('algosdk'); +const { accounts } = require('./common/constants'); +const { + openExtension, + getLedgerSuggestedParams, + buildSdkTx, + prepareWalletTx, + base64ToByteArray, + byteArrayToBase64, +} = require('./common/helpers'); +const { CreateWallet, ConnectWithAlgorandObject, ImportAccount } = require('./common/tests'); + +const uiAccount = accounts.ui; + +let ledgerParams; + +const getBasicSdkTxn = () => buildSdkTx({ + type: 'pay', + from: uiAccount.address, + to: uiAccount.address, + amount: Math.ceil(Math.random() * 100), + ...ledgerParams, + fee: 1000, +}); + +const getSdkTxnArray = () => [getBasicSdkTxn(), getBasicSdkTxn()]; + +const signWithSDK = (tx) => byteArrayToBase64(tx.signTxn(algosdk.mnemonicToSecretKey(uiAccount.mnemonic).sk)); + +describe('Wallet Setup', () => { + beforeAll(async () => { + await openExtension(); + }); + + CreateWallet(); + ConnectWithAlgorandObject(); + + test('Get TestNet params', async () => { + ledgerParams = await getLedgerSuggestedParams(); + }); + + ImportAccount(uiAccount); +}); + +describe('PostTxns Validations', () => { + test('Error on missing group(s)', async () => { + const sdkTxns = getSdkTxnArray(); + const signedTxns = sdkTxns.map(signWithSDK); + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.postTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, signedTxns) + ).resolves.toMatchObject({ + message: expect.stringContaining('reasons are provided'), + code: 4300, + data: expect.stringContaining('same group') + }); + }); + + + test('Error on unordered group(s)', async () => { + const sdkTxns = algosdk.assignGroupID(getSdkTxnArray()); + const unsignedTxns = sdkTxns.map(signWithSDK); + const unorderedTxns = [unsignedTxns[1], unsignedTxns[0]]; + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.postTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unorderedTxns) + ).resolves.toMatchObject({ + message: expect.stringContaining('reasons are provided'), + code: 4300, + data: expect.stringContaining('different order') + }); + }); + + + test('Error on incomplete group(s)', async () => { + const sdkTxns = algosdk.assignGroupID(getSdkTxnArray()); + const unsignedTxns = sdkTxns.map(signWithSDK); + const incompleteTxns = [unsignedTxns.shift()]; + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.postTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, incompleteTxns) + ).resolves.toMatchObject({ + message: expect.stringContaining('reasons are provided'), + code: 4300, + data: expect.stringContaining('group is incomplete') + }); + }); + + jest.setTimeout(20000); + + test('Partially succeded post', async () => { + async function signTxnGroups(transactionsToSign) { + return await dappPage.evaluate( + async (transactionsToSign) => { + const signPromise = algorand.signTxns(transactionsToSign) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + + const amountOfGroups = Array.isArray(transactionsToSign[0]) ? transactionsToSign.length : 1; + await window['authorizeSignTxnGroups'](amountOfGroups); + + return await Promise.resolve(signPromise); + }, + transactionsToSign, + ); + } + + const noFeeTx = buildSdkTx({ + type: 'pay', + from: uiAccount.address, + to: uiAccount.address, + amount: Math.ceil(Math.random() * 100), + ...ledgerParams, + }) + + const sdkTxns = [noFeeTx, getBasicSdkTxn()]; + const unsignedTxns = sdkTxns.map(prepareWalletTx); + const nestedTxns = unsignedTxns.map((tx) => [tx]); + const signedTxns = await signTxnGroups(nestedTxns); + const postResponse = await dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.postTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, signedTxns); + + const txID = algosdk.decodeSignedTransaction(base64ToByteArray(signedTxns[1][0])).txn.txID(); + await expect(postResponse).toMatchObject({ + message: expect.stringContaining('unsuccessful group'), + code: 4400, + data: expect.anything(), + successTxnIDs: expect.anything(), + }); + await expect(postResponse.successTxnIDs).toHaveLength(2); + await expect(postResponse.successTxnIDs[1]).toHaveLength(1); + await expect(postResponse.successTxnIDs[1]).toContain(txID); + await expect(postResponse.data).toHaveLength(2); + }); + + test('Sign and Send commits txs to the network', async () => { + async function signAndPostTxns(transactionsToSign) { + return await dappPage.evaluate( + async (transactionsToSign) => { + const signPromise = algorand.signAndPostTxns(transactionsToSign) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + + await window['authorizeSignTxn'](); + return await Promise.resolve(signPromise); + }, + transactionsToSign, + ); + } + + const sdkTxn = getBasicSdkTxn(); + const unsignedTxns = [sdkTxn].map(prepareWalletTx); + const txID = sdkTxn.txID(); + await expect(signAndPostTxns(unsignedTxns)).resolves.toMatchObject({ + txnIDs: expect.arrayContaining([txID]), + }); + + }); +}); \ No newline at end of file diff --git a/packages/test-project/tests/dapp-arcs-signtxns.test.js b/packages/test-project/tests/dapp-arcs-signtxns.test.js new file mode 100644 index 00000000..93b92f2b --- /dev/null +++ b/packages/test-project/tests/dapp-arcs-signtxns.test.js @@ -0,0 +1,355 @@ +/** + * dapp e2e tests for the AlgoSigner V2 Signing functionality + * + * @group dapp/arcs/signtxns + */ + +const { accounts } = require('./common/constants'); +const { + openExtension, + getPopup, + getLedgerSuggestedParams, + signDappTxnsWAlgorand, + decodeBase64Blob, + buildSdkTx, + prepareWalletTx, +} = require('./common/helpers'); +const { CreateWallet, ConnectWithAlgorandObject, ImportAccount } = require('./common/tests'); + +const msigAccount = accounts.multisig; +const account1 = msigAccount.subaccounts[0]; +const account2 = msigAccount.subaccounts[1]; + +let ledgerParams; +let unsignedTransactions; +let msigTxn; + +describe('Wallet Setup', () => { + beforeAll(async () => { + await openExtension(); + }); + + CreateWallet(); + ConnectWithAlgorandObject(); + + test('Get TestNet params', async () => { + ledgerParams = await getLedgerSuggestedParams(); + const baseTxn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: msigAccount.address, + to: msigAccount.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + fee: 1000, + }) + ); + const msigMetadata = { + version: 1, + threshold: 2, + addrs: msigAccount.subaccounts.map((acc) => acc.address), + }; + msigTxn = { ...baseTxn, msig: msigMetadata }; + }); + + ImportAccount(account1); + ImportAccount(account2); +}); + +describe('Txn Signing Validation errors', () => { + // General validations + test('Error on User signing refusal', async () => { + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: account1.address, + to: account1.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + fee: 1000, + }) + ); + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate(async (transactions) => { + const signPromise = algorand.signTxns(transactions); + await window.rejectSign(); + return Promise.resolve(signPromise) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('The extension user does not'), + code: 4001, + }); + }); + + test('Error on Sender not imported to AlgoSigner', async () => { + const invalidAccount = accounts.ui.address; + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: invalidAccount, + to: invalidAccount, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + fee: 1000, + }) + ); + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.signTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('There was a problem signing the transaction(s).'), + code: 4100, + name: expect.stringContaining('AlgoSignerRequestError'), + data: expect.stringContaining(invalidAccount), + }); + }); + + // Signers validations + test('Error on Empty signers for Single transactions', async () => { + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: account1.address, + to: account1.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + fee: 1000, + }) + ); + txn.signers = []; + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.signTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('There are no transactions to sign'), + code: 4300, + name: expect.stringContaining('AlgoSignerRequestError'), + }); + }); + + test('Error on Single signer not matching the sender', async () => { + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: account1.address, + to: account1.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + fee: 1000, + }) + ); + txn.signers = [account2.address]; + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.signTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('There was a problem signing the transaction(s).'), + code: 4300, + name: expect.stringContaining('AlgoSignerRequestError'), + data: expect.stringContaining("When a single-address 'signers'"), + }); + }); + + test('Error on Single signer not matching authAddr', async () => { + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: account1.address, + to: account1.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + fee: 1000, + }) + ); + txn.signers = [account1.address]; + txn.authAddr = account2.address; + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.signTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('There was a problem signing the transaction(s).'), + code: 4300, + name: expect.stringContaining('AlgoSignerRequestError'), + data: expect.stringContaining("When a single-address 'signers'"), + }); + }); + + test('Error on Invalid signer address', async () => { + const fakeAccount = 'THISSIGNERDOESNTEXIST'; + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: account1.address, + to: account1.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + fee: 1000, + }) + ); + txn.signers = [fakeAccount]; + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.signTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('There was a problem signing the transaction(s).'), + code: 4300, + name: expect.stringContaining('AlgoSignerRequestError'), + data: expect.stringContaining(`Signers array contains the invalid address "${fakeAccount}"`), + }); + }); + + // AuthAddr validations + test('Error on Invalid authAddr', async () => { + const fakeAccount = 'THISAUTHADDRDOESNTEXIST'; + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: account1.address, + to: account1.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + fee: 1000, + }) + ); + txn.authAddr = fakeAccount; + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.signTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('There was a problem signing the transaction(s).'), + code: 4300, + name: expect.stringContaining('AlgoSignerRequestError'), + data: expect.stringContaining(`'authAddr' contains the invalid address "${fakeAccount}"`), + }); + }); + + // Msig validations + test('Error on Msig Signer not imported to AlgoSigner', async () => { + const invalidAccount = msigAccount.subaccounts[2].address; + const txn = { ...msigTxn }; + txn.signers = [account1.address, invalidAccount]; + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transactions) => { + return Promise.resolve(algorand.signTxns(transactions)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('There was a problem signing the transaction(s).'), + code: 4300, + name: expect.stringContaining('AlgoSignerRequestError'), + data: expect.stringContaining(invalidAccount), + }); + }); + + // // @TODO: Wallet Transaction Structure check tests +}); + +describe('Multisig Transaction Use cases', () => { + test('Sign MultiSig Transaction with All Accounts', async () => { + unsignedTransactions = [msigTxn]; + const signedTransactions = await signDappTxnsWAlgorand(unsignedTransactions, async () => { + const popup = await getPopup(); + const tooltipText = await popup.evaluate(() => { + return getComputedStyle( + document.querySelector('[data-tooltip]'), + '::before' + ).getPropertyValue('content'); + }); + await expect(tooltipText).toContain('Multisignature'); + }); + + // Verify signature is added + const decodedTransaction = decodeBase64Blob(signedTransactions[0]); + expect(decodedTransaction).toHaveProperty('txn'); + expect(decodedTransaction).toHaveProperty('msig'); + expect(decodedTransaction.msig).toHaveProperty('subsig'); + expect(decodedTransaction.msig.subsig).toHaveLength(3); + expect(decodedTransaction.msig.subsig[0]).toHaveProperty('s'); + expect(decodedTransaction.msig.subsig[1]).toHaveProperty('s'); + expect(decodedTransaction.msig.subsig[2]).not.toHaveProperty('s'); + }); + + test('Sign MultiSig Transaction with Specific Signer', async () => { + unsignedTransactions[0].signers = [account1.address]; + const signedTransactions = await signDappTxnsWAlgorand(unsignedTransactions); + + // Verify correct signature is added + const decodedTransaction = decodeBase64Blob(signedTransactions[0]); + expect(decodedTransaction).toHaveProperty('txn'); + expect(decodedTransaction).toHaveProperty('msig'); + expect(decodedTransaction.msig).toHaveProperty('subsig'); + expect(decodedTransaction.msig.subsig).toHaveLength(3); + expect(decodedTransaction.msig.subsig[0]).toHaveProperty('s'); + expect(decodedTransaction.msig.subsig[1]).not.toHaveProperty('s'); + expect(decodedTransaction.msig.subsig[2]).not.toHaveProperty('s'); + }); +}); diff --git a/packages/test-project/tests/dapp-groups.test.js b/packages/test-project/tests/dapp-groups.test.js index 85ad7caa..d431a89d 100644 --- a/packages/test-project/tests/dapp-groups.test.js +++ b/packages/test-project/tests/dapp-groups.test.js @@ -9,11 +9,11 @@ const { accounts } = require('./common/constants'); const { openExtension, getLedgerSuggestedParams, - signDappTxns, + signDappTxnsWAlgoSigner, buildSdkTx, prepareWalletTx, } = require('./common/helpers'); -const { CreateWallet, ConnectAlgoSigner, ImportAccount } = require('./common/tests'); +const { CreateWallet, ConnectWithAlgoSignerObject, ImportAccount } = require('./common/tests'); const account = accounts.ui; @@ -58,7 +58,7 @@ describe('Wallet Setup', () => { }); CreateWallet(); - ConnectAlgoSigner(); + ConnectWithAlgoSignerObject(); test('Get TestNet params', async () => { ledgerParams = await getLedgerSuggestedParams(); @@ -111,7 +111,7 @@ describe('Group Transactions Use cases', () => { const groupedTransactions = algosdk.assignGroupID([tx1]); const unsignedTransactions = [prepareWalletTx(groupedTransactions[0])]; - const signedTransactions = await signDappTxns(unsignedTransactions); + const signedTransactions = await signDappTxnsWAlgoSigner(unsignedTransactions); await expect(signedTransactions[0]).not.toBeNull(); }); @@ -143,7 +143,7 @@ describe('Group Transactions Use cases', () => { const unsignedTransactions = groupedTransactions.map((txn) => prepareWalletTx(txn)); unsignedTransactions[2].signers = []; - const signedTransactions = await signDappTxns(unsignedTransactions); + const signedTransactions = await signDappTxnsWAlgoSigner(unsignedTransactions); await expect(signedTransactions[2]).toBeNull(); await expect(signedTransactions.filter((i) => i)).toHaveLength(2); @@ -156,7 +156,7 @@ describe('Group Transactions Use cases', () => { unsignedTransactions[1].signers = []; unsignedTransactions[1].stxn = signedTxn.blob; - const signedTransactions = await signDappTxns(unsignedTransactions); + const signedTransactions = await signDappTxnsWAlgoSigner(unsignedTransactions); await expect(signedTransactions[1]).toStrictEqual(signedTxn); await expect(signedTransactions[2]).not.toBeNull(); await expect(signedTransactions.filter((i) => i)).toHaveLength(3); diff --git a/packages/test-project/tests/dapp-signtxn.test.js b/packages/test-project/tests/dapp-legacy-signtxn.test.js similarity index 96% rename from packages/test-project/tests/dapp-signtxn.test.js rename to packages/test-project/tests/dapp-legacy-signtxn.test.js index e2e7ad37..fec76c61 100644 --- a/packages/test-project/tests/dapp-signtxn.test.js +++ b/packages/test-project/tests/dapp-legacy-signtxn.test.js @@ -1,7 +1,7 @@ /** * dapp e2e tests for the AlgoSigner V2 Signing functionality * - * @group dapp/signtxn + * @group dapp/legacy/signtxn */ const { accounts } = require('./common/constants'); @@ -9,12 +9,12 @@ const { openExtension, getPopup, getLedgerSuggestedParams, - signDappTxns, + signDappTxnsWAlgoSigner, decodeBase64Blob, buildSdkTx, prepareWalletTx, } = require('./common/helpers'); -const { CreateWallet, ConnectAlgoSigner, ImportAccount } = require('./common/tests'); +const { CreateWallet, ConnectWithAlgoSignerObject, ImportAccount } = require('./common/tests'); const msigAccount = accounts.multisig; const account1 = msigAccount.subaccounts[0]; @@ -30,7 +30,7 @@ describe('Wallet Setup', () => { }); CreateWallet(); - ConnectAlgoSigner(); + ConnectWithAlgoSignerObject(); test('Get TestNet params', async () => { ledgerParams = await getLedgerSuggestedParams(); @@ -316,7 +316,7 @@ describe('Txn Signing Validation errors', () => { describe('Multisig Transaction Use cases', () => { test('Sign MultiSig Transaction with All Accounts', async () => { unsignedTransactions = [msigTxn]; - const signedTransactions = await signDappTxns(unsignedTransactions, async () => { + const signedTransactions = await signDappTxnsWAlgoSigner(unsignedTransactions, async () => { const popup = await getPopup(); const tooltipText = await popup.evaluate(() => { return getComputedStyle( @@ -340,7 +340,7 @@ describe('Multisig Transaction Use cases', () => { test('Sign MultiSig Transaction with Specific Signer', async () => { unsignedTransactions[0].signers = [account1.address]; - const signedTransactions = await signDappTxns(unsignedTransactions); + const signedTransactions = await signDappTxnsWAlgoSigner(unsignedTransactions); // Verify correct signature is added const decodedTransaction = decodeBase64Blob(signedTransactions[0].blob); diff --git a/packages/test-project/tests/ledger-arcs-e2e.test.js b/packages/test-project/tests/ledger-arcs-e2e.test.js new file mode 100644 index 00000000..fea07ffd --- /dev/null +++ b/packages/test-project/tests/ledger-arcs-e2e.test.js @@ -0,0 +1,193 @@ +/** + * Basic e2e tests for ensuring functionality of Ledger devices + * + * @group ledger/arcs + */ + +const { wallet } = require('./common/constants'); +const { + openExtension, + getOpenedTab, + selectAccount, + verifyUITransaction, + getLedgerSuggestedParams, + decodeBase64Blob, + buildSdkTx, + prepareWalletTx, + sendTransaction, + signDappTxnsWAlgorand, +} = require('./common/helpers'); +const { + CreateWallet, + VerifyAccount, + ConnectWithAlgorandObject, +} = require('./common/tests'); + +jest.setTimeout(18000); +jest.retryTimes(0, { logErrorsBeforeRetry: true }); +const testAssetIndex = 13169404; +const linkedLedgerAccount = { + name: 'Ledger Account', +}; +let openedTab; +let ledgerParams; + +describe('Wallet Setup', () => { + beforeAll(async () => { + await openExtension(); + }); + + CreateWallet(); + ConnectWithAlgorandObject(); + + test('Get TestNet params', async () => { + ledgerParams = await getLedgerSuggestedParams(); + }); +}); + +// Create a new account in AlgoSigner +describe('Link Ledger Account', () => { + test('Link An Account, Step 1 - Begin Linking Process', async () => { + await extensionPage.waitForSelector('#addAccount'); + await extensionPage.click('#addAccount'); + await extensionPage.click('#linkAccount'); + await extensionPage.click('#nextStep'); + openedTab = await getOpenedTab(); + }); + + test('Link An Account, Step 2 - Select Address to Link and Name', async () => { + await openedTab.waitForSelector('#loadAccounts'); + await openedTab.click('#loadAccounts'); + await openedTab.waitForSelector('[data-public-address]'); + linkedLedgerAccount.address = await openedTab.$eval( + '[data-public-address]', + (e) => e.innerText + ); + await openedTab.type('#accountName', linkedLedgerAccount.name); + await openedTab.click('#nextStep'); + }); + + test('Link an Account, Step 3 - Write Account into Storage', async () => { + await openedTab.waitForSelector('#enterPassword'); + await openedTab.type('#enterPassword', wallet.password); + await openedTab.waitForTimeout(200); + await openedTab.click('#authButton'); + }); + + test('Link An Account, Step 4 - Close tab and reload extension', async () => { + await openedTab.waitForTimeout(5000); + await openedTab.close(); + await openExtension(); // Reloads extension + }); + + VerifyAccount(linkedLedgerAccount); +}); + +describe('dApp functionalities', () => { + let txPromise; + let signedBlob; + + test('Create Asset Opt-in tx and send it to AlgoSigner', async () => { + await extensionPage.waitForSelector('#addAccount'); + + const optinTx = prepareWalletTx( + buildSdkTx({ + type: 'axfer', + from: linkedLedgerAccount.address, + to: linkedLedgerAccount.address, + assetIndex: testAssetIndex, + note: new Uint8Array(Buffer.from('Opt-in to gAlgo')), + amount: 0, + ...ledgerParams, + fee: 1000, + }) + ); + txPromise = signDappTxnsWAlgorand([optinTx]); + await extensionPage.waitForTimeout(3000); + }); + + test('Send Tx to Ledger Device', async () => { + await extensionPage.waitForTimeout(3000); + openedTab = await getOpenedTab(); + await openedTab.waitForSelector('a[href*="asset"]'); + const sentAssetIndex = await openedTab.$eval('a[href*="asset"]', (e) => e.innerText); + await expect(+sentAssetIndex).toBe(testAssetIndex); + await openedTab.waitForSelector('#nextStep'); + await openedTab.click('#nextStep'); + }); + + test('Verify and Send transaction', async () => { + // Obtain ID from open tab before closing + await openedTab.waitForSelector('#txResponseDetail'); + const printedResponse = JSON.parse( + await openedTab.$eval('#txResponseDetail', (e) => e.innerText) + ); + const printedSignedTx = decodeBase64Blob(printedResponse[0]); + const dappResponse = await Promise.resolve(txPromise); + signedBlob = dappResponse[0]; + const dappSignedTx = decodeBase64Blob(signedBlob); + + await expect(printedResponse[0]).toBe(dappResponse[0]); + await expect(dappSignedTx).toHaveProperty('sig'); + await expect(dappSignedTx).toHaveProperty('txn'); + await expect(dappSignedTx['txn']).toHaveProperty('type'); + await expect(dappSignedTx['txn']['type']).toBe('axfer'); + await expect(dappSignedTx['txn']).toHaveProperty('xaid'); + await expect(dappSignedTx['txn']['xaid']).toBe(testAssetIndex); + await expect(dappSignedTx).toStrictEqual(printedSignedTx); + }); + + test('Wait for Tx to complete and reload extension', async () => { + await sendTransaction(signedBlob); + await openedTab.waitForTimeout(8000); + await openedTab.close(); + await openExtension(); // Reloads extension + }); +}); + +describe('UI functionalities', () => { + let signedTxId; + + test('Opt-out of test Asset', async () => { + await selectAccount(linkedLedgerAccount); + const assetSelector = `[data-asset-id="${testAssetIndex}"]`; + await extensionPage.waitForSelector(assetSelector); + const assetBalance = +(await extensionPage.$eval( + assetSelector, + (e) => e.dataset['assetBalance'] + )); + await expect(assetBalance).toBe(0); + await extensionPage.click(assetSelector); + await extensionPage.waitForSelector('#assetOptOut'); + await extensionPage.click('#assetOptOut'); + await extensionPage.waitForSelector('#enterPassword'); + await extensionPage.type('#enterPassword', wallet.password); + await extensionPage.waitForTimeout(200); + await extensionPage.click('#authButton'); + await extensionPage.waitForTimeout(3000); + }); + + test('Send Tx to Ledger Device', async () => { + openedTab = await getOpenedTab(); + await openedTab.waitForSelector('a[href*="asset"]'); + const sentAssetIndex = await openedTab.$eval('a[href*="asset"]', (e) => e.innerText); + await expect(+sentAssetIndex).toBe(testAssetIndex); + await openedTab.waitForSelector('#nextStep'); + await openedTab.click('#nextStep'); + await openedTab.waitForSelector('#txResponseDetail'); + signedTxId = await openedTab.$eval('#txResponseDetail', (e) => e.innerText); + await openedTab.waitForTimeout(1000); + await openedTab.close(); + }); + + test('Verify Asset is not present', async () => { + await extensionPage.waitForTimeout(8000); + await openExtension(); // Reloads extension + await selectAccount(linkedLedgerAccount); + await extensionPage.waitForTimeout(3000); + const assetSelector = `[data-asset-id="${testAssetIndex}"]`; + await expect(extensionPage.select(assetSelector)).rejects.toThrow(); + }); + + test('Verify transaction', () => verifyUITransaction(signedTxId, 'Asset transfer', linkedLedgerAccount.address)); +}); diff --git a/packages/test-project/tests/ledger-e2e.test.js b/packages/test-project/tests/ledger-legacy-e2e.test.js similarity index 98% rename from packages/test-project/tests/ledger-e2e.test.js rename to packages/test-project/tests/ledger-legacy-e2e.test.js index ef63b4d2..8ee9a5b9 100644 --- a/packages/test-project/tests/ledger-e2e.test.js +++ b/packages/test-project/tests/ledger-legacy-e2e.test.js @@ -1,7 +1,7 @@ /** * Basic e2e tests for ensuring functionality of Ledger devices * - * @group ledger + * @group ledger/legacy */ const { wallet } = require('./common/constants'); @@ -15,14 +15,15 @@ const { buildSdkTx, prepareWalletTx, sendTransaction, + } = require('./common/helpers'); const { CreateWallet, VerifyAccount, - ConnectAlgoSigner, + ConnectWithAlgoSignerObject, } = require('./common/tests'); -jest.setTimeout(18000); +jest.setTimeout(20000); jest.retryTimes(0, { logErrorsBeforeRetry: true }); const testAssetIndex = 13169404; const linkedLedgerAccount = { @@ -51,7 +52,7 @@ describe('Wallet Setup', () => { }); CreateWallet(); - ConnectAlgoSigner(); + ConnectWithAlgoSignerObject(); test('Get TestNet params', async () => { ledgerParams = await getLedgerSuggestedParams(); diff --git a/packages/test-project/tests/ui-networks-e2e.test.js b/packages/test-project/tests/ui-networks-e2e.test.js index 60c2a8d9..be863319 100644 --- a/packages/test-project/tests/ui-networks-e2e.test.js +++ b/packages/test-project/tests/ui-networks-e2e.test.js @@ -40,7 +40,7 @@ describe('Create and Test Custom Networks', () => { const otherNet = 'OtherNet'; const otherNetSelector = `button#select${otherNet}`; - test('Add Custom TestNet and test it', async () => { + test('Add Custom TestNet proxy and test it', async () => { // Create network await openNetworkMenu(); await extensionPage.click('#createNetwork'); @@ -74,12 +74,12 @@ describe('Create and Test Custom Networks', () => { // // Save Network await extensionPage.waitForTimeout(1000); await extensionPage.click('#saveNetwork:not(disabled)'); - await extensionPage.waitForTimeout(2000); + await extensionPage.waitForTimeout(4000); }); ImportAccount(accounts.ui); - test('Test Modifying Network', async () => { + test('Test Modifying Custom Network', async () => { await openNetworkMenu(); // Change Network name @@ -93,7 +93,7 @@ describe('Create and Test Custom Networks', () => { await inputPassword(); }); - test('Test Deleting Networks', async () => { + test('Test Deleting Custom Network', async () => { await openNetworkMenu(); // Delete OtherNet diff --git a/packages/test-project/tests/ui-transactions-e2e.test.js b/packages/test-project/tests/ui-transactions-e2e.test.js index facc4db5..cb4459de 100644 --- a/packages/test-project/tests/ui-transactions-e2e.test.js +++ b/packages/test-project/tests/ui-transactions-e2e.test.js @@ -31,7 +31,7 @@ describe('Wallet Setup', () => { }); describe('UI Transactions Tests', () => { - const amount = Math.ceil(Math.random() * 9); // txn size, modify multiplier for bulk + const amount = Math.ceil(Math.random() * 3); // txn size, modify multiplier for bulk let txId; // returned tx id from send txn let txTitle; // for tx verification diff --git a/packages/ui/package.json b/packages/ui/package.json index 99d3a2cf..c6ba9ddb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "algosigner-ui", - "version": "1.9.6", + "version": "1.10.0", "author": "https://developer.purestake.io", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", @@ -16,7 +16,7 @@ "@fortawesome/fontawesome-free": "^5.15.0", "@ledgerhq/hw-app-algorand": "^5.51.1", "@ledgerhq/hw-transport-webhid": "^5.46.0", - "algosdk": "1.19.0", + "algosdk": "1.22.0", "buffer": "^6.0.3", "history": "^5.0.0", "htm": "^3.0.4", diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js index abc4f3d4..aa96a6c4 100644 --- a/packages/ui/src/index.js +++ b/packages/ui/src/index.js @@ -10,6 +10,7 @@ import MainHeader from 'components/MainHeader'; import Footer from 'components/Footer'; import Authorize from 'pages/Authorize'; +import Enable from 'pages/Enable'; import Welcome from 'pages/Welcome'; import SetPassword from 'pages/SetPassword'; import Login from 'pages/Login'; @@ -49,6 +50,7 @@ const App = () => { <${Router} history=${createHashHistory()}> <${SignWalletTransaction} path="/sign-v2-transaction" /> <${Authorize} path="/authorize" /> + <${Enable} path="/enable" /> <${Welcome} path="/" /> <${SetPassword} path="/set-password" /> <${Login} path="/login/:redirect?" /> diff --git a/packages/ui/src/pages/Enable.ts b/packages/ui/src/pages/Enable.ts new file mode 100644 index 00000000..b3e80dca --- /dev/null +++ b/packages/ui/src/pages/Enable.ts @@ -0,0 +1,277 @@ +import { FunctionalComponent } from 'preact'; +import { html } from 'htm/preact'; +import { useState, useContext, useEffect } from 'preact/hooks'; +import { useObserver } from 'mobx-react-lite'; +import { JsonRpcMethod } from '@algosigner/common/messaging/types'; +import { obfuscateAddress } from '@algosigner/common/utils'; +import { StoreContext } from 'services/StoreContext'; +import logotype from 'assets/logotype.png'; +import { sendMessage } from 'services/Messaging'; + +function deny() { + chrome.runtime.sendMessage({ + source: 'extension', + body: { + jsonrpc: '2.0', + method: JsonRpcMethod.AuthorizationDeny, + params: { + responseOriginTabID: responseOriginTabID, + }, + }, + }); +} + +let responseOriginTabID; + +const Enable: FunctionalComponent = () => { + const store: any = useContext(StoreContext); + const [genesisID, setGenesisID] = useState(''); + const [genesisHash, setGenesisHash] = useState(''); + const [networkSpecifiedType, setNetworkSpecifiedType] = useState(''); + const [accounts, setPromptedAccounts] = useState([]); + const [request, setRequest] = useState({}); + const [active, setActive] = useState(false); + let sessionLedgers; + let ddClass: string = 'dropdown'; + + store.getAvailableLedgers((availableLedgers) => { + if (!availableLedgers.error) { + let restrictedLedgers: any[] = []; + if (networkSpecifiedType === 1) { + restrictedLedgers.push( + availableLedgers.find((l) => l.genesisId === genesisID && l.genesisHash === genesisHash) + ); + } else if (networkSpecifiedType === 2) { + for (let i = 0; i < availableLedgers.length; i++) { + if (availableLedgers[i]['genesisId'] === genesisID) { + restrictedLedgers.push(availableLedgers[i]); + } + } + } else { + restrictedLedgers = availableLedgers; + } + sessionLedgers = restrictedLedgers; + } + }); + + if (active) ddClass += ' is-active'; + const flip = () => { + setActive(!active); + }; + + const setDetails = (params) => { + // Check for existence of the params and set page values + if (params.promptedAccounts && params.promptedAccounts.length > 0) { + setPromptedAccounts(params.promptedAccounts); + } else { + setPromptedAccounts(null); + } + if (params.genesisID) { + setGenesisID(params.genesisID); + } + if (params.genesisHash) { + setGenesisHash(params.genesisHash); + } + if (params.ledger) { + // Ledger is added during EnableAuthorization to match with legacy ledger name and with GetEnableAccounts + store.setLedger(params.ledger); + } + if (params.networkSpecifiedType) { + setNetworkSpecifiedType(params.networkSpecifiedType); + } + }; + + const setLedger = (ledger) => { + store.setLedger(ledger); + flip(); + + // Set the new ledger to be loaded + request.body.params['ledger'] = ledger; + + sendMessage(JsonRpcMethod.GetEnableAccounts, request.body.params, function (response) { + if (response.error) { + console.error(response.error); + } else { + setDetails(response); + } + }); + }; + + useEffect(() => { + // eslint-disable-next-line no-unused-vars + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.body.method == JsonRpcMethod.EnableAuthorization) { + // Set the request in the store with origin so we can respond later + setRequest(request); + store.saveRequest(request); + responseOriginTabID = request.originTabID; + + // Check for existence of the params and set page values + setDetails(request.body.params); + } + }); + }, []); + + const grant = () => { + window.removeEventListener('beforeunload', deny); + chrome.runtime.sendMessage({ + source: 'extension', + body: { + jsonrpc: '2.0', + method: JsonRpcMethod.AuthorizationAllow, + params: { + responseOriginTabID: responseOriginTabID, + isEnable: true, + genesisID: genesisID, + genesisHash: genesisHash, + accounts: accounts, + ledger: store.ledger, + }, + }, + }); + }; + + return useObserver( + () => html` +
+
+ +
+
+
+
+ ${request.favIconUrl && + html` `} +

+ Access requested to your + wallet${request.originTitle && html` from ${request.originTitle}`} +

+
+
+
+

Select the accounts to + share${request.originTitle && html` with ${request.originTitle}`}. + Bolded accounts are required by the dApp.

+
+ ${sessionLedgers && + sessionLedgers.length === 1 && + html` Sharing accounts on the ${store.ledger} network. `} + ${sessionLedgers && + sessionLedgers.length > 1 && + html` +
Shared Network:
+
+ + + `} +
+ ${!!store[store.ledger] && + html` + ${(!accounts || accounts.length === 0) && + html` +
There are no accounts available to share on this network.
+ `} + ${accounts && + accounts.length > 0 && + html` +
+ + + + + + + + + ${accounts && + accounts.map( + (account) => html` + + ${account.missing && + html` `} + ${!account.missing && + html``} + ` + )} + +
AccountShared
+ ${account.requested && + html`${obfuscateAddress(account.address, 7)}`} + ${!account.requested && html`${obfuscateAddress(account.address, 7)}`} + Not Available + { + account.selected = !account.selected; + }} + /> +
+
+ `} + `} +
+
+
+ + +
+
+ ` + ); +}; + +export default Enable; diff --git a/packages/ui/src/pages/Login.ts b/packages/ui/src/pages/Login.ts index b51b9977..2adcf491 100644 --- a/packages/ui/src/pages/Login.ts +++ b/packages/ui/src/pages/Login.ts @@ -42,8 +42,15 @@ const Login: FunctionalComponent = (props: any) => { store.setAvailableLedgers(response.availableLedgers); store.updateWallet(response.wallet, () => { store.setLedger(response.ledger); - if (redirect.length > 0) route(`/${redirect}`); - else route('/wallet'); + if (redirect.length > 0 && redirect === 'close') { + window.close(); + } + else if (redirect.length > 0) { + route(`/${redirect}`); + } + else { + route('/wallet'); + } }); } }); diff --git a/packages/ui/src/styles.scss b/packages/ui/src/styles.scss index 9a487409..9665a381 100644 --- a/packages/ui/src/styles.scss +++ b/packages/ui/src/styles.scss @@ -99,6 +99,7 @@ $tooltip-background-opacity: 1; @import 'bulma/sass/elements/box.sass'; @import 'bulma/sass/elements/button.sass'; @import 'bulma/sass/elements/container.sass'; +@import 'bulma/sass/elements/table.sass'; @import 'bulma/sass/elements/title.sass'; @import 'bulma/sass/elements/progress.sass'; @import 'bulma/sass/form/_all.sass';