On the Sonic testnet, you can do daily tasks that include swaps of a set of the official ERC-20 tokens dispersed from the Sonic faucet.
The DEX has the UI but no instructions on how to automate the swaps (obviously). The contract involved in the swap is not verified, which means we have no contract source and don’t have the contract ABI, which in turn adds another obstacle in achieving our objective.
This guide is a quick & fun walkthrough on how to approach the problem, investigate it, and ultimately achieve the automatic swaps.
Going to the swap, account, faucet (all in one place) page, you can see that what you can do there is:
Request different testnet coins (CORAL, ONYX, and so on)
Swap them in the interface
But you of course don’t want to do any of that manually, so let’s FAFO and try to automate the coin swapping. Will implement a round-robin approach in which can just continuously swap coins to farm points.
Once you have requested all the coins for swapping, do a sample swap. As a reminder, here’s he swap, account, faucet.
The sample swap transaction will reveal an unverified contract address for which you have no source (which is understandable — why publish it and let people farm). Here’s the address: 0x086D426f8B653b88a2d6D03051C8b4aB8783Be2b . The explorer is a paid implementation by the etherscan, a team that produces the best explorers in the world. It was very nice of the Sonic team do run an etherscan version, which is extremely convenient in everything, including a built-in decompiler.
So just go ahead on the Contract tab, hit the Decompile Bytecode button.
This will produce a bunch of output, including a familiar name—UniswapV2Library. So, it’s Uniswap V2 fork, which means 0x086D426f8B653b88a2d6D03051C8b4aB8783Be2b is likely the router address. And any token exchange going through the router does the actual exchange on an LP address for the token pair.
And we already know the token addresses, since they are in the faucet and you can request them. And you can also look through all the exchanges going on there on the address: 0x086D426f8B653b88a2d6D03051C8b4aB8783Be2b and see that the function signature used for the swaps is 0xddba27a7.
To be able to script the actual swaps, we need the LP addresses of the tokens.
Here’s what we know so far:
Router (a modification of Uniswap V2): 0x086D426f8B653b88a2d6D03051C8b4aB8783Be2b
Token addresses on the faucet:
DIAM: 0x30BF3761147Ef0c86E2f84c3784FBD89E7954670
CORAL: 0xAF93888cbD250300470A1618206e036E11470149
FLE: 0x9Fa14D267d331c9E8BB7979bcDC212136915eCE8
MACH: 0x50971F8978C431D560ff658a83a8a03fdf199055
OBS: 0x3e6eE2F3f33766294C7148bc85c7d145E70cBD9A
ONYX: 0xE73c4f6A0A3B0EF8337fD080b76C08172b3eB958
A token swap involves at least four addresses:
Router
TokenA
TokenB
LP address Knowing that, let’s create a Python script that scan through blocks for transactions the router address and our token addresses and prints the LP address associated with each swap:
import jsonimport sysfrom web3 import Web3from eth_abi import decodefrom hexbytes import HexBytes# Sonic Blaze Testnet configurationRPC_URL ="SONIC_BLAZE_NODE"ROUTER_ADDRESS ="0x086d426f8b653b88a2d6d03051c8b4ab8783be2b"SWAP_METHOD_ID ="0xddba27a7"# Method ID for the swap functionSWAP_METHOD_ID_BYTES =bytes.fromhex(SWAP_METHOD_ID[2:])# Convert to bytes for comparison# Define start block directly in the script from which to start scanningSTART_BLOCK =24069000# Replace with your desired start block# Token addresses to trackTOKENS ={"DIAM":"0x30BF3761147Ef0c86E2f84c3784FBD89E7954670","CORAL":"0xAF93888cbD250300470A1618206e036E11470149","FLE":"0x9Fa14D267d331c9E8BB7979bcDC212136915eCE8","MACH":"0x50971F8978C431D560ff658a83a8a03fdf199055","OBS":"0x3e6eE2F3f33766294C7148bc85c7d145E70cBD9A","ONYX":"0xE73c4f6A0A3B0EF8337fD080b76C08172b3eB958"}# Normalize addresses to lowercase for comparisonROUTER_ADDRESS = ROUTER_ADDRESS.lower()TOKENS ={k: v.lower()for k, v in TOKENS.items()}# Connect to the Sonic Blaze Testnetw3 = Web3(Web3.HTTPProvider(RPC_URL))ifnot w3.is_connected():print("Failed to connect to the Sonic Blaze Testnet") sys.exit(1)print(f"Connected to Sonic Blaze Testnet. Chain ID: {w3.eth.chain_id}")defextract_addresses_from_input(input_data):""" Extract all Ethereum addresses from the input data. This is a more robust approach that doesn't rely on specific offsets.""" addresses =[]# Remove '0x' prefix if presentif input_data.startswith('0x'): data = input_data[2:]else: data = input_data# Scan through the data looking for potential addresses# An Ethereum address is 20 bytes (40 hex chars)for i inrange(0,len(data)-40,2):# Step by 2 because each byte is 2 hex chars# Check if this could be an address (look for leading zeros that pad addresses in ABI encoding) potential_address_with_padding = data[i:i+64]iflen(potential_address_with_padding)==64and potential_address_with_padding.startswith('000000000000000000000000'): address ='0x'+ potential_address_with_padding[24:64]# Basic validation: check if it looks like an addressifall(c in'0123456789abcdefABCDEF'for c in address[2:]): addresses.append(address.lower())return addressesdefdecode_swap_input(input_data):""" Decode the input data of a swap transaction"""try:# Extract all addresses from the input data addresses = extract_addresses_from_input(input_data)ifnot addresses:print("No addresses found in input data")returnNone# Filter addresses to find our tokens token_addresses =[]for addr in addresses:if addr.lower()in TOKENS.values(): token_addresses.append(addr.lower())# The last address is typically the LP address lp_address =Nonefor addr inreversed(addresses):if addr.lower()notin TOKENS.values(): lp_address = addr.lower()breakreturn{"path": token_addresses,"lp_address": lp_address}except Exception as e:print(f"Error decoding input data: {e}")returnNonedefscan_blocks(start_block):""" Scan blocks for swap transactions involving the specified tokens. Continues until LP addresses are found forall tokens."""# Track LP addresses for each token token_lp_map ={token_addr:set()for token_addr in TOKENS.values()}# Keep track of tokens that still need LP addresses tokens_remaining =set(TOKENS.values())print(f"Scanning blocks starting from {start_block}...")print(f"Looking for LP addresses for {len(tokens_remaining)} tokens...") block_num = start_blockwhile tokens_remaining:if block_num %100==0:print(f"Processing block {block_num}...")try: block = w3.eth.get_block(block_num, full_transactions=True)for tx in block.transactions:# Check if transaction is to the router contractifhasattr(tx,'to')and tx.to and tx.to.lower()== ROUTER_ADDRESS:# Check if it's a swap transaction by comparing the first 4 bytes (method ID)if tx.input[:4]== SWAP_METHOD_ID_BYTES:# Get transaction hash tx_hash = tx.hash.hex() decoded = decode_swap_input(tx.input.hex())if decoded and decoded["lp_address"]and decoded["path"]:# Check if any of our tracked tokens are in the pathfor token_addr in decoded["path"]:if token_addr in tokens_remaining: token_name =next((name for name, addr in TOKENS.items()if addr == token_addr), token_addr)# Add LP address to the map token_lp_map[token_addr].add(decoded["lp_address"])# Print only if this is the first LP address found for this tokeniflen(token_lp_map[token_addr])==1:print(f"Block {block_num}, TX {tx_hash}: Found LP for {token_name}: {decoded['lp_address']}")# Remove token from remaining list tokens_remaining.remove(token_addr)print(f"Remaining tokens to find: {len(tokens_remaining)}")# Move to the next block block_num +=1except Exception as e:print(f"Error processing block {block_num}: {e}")# Continue to the next block even if there's an error block_num +=1print(f"Found LP addresses for all tokens after scanning to block {block_num-1}")return token_lp_mapdefmain(): token_lp_map = scan_blocks(START_BLOCK)print("\n--- Summary of LP Addresses ---")for token_addr, lp_addresses in token_lp_map.items(): token_name =next((name for name, addr in TOKENS.items()if addr == token_addr), token_addr)if lp_addresses:print(f"\n{token_name} ({token_addr}):")for lp in lp_addresses:print(f" LP: {lp}")else:print(f"\n{token_name} ({token_addr}): No LP addresses found")if __name__ =="__main__": main()
Make sure you put:
RPC_URL — the actual Sonic Blaze testnet endpoint URL that you can get from Chainstack.
START_BLOCK — go for eg 300 blocks in the past
The script will keep running until it finds the LP addresses.
It will print something like this:
Connected to Sonic Blaze Testnet. Chain ID: 57054Scanning blocks starting from 24069000...Looking for LP addresses for6 tokens...Processing block 24069000...Block 24069018, TX 02fd4c396e9d146eab07b6427a712644fac14cdbc85ea52615f5144484df7c81: Found LP for CORAL: 0x0db7087e050ef022ec1d0432e983ff42506cc990Remaining tokens to find: 5Block 24069039, TX b4995dabb7c74ea1bf01834b6c1b305b426905d27b9bd82dc2a55c44050069d7: Found LP for MACH: 0xf88e70b3e1f848c55781297329093e8b15969908Remaining tokens to find: 4Block 24069078, TX 9a9680eb2487b8d6d4bd226a624104bdec76a2dff08ae2d90219aa68f2e70642: Found LP for FLE: 0xce86c8d81d24dcfe54d29409afeffde81852b8adRemaining tokens to find: 3Block 24069085, TX a86f4b4f3d46e0a1fae4c369ae5d2541cc43c5d83ee889205d98da5283d401fa: Found LP for ONYX: 0x7d5be487743f73264d6aa4ae202b6103078cd1a8Remaining tokens to find: 2Processing block 24069100...Processing block 24069200...Block 24069235, TX c6bcd8d9d783aef33819343b46924b33f67b9115a5ab45b5eaa56500caaf1174: Found LP for DIAM: 0x9785f7336ccfdd863ffc8179761f51f81e45bdf4Remaining tokens to find: 1Processing block 24069300...Processing block 24069400...Processing block 24069500...Processing block 24069600...Block 24069659, TX 3a0f5f962ec06a8b91dd99128311f1e2ecc760de48fb1b7732bd04c8db43f6c7: Found LP for OBS: 0xd7d04d8a33b6e6eb42a2e0e273e0fe1f23f818fdRemaining tokens to find: 0Found LP addresses for all tokens after scanning to block 24069659--- Summary of LP Addresses ---DIAM (0x30bf3761147ef0c86e2f84c3784fbd89e7954670): LP: 0x9785f7336ccfdd863ffc8179761f51f81e45bdf4CORAL (0xaf93888cbd250300470a1618206e036e11470149): LP: 0x0db7087e050ef022ec1d0432e983ff42506cc990FLE (0x9fa14d267d331c9e8bb7979bcdc212136915ece8): LP: 0xce86c8d81d24dcfe54d29409afeffde81852b8adMACH (0x50971f8978c431d560ff658a83a8a03fdf199055): LP: 0xf88e70b3e1f848c55781297329093e8b15969908OBS (0x3e6ee2f3f33766294c7148bc85c7d145e70cbd9a): LP: 0xd7d04d8a33b6e6eb42a2e0e273e0fe1f23f818fdONYX (0xe73c4f6a0a3b0ef8337fd080b76c08172b3eb958): LP: 0x7d5be487743f73264d6aa4ae202b6103078cd1a8
Now that we have the LP addresses, and we know this is actually a modified Uniswap V2 contract, let’s implement a round-robin swap:
It swaps 1 ONYX to 1 CORAL. LP Address: 0x7D5bE487743F73264D6aA4Ae202B6103078cD1a8
Then it swaps 1 CORAL to 1 OBS. LP Address: 0xD7D04d8A33b6E6EB42a2e0e273e0fe1F23f818fD
Then it swaps 1 OBS to 1 ONYX. LP Addresss: 0xCE1c63381b03bd5f227C1cCfa71c5E93154f336F
And it starts all over again (1 ONYX to 1 CORAL and so on)
Also don’t forget that we need token approvals and token approval checks to do the actual swaps.
import jsonimport timeimport sysfrom web3 import Web3from eth_abi import encodefrom hexbytes import HexBytesimport requests# Sonic Blaze Testnet configurationRPC_URL ="SONIC_BLAZE_NODE"ROUTER_ADDRESS ="0x086d426f8b653b88a2d6d03051c8b4ab8783be2b"SWAP_METHOD_ID ="0xddba27a7"# Method ID for the swap signatureROUTER_ABI =[{"inputs":[{"name":"amountIn","type":"uint256"},{"name":"path_offset","type":"uint256"},{"name":"to_offset","type":"uint256"},{"name":"path","type":"address[]"},{"name":"lp","type":"address[]"}],"name":"swap","outputs":[{"name":"amounts","type":"uint256[]"}],"stateMutability":"nonpayable","type":"function","function_selector":"0xddba27a7"}]# Token addressesTOKENS ={"ONYX":"0xE73c4f6A0A3B0EF8337fD080b76C08172b3eB958","CORAL":"0xAF93888cbD250300470A1618206e036E11470149","OBS":"0x3e6eE2F3f33766294C7148bc85c7d145E70cBD9A"}# LP addresses for each swap pairLP_ADDRESSES ={"ONYX_CORAL":"0x7D5bE487743F73264D6aA4Ae202B6103078cD1a8","CORAL_OBS":"0xD7D04d8A33b6E6EB42a2e0e273e0fe1F23f818fD","OBS_ONYX":"0xCE1c63381b03bd5f227C1cCfa71c5E93154f336F"}# Swap configuration# IMPORTANT: Replace with your own private keyPRIVATE_KEY ="PRIVATE_KEY"CYCLES =4# Number of swap cycles (0 for infinite)AMOUNT =1# Amount to swap each timeDELAY =10# Delay between swaps in seconds# ERC20 ABI for token balance checking and approvalsERC20_ABI =[{"constant":True,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"type":"function"},{"constant":False,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"type":"function"},{"constant":True,"inputs":[{"name":"_owner","type":"address"},{"name":"_spender","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"type":"function"}]# Connect to the Sonic Blaze Testnetw3 = Web3(Web3.HTTPProvider(RPC_URL))ifnot w3.is_connected():print("Failed to connect to the Sonic Blaze Testnet") sys.exit(1)# Get the chain IDCHAIN_ID = w3.eth.chain_idprint(f"Connected to Sonic Blaze Testnet. Chain ID: {CHAIN_ID}")# Convert all addresses to checksum formatROUTER_ADDRESS = Web3.to_checksum_address(ROUTER_ADDRESS)TOKENS ={k: Web3.to_checksum_address(v)for k, v in TOKENS.items()}LP_ADDRESSES ={k: Web3.to_checksum_address(v)for k, v in LP_ADDRESSES.items()}# Derive wallet address from private keyaccount = w3.eth.account.from_key(PRIVATE_KEY)WALLET_ADDRESS = account.addressprint(f"Using wallet: {WALLET_ADDRESS}")defget_token_balance(token_name):""" Get the balance of a specific token for the wallet""" token_address = TOKENS[token_name] token_contract = w3.eth.contract(address=token_address, abi=ERC20_ABI) balance_wei = token_contract.functions.balanceOf(WALLET_ADDRESS).call() balance_eth = w3.from_wei(balance_wei,'ether')return balance_ethdefcheck_all_balances():""" Check and display balances forall tokens"""print("\n--- Current Token Balances ---")for token_name in TOKENS.keys(): balance = get_token_balance(token_name)print(f"{token_name}: {balance}")print("-----------------------------")defcheck_and_approve_token(token_name, amount):""" Check if the router has enough allowance to spend the token If not, approve the router to spend the token""" token_address = TOKENS[token_name] token_contract = w3.eth.contract(address=token_address, abi=ERC20_ABI)# Convert amount to Wei amount_wei = w3.to_wei(amount,'ether')# Check current allowance current_allowance = token_contract.functions.allowance(WALLET_ADDRESS, ROUTER_ADDRESS).call()if current_allowance < amount_wei:print(f"Approving {token_name} for the router...")# Approve a large amount to avoid frequent approvals# Using max uint256 value (2^256 - 1) max_amount =2**256-1# Build the approval transaction tx = token_contract.functions.approve( ROUTER_ADDRESS, max_amount).build_transaction({'from': WALLET_ADDRESS,'gas':100000,'gasPrice': w3.eth.gas_price,'nonce': w3.eth.get_transaction_count(WALLET_ADDRESS),'chainId': CHAIN_ID})# Sign and send the transaction signed_tx = w3.eth.account.sign_transaction(tx, PRIVATE_KEY) tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)print(f"Approval transaction sent: {tx_hash.hex()}")# Wait for the transaction to be mined receipt = w3.eth.wait_for_transaction_receipt(tx_hash)if receipt.status ==1:print(f"{token_name} approved successfully!")else:print(f"Failed to approve {token_name}!")returnFalsereturnTruedefcreate_swap_transaction(from_token, to_token, amount, lp_address):# Convert amount to Wei (assuming 18 decimals for all tokens) amount_wei = w3.to_wei(amount,'ether')# Create the path array with the token addresses path =[TOKENS[from_token], TOKENS[to_token]]# Function selector function_selector = SWAP_METHOD_ID[2:]# Remove '0x' prefix# Encode parameters# 1. amountIn (uint256) amount_hex =f"{amount_wei:064x}"# 2. path_offset (uint256) - fixed at 0x60 (96 in decimal) path_offset_hex ="0000000000000000000000000000000000000000000000000000000000000060"# 3. to_offset (uint256) - fixed at 0xc0 (192 in decimal) to_offset_hex ="00000000000000000000000000000000000000000000000000000000000000c0"# 4. path_length (uint256) - 2 tokens path_length_hex ="0000000000000000000000000000000000000000000000000000000000000002"# 5. path tokens (address[]) path_tokens_hex =""for token in path: path_tokens_hex +=f"000000000000000000000000{token[2:].lower()}"# 6. lp_length (uint256) - 1 LP lp_length_hex ="0000000000000000000000000000000000000000000000000000000000000001"# 7. lp_address (address) lp_address_hex =f"000000000000000000000000{lp_address[2:].lower()}"# Combine all parts data =f"0x{function_selector}{amount_hex}{path_offset_hex}{to_offset_hex}{path_length_hex}{path_tokens_hex}{lp_length_hex}{lp_address_hex}"# Debug printprint(f"Transaction data: {data}")print(f"From token: {TOKENS[from_token]}")print(f"To token: {TOKENS[to_token]}")print(f"LP address: {lp_address}")# Create transaction dictionary tx ={'from': WALLET_ADDRESS,'to': ROUTER_ADDRESS,'value':0,'gas':500000,'gasPrice': w3.eth.gas_price,'nonce': w3.eth.get_transaction_count(WALLET_ADDRESS),'data': data,'chainId': CHAIN_ID}return txdefsign_and_send_transaction(tx):""" Sign and send a transaction"""try:# Sign the transaction signed_tx = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)# Send the transaction tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)print(f"Transaction sent: {tx_hash.hex()}")# Wait for the transaction to be mined receipt = w3.eth.wait_for_transaction_receipt(tx_hash)if receipt.status ==1:print("Transaction successful!")else:print("Transaction failed!")# Try to get more information about the failuretry:# Try to get the transaction trace trace_data ={"jsonrpc":"2.0","method":"trace_transaction","params":[tx_hash.hex()],"id":1}# Make a direct RPC call to get trace data response = requests.post(RPC_URL, json=trace_data)if response.status_code ==200: trace_result = response.json()if'result'in trace_result and trace_result['result']:for trace in trace_result['result']:if'error'in trace:print(f"Transaction error: {trace['error']}")else:print("No trace data available")else:print(f"Failed to get trace data: {response.status_code}")except Exception as e:print(f"Error getting trace data: {e}")return receiptexcept Exception as e:print(f"Error sending transaction: {e}")returnNonedefperform_round_robin_swaps(cycles=CYCLES, amount=AMOUNT, delay=DELAY):""" Perform continuous round-robin swaps between ONYX, CORAL,and OBS Args: cycles: Number of complete cycles to perform (0for infinite) amount: Amount of tokens to swap each time delay: Delay in seconds between swaps"""# Define the swap sequence swap_sequence =[{"from":"ONYX","to":"CORAL","lp": LP_ADDRESSES["ONYX_CORAL"]},{"from":"CORAL","to":"OBS","lp": LP_ADDRESSES["CORAL_OBS"]},{"from":"OBS","to":"ONYX","lp": LP_ADDRESSES["OBS_ONYX"]}]# Approve all tokens before startingprint("Checking and approving tokens...")for token_name in TOKENS.keys():ifnot check_and_approve_token(token_name, amount):print(f"Failed to approve {token_name}. Aborting.")return# Wait a bit after approvals to ensure they're processedprint("Waiting 5 seconds for approvals to be fully processed...") time.sleep(5) cycle_count =0# Check initial balances check_all_balances()try:while cycles ==0or cycle_count < cycles:print(f"\n--- Starting cycle {cycle_count +1} ---")for swap in swap_sequence: from_token = swap['from'] to_token = swap['to']# Check balance before swap from_balance_before = get_token_balance(from_token) to_balance_before = get_token_balance(to_token)print(f"\nSwapping {amount}{from_token} to {to_token} via LP {swap['lp']}")print(f"Balance before swap: {from_token}: {from_balance_before}, {to_token}: {to_balance_before}")# Check if we have enough balanceif from_balance_before < amount:print(f"Insufficient {from_token} balance. Skipping this swap.")continue# Create and send the swap transaction tx = create_swap_transaction(from_token, to_token, amount, swap['lp']) receipt = sign_and_send_transaction(tx)# Check balance after swap from_balance_after = get_token_balance(from_token) to_balance_after = get_token_balance(to_token)print(f"Balance after swap: {from_token}: {from_balance_after}, {to_token}: {to_balance_after}")print(f"Change: {from_token}: {from_balance_after - from_balance_before}, {to_token}: {to_balance_after - to_balance_before}")# Wait between swapsif delay >0:print(f"Waiting {delay} seconds before next swap...") time.sleep(delay) cycle_count +=1print(f"Completed cycle {cycle_count}")# Check all balances at the end of each cycle check_all_balances()except KeyboardInterrupt:print("\nSwap process interrupted by user")# Show final balances check_all_balances()except Exception as e:print(f"\nError during swap process: {e}")# Show balances even if there was an error check_all_balances()defmain():print("=== Sonic Token Round-Robin Swap ===")print("This script will continuously swap between ONYX, CORAL, and OBS tokens")print(f"Configured for {CYCLES} cycles, {AMOUNT} tokens per swap, {DELAY}s delay between swaps")# Perform the swaps perform_round_robin_swaps()print("\nSwap process completed")if __name__ =="__main__": main()
Make sure you put:
RPC_URL — the actual Sonic Blaze testnet endpoint URL that you can get from Chainstack.
PRVATE_KEY — the key that holds the tokens you are going to swap.
CYCLES — how many cycles to run. 0 for infinite.
AMOUNT — the amount of each token to keep swapping.
DELAY — if you want to add a bit of a delay there and not keep blasting the swaps like a bot (which you are).
And there you have it. We’ve walked you through on how to make your life easier though FAFO. And honestly the sort of people that can investigate and automate this for points are the type of people that network onboarders may want — active and capable developers.