Skip to content

Commit 8382346

Browse files
authored
Support signing EIP 7702 transactions with a sign server, e.g., AWS KMS. (trustwallet#4363)
* Support signing EIP 7702 transactions with a sign server, e.g., AWS KMS. * chore: rename MaybeOtherAuthFields to AuthorizationCustomSignature
1 parent 1211b11 commit 8382346

File tree

6 files changed

+193
-45
lines changed

6 files changed

+193
-45
lines changed

android/app/src/androidTest/java/com/trustwallet/core/app/blockchains/ethereum/TestBarz.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ class TestBarz {
281281
}.build()
282282
}.build()
283283

284-
eip7702Authority = Ethereum.Authority.newBuilder().apply {
284+
eip7702Authorization = Ethereum.Authorization.newBuilder().apply {
285285
address = "0x117BC8454756456A0f83dbd130Bb94D793D3F3F7"
286286
}.build()
287287
}

rust/tw_evm/src/modules/tx_builder.rs

Lines changed: 92 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use crate::address::{Address, EvmAddress};
1313
use crate::evm_context::EvmContext;
1414
use crate::modules::authorization_signer::AuthorizationSigner;
1515
use crate::transaction::access_list::{Access, AccessList};
16-
use crate::transaction::authorization_list::{Authorization, AuthorizationList};
16+
use crate::transaction::authorization_list::{
17+
Authorization, AuthorizationList, SignedAuthorization,
18+
};
1719
use crate::transaction::transaction_eip1559::TransactionEip1559;
1820
use crate::transaction::transaction_eip7702::TransactionEip7702;
1921
use crate::transaction::transaction_non_typed::TransactionNonTyped;
@@ -23,8 +25,11 @@ use crate::transaction::UnsignedTransactionBox;
2325
use std::marker::PhantomData;
2426
use std::str::FromStr;
2527
use tw_coin_entry::error::prelude::*;
28+
use tw_encoding::hex::DecodeHex;
2629
use tw_hash::H256;
2730
use tw_keypair::ecdsa::secp256k1;
31+
use tw_keypair::ecdsa::secp256k1::Signature;
32+
use tw_keypair::KeyPairError;
2833
use tw_memory::Data;
2934
use tw_number::U256;
3035
use tw_proto::Common::Proto::SigningError as CommonError;
@@ -363,16 +368,6 @@ impl<Context: EvmContext> TxBuilder<Context> {
363368
payload: Data,
364369
to_address: Option<Address>,
365370
) -> SigningResult<Box<dyn UnsignedTransactionBox>> {
366-
let signer_key = secp256k1::PrivateKey::try_from(input.private_key.as_ref())
367-
.into_tw()
368-
.context("Sender's private key must be provided to generate an EIP-7702 transaction")?;
369-
let signer = Address::with_secp256k1_pubkey(&signer_key.public());
370-
if to_address != Some(signer) {
371-
return SigningError::err(SigningErrorType::Error_invalid_params).context(
372-
"Unexpected 'accountAddress'. Expected to be the same as the signer address",
373-
);
374-
}
375-
376371
let nonce = U256::from_big_endian_slice(&input.nonce)
377372
.into_tw()
378373
.context("Invalid nonce")?;
@@ -381,6 +376,10 @@ impl<Context: EvmContext> TxBuilder<Context> {
381376
.into_tw()
382377
.context("Invalid gas limit")?;
383378

379+
let to = to_address
380+
.or_tw_err(SigningErrorType::Error_invalid_params)
381+
.context("'to' must be provided for `SetCode` transaction")?;
382+
384383
let max_inclusion_fee_per_gas =
385384
U256::from_big_endian_slice(&input.max_inclusion_fee_per_gas)
386385
.into_tw()
@@ -393,37 +392,14 @@ impl<Context: EvmContext> TxBuilder<Context> {
393392
let access_list =
394393
Self::parse_access_list(&input.access_list).context("Invalid access list")?;
395394

396-
let authority: Address = input
397-
.eip7702_authority
398-
.as_ref()
399-
.or_tw_err(SigningErrorType::Error_invalid_params)
400-
.context("'eip7702Authority' must be provided for `SetCode` transaction")?
401-
.address
402-
// Parse `Address`
403-
.parse()
404-
.into_tw()
405-
.context("Invalid authority address")?;
406-
407-
let chain_id = U256::from_big_endian_slice(&input.chain_id)
408-
.into_tw()
409-
.context("Invalid chain ID")?;
410-
411-
let authorization = Authorization {
412-
chain_id,
413-
address: authority,
414-
// `authorization.nonce` must be incremented by 1 over `transaction.nonce`.
415-
nonce: nonce + 1,
416-
};
417-
let signed_authorization = AuthorizationSigner::sign(&signer_key, authorization)?;
418-
let authorization_list = AuthorizationList::from(vec![signed_authorization]);
395+
let authorization_list = Self::build_authorization_list(input, to)?;
419396

420397
Ok(TransactionEip7702 {
421398
nonce,
422399
max_inclusion_fee_per_gas,
423400
max_fee_per_gas,
424401
gas_limit,
425-
// EIP-7702 transaction calls a smart contract function of the authorized address.
426-
to: Some(signer),
402+
to,
427403
amount: eth_amount,
428404
payload,
429405
access_list,
@@ -664,4 +640,84 @@ impl<Context: EvmContext> TxBuilder<Context> {
664640
let signer = Address::with_secp256k1_pubkey(&signer_key.public());
665641
Ok(signer)
666642
}
643+
644+
fn build_authorization_list(
645+
input: &Proto::SigningInput,
646+
destination: Address, // Field `destination` is only used for sanity check
647+
) -> SigningResult<AuthorizationList> {
648+
let eip7702_authorization = input
649+
.eip7702_authorization
650+
.as_ref()
651+
.or_tw_err(SigningErrorType::Error_invalid_params)
652+
.context("'eip7702Authorization' must be provided for `SetCode` transaction")?;
653+
654+
let address = eip7702_authorization
655+
.address
656+
.parse()
657+
.into_tw()
658+
.context("Invalid authority address")?;
659+
660+
let signed_authorization =
661+
if let Some(other_auth_fields) = &eip7702_authorization.custom_signature {
662+
// If field `custom_signature` is provided, it means that the authorization is already signed.
663+
let chain_id = U256::from_big_endian_slice(&other_auth_fields.chain_id)
664+
.into_tw()
665+
.context("Invalid chain ID")?;
666+
let nonce = U256::from_big_endian_slice(&other_auth_fields.nonce)
667+
.into_tw()
668+
.context("Invalid nonce")?;
669+
let signature = Signature::try_from(
670+
other_auth_fields
671+
.signature
672+
.decode_hex()
673+
.map_err(|_| KeyPairError::InvalidSignature)?
674+
.as_slice(),
675+
)
676+
.tw_err(SigningErrorType::Error_invalid_params)
677+
.context("Invalid signature")?;
678+
679+
SignedAuthorization {
680+
authorization: Authorization {
681+
chain_id,
682+
address,
683+
nonce,
684+
},
685+
y_parity: signature.v(),
686+
r: U256::from_big_endian(signature.r()),
687+
s: U256::from_big_endian(signature.s()),
688+
}
689+
} else {
690+
// If field `custom_signature` is not provided, the authorization will be signed with the provided private key, nonce and chainId
691+
let signer_key = secp256k1::PrivateKey::try_from(input.private_key.as_ref())
692+
.into_tw()
693+
.context(
694+
"Sender's private key must be provided to generate an EIP-7702 transaction",
695+
)?;
696+
let signer = Address::with_secp256k1_pubkey(&signer_key.public());
697+
if destination != signer {
698+
return SigningError::err(SigningErrorType::Error_invalid_params).context(
699+
"Unexpected 'destination'. Expected to be the same as the signer address",
700+
);
701+
}
702+
703+
let chain_id = U256::from_big_endian_slice(&input.chain_id)
704+
.into_tw()
705+
.context("Invalid chain ID")?;
706+
let nonce = U256::from_big_endian_slice(&input.nonce)
707+
.into_tw()
708+
.context("Invalid nonce")?;
709+
710+
AuthorizationSigner::sign(
711+
&signer_key,
712+
Authorization {
713+
chain_id,
714+
address,
715+
// `authorization.nonce` must be incremented by 1 over `transaction.nonce`.
716+
nonce: nonce + 1,
717+
},
718+
)?
719+
};
720+
721+
Ok(AuthorizationList::from(vec![signed_authorization]))
722+
}
667723
}

rust/tw_evm/src/transaction/transaction_eip7702.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub struct TransactionEip7702 {
2121
pub max_inclusion_fee_per_gas: U256,
2222
pub max_fee_per_gas: U256,
2323
pub gas_limit: U256,
24-
pub to: Option<Address>,
24+
pub to: Address,
2525
pub amount: U256,
2626
pub payload: Data,
2727
pub access_list: AccessList,
@@ -143,7 +143,7 @@ mod tests {
143143
max_inclusion_fee_per_gas: U256::from(2_u32),
144144
max_fee_per_gas: U256::from(3_u32),
145145
gas_limit: U256::from(4_u32),
146-
to: Some(Address::from_str("0x0101010101010101010101010101010101010101").unwrap()),
146+
to: Address::from_str("0x0101010101010101010101010101010101010101").unwrap(),
147147
amount: U256::from(5_u32),
148148
payload: "0x1234".decode_hex().unwrap(),
149149
access_list: AccessList::default(),

rust/tw_evm/tests/barz.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,9 @@ fn test_biz_eip7702_transfer() {
437437
)),
438438
// TWT token.
439439
to_address: "0x4B0F1812e5Df2A09796481Ff14017e6005508003".into(),
440-
eip7702_authority: Some(Proto::Authority {
440+
eip7702_authorization: Some(Proto::Authorization {
441441
address: "0x117BC8454756456A0f83dbd130Bb94D793D3F3F7".into(),
442+
custom_signature: None,
442443
}),
443444
..Proto::SigningInput::default()
444445
};
@@ -518,8 +519,9 @@ fn test_biz_eip7702_transfer_batch() {
518519
wallet_type: Proto::SCWalletType::Biz,
519520
}),
520521
}),
521-
eip7702_authority: Some(Proto::Authority {
522+
eip7702_authorization: Some(Proto::Authorization {
522523
address: "0x117BC8454756456A0f83dbd130Bb94D793D3F3F7".into(),
524+
custom_signature: None,
523525
}),
524526
..Proto::SigningInput::default()
525527
};

rust/tw_tests/tests/chains/ethereum/ethereum_compile.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use tw_memory::test_utils::tw_data_helper::TWDataHelper;
1414
use tw_memory::test_utils::tw_data_vector_helper::TWDataVectorHelper;
1515
use tw_number::U256;
1616
use tw_proto::Ethereum::Proto;
17+
use tw_proto::Ethereum::Proto::{Authorization, AuthorizationCustomSignature, TransactionMode};
1718
use tw_proto::TxCompiler::Proto as CompilerProto;
1819
use tw_proto::{deserialize, serialize};
1920

@@ -83,6 +84,83 @@ fn test_transaction_compiler_eth() {
8384
assert_eq!(output.encoded.to_hex(), expected_encoded);
8485
}
8586

87+
#[test]
88+
fn test_transaction_compiler_eip7702() {
89+
let transfer = Proto::mod_Transaction::Transfer {
90+
amount: U256::encode_be_compact(0),
91+
data: Cow::default(),
92+
};
93+
let input = Proto::SigningInput {
94+
tx_mode: TransactionMode::SetCode,
95+
nonce: U256::encode_be_compact(0),
96+
chain_id: U256::encode_be_compact(17000), // Holesky Testnet
97+
max_inclusion_fee_per_gas: U256::encode_be_compact(20_000_000_000),
98+
max_fee_per_gas: U256::encode_be_compact(20_000_000_000),
99+
gas_limit: U256::encode_be_compact(50_000),
100+
to_address: "0x18356de2Bc664e45dD22266A674906574087Cf54".into(),
101+
transaction: Some(Proto::Transaction {
102+
transaction_oneof: Proto::mod_Transaction::OneOftransaction_oneof::transfer(transfer),
103+
}),
104+
eip7702_authorization: Some(Authorization {
105+
address: "0x3535353535353535353535353535353535353535".into(),
106+
custom_signature: Some(AuthorizationCustomSignature {
107+
nonce: U256::encode_be_compact(1),
108+
chain_id: U256::encode_be_compact(0), // chain id 0 means any chain
109+
signature: "08b7bfc6bcaca1dfd7a295c3a6908fea545a62958cf2c048639224a8bede8d1f56dce327574529c56f7f3db308a34d44e2312a11c89db8af99371d4fe490e55f00".into(),
110+
}),
111+
}),
112+
..Proto::SigningInput::default()
113+
};
114+
115+
// Step 2: Obtain preimage hash
116+
let input_data = TWDataHelper::create(serialize(&input).unwrap());
117+
let preimage_data = TWDataHelper::wrap(unsafe {
118+
tw_transaction_compiler_pre_image_hashes(CoinType::Ethereum as u32, input_data.ptr())
119+
})
120+
.to_vec()
121+
.expect("!tw_transaction_compiler_pre_image_hashes returned nullptr");
122+
123+
let preimage: CompilerProto::PreSigningOutput =
124+
deserialize(&preimage_data).expect("Coin entry returned an invalid output");
125+
126+
assert_eq!(preimage.error, SigningErrorType::OK);
127+
assert!(preimage.error_message.is_empty());
128+
assert_eq!(
129+
preimage.data_hash.to_hex(),
130+
"d5f3f94ff2c70686623c71ef9e367f341a3ef6691d02e9e9db671c9281d56432"
131+
);
132+
133+
// Step 3: Compile transaction info
134+
135+
// Simulate signature, normally obtained from signature server
136+
let signature = "601148c0af0108fe9f051ca5e9bd5c0b9e7c4cc7385b1625eeae0ec4bbe5537960d8c76d98279329d1ef9d79002ccddd53e522c0e821003590e74eba51a1c95601".decode_hex().unwrap();
137+
let public_key = "04ec6632291fbfe6b47826a1c4b195f8b112a7e147e8a5a15fb0f7d7de022652e7f65b97a57011c09527688e23c07ae9c83a2cae2e49edba226e7c43f0baa7296d".decode_hex().unwrap();
138+
139+
let signatures = TWDataVectorHelper::create([signature]);
140+
let public_keys = TWDataVectorHelper::create([public_key]);
141+
142+
let input_data = TWDataHelper::create(serialize(&input).unwrap());
143+
let output_data = TWDataHelper::wrap(unsafe {
144+
tw_transaction_compiler_compile(
145+
CoinType::Ethereum as u32,
146+
input_data.ptr(),
147+
signatures.ptr(),
148+
public_keys.ptr(),
149+
)
150+
})
151+
.to_vec()
152+
.expect("!tw_transaction_compiler_compile returned nullptr");
153+
154+
let output: Proto::SigningOutput =
155+
deserialize(&output_data).expect("Coin entry returned an invalid output");
156+
157+
assert_eq!(output.error, SigningErrorType::OK);
158+
assert!(output.error_message.is_empty());
159+
let expected_encoded = "04f8cc824268808504a817c8008504a817c80082c3509418356de2bc664e45dd22266a674906574087cf548080c0f85cf85a809435353535353535353535353535353535353535350180a008b7bfc6bcaca1dfd7a295c3a6908fea545a62958cf2c048639224a8bede8d1fa056dce327574529c56f7f3db308a34d44e2312a11c89db8af99371d4fe490e55f01a0601148c0af0108fe9f051ca5e9bd5c0b9e7c4cc7385b1625eeae0ec4bbe55379a060d8c76d98279329d1ef9d79002ccddd53e522c0e821003590e74eba51a1c956";
160+
// Successfully broadcasted: https://holesky.etherscan.io/tx/0x1c349e81dd135bfe104c8051fa9e668d6f8c0323abe852b1d1522b772932c0ec
161+
assert_eq!(output.encoded.to_hex(), expected_encoded);
162+
}
163+
86164
#[test]
87165
fn test_transaction_compiler_plan_not_supported() {
88166
let transfer = Proto::mod_Transaction::Transfer {

src/proto/Ethereum.proto

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,22 @@ message Access {
194194
repeated bytes stored_keys = 2;
195195
}
196196

197-
// [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authority.
198-
message Authority {
197+
// [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorization.
198+
message Authorization {
199199
// Address to be authorized, a smart contract address.
200200
string address = 2;
201+
// If custom_signature isn't provided, the authorization will be signed with the provided private key, nonce and chainId
202+
AuthorizationCustomSignature custom_signature = 3;
203+
}
204+
205+
// [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) authorization.
206+
message AuthorizationCustomSignature {
207+
// Chain id (uint256, serialized big endian).
208+
bytes chain_id = 1;
209+
// Nonce, the nonce of authority.
210+
bytes nonce = 2;
211+
// The signature, Hex-encoded.
212+
string signature = 3;
201213
}
202214

203215
// Smart Contract Wallet type.
@@ -261,10 +273,10 @@ message SigningInput {
261273
// Used in `TransactionMode::Enveloped` only.
262274
repeated Access access_list = 12;
263275

264-
// A smart contract to which we’re delegating to.
276+
// EIP7702 authorization.
265277
// Used in `TransactionMode::SetOp` only.
266278
// Currently, we support delegation to only one authority at a time.
267-
Authority eip7702_authority = 15;
279+
Authorization eip7702_authorization = 15;
268280
}
269281

270282
// Result containing the signed and encoded transaction.

0 commit comments

Comments
 (0)