Decoding an Ethereum Transaction Webhook Payload

Who is this tutorial for:

  • Users receiving an Ethereum Webhook and those who want to use the co-signing-service example to write custom rules for signing transaction
  • Anyone interested in how to parse the Ethereum transaction payload

Introduction

For this tutorial we will be looking at how we can pull apart an Ethereum transaction that a co-signing-service could use to implement specific rules. In this example our co-signing-service interacts with the Gnosis Safe (docs).

Setup Process

Image a the following has been setup:

  1. You have a TrustVault account
  2. You have connected MetaMask to your TrustVault account
  3. You have setup a webhook
  4. You have an API key to interact with the TrustVault SDK
  5. You have registered your external private key to act as a signer on your wallet. This means that no transactions are sent from TrustVault unless your external key signs them
  6. You have defined some rules on which transactions to sign
  7. You have written your code and setup a co-signing-service of your own to listen to the webhook
  8. You have created a transaction in Metamask via any DApp (In this example we’re using Gnosis Safe)
  9. Your co-signing-service has received a webhook from TrustVault informing you that a new transaction has been created

Once we receive the Webhook we can look deeper at what the payload would look like and how we can see what its trying to do. To see an example of the payload for a transaction would be see Sample Ethereum Transaction Created Event Object.

Our current Webhooks provide the full ethereum transaction data payload.
e.g.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
{
"version": "1.0.1",
"type": "ETHEREUM_TRANSACTION_CREATED",
"payload": {
"assetSymbol": "ETH",
"chain": "ETHEREUM",
"signData": {
"transaction": {
"nonce": 10,
"gasPrice": "172000000000",
"gasLimit": "51303",
"chainId": 3,
"v": 3,
"to": "0x7FC6F18fa1461189aB89732d2ECAaD988E1Be26A",
"fromAddress": "0x6fe668915B32A1364FE63386b4E3cE919b267540",
"value": "0",
"data": "0xa9059cbb000000000000000000000000671e96593Ea93bfcb510375f4CeC111d0E5cf1b800000000000000000000000000000000000000000000000caf67003701680000",
"decodedInput": {
"id": "0xa9059cbb",
"signature": "transfer(address to, uint256 amount)",
"params": [
{
"name": "to",
"type": "address",
"value": "0x671e96593ea93bfcb510375f4cec111d0e5cf1b8"
},
{
"name": "amount",
"type": "uint256",
"value": "0xcaf67003701680000"
}
]
}
},
"hdWalletPath": [
"0x8000002c",
"0x8000003c",
"0x80000000",
"0x0",
"0x0"
],
"unverifiedDigestData": {
"transactionDigest": "7ebf00d9345b2f2217c125afc633f623d2253a2b092992032f0d6a5f9003a62a",
"signData": "303f04207ebf00d9345b2f2217c125afc633f623d2253a2b092992032f0d6a5f9003a62a301b0205008000002c0205008000003c02050080000000020100020100",
"shaSignData": "0bdb1e2b1c75e16c887c5186f734c6c049324f9a951aed7b43bc7c19138cce9c"
}
},
"requestId": "25127cea-dbc7-6d89-ca6c-199fdaeb03c6",
"subWalletId": {
"type": "ETH",
"index": 0,
"id": "7a9e053c-47fc-489b-956f-2aafbe7d2ff0"
},
"trustId": "e7aac98f-d5c2-4adf-a47a-564aa05ce554",
"subWalletIdString": "7a9e053c-47fc-489b-956f-2aafbe7d2ff0/ETH/0"
},
"messageId": "d913c151-f04b-47ca-a935-0949e4c27cb1",
"timestamp": 1611935455856,
"isoTimestamp": "2021-01-29T15:50:55.856Z"
}

Gnosis Safe

In order to understand the contract call we need to understand how the Gnosis safe works.

The safe uses the Proxy Delegate pattern to invoke methods on a common contract shared by all Safe instances (to avoid the high gas cost associated with deploying the actual safe contract).

So to interact with the Safe you invoke methods on GnosisSafeProxy.sol that simply forwards these requests to a masterCopy using the delegatecall message call. This pattern basically treats the masterCopy as library code in the context of the proxy. The current masterCopy is v1.1.1 deployed on mainnet here.

To further complicate matters the multi-sig nature of the Gnosis Safe can be handled both on- and off-chain. Consider, for example, a simple 2-of-2 safe with keys A and B. There are a number of alternative ways in which a transaction exection can proceed:

  1. A and B both sign the transaction off-chain (the format can be generated directly or a call can be made to the smart contract to encode the data on the clients behalf). Some party is responsible for collating these two signatures and submitting the execTransaction call. This could be A or B, but doesn’t necessarily have to be. The transaction could even be sent by a gas relay network as outlined in EIP-1613 with no direct involvement from A or B after they have signed
  2. A signs and forward their signature to B. B submits the execTransaction call directly and their signature is considered pre-validated as they signed the call directly
  3. A signs the transaction and uses the approveHash method to submit the signature on-chain. B then submits the execTransaction call with both A and B‘s addresses marked as pre-validated

(Theoretically both A and B can call approveHash and then someone else submit the transaction with pre-validated signatures for A and B, although I don’t see a scenario where this would be of use as A or B can simply call execTransaction instead of approveHash and save some gas)

Here is an example Gnosis Safe invocation taken from etherscan (this field would also be present in a TrustVault Webhook for an ethereum transaction that needs signing. See: Sample Ethereum Transaction Created Event Object and look at the data field):

Field Value
to 0x5791b08b3f51e80903af7a694392b793dbd9ca38
value 0x0
data 0x6a761202000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000009f3c000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000001c00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ba339b8271afea71
3008314487dd98f8d941720f00000000000000000000000000000000000000000000000000000002540be400000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000c3827868e9e02b2d7557db27cf7aef0b21e26a32b20b110c
814f853c5948c6ba9a5b576241b59280334f04ce5bc385e42eaf1320b5099643a73089f847c42ec46f1b000000000000000000000000ca14b0714e0e9e19bc4
a90c2af377d8000f0a113000000000000000000000000000000000000000000000000000000000000000001df862fe32b0bd3ff342134cb51570134168e1d81
271ad449d9b3df8a24075d9b407496c6a4ce22265fc0b8f273ac5c129ba20070948d8cf5e2c619df99aae0bf200000000000000000000000000000000000000
000000000000000000000

In this transaction the to address is the address of a Gnosis safe proxy contract. This proxy contract points to the 1.1.1 version of the masterCopy described above. There is no value for this call, but the data payload contains an execTransaction call that is forwarded to the masterCopy.

To break the arguments down further I’ve included a slightly more readable version of the data field and annotated the various arguments. (The arguments are encoded according to the Solidity Contract ABI Specification):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Function: execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 dataGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) ***

MethodID: 0x6a761202
[0]: 000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7 <= to
[1]: 0000000000000000000000000000000000000000000000000000000000000000 <= value
[2]: 0000000000000000000000000000000000000000000000000000000000000140 v data (encoded later)
[3]: 0000000000000000000000000000000000000000000000000000000000000000 <= operation
[4]: 0000000000000000000000000000000000000000000000000000000000009f3c <= safeTxGas
[5]: 0000000000000000000000000000000000000000000000000000000000000000 <= gasPrice
[6]: 0000000000000000000000000000000000000000000000000000000000000000 <= gasToken
[7]: 0000000000000000000000000000000000000000000000000000000000000000 <= refundReceiver
[8]: 0000000000000000000000000000000000000000000000000000000000000000 <= dataGas
[9]: 00000000000000000000000000000000000000000000000000000000000001c0 v signatures (encoded later)
[10]: 0000000000000000000000000000000000000000000000000000000000000044 <= data
[11]: a9059cbb000000000000000000000000ba339b8271afea713008314487dd98f8
[12]: d941720f00000000000000000000000000000000000000000000000000000002
[13]: 540be40000000000000000000000000000000000000000000000000000000000
[14]: 00000000000000000000000000000000000000000000000000000000000000c3 <= signatures
[15]: 827868e9e02b2d7557db27cf7aef0b21e26a32b20b110c814f853c5948c6ba9a
[16]: 5b576241b59280334f04ce5bc385e42eaf1320b5099643a73089f847c42ec46f
[17]: 1b000000000000000000000000ca14b0714e0e9e19bc4a90c2af377d8000f0a1
[18]: 1300000000000000000000000000000000000000000000000000000000000000
[19]: 0001df862fe32b0bd3ff342134cb51570134168e1d81271ad449d9b3df8a2407
[20]: 5d9b407496c6a4ce22265fc0b8f273ac5c129ba20070948d8cf5e2c619df99aa
[21]: e0bf200000000000000000000000000000000000000000000000000000000000

Notice that the embedded data and signatures field are of variable length and so are included after the fixed size arguments.

To find out more about this safe we can find out it’s owners by executing a call on the contract (0xa0e67e2b is the function selector for getOwners() - you can always validate this yourself):

curl -X POST -H ‘Content-Type: application/json’ –data ‘{“jsonrpc”:”2.0”,”method”:”eth_call”,”id”:1,”params”:[{“to”:”0x5791b08B3F51e80903af7a694392B793DBd9CA38”,”data”:”0xa0e67e2b”}, “latest”]}’ https://cloudflare-eth.com/

1
{"jsonrpc":"2.0","id":1,"result":"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005000000000000000000000000ad8627895a7ea6b9fb8fe0b219d4eda3a6ce45f6000000000000000000000000fbc652036cee0bd4a70ad7993a0dcc217863b1990000000000000000000000009bdfd96efb2a24d87f13eef2770ed071a9373f9f000000000000000000000000ca14b0714e0e9e19bc4a90c2af377d8000f0a113000000000000000000000000c80b893cf169310c6dbe3e3245fb9fbb9dcf40a8"}

This response uses the same ABI encoding format. The first element of the result is of variable length (address[]) and so we encode the offset within the data to find the result (simple given that this is the only data in the result!), then the length of the array (5), followed by 5 distinct addresses.

I’ll leave figuring out the threshold of this multi-sig as an exercise for the reader (hint, change 8 characters in the curl command above!).

Now we know this is a 3-of-5 multi-sig vault we can go back and look at the transaction arguments in more detail:

Field Value Description
to 0xdac17f958d2ee523a2206206994597c13d831ec7 This is the Tether USD ERC-20 contract
value 0x0 This transaction is sending no value
data 0xa9059cbb
000000000000000000000000ba339b8271afea713008314487dd98f8d941720f
00000000000000000000000000000000000000000000000000000002540be4
An ABI encoded invocation to send to the Tether contract (in this case, to send 1,000,000,000
TUSD to address 0xba339b8271afea713008314487dd98f8d941720f)
operation 0x0 0x0 = CALL, 0x1 = DELEGATECALL
safeTxGas
gasPrice
gasToken
refundReceiver
dataGas
0x9f3c
0x0
0x0
0x0
0x0
Can be used to pay senders in gas relay networks (see above)
signatures 0x827868e9e02b2d7557db27cf7aef0b21e26a32b20b110c814f853c5948c6ba9a
5b576241b59280334f04ce5bc385e42eaf1320b5099643a73089f847c42ec46f
1b
000000000000000000000000ca14b0714e0e9e19bc4a90c2af377d8000f0a113
0000000000000000000000000000000000000000000000000000000000000000
01
df862fe32b0bd3ff342134cb51570134168e1d81271ad449d9b3df8a24075d9b
407496c6a4ce22265fc0b8f273ac5c129ba20070948d8cf5e2c619df99aae0bf
20
This request contains 3 signatures. The first and third are ECDSA signatures that would have
been collected offline. The second is a pre-validated signature of the sender,
0xca14b0714e0e9e19bc4a90c2af377d8000f0a113.

We can assume that the 3 signatures on the transaction satisfied the 3-of-5 multi-sig of the target safe and so the Tether transaction was correctly forwarded on to the ERC-20 contract and executed.

Summary

Once we have understood the details of the transactions we can then use them in rules that a co-signing-service can use to decide whether or not to sign the transaction. We could use any one of the parameters against a set of rules to decide if we sign the transaction. Can we:

  • Only sign transactions to certain addresses? Yes
  • Only sign transactions to to certain method calls? Yes
  • Only sign transactions of a certain value? Yes

We get access to the full set of parameters and this opens up a whole new world of rules and rules configuration.

In our next tutorial we’ll show how you can take this newly understood transaction and decide if we want to sign it based on some predefined rules.