[no changelog]
17 KiB
Hello world feature in TT
(How to develop on Trezor)
Overview
This document shows the creation of a custom functionality (feature, application) on TT. It explains how to build both the Trezor (device, core) logic, as well as the client (computer, host, trezorlib) logic needed to speak with Trezor. For most new features, also the communication layer between Trezor and computer (protobuf) needs to be modified, to set up the messages they will exchange.
Intermediate knowledge of python
and linux
environment is assumed here to easily follow along. For steps how to set up the Trezor dev environment, refer to other docs - build or emulator. The most important part is being in the poetry shell
of this project, so all dependencies are installed.
Feature description
We will implement a simple hello-world feature where Trezor gets some information from the host, will do something with it (optionally shows something on the screen), and returns some information back to the host, where we want to display them. (Note that there are no cryptographic operations involved in this example, it focuses only on basic communication between Trezor and host.)
Implementation
As already mentioned, to get something useful from Trezor, writing device logic is not enough. We need to have a specific communication channel between the computer and Trezor, and also the computer needs to know how to speak to the device to trigger wanted action.
TLDR: implementation in a single commit
1. Communication part (protobuf)
Communication between Trezor and the computer is handled by a protocol called protobuf
. It allows for the creation of specific messages (containing clearly defined data) that will be exchanged. More details about this can be seen in docs.
Trezor on its own cannot send data to the computer, it can only react to a "request" message it recognizes and send a "response" message. Both of these messages will need to be specified, and both parts of communication will need to understand them.
Protobuf messages are defined in common/protob
directory in .proto
files. When we are creating a brand-new feature (application), it is worth creating a new .proto
file dedicated only for this feature. Let's call it messages-hello.proto
and fill it with the content below.
common/protob/messages-helloworld.proto
syntax = "proto2";
package hw.trezor.messages.helloworld;
// Sugar for easier handling in Java
option java_package = "com.satoshilabs.trezor.lib.protobuf";
option java_outer_classname = "TrezorMessageHelloWorld";
import "messages.proto";
/**
* Request: Hello world request for text
* @next HelloWorldResponse
* @next Failure
*/
message HelloWorldRequest {
required string name = 1;
optional uint32 amount = 2 [default=1];
optional bool show_display = 3;
}
/**
* Response: Hello world text
* @end
*/
message HelloWorldResponse {
required string text = 1;
}
There are some officialities at the top, the most important things are the message
declarations. We are defining a HelloWorldRequest
, that will be sent from the computer to Trezor, and HelloWorldResponse
, that will be sent back from Trezor. There are many features and data-types protobuf
supports - see Google docs or other common/protob/messages-*.proto
files.
After defining the details of communication messages, we will also need to give these messages their unique IDs and specify the direction in which they are sent (into Trezor or from Trezor). That is done in common/protob/messages.proto
file. We will append a new block at the end of the file:
common/protob/messages.proto
// Hello world
MessageType_HelloWorldRequest = 900 [(wire_in) = true];
MessageType_HelloWorldResponse = 901 [(wire_out) = true];
After this, we are almost done with protobuf
! The only thing left is to run make gen
in the root directory to create all the auto-generated files. By running this, the protobuf
definitions will be translated into python
classes in both core
and python
sub-repositories, so that they can understand these messages. Files under core/src/trezor
and python/src/trezorlib
should be modified by this.
Optional step
This feature will be implemented only on TT
and not the older T1
model. If we want to be compatible with CI
, we need to define these messages as unused for T1
. That is done in legacy/firmware/protob/Makefile
, where we will extend the SKIPPED_MESSAGES
variable:
legacy/firmware/protob/Makefile
SKIPPED_MESSAGES := ... \
HelloWorldRequest HelloWorldResponse
2. Trezor part (core)
The second part deals with creating the "application code" on Trezor. Surprisingly, this part is probably the easiest one from all three parts here (as this is just hello-world example).
All the applications running on Trezor are situated under core/src/apps
directory. We could create a new application, or reuse the existing one if the feature logically corresponds to it. We will choose to implement this feature under misc
application, as it is really a miscellaneous one.
We can therefore create a file core/src/apps/misc/hello_world.py
and fill it with the content below:
core/src/apps/misc/hello_world.py
from typing import TYPE_CHECKING
from trezor.messages import HelloWorldResponse
from trezor.ui.layouts import confirm_text
if TYPE_CHECKING:
from trezor.messages import HelloWorldRequest
async def hello_world(msg: HelloWorldRequest) -> HelloWorldResponse:
text = _get_text_from_msg(msg)
if msg.show_display:
await confirm_text(
"confirm_hello_world",
"Hello world",
text,
description="Hello world example",
)
return HelloWorldResponse(text=text)
def _get_text_from_msg(msg: HelloWorldRequest) -> str:
return msg.amount * f"Hello {msg.name}!\n"
Note that we need to import the newly created protobuf messages (HelloWorldRequest
and HelloWorldResponse
) to provide type hints and to be able to return the response. We are also importing a UI
layout so that we can show a confirmation dialog.
All the protobuf fields are accessible on the msg
object and are accessed via dot notation like class attributes (msg.show_display
). When instantiating the response object, keyword-arguments need to be used - HelloWorldResponse(text=text)
.
Even though the code in core
is run by a micropython
interpreter, almost all basic features from "classic" python are supported - like f-strings
here.
As we want to also write unittests for this module, we define a helper function _get_text_from_msg
, even though it could easily be inlined in this case.
To see the details about code style and conventions, refer to codestyle.md.
We have defined all the logic, but it is not being called anywhere. We need to register the function to be called as a response to the appropriate message - in our case HelloWorldRequest
. Registration is done in core/src/apps/workflow_handlers.py
and the following two lines need to be added there (ideally under the misc
section):
core/src/apps/workflow_handlers.py
if msg_type == MessageType.HelloWorldRequest:
return "apps.misc.hello_world"
The above will make sure that the msg
(of type HelloWorldRequest
) will be supplied into the hello_world
function we created.
Lastly, running make gen
in the root directory makes sure the new misc/hello_world.py
module will be discovered. core/src/all_modules.py
should be modified as a result.
These are all the necessary code changes in core
. For this code to work, we will still need to build it, but that will be done in Part 4. Next, we will focus on the client implementation.
3. Host part (trezorlib)
So far we have defined the messages going to the Trezor and back and the Trezor logic itself. What remains is the code sitting on the computer and sending these messages into Trezor and receiving them.
There are more ways how to achieve this, for example Connect is a way of communicating with Trezor from a web browser. However, we will decide to implement this connection via trezorlib
, our own python library, which lives under python/src/trezorlib
and acts as a CLI
(Command-line interface) to communicate with Trezor (via trezorctl
command).
This implementation will be split into two parts, as we will create the Trezor-communication logic in one file and the CLI
logic taking arguments and calling this code in the second file. (It would be possible to define everything at once in the CLI
file, but we want the possibility to call the Trezor-speaking function separately, for example when testing.)
We will create the python/src/trezorlib/hello_world.py
file and fill it with code to speak with Trezor:
python/src/trezorlib/hello_world.py
from typing import TYPE_CHECKING, Optional
from . import messages
from .tools import expect
if TYPE_CHECKING:
from .client import TrezorClient
from .protobuf import MessageType
@expect(messages.HelloWorldResponse, field="text", ret_type=str)
def say_hello(
client: "TrezorClient",
name: str,
amount: Optional[int],
show_display: bool,
) -> "MessageType":
return client.call(
messages.HelloWorldRequest(
name=name,
amount=amount,
show_display=show_display,
)
)
Code above is sending HelloWorldRequest
into Trezor and is expecting to get HelloWorldResponse
back (from which it extracts the text
string as a response).
This function is then called from the CLI
function, which we will define in python/src/trezorlib/cli/hello_world.py
.
python/src/trezorlib/cli/hello_world.py
from typing import TYPE_CHECKING, Optional
import click
from .. import hello_world
from . import with_client
if TYPE_CHECKING:
from ..client import TrezorClient
@click.group(name="helloworld")
def cli() -> None:
"""Hello world commands."""
@cli.command()
@click.argument("name")
@click.option("-a", "--amount", type=int, help="How many times to greet.")
@click.option(
"-d", "--show-display", is_flag=True, help="Whether to show confirmation screen."
)
@with_client
def say_hello(
client: "TrezorClient", name: str, amount: Optional[int], show_display: bool
) -> str:
"""Simply say hello to the supplied name."""
return hello_world.say_hello(client, name, amount, show_display=show_display)
Code above is importing the hello_world
module defined before and is calling its say_hello()
function with arguments received from the user. We are using click library to create the CLI
- first the helloworld
group and then the say_hello
command (which is invoked by say-hello
).
Example of calling the say_hello
function via command line is trezorctl helloworld say-hello George -a 3 -d
, which utilizes all the defined arguments and options (only the name
argument is required here).
However, the command above will not work yet, as the helloworld
group is not registered in the main CLI
file - python/src/trezorlib/cli/trezorctl.py
. It will therefore need some small modifications - importing the new module and registering it:
python/src/trezorlib/cli/trezorctl.py
from . import helloworld
...
cli.add_command(hello_world.cli)
If we are currently in poetry shell
, the trezorctl
command is being evaluated directly from the source code in python/src/trezorlib
. That means it should be able to understand our example command trezorctl helloworld say-hello George -a 3 -d
.
The example command on its own will however not work without listening Trezor which understands the new messages. In the next and final part, we will build and spawn a Trezor on our computer with all the changes made in Part 1 and 2.
4. Putting it together
Looks like all the code changes have been done, the final part is to build a Trezor image - emulator
- so that we can actually run and test all the logic we created.
Detailed information about the emulator can be found in its docs, but we only need two most important commands, that will build and spawn the emulator:
cd core
make build_unix
./emu.py
After this, the emulator screen should be visible. Trying our example command should give a nice confirmation screen, and when confirming it with the green button, we should see the output in our terminal.
$ trezorctl helloworld say-hello George -a 3 -d
Please confirm action on your Trezor device.
Hello George!
Hello George!
Hello George!
For building the new feature into a physical Trezor, refer to embedded.
Testing
It is always good to include some tests exercising the created functionality, so when we break it later, it will be noticed. Trezor model T supports both unit tests
and integration tests
(which are called device tests
).
Unit tests
Unit tests can verify individual (mostly helper) functions that have clearly defined inputs and outputs.
They are stored under core/tests
and can be run by make test
in the core
directory.
To call a specific test (the one we are about to create), run make test TESTOPTS=test_apps.misc.hello_world.py
core/tests/test_apps.misc.hello_world.py
from common import *
from trezor.messages import HelloWorldRequest
from apps.misc.hello_world import _get_text_from_msg
class TestHelloWorld(unittest.TestCase):
def test_get_text_from_msg(self):
msg = HelloWorldRequest(name="Satoshi", amount=2, show_display=False)
self.assertEqual(_get_text_from_msg(msg), "Hello Satoshi!\nHello Satoshi!\n")
if __name__ == "__main__":
unittest.main()
Code above is using the unittest
testing framework, however not directly from python's standard library. As these unit tests are run by micropython
, which does not have unittest
library, we had to create the functionality ourselves in core/tests/unittest.py
- see from common import *
.
Current code checks one usage of _get_text_from_msg
, the only deterministic helper function we use in our feature. One could create many test vectors trying different inputs and expecting different outputs.
Device tests
Device tests (our name for integration tests) should test the whole workflow from sending the first request into Trezor to Trezor sending the final response.
trezorlib
is used extensively in these tests as a way to request something from Trezor and then assert the expected response (it actually uses the code we created in Part 3).
They are closely connected with ui tests, which assert Trezor's screens have a known and expected content during the device tests.
Device tests are stored in tests/device_tests
and they can be run by make test_emu
in core
. Running the specific file we will create can be done by make test_emu TESTOPTS="-k test_hello_world.py"
.
tests/device_tests/misc/test_hello_world.py
from typing import Optional
import pytest
from trezorlib import hello_world
from trezorlib.debuglink import TrezorClientDebugLink as Client
VECTORS = ( # name, amount, show_display
("George", 2, True),
("John", 3, False),
("Hannah", None, False),
)
@pytest.mark.skip_t1b1
@pytest.mark.parametrize("name, amount, show_display", VECTORS)
def test_hello_world(
client: Client, name: str, amount: Optional[int], show_display: bool
):
with client:
greeting_text = hello_world.say_hello(
client, name=name, amount=amount, show_display=show_display
)
greeting_lines = greeting_text.strip().splitlines()
assert len(greeting_lines) == amount or 1
assert all(name in line for line in greeting_lines)
Unlike in unit tests, pytest is used as the test framework, which is more suitable for bigger and more complex test suites.
As the functionality is developed only for TT
, to not break the CI
, we want to skip this test's execution for model T1
, by adding @pytest.mark.skip_t1b1
decorator.
We are also using the @pytest.mark.parametrize
decorator, which is an efficient way of testing multiple inputs into the same test case.
We are not asserting the exact result of the greeting (that is done by unit tests), we just check it has the expected structure - but we can check really anything here.
Note the usage of trezorlib.hello_world.say_hello
, which we defined earlier, so we see how it can be useful for testing purposes.
Optional step
If we want to be fully compatible with CI
, we need to create expected UI-test
results. The most straightforward way to do it is to run make test_emu_ui_record
in core
directory.
Conclusion
All changes in one commit can be seen here.
Ideas for potentially useful Trezor features are welcome. Feel free to submit issues and open PRs, even if incomplete.