The past week, I have been tinkering with a new Soroban project.
It's been quite a learning experience, both in Soroban and in frontend.
I'll talk you through how I built my app, and share some of my lessons and thoughts.
The code (and a large readme) can be found on github
Reverse Dutch Auction
What I've made is a Reverse Dutch Auction.
I am still missing a few cards from my RPCiege collection, so I want to find a way to acquire the missing ones.
With a Reverse Auction, a buyer can:
-Set a (low) starting price
-Define how much and how often the price will increase over time
A seller can then interact with the auction, to sell their card/asset when the price is high enough for them.
However, if they wait too long, someone else may take the offer!
Soroban
To set this up in Soroban, I've made a contract with 5 functions.
setup_auction
The first function is for setting up the auction.
It takes in some arguments, and returns a Status (or an error)
The arguments define what's being bought and sold, and how the price behaves.
For example: I (host) will buy 1 RPCiege card (auction_asset) and pay in XLM (counter_asset). My starting bid will be 20 XLM (starting_bid), and I will increase it with 10 XLM (bid_incr_amount) every 17000 ledgers (about 1x per day) (bid_incr_interval).
I will increase it 10 times (bid_incr_times).
That means I will pay at least 20 XLM, up to 120 XLM after 10 days.
The function starts out with some sanity checks:
The host must be authorised: in effect, that means the address/account we provide must sign the transaction. After all, they will provide the 'money' to buy the asset with.
Then we check if the contract was already initialised/started.
We do that by seeing if there is already a Datakey for State set in storage.
If there is, we return an Status::AuctionAlreadyInitialised.
This lets users know what's up, without a full error.
Then, we check if the bid is actually positive. We don't want any weird negative prices being paid.
Next, we ensure that the contract will have it's rent paid for the duration of the auction.
In Soroban, entries need to pay rent to stay on the ledger.
This avoids bloating the ledger with old and irrelevant data.
In this case, I've put all the contract's data in the .instance()
storage.
That means any bump/extend_ttl operation will pay the rent for our data and the contract itself.
Then, we transfer the counter_asset from the buyer to the contract:
That means the contract acts as broker, and the funds are guaranteed to be available for a seller.
Note that we use the try_transfer
method. If it were to fail, we can catch that error.
Then, we save all the information we have received and calculated.
I've made a custom struct AuctionData to hold all that information.
That makes it easier to pass that object around, for example into and out of storage. It's probably not the most efficient way though.
We store that AuctionData struct into the instance()
storage.
We also store a datakey to show that the auction is now Running.
Next, we emit an event.
Events can be monitored via the Soroban RPC servers.
We emit all information needed to calculate how the bid changes over time, so that sellers don't need to invoke the contract.
Then finally, we return a Status to the user, to let them know the auction has started.
Since a Soroban contract/transaction will execute atomically, any errors will result in the whole function failing/reverting.
No need to worry whether any funds were transferred if the contract fails to save it's data. It's all or nothing!
get_bid_info
The second function in the auction contract is get_bid_info().
This one doesn't take any parameters!
It does return another custom struct: BidInfo.
This functions does the basic arthritics to determine what the price is at this moment.
The price increase happens not strictly on a time base, but on the closing of ledgers. A ledger will generally close every 5 or 6 seconds.
First, we do a bit of state checking:
If there is no State datakey set, the auction has not been setup, so we throw an error.
And similar, if the State is anything other than Running, we also throw an error.
Then, we ask the Soroban env what the current ledger is. We also retrieve our AuctionData from storage:
After some basic calculations, we stumble on some mildly interesting (to me) Rust.
I want to have two different flows, based on if the price has reached its maximum or not.
But if we declare the variables inside the scope of the if, we can't use them outside it.
So, we declare (but don't assign values to) the variables first.
Then, we go on to calculate what the price is now, when it will increase, and when it will reach the maximum:
Finally, we create a BidInfo struct with all this information, and return that.
We also emit an event, for anyone interested. It saves them a contract invoke.
sell_asset
Next up is the function to be used by a seller.
They call the sell_asset function, which requires their address as parameter:
We then make sure the state of the auction is right, and that the seller is authorised (has signed the transaction):
To proceed with the sale, we retrieve the auction data, but we also need to know what the current bid is.
Good thing we already wrote a function to get that bid information!
We call the contracts own get_bid_info() function with Self::get_bid_info(env.clone()).unwrap().current_bid;
We pass it a clone of the current env, then take the current bid value out.
It's not the most efficient way. We spend some cycles to calculate things we don't need, we only use the .current_bid
But, it saves me from writing that specific part of code.
And this function will only rarely be used: once per auction. So, I accept that overhead.
We go on to transfer the funds:
The seller sends 1 stroop of the NFT asset to to contract, and the contract returns the current price.
Again, this happens all at once, or not at all.
The way Soroban is built ensures that execution can not stop after the first transfer. If the second transfer fails, none of the operations take place.
We emit an event with the sell price, set the auction state to Fulfilled and return a Status message to the seller, and that's it for this function.
close_auction
The organiser of the auction (the buyer) has the option to close the auction down again.
They can do this before or after a seller sells their asset.
If it's done before, the auction is Aborted, the funds return to the buyer and the auction becomes inactive.
If it's done after the sale took place, it goes slightly different.
The NFT is transferred to the buyer, along with any left-over funds.
Then, the auction is set to Closed
We start again with some checks on the state of the auction:
Then, we load the auction data and make sure the host/organiser is signing the transaction:
If the auction is running, we return all the funds to the buyer, before setting the status to Aborted.
Notice how we get the balance: we cross contract call the contract of the funds, and ask it for the balance of our contract.
It's possible to avoid this by keeping track of the balance ourselves.
But, that also opens up chances for errors and bugs.
Again, I'll take that overhead cost and use this method of getting the balance.
If the auction was Fulfilled, we use two transfers.
One for the NFT, and one for any remaining funds (if any, we check to see if that is > 0).
All that remains is to set the state to Closed and return the buyer the good news:
reset_auction
For demonstration purposes, I've added a reset function to the auction.
If it has been closed, we remove the storage information.
Effectively, this means the auction contract can be reset again.
For here, the interesting part is to show how we clear the storage:
We only have the two datakeys, one for State and one for AuctionData.
We simply remove them.
Note though, the information can still be found by people going back through ledger histories. So don't put any secrets in a public ledger, thinking you can remove them later :)
That concludes the Rust code used in the Soroban Reverse Auction.
The readme on github has a detailed step-by-step guide on how to compile(build), deploy and interact with this contract yourself.
It also has files and instructions for hosting a local copy of the interactive demo, so you can experiment with your own auction contract.
I'll spare you the details of the many hours spent getting that demo working. I'll summarize:
Happy coding!