Compiles the contracts with Hardhat
Runs the deployment script with the network set in process.env.NETWORK
.
Starts a local Hardhat node with the localhost
network.
There are 5 main types of contracts along with supporting solidity libraries, which are located in the /contracts
folder.
- Token
Token.sol
- Vault
Vault.sol
- Market
Market.sol
andMarketCurved.sol
- Oracle
MarketOracle.sol
- Registry
Registry.sol
Horse Link issues 100 million standard ERC20 tokens HL / Horse Link for its members to be used in the future as a DAO governance token, distribution of protocol fees and other member perks.
The Vault contracts are ERC4626 contracts used to manage the underlying ERC20 assets of the LP Providers. They are used to store the assets of the users and to allow them to deposit and withdraw assets that get lent to Market contracts for a fee which is agreed upon in the setMarket
function. The users are minted a ERC20 share that represents their share of the underlying assets in the Vault.
The following is a worked example of the relationship between users' deposits and shares.
- Alice deposits 1000 DAI into the Vault and receives 1000 shares.
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Initial share price is 1 DAI so Alice receives 1000 shares.
- Bob deposits 1000 DAI into the Vault and receives 1000 shares.
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
Number of shares received when depositing is represented by the following equation:
shares received = total shares / total assets * deposit
So when Bob deposits 1000 DAI they receive 1000 shares:
shares received = 1000 / 1000 * 1000 = 1000
The Vault is now holding 2000 DAI in "totalAssets". If Alice redeems 500 shares, she will receive 500 DAI. Now, let's say Carol places a bet of 1800 DAI on a market that is backed by the Vault at 1:1. The Vault will lend 1800 DAI to the market. 200 DAI remain in the Vault and it will have a total exposure of 1800 DAI.
- Carol places bet of 1800 DAI at 1:1 odds
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
Vault (on Carol's behalf) | Lend | 1800 DAI | 0 | 200 DAI | 2000 |
Vault lends 1800 DAI to the market (to cover the exposure for Carol's bet) and has 200 DAI left in total assets.
While Carol's bet is active, Alice can redeem her 1000 shares if she chooses so, but the exchange rate per share will be significantly lower than 1 DAI per share. Her share is represented by the following equation:
share = totalAssets * shares / totalShares
Eg:
share = 200 * 1000 / 2000 = 100 DAI
Upon redeeming, Alice's shares would be burnt.
- Alice redeems 1000 shares for 100 DAI.
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
Vault (on Carol's behalf) | Lend | 1800 DAI | 0 | 200 DAI | 2000 |
Alice | Redeem | 100 DAI | (1000) | 100 DAI | 1000 |
Alice's shares are burnt, reducing her share balance to 0. The Vault will have 100 DAI in total assets and 1000 shares.
- Vault settles Carol's bet and has now 3700 DAI in total assets, while the total shares are still 1000 DAI.
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
Vault (on Carol's behalf) | Lend | 1800 DAI | 0 | 200 DAI | 2000 |
Alice | Redeem | 100 DAI | (1000) | 100 DAI | 1000 |
Market (revenue from Carol's losing bet) | Settle | 3600 DAI | 0 | 3700 DAI | 1000 |
Carol's bet is now settled and the Vault has an exposure of 0 DAI and has made 1800 DAI on the losing bet. The profit and exposure are returned to the Vault. The total assets in the Vault are now 3700 DAI.
If the bet was a winning bet, the market pays out the winning proposition. The performance of the Vault would be low, as the bettor has won assets from the Market, which will now not be returned to the Vault.
The performance of the Vault is the ratio of the shares to the assets. In the above example, the performance is: 3700 DAI / 1000 shares * 100 = 370% (3.7 DAI per share).
The donation attack (aka inflation attack) is a known exploit with ERC4626 vaults when the amount of underlying assets change without the amount of shares reflecting that. This makes all the share more (or less) valuable, which means that someone receiving shares will get less (or more) than they would have expected. The bigger the inflation (or deflation), the bigger the effect of the attack, which makes Horse Link particularly sensitive as the vaults balance can change significatly when funds are withdrawn to cover the exposure of bets.
In our use case, we discuss the possibility that an attacker could attempt to place a bet with high odds, draining the Vault, then deposit a large amount of assets into the Vault to skew the ratio of assets to shares.
- Assuming Alice and Bob have deposited into the Vault as shown in Vault example above
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
- Attacker makes a 10 DAI bet on runner with odds of 180:1
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
Vault (on attacker's behalf) | Lend | 1800 DAI | 0 | 200 DAI | 2000 |
Vault lends 1800 DAI to the market (to cover the exposure for the attackers's bet) leaving 200 DAI of total assets. Given the Vault now has 200 DAI in total assets but still 2000 shares, the performance of the Vault is 200 / 2000 = 0.1 devaluing each share making them comparatively cheap for the attacker to acquire.
- Attacker deposits 10,000 DAI and receives 100,000 shares
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
Vault (on attacker's behalf) | Lend | 1800 DAI | 0 | 200 DAI | 2000 |
Attacker | Deposit | 10000 DAI | 100000 | 10200 DAI | 102000 |
shares received = 2000 / 200 * 10000 = 100000
- The attacker loses their bet and it is settled, causing the market to return the profit and exposure.
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
Vault (on attacker's behalf) | Lend | 1800 DAI | 0 | 200 DAI | 2000 |
Attacker | Deposit | 10000 DAI | 100000 | 10200 DAI | 102000 |
Market (revenue from Attacker's losing bet) | Settle | 1810 DAI | 0 | 12010 DAI | 102000 |
- The attacker redeems their shares for 11774.51 DAI
User | Action | Amount | Shares | Total Assets | Total Shares |
---|---|---|---|---|---|
Alice | Deposit | 1000 DAI | 1000 | 1000 DAI | 1000 |
Bob | Deposit | 1000 DAI | 1000 | 2000 DAI | 2000 |
Vault (on attacker's behalf) | Lend | 1800 DAI | 0 | 200 DAI | 2000 |
Attacker | Deposit | 10000 DAI | 100000 | 10200 DAI | 102000 |
Market (revenue from Attacker's losing bet) | Settle | 1810 DAI | 0 | 12010 DAI | 102000 |
Attacker | Redeem | 11774.51 DAI | (100000) | 235.49 DAI | 2000 |
share = 12010 * 100000 / 102000 = 11774.51
The attacker spent 10 DAI to place the bet, 10000 depositing into Vault and was able to redeem 11774.51 resulting in a profit of 1764.51 DAI.
The total assets in the Vault is now 235.49 DAI giving a performance of: 235.49 DAI / 2000 shares * 100 = 11.77% (0.12 DAI per share) compared to before the attack when the performance was 2000 DAI / 2000 shares * 100 = 100% (1 DAI per share).
Market contracts define the logic in which they calculate the odds per event or market. Our protocol offers two types of market contracts, where the odds slippage calculation is either on a linear decay or a non-linear decay. The linear decay market Market.sol
is a simple market that calculates the odds based on the total assets in the Vault and the total exposure of the Vault. The non-linear decay market MarketCurved.sol
is more complex and is more expensive to calculate the odds, but offers smoother odds to its caller.
Non collateralised markets draw 100% of the lay collateral from the Vault.
o = O * O * w / (v + w)
Collateralised markets use the lay collateral in the total liquidity available to calculate the odds.
o = O - O * (w / (v + (sm - sp)))
where
o = Actual odds
O = Offered odds
v = Vault liquidity
w = Wager amount
sm = Sum of all wagers on that market
sp = Sum of all wagers on that proposition
The market contract implements the ERC721 standard to issue betslips as NFTs. Bets are settled by invoking the settle
function along with the respective NFT token ID once the MarketOracle.sol
result has been set. Should the Oracle not be updated within 30 days, the settle
function will pay out the proposition regardless. This prevents the market operator not to unfairly withhold users assets.
Markets can either be "Collateralised" or "Non-collateralised", but for v1.0 we assume Non-collateralised Markets.
Non-Collateralised markets draw 100% of the lay collateral from the Vault. This is favourable for Vault owners, as they get maximum dividends for collateral they lend.
Given calculated target odds are 5.0,
And the Vault has 1,000 tokens,
When a bet of 50 tokens is placed
Then the true odds are 4.75
And Vault lends 137.50 tokens to the Market
Markets that are collateralised use the collateral under management first, instead of borrowing assets from the connected Vault.
Given calculated odds are 3:1,
And the Vault has 10,000 tokens,
And the Market has 250 tokens,
And the runner being bet on has 0 existing bets
When a bet of 100 tokens is placed
Then the Vault lends 50 tokens to the Market
The registry contract is a mapping of Vaults and Markets used by the protocol. This allows a single source of truth for calling applications and smart contracts. It also has the ability to only allow token holders to modify the contracts it registers.
Network | Address |
---|---|
Arbitrum | 0xa110D6Bd21c226285b18f91b1749eBc12007a7E7 |
The MarketOracle.sol
contract allows authorised accounts to set results based on the Market ID and the Proposition ID. The results are either set from a python script settle.py
or TypeScript script in the event of a losing Proposition or by the front end should the user win and claim their profits. The market owner is responsible for providing a signed result after the event.
MarketId
is an arbitrary byte16 string which partitions the bets into a specific market. For our purposes, we use the following syntax for Racing markets. For example, a Race 1 at Brisbane (BNE) on the 1st of January 2024 would be represented as 20240101BNE01W
.
- Date 202401 first 6 chars
- Location mnemonic BNE next 3 chars
- Race number 1 with leading 0 as the last 2 chars
- W for Win, P for Place, Q for Quinella, E for Exacta, T for Trifecta, F for First Four, M for Multi
Note: The api returns in the string format YYYY-MM-DD_{code}_{raceNumber}_W{number}
for readability.
The contract uses bytes16, so the following functions are used to convert between bytes16 and strings. These can be found in utils.ts
.
export function hydrateMarketId(
marketId: string | DataHexString
): MarketDetails {
const id = isHexString(marketId) ? bytes16HexToString(marketId) : marketId;
const daysSinceEpoch = parseInt(id.slice(0, 6));
const location = id.slice(6, 9);
const race = parseInt(id.slice(9, 11));
return {
id,
date: daysSinceEpoch,
location,
race
};
}
export function hydratePropositionId(propositionId: string): RaceDetails {
const id = propositionId.slice(0, 13);
const market = hydrateMarketId(propositionId.slice(0, 11));
const number = propositionId.slice(12, 14);
return {
id,
market,
number
};
}
In Version 2 of the oracle, we change the arguments to be predictionId of type uint64. By using a modulus, this will enable us to have place bets.
Place bets are defined as more than 1 winning proposition on a single market. For example, a bet on a horse to win and place would be 2 winning propositions. A bet on a horse to win, place and show would be 3 winning propositions.
Truth Table
PredictionId | Win | Place | Result | Modulus |
---|---|---|---|---|
1 | 1 | 0 | 64 | 1 |
Network | Address |
---|---|
Arbitrum | 0x8D54e1319A50B715a13E1E1a86297fC039B7C949 |
Network | Address | Token |
---|---|---|
Arbitrum | 0x38A25f9eCF0DE857d6E4404ED0ecBd42705414fA | USDC |
Arbitrum | 0xE37ae0A43d0f0e01a4AdB8605da2D2CD915E3906 | USDT |
Network | Address | Token |
---|---|---|
Arbitrum | 0x48B3cD888C901d01d04a270205aA17028943A9D2 | USDC |
Arbitrum | 0x47563a2fA82200c0f652fd4688c71f10a2c8DAF3 | USDT |
See /hardhat.config.ts
for Hardhat configuration. Some values are fetched from environment variables, see .env.development
for local development environment variables and copy it into .env
before changing the values.
Deployments for each network are defined in the package.json
file. To deploy to a network, run the following command: yarn deploy:env_network
eg:
yarn deploy:prod_arbitrum
yarn deploy:prod_arbitrum"
Deployed addresses are saved in /contracts.json
for each network. This file should be committed so that addresses are managed by git.
Tasks are located in the /scripts/tasks
folder.
A Hardhat task allows for easy contract interaction from the command line. To run a contract task, run a command with the following structure:
npx hardhat <taskName>
--network <networkName>
[--argName <argValue>]
For the local hardhat network, use the default localhost
value for networkName
.
npx hardhat accounts --network localhost
Output:
0xA39560b08FAF6d8Cd2aAC286479D25E0ea70f510: 10.0 ETH
Minting the deployed example Token.sol
as an ERC20 with 0 decimals.
npx hardhat mint --amount 1 --address 0xA39560b08FAF6d8Cd2aAC286479D25E0ea70f510 --network localhost
Output:
network is localhost
token address is 0x47A78de7a881CCa1a0f510efA2E520b447F707Bb
waiting for confirmation...
minted 1 for address 0xA39560b08FAF6d8Cd2aAC286479D25E0ea70f510
Reading balance for the deployed example Token.sol
as an ERC20 with 0 decimals.
npx hardhat read-balance --address 0xA39560b08FAF6d8Cd2aAC286479D25E0ea70f510 --network localhost
Output:
network is localhost
token address is 0x47A78de7a881CCa1a0f510efA2E520b447F707Bb
balance is 1 wei for address 0xA39560b08FAF6d8Cd2aAC286479D25E0ea70f510
We've got automated scripts running in utils.horse.link:
ssh root@170.64.176.240
We use private keys to log onto the droplet without a password, so if you get a reply about authorization or password get someone with access to add your public key to the droplet.
If you need to make changes to the scripts you can copy them across with scp to make sure they work: scp scripts/*.ts root@170.64.176.240:contracts.horse.link/scripts/
and run the scripts manually like npx ts-node scripts/settle.ts
.
To deploy changes, pull the latest changes on the server: ssh root@170.64.176.240 'cd contracts.horse.link; git pull'
(it shouldn't make a difference if you do it in a single command like this or log in and then pull or even use the console in the DigitalOcean dashboard). Use git pull -f
if you want to clear out any changes that have been made on the server or copied across.
The scripts are usually run with crontab. You can check the current settings with crontab -l
on the droplet:
0,10,20,30,40,50 * * * * cd /root/contracts.horse.link && npx ts-node scripts/settle.ts >> $HOME/logs/settle.log 2>&1
5,15,25,35,45,55 * * * * cd /root/contracts.horse.link && npx ts-node scripts/scratch.ts >> $HOME/logs/scratch.log 2>&1
This says to run settle.ts
every 10 minutes, and to run scratch.ts also every 10 minutes but offset by 5 minutes.
You can update the crontab with crontab -e