Or How to handle ECDSA signatures in TON
This post explains how to sign and verify data and transactions in TON using ECDSA (though TON primarily uses EDDSA). It assumes familiarity with EVM and basic TON concepts)
Keep in mind, this is my very first post. Cheers.
Hey, it's @swift1337! I do protocol @ ZetaChain š²
Being a protocol engineer is fun. It involves solving specific, niche challenges every day. At ZetaChain we're are working hard to bring omnichain capabilities to The Open Network (TON) š
During this journey, one of the interesting technical challenges was signing TON transactions using ZetaChain's Threshold Signature Schemes (TSS) protocol, which relies on cryptography that differs from what is used in TON. The main use case is to bridge and withdraw locked TON during cross-chain transactions, with support of calling arbitrary smart contracts on connected chains.
In this post, I want to elaborate on how we solved ECDSA on TON and enabled support for Multi-Party Computation (MPC).
Other use cases may include using Metamask for signing ton transactions, as well as cross-chain governance.
All of the sources are available in our repo:
Bitcoin, Ethereum, and many other blockchains use the Elliptic Curve Digital Signature Algorithm (ECDSA) and specifically the secp256k1
curve to sign and verify data. TON, on the other hand, uses the Edwards-curve Digital Signature Algorithm (EdDSA) with ed25519
curve.
Basically, it's a mathematical way of proving that a message came from a specific signer.
For example, we can make a signature of "Alice says Hello, Bob!
". Technically, it would work like this:
Signer: Alice (0x0fA03A88042647025f8524b12899c6A3f1781A2e)
Input: "Hello, Bob!"
Signature:
0x07965b9c69767e2b45fe086aebc4fc103baf26f27f6176e9eb6a3458743ceef877d4e04987619321c7bcf5a2311d25fa61e48ac68c388b4d45bf42f098de831c1b
Magic š§š»āāļø This is literally the backbone of any blockchain
The big brains of cryptography (not meme coins) š§ realized that it's inconvenient to operate with raw messages of arbitrary size (imagine signing a large book). To keep signatures consistent, we sign the hash of the message (usually SHA-256) instead of the entire message.
A process of getting a public key from a signature is called signature recovery. Technically, it looks like this:
// pseudo code
alicePubKey = ecdsa_recover(sha256("Hello, Bob!"), signature)
alice = pubKeyToAddress(alicePubKey) // 0x0fA03A88042647025f85....
Regarding the structure of this signature, it has 65 bytes:
32 bytes for R: One of the elliptic curve point coordinates, linking the message to the signature.
32 bytes for S: Derived from the message, private key, and r value. Both r and s are needed to verify the signature.
1 byte for V: A recovery identifier (0 or 1) used to determine the correct public key for signature verification.
If you want to have an overview the topic, I suggest these introductory sources:
TON is far superior to ETH in technical terms (link). Because async
and sharding
are badass words for a reason! Just acknowledge it, comrade. /s
Very Interesting, but a regular wallet is also a smart contract. Due to this, even a simple coin transfer is actually two transactions ("Alice sends a message with coins to Bob" + "Bob receives a message with coins from Alice").
When Alice sends 1 TON to Bob, Alice creates and signs a message off-chain and then broadcasts it to Alice's smart contract on TON. As of Q4 2024, the latest wallet implementation is WalletV5. It uses the following message structure for handling an external message (recv_external
):
// pseudocode
cell msg_body: $someData + $signature
Okay, forget pseudo code, we're not pseudo nerds! Warm up your fingers, let's see some superiority in practice:
() process_signed_request(slice in_msg_body, int is_external) impure inline {
slice signature = in_msg_body.get_last_bits(size::signature);
slice signed_slice = in_msg_body.remove_last_bits(size::signature);
;; ...
slice data_slice = get_data().begin_parse();
;; The wallet fetches Alice's public key from its state
;; It's stored when Alice deploys her wallet
;; This is usually done automatically by most wallets (e.g. TonKeeper)
;; during the first transaction
int public_key = data_slice~load_uint(size::public_key);
;; ...
int is_signature_valid = check_signature(slice_hash(signed_slice), signature, public_key);
;; further wallet's logic ......
}
As you can see, the logic is pretty straightforward:
Split the message into signed_slice
(payload) and signature
Hash the payload: slice_hash(signed_slice)
Take the owner's public key from the contract's state
Call check_signature(hash, signature, public_key)
And check_signature
is actually an alias from stdlib.fc
:
;; EDDSA signature check
int check_signature(int hash, slice signature, int public_key) asm "CHKSIGNU";
You can read more about TVM instructions here
To implement a contract or a wallet in TON that can send assets and invoke operations based on an "EVM signer", we need to have a way to recover an ECDSA signature. Actually, there is a dedicated TVM instruction for that! Meet ECRECOVER
:
Recovers public key from signature, identical to Bitcoin/Ethereum operations. Takes 32-byte hash as uint256
hash
; 65-byte signature as uint8v
and uint256r
,s
. Returns0
on failure, public key and-1
on success. The 65-byte public key is returned as uint8h
, uint256x1
,x2
.
Here's the flow that we'll use:
Encode TON external message as bytes
Sign it using ECDSA
Prepend/Append it to the message
Check the signature using ECRECOVER
If the signature matches the expected sender, invoke the operation (e.g., send TON coins)
Let's say we want to implement a custody contract that allows us to send TON based on an ECDSA signature signed from Metamask. Then we can use the following external message body:
cell
> signature: 65 bytes -> slice of 520 bits
> cell_ref:
> cell: arbitrary payload
If you think āwtf is a cell?!ā, you can read this.
As an example, I'll use ZetaChain's cross-chain Gateway implementation.
cell auth::ecdsa::external(slice message, slice expected_evm_address) inline {
;; 1. Get signature
slice signature = message~load_bits(size::signature_size);
;; 2. Get payload cell
throw_if(error::no_signed_payload, message.slice_refs_empty?());
cell payload = message~load_ref();
;; 3. Calculate payload hash
int payload_hash = cell_hash(payload);
;; 4. Check signature
int sig_check = check_ecdsa_signature(payload_hash, signature, expected_evm_address);
if (sig_check != true) {
~strdump("check_ecdsa_signature");
sig_check~dump();
throw(error::invalid_signature);
}
return payload;
}
crypto.fc
;; Returns keccak256 hash of the data as uint256
int hash_keccak256(builder b) asm "1 INT HASHEXT_KECCAK256";
;; ECRECOVER FunC wrapper
(int, int, int, int) ecdsa_recover(int hash, int v, int r, int s) asm "ECRECOVER";
;; TVM uses `v` ONLY as `0` or `1`. In ETH/BTC, a prefix is used. See RFC6979
;; See https://bitcoin.stackexchange.com/questions/38351/ecdsa-v-r-s-what-is-v
int normalize_ecdsa_recovery_id(int v) inline {
;; "compressed recovery_id for pub key"
if v >= 31 {
return v - 31;
}
;; "uncompressed recovery_id for pub key"
if v >= 27 {
return v - 27;
}
return v;
}
;; Checks ECDSA signature. Returns int as an outcome:
;; 1: unable to recover public key
;; 2: recovered key is not uncompressed
;; 3: recovered address does not match the expected address
;; -1 (true): signature is valid
(int) check_ecdsa_signature(int hash, slice signature, slice expected_evm_address) impure inline_ref {
;; 1 Parse (v, r, s)
int v = signature~load_uint(8).normalize_ecdsa_recovery_id();
int r = signature~load_uint(256);
int s = signature~load_uint(256);
;; 2. Recover public key
(int h, int x1, int x2, int flag) = ecdsa_recover(hash, v, r, s);
if flag != true {
return 1;
}
;; Deny compressed public keys (0x04 prefix means uncompressed)
if h != 4 {
return 2;
}
;; 3. Derive 20 bytes evm address from the public key
int pub_key_hash = begin_cell()
.store_uint(x1, 256)
.store_uint(x2, 256)
.hash_keccak256();
slice actual_evm_address = begin_cell()
.store_uint(pub_key_hash, 256)
.end_cell()
.begin_parse()
.slice_last(20 * 8);
;; 4. Compare with the expected address
if equal_slices(expected_evm_address, actual_evm_address) == false {
return 3;
}
return true;
}
And thatās a wrap. Congrats for reading this. ECDSA on TON ā because why settle for one chain when you can bridge them all?