Writing an Arbitrum bridge bot with Clojure =========================================== .. post:: Jul 14, 2026 :tags: programming :category: superposition :author: me :location: Adelaide :language: en I recently wrote a simple bot for Superposition -> Arbitrum ERC20 transfers using the standard Ethereum bridge. We have some great bridging providers, but we needed something that took users out of the chain in a pinch, so this was the best approach we felt at the time. The process works like the following, using a mixture of Superposition's ingesting infrastructure and our Arbitrum node: 1. Get tickets by `listening for the L2 to L1 events using our ingestor `_. 2. Have a view that checks another table to see if we redeemed the amount in the past in Postgres. So, if we redeem a ticket, then we need to record that we did so in a special table, or record that we failed. The view should check whether we're included there to know if we need to help them bridging out. 3. Get the last assertion that was sent on to Arbitrum from Superposition. This is the AssertionConfirmed event. I searched the latest events on the fly using `eth_getLogs` on Arbitrum, and my node let me go back 9950 blocks. Once we have the assertion, we need the sendCount field, for when we construct the outbox proof. 4. Create an outbox proof using the send count in the block hash that's in the field for the assertion confirmed event. This is simply calling the Arbsys precompile with the `constructOutboxProof(uint64,uint64)(bytes32,bytes32,bytes32[])` method. 5. Send `executeTransaction(bytes32[],uint256,address,address,uint256,uint256,uint256,uint256,bytes)` with the outbox proof. I used Clojure at first for this bot. Eventually I would just make a simple Plan9 rc script, but this was a fun journey to play around with, as Clojure is not an experience I've had in a professional capacity properly. I used Opus 4.6 to develop the frontend, and it made use of `@arbitrum/sdk `_. This was in simple Typescript, and we elected to build the bot separately. The good """""""" I set up with my editor live editing with acmeclj. I was able to set up middle click to execute against my Leinegen server. This was pretty nice! It felt more interactive, which is great, since that's the approach I prefer with development when there are side effects. I was able to make many simple functions and use Rich Comments to test parts of it in development. Clojure has some great hygiene and you can tell that generally it's a great language, and that its developers know what they're doing. I would be very happy to write with Clojure full time, though I wonder about static typing. This is having taken OCaml's Hindley Miller types in different domains for over a decade. The bad """"""" I should have eschewed web3j (the default JDM toolchain for working with the EVM RPC surface) and used raw RPC calls for searching out. It did not support the extra sendCount field that Arbitrum gives you. I needed to build a class in Java to do this properly. I wound up asking for help from Opus 4.6 with Hermes on this one after poking around. It made passing around my service type unwieldly in the end, to use this type I needed to pass around the raw URL to construct the underlying type to make the request. Byte handling was tough for me. Admittedly, I don't have familiarity with this domain so much, but it was hard to pass around the type for the raw calldata, and also Web3j doesn't enforce any type constraint here. I'm thankful for Clojure's bean function and how easy it is to develop in the REPL here. Decoding the return types from the function calls then passing it around to Clojure was tough. There is a library out there that makes this easier, but I tried to rawdog the whole thing to see what it was like using the Java interop. I tried to use byte manipulation to avoid defining any abis a la Go, but I couldn't figure this out easily without string manipulation. I feel that wrapping Web3j in an abstraction that makes it easier to work with with Clojure would be better. But for now, I'm on the clock, and I blew out my development time budget. I was also too scared to try the secrets handling/sending in the end. What's next? """""""""""" I will develop in Clojure in the future. I definitely feel I understand things better now to try again, but for now, I ended up with this Rc script. It fails a lot, but it works fine for our purposes. In our production deployment we have a harness that checks with our heartbeats: .. code-block:: rc #!/usr/bin/env -S rc -e url_arb= url_spn= db_spn=$SPN_TIMESCALE_URL key_sender=$SPN_TIMESCALE_KEY fn outstanding_markets { psql -c '\copy (select transaction_hash, position, caller, destination, arb_block_num, eth_block_num, timestamp, data from points_9lives_unresolved_arb_sys_l2_to_l1_1) to stdout csv' $db_spn } fn track_resolved_bridge_out { og_hash=$1 new_hash=$2 psql \ -c 'INSERT INTO points_9lives_resolved_bridge_1 (arb_sys_hash, resolution_tx_hash) values ('''$og_hash''', '''$new_hash''')' \ $db_spn } fn track_failed_bridge_out { og_hash=$1 psql \ -c 'INSERT INTO points_9lives_resolved_bridge_1 (arb_sys_hash) values ('''$og_hash''')' \ $db_spn } fn last_assertion_confirmed_block_hash { cur_block=`{cast bn --rpc-url $url_arb} early_block=`{echo $cur_block - 9950 | bc} r=`{ cast logs \ --json \ --rpc-url $url_arb \ --from-block $early_block \ --address 0xf3C4a84a948658D012C915Ad4bb4b501F6C3c075 \ 0xfc42829b29c259a7370ab56c8f69fce23b5f351a9ce151da453281993ec0090c \ | jq -r '.[0].data' } if(~ $r 'null') { echo logs not available >[1=2] exit 0 } cast abi-decode 'Block()(bytes32,bytes32)' $r \ | head -n1 } hash=`{last_assertion_confirmed_block_hash} last_block_send_count=`{cast block --json --rpc-url $url_spn $hash | jq -r .sendCount} for(m in `{outstanding_markets}) { transaction_hash=`{echo $m | cut -f1 -d,} position=`{echo $m | cut -f2 -d,} caller=`{echo $m | cut -f3 -d,} destination=`{echo $m | cut -f4 -d,} arb_block_num=`{echo $m | cut -f5 -d,} eth_block_num=`{echo $m | cut -f6 -d,} timestamp=`{echo $m | cut -f7 -d,} data=`{echo $m | cut -f8 -d,} outbox_proof=[`{ cast call \ --json \ --rpc-url $url_spn \ 0x00000000000000000000000000000000000000c8 \ 'constructOutboxProof(uint64,uint64)(bytes32,bytes32,bytes32[])' \ $last_block_send_count \ $position \ | jq -r '.[2] | @csv' \ | sed 's/[ "]//g' }] echo trying $transaction_hash >[1=2] resolution_hash=`{ cast send \ --json \ --private-key $key_sender \ --rpc-url $url_arb \ 0xa4b3B4D5f7976a8D283864ea83f1Bb3D815b1798 \ 'executeTransaction(bytes32[],uint256,address,address,uint256,uint256,uint256,uint256,bytes)' \ $outbox_proof \ $position \ $caller \ $destination \ $arb_block_num \ $eth_block_num \ $timestamp \ 0 \ $data \ | jq -r .transactionHash } if(~ $resolution_hash '') { track_failed_bridge_out $transaction_hash echo unable to resolve $transaction_hash >[1=2] } if not { track_resolved_bridge_out $transaction_hash $resolution_hash echo $transaction_hash $resolution_hash >[1=2] } } Thank you to members of the Clojure Discord who took time answering my questions about development here. This is the end result with the Clojure code, warning, it's bad, and incomplete. If I started again from scratch it would be cleaner, and nothing would be hardcoded: .. code-block:: clojure (ns spn-bridge-out-bot.core (:gen-class) (:import [org.web3j.protocol.http HttpService] [org.web3j.protocol Web3j] [org.web3j.protocol.core Request DefaultBlockParameterName DefaultBlockParameterNumber] [org.web3j.protocol.core.methods.request Transaction EthFilter] [org.web3j.abi FunctionEncoder] [org.web3j.abi.datatypes Function Address DynamicArray DynamicBytes] [org.web3j.abi.datatypes.generated Uint256 Bytes32] [org.web3j.utils Numeric] [com.spn_bridge_out_bot ArbitrumBlock])) (require '[next.jdbc :as jdbc] '[clojure.string :as str]) (def CHAIN-ID 42161) (def ARB-OUTBOX "0xa4b3B4D5f7976a8D283864ea83f1Bb3D815b1798") (def ARB-ROLLUP "0xf3C4a84a948658D012C915Ad4bb4b501F6C3c075") (def NODE-INTERFACE "0x00000000000000000000000000000000000000c8") (def TOPIC-ASSERTION-CONFIRMED "0xfc42829b29c259a7370ab56c8f69fce23b5f351a9ce151da453281993ec0090c") (def ZERO-ADDR "0x0000000000000000000000000000000000000000") (defn hex-decode "0x-prefixed (or bare) hex string -> BigInteger." ^BigInteger [^String s] (-> s (str/replace-first #"^0x" "") (BigInteger. 16))) (defn biginteger->hex-str "BigInteger -> bare (no 0x) lowercase hex string." ^String [^BigInteger x] (.toString x 16)) (defn bigint-left-pad "x (coercible to biginteger) -> 64-char zero-padded hex word (no 0x)." ^String [x] (format "%064x" (biginteger x))) (defn hex->words "Split a 0x-prefixed hex string into a vector of 32-byte (64 hex char) word strings, each without a 0x prefix." [^String hex] (let [body (str/replace-first hex #"^0x" "")] (mapv #(apply str %) (partition 64 body)))) (defn word->biginteger "A 64-hex-char word (no 0x) -> BigInteger." ^BigInteger [^String word] (BigInteger. word 16)) (defn word->bytes "A 64-hex-char word (no 0x) -> 32-byte byte[]." ^bytes [^String word] (Numeric/hexStringToByteArray word)) (defn web3-client [url] (-> url HttpService. Web3j/build)) (defn web3-estimate-gas "Returns the estimated gas as an int (or nil)." [client from target cd] (some-> (Transaction/createEthCallTransaction from target cd) (->> (.ethEstimateGas client)) .send .getResult hex-decode .intValue)) (defn web3-call "eth_call at LATEST. Returns the beaned response (use :result for the return data)." [client from target cd] (some-> (Transaction/createEthCallTransaction from target cd) (->> (#(.ethCall client % DefaultBlockParameterName/LATEST))) .send bean)) (defn web3-get-latest-block-number [client] (-> client (.ethGetBlockByNumber DefaultBlockParameterName/LATEST false) .send .getResult .getNumber)) (defn web3-get-assertion-confirmed-logs "Get the latest AssertionConfirmed events, assuming Arbitrum. Gets the current block then winds it back 9950 blocks." [client] (let [latest-no (web3-get-latest-block-number client) early-block (biginteger (max 0 (- latest-no 9950)))] (-> (EthFilter. (DefaultBlockParameterNumber. early-block) DefaultBlockParameterName/LATEST ARB-ROLLUP) (.addSingleTopic TOPIC-ASSERTION-CONFIRMED) (->> (#(.ethGetLogs client %))) .send .getLogs (->> (map bean))))) (defn web3-latest-assertion-confirmed-block-hash-str "The confirmed L2 block hash as a 0x-prefixed hex string (needed for eth_getBlockByHash). AssertionConfirmed(bytes32 indexed assertionHash, bytes32 blockHash, bytes32 sendRoot) -> blockHash is the first non-indexed word of `data`." ^String [client] (if-let [last-log (last (web3-get-assertion-confirmed-logs client))] (subs (:data last-log) 0 66) ; 0x + 64 (throw (ex-info "No logs were returned" {:no-logs true})))) (defn web3-latest-assertion-confirmed-block-hash "Same value as a 32-byte byte[]." ^bytes [client] (Numeric/hexStringToByteArray (web3-latest-assertion-confirmed-block-hash-str client))) (defn web3-get-send-count "sendCount from the block-hash lookup, re-instantiating the HttpService. Returns BigInteger." ^BigInteger [url ^String block-hash] (-> (Request. "eth_getBlockByHash" [block-hash true] (HttpService. url) ArbitrumBlock) .send .getArbitrumBlock .getSendCount hex-decode)) (defn construct-outbox-proof [spn-url arb-c position] (let [latest-send-count (web3-get-send-count spn-url (web3-latest-assertion-confirmed-block-hash-str arb-c)) cd (str "0x42696350" (bigint-left-pad latest-send-count) (bigint-left-pad position)) raw (:result (web3-call arb-c ZERO-ADDR NODE-INTERFACE cd)) words (hex->words raw) offset-bytes (.intValue (word->biginteger (nth words 2))) len-word-idx (quot offset-bytes 32) proof-len (.intValue (word->biginteger (nth words len-word-idx))) first-el-idx (inc len-word-idx) proof-words (subvec words first-el-idx (+ first-el-idx proof-len))] {:send (word->bytes (nth words 0)) :root (word->bytes (nth words 1)) :proof (mapv word->bytes proof-words)})) (defn construct-outbox-proof-words "Just the bytes32[] proof as a vector of byte[] (32 bytes each)." [spn-url arb-c position] (:proof (construct-outbox-proof spn-url arb-c position))) (defn- ->bytes "byte[] or hex string -> byte[]." ^bytes [x] (cond (bytes? x) x (string? x) (Numeric/hexStringToByteArray (if (str/starts-with? x "0x") x (str "0x" x))) :else (throw (ex-info "cannot coerce to bytes" {:val x})))) (defn encode-execute-transaction ^String [proof-words position caller destination arb-block-num eth-block-num timestamp value data] (let [proof-arr (mapv #(Bytes32. (->bytes %)) proof-words) data-bytes (->bytes data) fn (Function. "executeTransaction" [(DynamicArray. Bytes32 proof-arr) (Uint256. (biginteger position)) (Address. caller) (Address. destination) (Uint256. (biginteger arb-block-num)) (Uint256. (biginteger eth-block-num)) (Uint256. (biginteger timestamp)) (Uint256. (biginteger value)) (DynamicBytes. data-bytes)] [])] (FunctionEncoder/encode fn))) (comment (let [spn-url "" arb-c (web3-client "")] (web3-get-send-count spn-url (web3-latest-assertion-confirmed-block-hash-str arb-c)))) (defn -main "Get any outstanding bridge requests, take the fields needed to construct an outbox proof via the RPCs, then simulate sending them out on the outbox." [] (let [res (with-open [conn (jdbc/get-connection {:dbtype "postgres" :dbname "defaultdb" :user "tsdbadmin" :password "" :host "127.0.0.2" :port 8889})] (jdbc/execute! conn ["select position, caller, destination, arb_block_num, eth_block_num, timestamp, data from points_9lives_unresolved_arb_sys_l2_to_l1_1"])) spn-url "" arb-c (web3-client "")] (doseq [{position :points_9lives_unresolved_arb_sys_l2_to_l1_1/position caller :points_9lives_unresolved_arb_sys_l2_to_l1_1/caller destination :points_9lives_unresolved_arb_sys_l2_to_l1_1/destination arb-block-num :points_9lives_unresolved_arb_sys_l2_to_l1_1/arb_block_num eth-block-num :points_9lives_unresolved_arb_sys_l2_to_l1_1/eth_block_num timestamp :points_9lives_unresolved_arb_sys_l2_to_l1_1/timestamp data :points_9lives_unresolved_arb_sys_l2_to_l1_1/data} res] (let [proof-words (construct-outbox-proof-words spn-url arb-c position) cd (encode-execute-transaction proof-words position caller destination arb-block-num eth-block-num timestamp 0 data)] (println cd (web3-call arb-c ZERO-ADDR ARB-OUTBOX cd)))))) The class for looking up Arbitrum looks like this: .. code-block:: java package com.spn_bridge_out_bot; import org.web3j.protocol.core.methods.response.EthBlock; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; public class ArbitrumBlock extends EthBlock { @JsonIgnoreProperties(ignoreUnknown = true) public static class CustomBlockData extends EthBlock.Block { private String sendCount; public String getSendCount() { return sendCount; } public void setSendCount(String x) { this.sendCount = x; } } @Override @JsonDeserialize(as = CustomBlockData.class) public void setResult(EthBlock.Block result) { super.setResult(result); } public CustomBlockData getArbitrumBlock() { return (CustomBlockData) getBlock(); } }