Skip to main content

ADR 0014: Signing Runtime Transactions with Hardware Wallet

Component

Oasis SDK

Changelog

  • 2022-07-15: Initial public version

Status

Proposed

Context

This document proposes the APDUSPEC additions and general UI/UX guidelines for signing Runtime transactions on Ledger and other hardware wallets:

  1. Signing deposit, withdrawal and transfer transactions,
  2. Signing unencrypted smart contract transactions,
  3. Signing encrypted transactions,
  4. Signing EVM transactions.

Test vectors

Test vectors for the Runtime transactions can be generated by using gen_runtime_vectors tool as part of the Oasis SDK.

Runtime transaction format

The structure of the Runtime transaction to be signed by the hardware wallet is the following:

/// Transaction.
#[derive(Clone, Debug, cbor::Encode, cbor::Decode)]
pub struct Transaction {
#[cbor(rename = "v")]
pub version: u16,

pub call: Call,

#[cbor(rename = "ai")]
pub auth_info: AuthInfo,
}

The transaction can be signed with Secp256k1 ("Ethereum"), Ed25519 key, or Sr25519 key! Information on this along with the gas fee is stored inside ai field.

call is defined as follows:

/// Method call.
#[derive(Clone, Debug, cbor::Encode, cbor::Decode)]
pub struct Call {
/// Call format.
#[cbor(optional, default)]
pub format: CallFormat,
/// Method name.
#[cbor(optional, default, skip_serializing_if = "String::is_empty")]
pub method: String,
/// Method body.
pub body: cbor::Value,
/// Read-only flag.
///
/// A read-only call cannot make any changes to runtime state. Any attempt at modifying state
/// will result in the call failing.
#[cbor(optional, default, rename = "ro")]
pub read_only: bool,
}

If format equals 0, the transaction is unencrypted and it is CBOR-encoded inside the body field.

If format equals 1, the transaction is encrypted. In this case method is empty and body contains a CBOR-encoded CallEnvelopeX25519DeoxysII which includes the encrypted transaction body inside the data field.

Decision

APDUSPEC additions

GET_ADDR_SECP256K1

Command
FieldTypeContentExpected
CLAbyte (1)Application Identifier0x05
INSbyte (1)Instruction ID0x04
P1byte (1)Request User confirmationNo = 0
P2byte (1)Parameter 2ignored
Lbyte (1)Bytes in payload(depends)
Path[0]byte (4)Derivation Path Data44
Path[1]byte (4)Derivation Path Data60
Path[2]byte (4)Derivation Path Data?
Path[3]byte (4)Derivation Path Data?
Path[4]byte (4)Derivation Path Data?

The first three items in the derivation path are hardened.

Response
FieldTypeContentNote
PKbyte (32)Public Key
ADDRbyte (??)Hex addr
SW1-SW2byte (2)Return codesee list of return codes

GET_ADDR_SR25519

Command
FieldTypeContentExpected
CLAbyte (1)Application Identifier0x05
INSbyte (1)Instruction ID0x03
P1byte (1)Request User confirmationNo = 0
P2byte (1)Parameter 2ignored
Lbyte (1)Bytes in payload(depends)
Path[0]byte (4)Derivation Path Data44
Path[1]byte (4)Derivation Path Data474
Path[2]byte (4)Derivation Path Data?
Path[3]byte (4)Derivation Path Data?
Path[4]byte (4)Derivation Path Data?

The first three items in the derivation path are hardened.

Response
FieldTypeContentNote
PKbyte (32)Public Key
ADDRbyte (??)Bech 32 addr
SW1-SW2byte (2)Return codesee list of return codes

SIGN_PT_ED25519

Command
FieldTypeContentExpected
CLAbyte (1)Application Identifier0x05
INSbyte (1)Instruction ID0x05
P1byte (1)Payload desc0 = init
1 = add
2 = last
P2byte (1)----not used
Lbyte (1)Bytes in payload(depends)

The first packet/chunk includes only the derivation path.

All other packets/chunks should contain message to sign.

First Packet

FieldTypeContentExpected
Path[0]byte (4)Derivation Path Data44
Path[1]byte (4)Derivation Path Data474
Path[2]byte (4)Derivation Path Data?
Path[3]byte (4)Derivation Path Data?
Path[4]byte (4)Derivation Path Data?

Other Chunks/Packets

FieldTypeContentExpected
Databytes...Meta+Message

Data is defined as:

FieldTypeContentExpected
Metabytes..CBOR metadata to verify
Messagebytes..CBOR data to sign
Response
FieldTypeContentNote
SIGbyte (64)Signature
SW1-SW2byte (2)Return codesee list of return codes

SIGN_PT_SECP256K1

Command
FieldTypeContentExpected
CLAbyte (1)Application Identifier0x05
INSbyte (1)Instruction ID0x07
P1byte (1)Payload desc0 = init
1 = add
2 = last
P2byte (1)----not used
Lbyte (1)Bytes in payload(depends)

The first packet/chunk includes only the derivation path.

All other packets/chunks should contain message to sign.

First Packet

FieldTypeContentExpected
Path[0]byte (4)Derivation Path Data44
Path[1]byte (4)Derivation Path Data60
Path[2]byte (4)Derivation Path Data?
Path[3]byte (4)Derivation Path Data?
Path[4]byte (4)Derivation Path Data?

Other Chunks/Packets

FieldTypeContentExpected
Databytes...Meta+Message

Data is defined as:

FieldTypeContentExpected
Metabytes..CBOR metadata to verify
Messagebytes..CBOR data to sign
Response
FieldTypeContentNote
SIGbyte (64)Signature
SW1-SW2byte (2)Return codesee list of return codes

SIGN_PT_SR25519

Command
FieldTypeContentExpected
CLAbyte (1)Application Identifier0x05
INSbyte (1)Instruction ID0x06
P1byte (1)Payload desc0 = init
1 = add
2 = last
P2byte (1)----not used
Lbyte (1)Bytes in payload(depends)

The first packet/chunk includes only the derivation path.

All other packets/chunks should contain message to sign.

First Packet

FieldTypeContentExpected
Path[0]byte (4)Derivation Path Data44
Path[1]byte (4)Derivation Path Data474
Path[2]byte (4)Derivation Path Data?
Path[3]byte (4)Derivation Path Data?
Path[4]byte (4)Derivation Path Data?

Other Chunks/Packets

FieldTypeContentExpected
Databytes...Meta+Message

Data is defined as:

FieldTypeContentExpected
Metabytes..CBOR metadata to verify
Messagebytes..CBOR data to sign
Response
FieldTypeContentNote
SIGbyte (64)Signature
SW1-SW2byte (2)Return codesee list of return codes

Signing deposit, withdrawal and transfer transactions

Allowance (consensus layer!)

The allowance transaction is part of the consensus layer. In this document we propose an improved UI:

|     Type     > | <    To    > | <    Amount    > | <     Fee     > | < Gas limit > | <  Network  > | <             > | <              |
| Allowance | <TO> | ROSE +-<AMOUNT> | ROSE <FEE> | <GAS LIMIT> | <NETWORK> | APPROVE | REJECT |
| | | | | | | | |

IMPROVEMENT: The hardware wallet renders the following in place of TO for specific NETWORK and addresses:

  • Network: Mainnet, To: oasis1qrnu9yhwzap7rqh6tdcdcpz0zf86hwhycchkhvt8Cipher
  • Network: Testnet, To: oasis1qqdn25n5a2jtet2s5amc7gmchsqqgs4j0qcg5k0tCipher
  • Network: Mainnet, To: oasis1qzvlg0grjxwgjj58tx2xvmv26era6t2csqn22pteEmerald
  • Network: Testnet, To: oasis1qr629x0tg9gm5fyhedgs9lw5eh3d8ycdnsxf0runEmerald

Check the Mainnet network parameters and Testnet network parameters pages to obtain the current hash of the genesis document for Mainnet and Testnet networks respectively.

The mapping above should be hardcoded into the hardware wallet app. If you are interested in how addresses were derived from the Runtime ID check the staking document.

Deposit

We propose the following UI for consensus.Deposit Runtime transaction:

|     Type     > | <   To (1/1)  > | <   Amount    > | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <               |
| Deposit | <MIXED_TO> | <SYM> <AMOUNT> | <SYM> <FEE> | <GAS LIMIT> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | | | | | |

MIXED_TO can either be oasis1 or the Ethereum's 0x address. If Meta does not contain orig_to field, render the tx.call.body.to value in oasis1 format in place of MIXED_TO. If Meta contains orig_to field, then:

  1. Check that the 0x address stored in orig_to field maps to the oasis1 address of tx.call.body.to according to this mapping function.
  2. Render orig_to value in 0x format in place of MIXED_TO.

In addition, if tx.call.body.to is empty, then the deposit is made to the signer's account inside the Runtime. In this case Self literal is rendered in place of MIXED_TO.

AMOUNT and FEE show the amount of tokens transferred in the transaction and the transaction fee. The number must be formatted according to the number of decimal places and showing a corresponding symbol SYM beside. These are determined by the following mapping hardcoded in the hardware wallet:

(Network, Runtime ID, Denomination) → (Number of decimals, SYM)

Denomination information is stored in tx.part.body.amount[1] or tx.ai.fee.amount[1] for the tokens transferred in the transaction or the fee respectively. Empty Denomination is valid and signifies the native token for the known networks and Runtime IDs (see below).

The hardware wallet should have at least the following mappings hardcoded:

  • Network: Mainnet, Runtime ID: Cipher, Denomination: "" → 9, ROSE
  • Network: Testnet, Runtime ID: Cipher, Denomination: "" → 9, TEST
  • Network: Mainnet, Runtime ID: Emerald, Denomination: "" → 18, ROSE
  • Network: Testnet, Runtime ID: Emerald, Denomination: "" → 18, TEST

If the lookup fails, the following policy should be respected:

  1. SYM is rendered as empty string.
  2. The number of decimals is 18, if Runtime ID matches any Emerald Runtime on any network.
  3. Otherwise, the number of decimals is 9.

RUNTIME shows the 32-byte hex encoded Runtime ID stored in Meta.runtime_id. If NETWORK matches Mainnet or Testnet, then human-readable version of RUNTIME is shown:

  • Network: Mainnet, Runtime ID: 000000000000000000000000000000000000000000000000e199119c992377cbCipher
  • Network: Testnet, Runtime ID: 0000000000000000000000000000000000000000000000000000000000000000Cipher
  • Network: Mainnet, Runtime ID: 000000000000000000000000000000000000000000000000e2eaa99fc008f87fEmerald
  • Network: Testnet, Runtime ID: 00000000000000000000000000000000000000000000000072c8215e60d5bca7Emerald

SAFETY CHECK: Runtime chain domain separation context Meta.sig_context must be verified before showing transaction details: The last 64 characters must match the hex value of the hash derived from Meta.runtime_id and Meta.chain_context on the consensus. See golang implementation for the reference implementation.

Withdraw

The consensus.Withdraw transaction should have the following UI on the hardware wallet:

|     Type     > | <   To (1/1)  > | <   Amount    > | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <               |
| Withdraw | <TO> | <SYM> <AMOUNT> | <SYM> <FEE> | <GAS LIMIT> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | | | | | |

If tx.call.body.to is empty, then the withdrawal is made to the signer's consensus account. In this case Self literal is rendered in place of TO.

Transfer

The accounts.Transfer transaction should have the following UI on the hardware wallet:

|     Type     > | <   To (1/1)  > | <   Amount    > | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <               |
| Transfer | <MIXED_TO> | <SYM> <AMOUNT> | <SYM> <FEE> | <GAS LIMIT> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | | | | | |

Example

The user wants to deposit 100 ROSE to 0xDce075E1C39b1ae0b75D554558b6451A226ffe00 account on Emerald on the Mainnet. First they sign the deposit allowance transaction for Emerald.

|     Type     > | <    To    > | <   Amount   > | < Gas limit > | <     Fee     > | <  Network  > | <             > | <              |
| Allowance | Emerald | ROSE +100.0 | 1277 | ROSE 0.0 | Mainnet | APPROVE | REJECT |
| | Mainnet | | | | | | |

Next, they sign the Runtime deposit transaction.

|     Type     > | <   To (1/2)  > | <    To (2/2)   > | <   Amount    > | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <               |
| Deposit | 0xDce075E1C39b1 | 451A226ffe00 | ROSE 100.0 | ROSE 0.0 | 11310 | Mainnet | Emerald | APPROVE | REJECT |
| (ParaTime) | ae0b75D554558b6 | | | | | | | | |

Then, they transfer some tokens to another account inside the Runtime:

|     Type     > | <    To (1/2)  > | <    To (2/2)   > | <   Amount    > | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <               |
| Transfer | oasis1qpupfu7e2n | m8anj64ytrayne | ROSE 10.0 | ROSE 0.00015 | 11311 | Mainnet | Emerald | APPROVE | REJECT |
| (ParaTime) | 6pkezeaw0yhj8mce | | | | | | | | |

Finally, the user withdraws the remainder of tokens back to the Mainnet.

|     Type     > | <    To (1/2)  > | <    To (2/2)   > | <   Amount    > | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <               |
| Withdraw | oasis1qrec770vre | 504k68svq7kzve | ROSE 99.9997 | ROSE 0.00015 | 11311 | Mainnet | Emerald | APPROVE | REJECT |
| (ParaTime) | k0a9a5lcrv0zvt22 | | | | | | | | |

Signing unencrypted smart contract transactions

Uploading smart contract

contracts.Upload transaction will not be signed by the hardware wallet because the size of the Wasm byte code to sign may easily exceed the maximum size of the available encrypted memory.

Instantiating smart contract

contracts.Instantiate should have the following UI on the hardware wallet:

|  Review Contract > | < Code ID > | < Amount (1/1) > | < Data (1/1) > | ... | <    Fee    > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <               |
| Instantiation | <CODE ID> | <AMOUNT...> | <DATA> | ... | <SYM> <FEE> | <GAS LIMIT> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | ... | | | | | | |

DATA is a JSON-like representation of tx.call.body.data, if the latter is a CBOR-encoded map. If tx.call.body.data is empty or not present, then Data screen is hidden. If tx.call.body.data is in some other format, require blind signing mode and hide Data screen.

Blind signing means that the user does not see all contract information. In some cases - as is this - not even the amount or the contract address! When signing blindly, it is crucial that the user trusts the client application that it generated a non-malicious transaction!

AMOUNT... is the amount of tokens sent. Contract SDK supports sending multiple tokens at once, each with its own denomination symbol. The hardware wallet should render all of them, one per page. For rendering rules of each AMOUNT consult the consensus.Deposit behavior.

There can be multiple Data screens Data 1, Data 2, ..., Data N for each key in tx.call.body.data map. DATA can be one of the following types:

  • string
  • number (integer, float)
  • array
  • map
  • boolean
  • null

Strings are rendered as UTF-8 strings and the following characters need to be escaped: :, ,, }, ], .

Numbers are rendered in standard general base-10 encoding. Floats use decimal period and should be rendered with at least one decimal.

For strings and numbers that cannot fit a single page, a pagination is activated.

Boolean and null values are rendered as true, false and null respectively on a single page.

Array and map is rendered in form VAL1,VAL2,... and KEY1:VAL1,KEY1:VAL1,... respectively. For security, the items of the map must be sorted lexicographically by KEY. KEY and VAL can be of any supported type. If it is a map or array it is rendered as {DATA} or [DATA] respectively to avoid disambiguation. Otherwise, it is just DATA.

If the content of an array or a map cannot fit a single page, no pagination is introduced. Instead, the content is trimmed, ellipsis is appended at the end and the screen becomes confirmable. If the user double-clicks it, a subscreen for item n of an array or a map is shown. There is one subscreen for each item of the array or a map of size N titled Data n.1, Data n.2, ..., Data n.N which renders the item n as DATA for an array item or DATA:DATA for a map item:

|   Data 1.1 (1/1) > | < Data 1.2 (1/1) | < Data 1.3 (1/1) | ... | <          |
| <DATA> | <DATA> | <DATA> | | BACK |
| | | | | |

The recursive approach described above allows user to browse through a complete tree of data stracture (typically a request name along with the arguments) by using ⬅️ and ➡️ buttons, visit a child by double-clicking and returning to a parent node by confirming the BACK screen.

The maximum string length, the length of the array, the depth of a map must have reasonable limits on the hardware wallet. If that limit is exceeded, the hardware wallet displays an error on the initial screen. Then, if the user still wants to sign such a transaction, they need to enable blind signing.

The following UI is shown when blind-signing a non-encrypted transaction due to too complex function parameters.

|  Review Contract > | < BLIND > | < Instance ID (1/1) > | <   Amount    > | <     Fee     > | <  Network  > | <  ParaTime > | <            > | <             |
| Instantiation | SIGNING! | <INSTANCE ID> | <SYM> <AMOUNT> | <SYM> <FEE> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | | | | | |

Calling smart contract

The hardware wallet should show details of the Runtime transaction to the user, when this is possible. contracts.Call should have the following UI on the hardware wallet:

| Review Contract > | < Instance ID > | < Amount (1/1) > | < Data (1/1) > | ... | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <              |
| Call | <INSTANCE ID> | <AMOUNT...> | <DATA> | ... | <SYM> <FEE> | <GAS LIMIT> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | ... | | | | | | |

The Data screen behavior is the same as for contracts.Instantiate transaction.

Upgrading smart contracts

Signing contracts.Upgrade should show the following UI on the hardware wallet:

|  Review Contract > | < Instance ID (1/1) > | < Amount (1/1) > | < New Code ID (1/1) > | < Data (1/1) > | ... | < ParaTime > | <     Fee     > | < Gas limit > | < Network > | < ParaTime > | <             > | <               |
| Upgrade | <INSTANCE ID> | <AMOUNT...> | <CODE_ID> | <DATA> | | <RUNTIME> | <SYM> <FEE> | <GAS LIMIT> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | | | | | | | | | |

The Data screen behavior is the same as for contracts.Instantiate transaction.

Example

To upload, instantiate and call the hello world example running on Testnet Cipher the user first signs the contract upload transaction with a file-based ed25519 keypair. The user obtains the Code ID 3 for the uploaded contract.

Next, the user instantiates the contract and obtains the Instance ID 2.

|  Review Contract > | < Code ID > | <  Amount  > | <      Data      > | <    Fee    > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <               |
| Instantiation | 3 | ROSE 0.0 | {instantiate:{init | ROSE 0.0 | 1348 | Mainnet | Cipher | APPROVE | REJECT |
| (ParaTime) | | | ial_counter:42}} | | | | | | | |

Finally, they perform a call to say_hello function on a smart contract passing the {"who":"me"} object as a function argument.

| Review Contract > | < Instance ID > | <  Amount  > | <      Data      > | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <             > | <              |
| Call | 2 | ROSE 0.0 | {say_hello:{who:me | ROSE 0.0 | 1283 | Mainnet | Cipher | APPROVE | REJECT |
| (ParaTime) | | | }} | | | | | | |

As a complete example, the user can provide a more complex object:

{
"who": {
"username": "alice",
"client_secret": "e5868ebb4445fc2ad9f949956c1cb9ddefa0d421",
"last_logins": [1646835046, 1615299046, 1583763046, 1552140646],
"redirect": null
}
}

In this case the hardware wallet renders the following UI.

| Review Contract > | <  Instance ID  > | <   Amount  > | <      Data      > | <     Fee     > | < Gas limit > | <  Network  > | <  ParaTime  > | <            > | <              |
| Call | 2 | ROSE 0.0 | {say_hello:{who:{u | ROSE 0.15 | 1283 | Mainnet | Cipher | APPROVE | REJECT |
| (ParaTime) | | | sername:alice,cli… | | | | | | |

V V

| Data 1 > | < |
| say_hello:{who:{us | BACK |
| ername:alice,clie… | |

V V

| Data 1.1 > | < |
| who:{username:alic | BACK |
| e,client_secret:[… | |

V V

| Data 1.1.1 > | < Data 1.1.2 (1/2) > | < Data 1.1.2 (2/2) > | < Data 1.1.3 > | < Data 1.1.4 > | < |
| username:alice | client_secret:e5868e | 1cb9ddefa0d421 | last_logins:[1646835 | redirect:null | BACK |
| | bb4445fc2ad9f949956c | | 046,1615299046,1583… | | |

V V

| Data 1.1.3.1 > | < Data 1.1.3.2 > | < Data 1.1.3.3 > | < Data 1.1.3.4 | < |
| 1646835046 | 1615299046 | 1583763046 | 1552140646 | BACK |
| | | | | |

Signing encrypted transactions

Encrypted transactions (tx.call.format != 0) contain the call data encrypted with an ephemeral X25519DeoxysII key. The hardware wallet is not expected to implement this scheme and decrypt the transaction, neither it is safe to share the ephemeral key with anyone. Instead, the user must enable blind signing and the hardware wallet should show the hash of the encrypted transaction, the X25519DeoxysII public key and the nonce:

| Review Encrypted > | < BLIND > | < Tx hash (1/1) > | < Public key (1/1) > | <  Nonce (1/1) > | <    Fee    > | < Gas limit > | <  Network  > | <  ParaTime > | <             > | <             |
| Transaction | SIGNING! | <TX_HASH> | <PUBLIC_KEY> | <NONCE> | <SYM> <FEE> | <GAS LIMIT> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | | | | | | | |

TX_HASH is a hex representation of sha256 checksum of tx.call.data field.

PUBLIC_KEY is a hex representation of the 32-byte tx.call.pk field.

NONCE is a hex representation of the 15-byte tx.call.nonce field.

Signing EVM transactions

Creating smart contract

evm.Create transaction will not be managed by the hardware wallet because the size of the EVM byte code may easily exceed the wallet's encrypted memory size.

Calling smart contract

In contrast to contracts.Call, evm.Call would require contract ABI in order to extract argument names which are stored in the RLP-encoded transaction on the blockchain. We do not support this, so only blind signing is performed which the user needs to enable first. The UI should be as follows:

|   Review EVM   > | < BLIND > | < Address (1/1) > | <   Amount    > | <     Fee     > | <  Network  > | <  ParaTime > | <            > | <             |
| Contract Call | SIGNING! | <ADDRESS> | <SYM> <AMOUNT> | <SYM> <FEE> | <NETWORK> | <RUNTIME> | APPROVE | REJECT |
| (ParaTime) | | | | | | | | |

Consequences

Positive

Users will have a similar experience for signing Runtime transactions on any wallet implementing this ADR.

Negative

For some transactions, user will need to trust the client application and use blind signing.

Neutral

Consideration of roothash.SubmitMsg transactions

This ADR does not propose a UI for generic Runtime calls (roothash.SubmitMsg, see ADR 11). The proposed design in this ADR assumes a new release of the hardware wallet app each time a new Runtime transaction type is introduced.

Signing contract uploads on hardware wallets

In the future perhaps, if only the merkle root hash of the Wasm contract would be contained in the transaction, signing such a contract could be feasible. See how Ethereum 2.x contract deployment is done using this approach.

Consideration of adding From screen

None of the proposed UIs and the existing implementation of signing the consensus transactions on Ledger show who is a signer of the transaction. The signer's from address can be extracted from tx.ai.si[0].address_spec.signature.<SIGNATURE TYPE> for oasis native address and if the signer wants to show the Ethereum address, Meta.orig_from should be populated and the hardware wallet should verify it before showing the tx.

References