In the previous post we learned how to connect to the Snowsight mempool service, send a payment to keep our bot account in good credit, and subscribe to the Snowsight websocket to receive and watch simple two-token swaps on TraderJoe.
Snowsight offers another very useful service, a transaction propagator that accepts transactions directly from users, and relaying them to their validator nodes for fast mining.
Recall that mining is the process where a node processes transactions and proposes them in a certain order for final recording to the blockchain. All transactions on the blockchain pass through a miner, and on Avalanche these miners are called validators. So Snowsight’s propagator allows you get your transaction directly to them. This means lower latency and higher chance of success.
There’s a steep learning curve here, since the propagator is not an RPC. It cannot be queried for blockchain info and will not accept regular API calls. It only responds to an HTTP POST request using a signed key and a raw transaction.
So before we go down the rabbit hole, let’s review what a transaction actually is.
The Ethereum Virtual Machine operates entirely at the machine code level. While we’re all familiar with Solidity and Vyper, we must remember that these are high level languages that exist purely as a convenience for humans. They allow us to express ideas using familiar design patterns and share them. EVM has no idea what a uint256
is, for example, but it knows what to do with a series of binary values stored in a 256-bit storage slot.
Transactions are similar. EVM has no concept of a router or its various named functions. When we call the router function swapExactTokensForTokens()
, to use a familiar example, many things happen “under the hood” to convert that familiar function and its arguments into something that EVM can process.
We’ve been using Brownie exclusively until now, but here we will dive a little deeper into the fundamental Python library that powers it, web3py. Web3py is a key low level library that provides Brownie its ability to communicate with RPCs, encode and decode data, and work with both EVM-native and high level code.
There are two types: basic transactions and raw transactions. The difference between these is that basic transactions are unsigned (basically a dictionary with various key-value pairs) and raw transactions are signed and serialized (transformed using a particular private key and converted to hex data). A basic transaction does nothing special, and only a raw transaction may be broadcast and propagated to nodes on the network.
The Process: basic transaction (unsigned, unformatted) → raw transaction (signed, serialized) → blockchain (signed, serialized, confirmed to a block)
So now to the lesson! We will work with a concept we understand (a transaction with inputs), transform it to a raw transaction, then deliver it to the Snowsight propagator for final confirmation and recording to the blockchain.
To The Terminal
This lesson will take place entirely on the Brownie console, but with a twist! Instead of using the built-in web3 object that Brownie creates for us, we will import it the web3 library and use its low-level functions directly.
We’ll also use the requests
library for the first time.
WARNING: during this lesson I will frequently use the word “web3” throughout code with varying capitalizations. To avoid as much confusion as possible, I will use the web3
prefix for all of the web3py functions, classes and methods.
Start the Brownie console, connected to the Avalanche network.
(.venv) devil@hades:~/bots$ brownie console --network moralis-avax-main
Brownie v1.18.1 - Python development framework for Ethereum
No project was loaded.
Brownie environment is ready.
>>> import requests
>>> import web3
>>> import json
Pay Your Bill
IMPORTANT: The propagator will not accept a transaction from an account without a positive credit. Check prior to doing this lesson by loading the Snowsight contract in Brownie and confirming that the payments()
function returns True
and a block number far enough in the future that it will not expire before you finish. Avalanche block times typically average 1s each, and you can find the current block at Snowtrace’s gas tracker.
>>> snowsight = Contract.from_explorer('0xd9b1ee4ae46d4fe51eeaf644107f53a37f93352f')
Fetching source of 0xD9B1ee4AE46d4fe51Eeaf644107f53A37F93352f from api.snowtrace.io...
>>> snowsight.payments(degenbot)
(True, 14517503)
If your account is not active, call the pay()
function as in the previous lesson.
The Chainsight documentation tells us that to accept a transaction directly, we need to send an HTTP POST request to the URL http://tx-propagator.snowsight.chainsight.dev:8081
with a JSON-formatted data packet with two key-value pairs:
'signed_key'
, with a signed message signature in hex format (identical to the previous lesson)'raw_tx'
, with a signed raw transaction in hex format
Signing Keys
Generating the signature is easy, and mostly a repeat from the previous lesson:
>>> degenbot = accounts.load('degenbot')
Enter password for "degenbot":
>>> signed_message = degenbot.sign_defunct_message('Sign this message to authenticate your wallet with Snowsight.')
Now let’s get into the good stuff. We will execute an extremely simple transaction, wrapping some AVAX to WAVAX using the official Wrapped AVAX contract.
Here is where we start to blend Brownie and web3py.
First, create a Contract object in Brownie to get easy access to the address and ABI.
>>> wavax_brownie = Contract.from_explorer('0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7')
Fetching source of 0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7 from api.snowtrace.io...
Now let’s create a web3py object representing this same contract called wavax_web3
using the eth.contract()
method off the Web3()
class constructor. It requires two arguments, address
and abi
. We will use our Brownie object to pass these values along.
>>> wavax_web3 = web3.Web3().eth.contract(address=wavax_brownie.address, abi=wavax_brownie.abi)
Lots going on here, so let’s unpack a little. web3.Web3()
is the constructor to build a Web3
object. The Web3()
constructor accepts an argument provider
, which is typically used to connect the object to a node or RPC API. For example, if you wanted to build a bot using web3py directly, you would pass a Websocket or HTTP provider argument here.
When Web3()
is initalized without a provider, it allows you to perform offline tasks without needing a connection to the live blockchain. This is useful for us since signing our raw transaction is one of these offline tasks.
At the end of it, we end up with an object called wavax_web
with, among other things, a set of class methods called functions
that are populated by decoding the ABI. The function to wrap AVAX to WAVAX is called deposit
, and the function to unwrap WAVAX to AVAX is called withdraw
. Both become available under functions
after the object is created.
>>> dir(wavax_web3.functions)
[abi, address, allowance, approve, balanceOf, decimals, deposit, name, symbol, totalSupply, transfer, transferFrom, web3, withdraw]
So now let’s check the bot balance and decide how much AVAX to wrap.
>>> degenbot.balance()/(10**18)
0.694200000000000
As an example I will wrap 0.15 AVAX to WAVAX. The deposit()
function accepts all of the arguments that the deployed smart contract expects. Reviewing the contract, we find that deposit()
does not accept any arguments, but the function does expect an amount of AVAX to be sent along with it.
Build A Transaction
To build the transaction itself, we use the buildTransaction()
method off of deposit()
, which accepts a dictionary with key-value pairs. Please review the documentation for buildTransaction()
to see what key-value pairs the dictionary can hold.
In our case we want to specify the following items:
'from'
, the public address initiating this transaction'chainId'
, the ID of the blockchain (Avalanche uses 43114)'gas'
, the gas limit for the transaction'maxFeePerGas'
, the maximum fee per gas that we’ll allow for the transaction'maxPriorityFeePerGas'
, the priority fee per gas'nonce'
, the unique transaction number for this account (nonce = number used once)
Note that specifying 'maxFeePerGas'
and 'maxPriorityFeePerGas'
will cause web3py to generate a type 2 (EIP-1559) transaction. If you want to initiate a type 0 (legacy) transaction, omit both and specify 'gasPrice'
instead.
Now build the transaction and store it to a variable tx
:
>>> tx = wavax_web3.functions.deposit().buildTransaction({'from':degenbot.address, 'chainId':chain.id, 'gas':60000, 'maxFeePerGas':chain.base_fee, 'maxPriorityFeePerGas':0, 'nonce':degenbot.nonce, 'value':int(0.1*10**18)})
>>> tx
{
'chainId': 43114,
'data': "0xd0e30db0",
'from': [redacted],
'gas': 60000,
'maxFeePerGas': 85063358173,
'maxPriorityFeePerGas': 0,
'nonce': 512,
'to': "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7",
'type': "0x2",
'value': 150000000000000000
}
Convert to a Raw Transaction
Now sign it using the bot’s private key (which is managed by Brownie):
>>> signed_tx = web3.Web3().eth.account.sign_transaction(tx, degenbot.private_key)
Send It
Now the final step! Package the data packet up as a JSON-formatted dictionary, then hand it to requests
to deliver to the propagator.
>>> packet = {
"signed_key": signed_message.signature.hex(),
"raw_tx": signed_tx.rawTransaction.hex(),
}
>>> requests.post(
'http://tx-propagator.snowsight.chainsight.dev:8081',
data=json.dumps(packet)
)
<Response [200]>
Now check Snowtrace and confirm that the deposit went through. You might even experiment with using the withdraw()
function of the WAVAX contract. It accepts a single argument (amount), and requires no 'value'
to be sent.
Next Steps
Next lesson will dive deeper into using the mempool websocket to discover useful information about the network. I’ll also share a complete Snowsight bot that uses asynchronous loops to watch pending transactions, watch sync events to keep pools up-to-do, renew the subscription, and submit arbitrage transactions to the propagator. It will extremely dense so please spend time reviewing previous posts to get a handle on async programming with Python.