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 uint8Āv
Ā and uint256Ār
,Ās
. ReturnsĀ0
Ā on failure, public key andĀ-1
Ā on success. The 65-byte public key is returned as uint8Āh
, uint256Āx1
,Ā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?