Quickstart Tutorial
In this tutorial, we will learn how to build our first completely custom transaction monitoring agents using the Dedaub app. Writing monitoring agents using the Dedaub app is relatively simple once you get the hang of the paradigm. Monitoring agents are written in a SQL-based language called DedaubQL. However, unlike SQL, the aim is to write queries that, in practice, do not return results if no exceptional behavior is detected. When results are returned by monitoring queries, each returned result is delivered as an individual alert.
For our first custom agent, we will be writing a monitoring agent to detect when:
- A potential attacker withdraws funds from Tornado Cash.
- This attacker subsequently deploys a smart contract.
- Such smart contract references our project.
As in all coding endeavors, the most successful strategy to build new agents is to abstract away individual problems optimally. This means splitting a larger problem into smaller ones in such a way as to exploit the advantages of such a system.
In this case, it makes sense to first create a query that creates a table of all Tornado Cash Recipients. Approximately, the structure of this table will be as follows:
Tornado Cash Recipients
address |
---|
<user1> |
<user2> |
... |
Where entries like <user1>
are Ethereum addresses.
Creating your first query
Let's start by creating a query via the monitoring panel and let's give this query the name "Tornado Cash Recipients" inside a new folder called "Tutorial".
The query editor
The query editor is the main stage of the transaction monitoring facility.
Here, you can write, run, and save queries. In the current example queries are tied to a single network, but this restriction can be lifted.
The query editor shows the query, and the results appear in the lower panel for testing purposes.
Defining Tornado Cash Recipients
Now, let's define the Tornado Cash Recipients
query from scratch. Go to the query editor, type the following and then save it.
Make sure to choose Ethereum
as the default network.
select recipient as address
from {{ transaction_detail(
'0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b.withdraw(address, bytes, bytes32, bytes32, address recipient, address, uint256, uint256)'
) }} _
You can try to execute the query by pressing Run
. You will see the results at the bottom of the editor. This functionality is useful for testing your query. However, simply pressing run does not deploy it as a monitoring agent. We shall see later how this can be achieved.
The transaction_detail table gadget
You may have noticed that the query looks like SQL but has some differences, such as expressions inside {{ ... }}
. We call these gadgets. In this particular case, there is a transaction_detail
table gadget.
In DedaubQL, table gadgets are macros that provide a convenient way to reference and manipulate blockchain data. They allow you to write more ergonomic and powerful queries without dealing with the underlying complexity of the data structures. In this example we see just one of the available gadgets
{{ transaction_detail() }}
: This gadget represents thetransaction_detail
table, which contains all inputs and outputs to every EVM stack frame, including calldata, returndata, and the current EVM step. This data does not rely on emitted events. By using this gadget, you can access the latest transaction trace details, relevant to your query.
In this particular example we do go through all EVM stack frames for all transactions. Instead we filter the transactions by a particular address and signature. Therefore, we will only get the transactions that are calls to the function withdraw(address, bytes, bytes32, bytes32, address, address recipient, uint256, uint256)
of the 0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b
contract.
Materialization
For our application, it turns out that we are interested in all Tornado Cash users, including ones that have used Tornado Cash before we defined this monitoring agent, and any possible users who start using Tornado Cash after we define our monitoring agent. In other words, we want this table stay updated with the blockchain state, with no apparent delay.
Monitoring has been designed with this goal in mind, and to solve this each query can employ automatic backfilling and incremental materialization, without needing to define different queries.
To enable these, click on the settings button.
Select "Incremental" materialization with "Backfilling", and set a Unique Key of address
. A frequency of 5 minutes is adequate for this example.
With these settings enabled, the application will compile the above query in two separate ways: once for backfilling purposes and once for incrementalization. For backfilling purposes, it will look at all past Tornado Cash transactions and find all recipients, using a database engine optimized for analytical queries. For incremental purposes, the query will be compiled for the latest transactions only, on a low-latency data source.
Subsequent executions will run the query to incrementally keep the results up to date.
The system will always ensure that queries referencing (i.e., make use of the results of) Tornado Cash Recipients
will be consistent in time.
Congratulations! You created your first query that tracks all Tornado Cash Recipients!. Let's make use of this data in a monitoring agent.
A more complicated example
It is possible to reference data that another query has created. For example let's create another query called Suspicious Tornado Cash Creations
.
In DedaubQL, we can easily create another table containing new contract creations, including their initialization code and constructor arguments, like so:
select t.calldata as code_and_args, t.to_a as contract_address
from {{ ref('Tornado Cash Recipients') }} r
join {{ transaction_detail() }} t on t.from_a = r.address
where t.call_opcode = 'CREATE'
Note again that this is not pure SQL. What is {{ ref('Tornado Cash Recipients') }}
?
Referential Gadgets (ref
)
{{ ref('...') }}
: This gadget is used to reference other queries or tables that you have access to, such as materialized queries.
In our example, {{ ref('Tornado Cash Recipients') }}
references a table containing addresses of users who have withdrawn from Tornado Cash.
You can reference either queries you've authored yourself or queries that others have made public or shared with your team.
This is useful for referencing materialized views, which enable more complex monitoring agents.
Example Explained:
In the query above, we're selecting the calldata
and contract_address
for transactions where a user from our Tornado Cash Recipients
table (r.address
) has created a new contract.
We join the transaction_detail
gadget on the condition that the transaction originates from an address in our recipients table.
The where
clause filters for transactions where the call_opcode
is 'CREATE'
, indicating a contract creation.
Note that when a smart contract is created, especially from compiled code like Solidity or Vyper, the initialization code is within the prefix of the calldata,
while the constructor arguments are within the suffix of the calldata.
This means that any information initialized in the contract will be visible in the calldata of a CREATE
EVM stack frame.
We'll use this data later to check whether any of this data matches a predefined pattern to alert us.
The complete example
Finally, let's update the query so that we are only interested in contracts created that reference a single project, e.g., Balancer.
Note: To define a new protocol, you may need a higher paid tier.
with tornado_funded_contract_code as materialized (
select t.calldata as code_and_args, t.to_a as contract_address
from {{ transaction_detail() }} t
join {{ ref('Tornado Cash Recipients') }} r on r.address = t.from_a
where t.call_opcode = 'CREATE'
)
select contract_address
from tornado_funded_contract_code tfc,
{{ contracts(protocols=['Balancer']) }} c
where position(c.address in tfc.code_and_args) > 0
In this query:
- We create a Common Table Expression (CTE) called
tornado_funded_contract_code
that materializes the data we're interested in. - We select contracts created by addresses in our
Tornado Cash Recipients
table. - We then check if any of these contracts reference addresses associated with the Balancer protocol
by searching for Balancer contract addresses within the
code_and_args
of the created contracts.
The contracts table macro
You probably noticed that another macro has been used.
{{ contracts() }}
: Thecontracts
macro is used to access the contracts belonging to a specified protocol, or all available contracts if no protocol is specified. Depending on the tier you are, you can or cannot define protocols that are collections of contract addresses
Compiled Example:
You can view the complete expanded version of your full query by pressing the Preprocess
button in the query editor
with tornado_funded_contract_code as materialized (
select t.calldata as code_and_args, t.to_a as contract_address
from ( select td.* from transaction_detail_1h td where committed and block_number > get_historical_block_number('5m'::interval) ) t
join tsql_materializations."6708aa3780c4247dfdafdb57c34d65ac_1" r on r.address = t.from_a
where t.call_opcode = 'CREATE'
)
select contract_address
from tornado_funded_contract_code tfc,
(select * from contracts join protocol_contract using (address) where protocol_id in (55)) c
where position(c.address in tfc.code_and_args) > 0
Where transaction_detail_1h
is the new transaction_detail
rows since the last time the system ran your query.
🕒 block_number | tx_index | vm_step_start | from_a | calldata | ... |
---|---|---|---|---|---|
18,000,000 | 4 | 987 | 0xBEEF... | 0x602080000... | ... |
18,000,000 | 5 | 0 | 0xDEF1... | 0x202060000... | ... |
tsql_materializations."6708aa3780c4247dfdafdb57c34d65ac_1"
is the name of the table created for the Tornado Cash Recipients
query.
Your version of the query will have a different name since each query has unique underlying tables.
The contract list macro
{{ contract_list('aave', 'contract_list_user') }}
: Thecontract_list
macro is used to access the contract list by specifying the list name and the list author as mentioned in the Lists page. You can also use the list auto suggest as you type the contract list macro to help navigate the available lists and its owners.
Alerting and Notification Methods
To turn the above query into a fully-fledged agent, we need to enable alerting in the settings of the query, as shown in the screenshot.
You can configure the notification channels that you wish to enable. The notification methods that we currently support are:
- Email notifications
- Webhooks
Try setting up your first email notification for this query!
The execution model
Now that we've got a glimpse of a couple of queries, the next step is to understand the execution model of the monitoring agents. The execution model of these queries is that of a monotonic queries executing on top of new blockchain data, applied per frequency period in temporal order. The main tables describing new time-series blockchain data are:
block
: Contains metadata about newly mined blocks.outer_transaction
: Contains metadata about an on-chain transaction, such as the initiator (from_a
), the amount of gas used, block number (block_number
), and index within this block (tx_index
).transaction_detail
: Contains all inputs and outputs to every EVM stack frame, including thecalldata
, thereturndata
, and the actual VM step in the current transaction at the beginning of the current EVM stack frame (vm_step_start
).logs
: Contains all the logs emitted recently, including references to the current EVM stack frame.token_ledger
: Contains double entries for all tokens or native cryptocurrency entering and exiting a specific blockchain address, along with references to the current EVM stack frame and VM step when the transfer took place.
These tables are overloaded as macros that implement Table Gadgets. These gadgets allow you to instantly extract high-level function input and output, restrict the blockchain data to specific addresses or signatures, or add temporal conditions to the newly arriving time-series blockchain data while remaining consistent with the actual blockchain state.
Table Gadgets
Table gadgets are macros that allow for ergonomic synthesis of queries that would otherwise be too cumbersome, clunky, or error-prone to write by hand. Furthermore, the use of gadgets is essential if you want to turn your query into a continuously running monitoring agent or materialized view.
Time-Series Table Gadgets
The time-series tables we saw in the previous sections are also overloaded as table gadgets. The difference between the raw blockchain data tables and their table gadget counterparts (which we will refer to as chain table gadgets) is that:
-
Chain table gadgets can take a duration argument that limits the time slice on which they operate. By default, the time slice will be equal to the frequency of the agent. However, when the query is being run from the editor, this can be set using the duration widget.
-
The first (optional) argument of the chain table gadgets is both a filtering and decoding expression. For instance, in the query below:
select owner, tx_hash(block_number, tx_index)
from {{ logs('0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552.AddedOwner(address owner)') }} _Only the logs pertaining to address
0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
will be returned, and from these, only the ones matching the signature ofAddedOwner(address)
. In addition, the event data for these kinds of events will be automatically decoded according to this signature, and the variableowner
in this case can be projected or selected in the rest of the query.
Additional Considerations:
-
Autocomplete Suggestions: Typing in the editor
logs('0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552.
launches autocomplete suggestions for that address, which facilitates the writing of table gadgets. -
Omitting Address or Signature: Both the address
0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
and the signatureAddedOwner(address)
can be omitted. If the address is omitted, all events emitted on-chain matching that signature are returned. If the signature is omitted, all the events pertaining to that address are returned, but only the raw event data is available since decoding the event requires the signature. -
Filtering Capabilities:
transaction_detail
andlogs
can be filtered by both address and signature, whiletoken_ledger
andouter_transaction
can only be filtered by address.
Congratulations! You 've successfully set up two queries and used the monitoring agents. You can browse the rest of the documentation on details for each of the features that we described. You can check the Quickstart Examples and public queries for real examples of monitoring agents