Getting Started - API v3
Market Making on API v3 is JSON WebSocket based, similar to API v2.
1a. Creating a Pool (EVM)
Every Hashflow trade happens via a HashflowPool contract. Prior to creating a pool, you will have to request to be added to the allowlist. Please reach out to the Hashflow team to have your pool creator wallet added.
There are two ways to create a pool:
via UI on the Hashflow App
directly via the HashflowFactory smart contract
The HashflowFactory addresses on the supported chains are as such
Mainnets:
Ethereum
Arbitrum
Optimism
Polygon
BSC
Avalanche
Testnets:
BSC Testnet
In order to create a pool, you will need two pieces of information:
name
- this can be anything, but shorter names are preferred for displaysigner
- the 20-byte address derived from the Public Key of asecp256k1
keypair that will be used to sign quote payloads; the simplest example of this is the address of a MetaMask wallet, the private key of which can be used to sign quotes
1b. Creating a Pool (Solana)
Liquidity pools are the core structure (albeit in a different from) on Solana as well.
There are two ways to create a pool:
via UI on the Hashflow App
directly via the Hashflow Program
The Hashflow Program is written in Anchor and can be found at the following addresses:
Solana Devnet
HB3LQRRBZqZWkYXhFgrPbNvXzAhUvQfX2PmFTpcceGg2
Solana Mainnet
CRhtqXk98ATqo1R8gLg7qcpEMuvoPzqD5GNicPPqLMD
The Anchor IDL for the pool creation method is the following
The pool creation method takes two parameters:
poolId
- an arbitraryu64
integer that is used to derive the pool address (PDA)quoteSignerPubKey
- similar to EVM chains, this is the uncompressed Public Key of asecp256k1
signer (the first04
byte is omitted, so this is only 64 bytes) - because of this the signing mechanism for Ethereum and Solana quotes is very similar (the payloads differ)
In Solana, every account that will be touched by a transaction has to be explicitly declared within the transaction. The accounts that we need to pass to this instruction are:
owner
- this is the signer of the transaction and owner of the poolpool
- the pool PDA address (details below on how to derive it)systemProgram
- the System Program11111111111111111111111111111111
Deriving the PDA is done by using the following two data:
"pool"
the Little Endian byte notation of the
poolId
(8 bytes, from least significant to most significant)
In Anchor, the derivation of the pool PDA looks as such:
The Pool
account is defined as such
In JavaScript / TypeScript, the @hashflow/contracts-solana
package provides the full IDL, as well as program addresses.
2. Connecting to the Maker API
Once a pool has been created, a WebSocket connection can be used to start market making. API v3 has two WebSocket endpoints for Makers:
Staging:
wss://maker-ws-staging.hashflow.com/v3
Production:
wss://maker-ws.hashflow.com/v3
The servers send ping
requests to the clients every 30 seconds.
In order to authenticate, the following headers need to be present in the WebSocket request:
marketmaker: the name of your Market Maker
authorization: the authorization key used by the Market Maker
marketmakerindex (optional): used to run different logical Market Makers
3. Publishing Price Levels
In order to receive RFQs, the Market Maker needs to publish Price Levels (or indicative quotes) to the API. These should be published every second for every supported pair.
When publishing levels, both sides (BUY and SELL) are included in the message. All prices are specified relative to the same market (e.g. ETH/USDC
) for both directions.
Message schema:
A BUY level means that the market maker is buying the baseToken
(and the trader is selling it).
A SELL level means that the market maker is selling the baseToken
(and the trader is buying it).
Levels are an ordered list of how much (q
) is available at what price (p
), incrementally. The first level also serves as the smallest quantity that the Market Maker is willing to offer a trade for. If the Market Maker can take arbitrarily small orders, the first level should have quantity 0
and the price of the following level.
If you want to send pricelevle for all sources, put null
as value for field source
or do not send source
field (this is an optional field)
Sending only one level will be rejected by the API.
Sending an empty list of levels (for either BUY
or SELL
) means that trades are not available in that direction. Sending empty levels is also the recommended way to gracefully disconnect from the WebSocket.
Let's look at the following example for a ETH/USDC
pair on Ethereum:
Suppose the trader wanted to swap 1.2 ETH
for USDC
. In this case, the maker would be selling 0.1 ETH
at 1600.00
, another 1 ETH
at 1600.00
, and up to another 0.5 ETH
at 1599.00
. If the trader were to make an RFQ, they would get 0.1 * 1600.00 + 1 * 1600.00 + 0.1 * 1599.00
, which is 1919.9 USDC
If the trader wanted to swap 0.05 ETH
instead, the maker would not be able to honor the quote, as 0.1
is the minimum amount (first level).
Suppose, on the other hand that the trader wanted to swap 2000 USDC for ETH
.We know the market maker is selling 1 ETH
for 1601.00
, and up to another 1 ETH
for 1602.00
. The trader would spend 1601 USDC
at the 1601.00
price point to get 1 ETH
, and then another 399 USDC
at the 1602.00
price to get 0.24906367041 ETH
. Therefore, the RFQ would yield 1.24906367041 ETH
In the case of swapping USDC
for ETH
, the trader could swap any small amount (e.g. 0.1 USDC
), because the first level has a quantity of 0
4. Receiving an RFQ
Whenever a trader makes an RFQ, the Hashflow servers determine the best way to route that RFQ among the existing Market Makers. The winning Market Maker(s) receive messages of the following type:
Exactly one of baseTokenAmount
/ quoteTokenAmount
will be present in the RFQ.
If baseTokenAmount
is present, then the rfqTQuote
will need to fill the quoteTokenAmount
. This is the equivalent of a request asking "How much USDC can I get if I pay 1 ETH?".
If quoteTokenAmount
is present, then the rfqTQuote
will have to fill the baseTokenAmount
. This is the equivalent of a request asking "How much ETH do I have to pay in order to get 100 USDC?".
The generated quote should have baseTokenAmount
/ quoteTokenAmount
fields that respect the price level values sent. The price levels are used in deciding which Market Makers to route the RFQs to. Therefore, the Hashflow servers will check the quote values for correctness against Price Levels and penalize Market Makers that do not respect the prices sent in advance.
5. Adjusting the RFQ for fees
The Price Levels do not account for fees, which are computed at the time of the RFQ by each of Hashflow's taker sources (e.g. aggregators). The fees are sent in basis points as part of the feesBps
field. Since the Price Levels do not include fees, the quotes have to be adjusted for fees before being sent out. The adjustments should be as follows:
if the
rfqT
message containsbaseTokenAmount
, then thequoteTokenAmount
should be multiplied by(1 - (feesBps * 0.0001))
- this means that the trader receives less than they normally wouldif the
rfqT
message containsquoteTokenAmount
, then thebaseTokenAmount
should be divided by(1 - (feesBps * 0.0001))
- this means that the trader pays more than they normally would
Note that if accounting is done correctly, fees will have no effect on Market Maker price levels. The Market Maker worsens the rate by feesBps
, and then remits that amount in fees monthly back to the various aggregators that compose their liquidity.
6. Sending the Quote
Quote messages should be sent on the same WebSocket connection that received the RFQ. The rfqTQuote
message should be sent within 750ms of receiving the rfqT
message.
The quote messages are of the following type
In short, the Market Maker picks a liquidity source (pool
), fills in the missing amount as described in the sections above, and signs the quote.
Every trade happens through a HashflowPool contract (populated in the pool
field).
The pool contract can store the funds used for market making. Alternatively, an external account (either an EOA or a Smart Contract) can be used to custody the funds. That account has to set allowance to the HashflowPool
contract for every token that the Market Maker supports. This means that Market Makers can use their liquidity on multiple venues (including Hashflow) at the same time. This is only possible on EVM chains.
Whenever external accounts are used, they have to be passed in the externalAccount
field.
The signature field proves to the HashflowPool smart contract that the information provided is correct. It should be provided using the Private Key of the signer used to deploy the pool. Details on how to generate this signature are presented in the next section.
7a. Signing the Quote (single-chain)
EVM
Signing the quote can be split into two parts:
generating the payload (hash)
signing the payload
The Solidity code that generates the payload at the smart contract level can be found in this file and looks as such:
Some clarifications:
address(this)
is the address of the pool contract itself (thepool
field in the quote)externalAccount
should be set to0x00..0
if no external accounts are usedblock.chainid
is the Chain ID of the EVM chain (e.g.1
for Ethereum,137
for Polygon)txid
is therfqId
received in therfqT
message
The above code results in a 32-byte keccak hash. This is the hash that needs to be signed with the Private Key.
IMPORTANT: The contracts use the EIP-191 standard, which prepends the hash with the \x19Ethereum Signed Message:\n32
string before verifying the signature. Most signing libraries automatically do this. For example the signMessage
function in ethers.js
does this. If the signing library that you are using does not provide support for this, please take note of the full form of the verified payload:
Once the hash has been generated, it can be signed with the Private Key corresponding to the signer address that was used to create the pool.
Example code on how to generate the hash in TypeScript (using ethers.js
v6):
Solana
As with EVM signing the quote can be split into two parts:
generating the payload (hash)
signing the payload
Also as with EVM signing, Solana quotes are signed with the SECP256k1 curve, the same curve as EVM quotes. This is different from the curve used to sign Solana transactions (ed25519).
The biggest difference between EVM & Solana hash that you sign, is that for EVM you first create the payload, hash it, prepend the hash with the EIP-191 prefix, and then hash it again. For Solana, you create the payload, hash it, and then sign that hash directly, with no EIP-191 prefix.
The second biggest difference is that for Solana quotes integers are encoded little-endian, while for EVM quotes integers are encoded-big endian.
The Solana quote payload is packed like this:
trader
32
baseToken
32
quoteToken
32
pool
32
baseTokenAmount
8
quoteTokenAmount
8
floor (set to 0)
8
quoteExpiry
8
rfqId
32
Hash the payload with KECCAK-256 and then sign it with your SECP256k1 key. While for EVM quote signatures the ‘v’ (final byte) should be 27 or 28, for Solana quote signatures the final ‘v’ byte should be 0 or 1.
7b. Signing the Quote (cross-chain)
Cross-chain quote signing works in similar fashion. However, the message hash is computed differently.
Before we describe the hash, it is working mentioning the concept of a Hashflow Chain ID. This a chain ID that is agnostic to whether the chain is an EVM chain, Solana, Sui, etc. When signing / submitting cross-chain quotes, we use the Hashflow Chain ID and not the EVM Chain ID.
Another important concept in cross-chain is that every cross-chain trade uses a Cross-Chain Messenger. Different cross-chain messengers are built on different protocols (e.g. Wormhole, LayerZero) and thus have different underlying security assumptions. That is why pools have to explicitly allow cross-chain messengers. The Hashflow Foundation recommends using the Wormhole Messenger.
The Hashflow Chain ID and Wormhole messenger deployment address for each network can be found in the table below.
Ethereum
1
0x0a09b370950f69adc4c2fbf8677c7b0047599c9f
Arbitrum
2
0xab24a3306748e72520db800c3e93d6c861d1ba49
Optimism
3
0x7cdab80109d74372f1682ed0e4e65255f20ccbaa
Polygon
5
0xfafb0fc30140d1071606489ff36b9893f8db80bf
BSC
6
0x771cad61ec6dfde4a67891e982cf433aca1af7c8
Avalanche
4
0x771cad61ec6dfde4a67891e982cf433aca1af7c8
BSC Testnet
104
0x7CDAb80109D74372F1682ed0E4e65255f20ccbaA
Solana Devnet
100
N/A
Solana Mainnet
20
N/A
EVM
With the information above, the hash for a cross-chain quote where the base-chain is EVM is defined as such:
Due to EVM stack limitations, we have to first compute an inner hash of 6 fields, before we compute the hash that needs to be EIP-191 signed.
The TypeScript equivalent is
A few things to pay attention to:
srcChainId
anddstChainId
are Hashflow Chain IDsAll cross-chain addresses are 32 bytes long. EVM addresses however are 20 bytes long. In order to convert them to 32 bytes, we pre-pend them with 12 (twelve) 0-bytes. For example the
0xE8bc44AE4bA6EDDB88C8c087fD9b479Dff729850
address becomes0x000000000000000000000000E8bc44AE4bA6EDDB88C8c087fD9b479Dff729850
The above 20-byte to 32-byte conversion only applies to fields that are sent to the destination chain:
dstTrader
,quoteToken
,dstPool
,dstExternalAccount
Solana
The Solana cross-chain quote hash is not doubly-hashed like the EVM cross-chain quote hash. The same rules described above apply to cross-chain as well as single-chain. (i.e. Little-Endian, no EIP-191 prefix, SECP256k1 signing)
As with EVM cross-chain quotes, the chain ids are the Hashflow Chain Ids. If you are encoding an EVM address into a 32-byte field, it must be left-padded to 32-bytes. Note also that quoteTokenAmount is encoded to 32 bytes, while baseTokenAmount is encoded to 8 bytes.
Solana cross-chain quotes do not need a wormhole messenger address.
The payload for cross-chain:
srcChainId
2
dstChainId
2
pool
32
dstPool
32
dstExternalAccount
32
dstTrader
32
baseToken
32
quoteToken
32
baseTokenAmount
8
quoteTokenAmount
32
floor (set to 0)
8
quoteExpiry
8
rfqId
32
8. Subscribe to Trades
Hashflow offers the option for Market Makers to subscribe to trade events on their pools. If you would like to do so, please ask the Hashflow team to set up a delivery pipeline for you. If you do not require this functionality, you can skip this step.
Once the pipeline is set up, you can subscribe to a pool's trades by sending a subscribeToTrades
message over the WebSocket
If you subscribe to trades on a pool, you will start getting trade
messages whenever the pools that you have subscribed to receive a trade. These messages will have the following format:
The Hashflow systems will re-try messages for a period of 20 minutes until an acknowledgment is received.
The system guarantees at least once delivery. This means that in some rare cases messages could be delivered twice.
In order to let the Hashflow delivery pipeline know that the message has been processed, a matching tradeAck
message has to be sent back following each trade
message received. The pipeline will retry so long as these acknowledgement messages have not been received.
IMPORTANT: In the case of a chain re-org, a canceled
message will be sent. This message also has to be acknowledged.
The shape of acknowledgement messages is as follows:
9. (If using External Accounts) Set allowance for Private Pool (EVM Only)
If using an External Account, set an allowance from the External Account to the Private Pool so that it can move funds on behalf of the external account. Allowances need to be sent for:
the ERC-20 tokens you plan on supporting
the wrapped native token (e.g. WETH) if you plan on supporting native token trades (e.g. ETH)
In the case of an ETH trade, the Hashflow Smart Contracts will automatically wrap / unwrap as necessary, so that your External Account only gets debited / credited WETH instead.
NOTE: You have to send both ETH/x and WETH/x Price Levels in order to support both types of trades
If you don't set an allowance, your quotes will fail on-chain since the pool won't be able to access the funds in the External Account.
10. (For Cross-Chain, EVM Only) Authorize the Cross-Chain Messenger
Because different cross-chain messengers come with different security considerations (e.g. Wormhole has different security parameters than LayerZero), the pools have to opt into the cross-chain messenger. The Hashflow Foundation provides deployments for Wormhole messengers.
In order to authorize a cross-chain messenger, the following call has to be made on a HashflowPool
contract:
11. (For Cross-Chain, EVM Only) Authorize peer cross-chain pools
Each cross-chain pool needs to authorize the pools that it will executes swaps against on the peer chains. For example, if we want to set up cross-chain trades between Ethereum and BSC, the Ethereum pool has to authorize the BSC pool, and vice versa.
To authorize cross-chain peer pools, the following call has to be made on the HashflowPool
contract.
Note that the chain IDs that are being used are Hashflow Chain IDs, as documented in section 7b.
12. Test the RFQ flow end-to-end
Once you have completed the above steps, you can test your connection in the Hashflow UI. First off, you will need to ask the team for access to an UI that connects to the Staging API.
Next up, if your indicative Price Levels are propagating properly, you should be able to see your liquidity source, as well as supported pairs in the UI.
To select the liquidity source, click on the "Gear" icon at the top of the trading terminal. You should see your obscured Market Maker name (e.g. mmX) in that list.
Next, you should unselect all liquidity sources aside from your own.
Next, select a pair and write a number in the top box. If things are working well, you should see the rfq and signQuote messages in succession, then see a quote in the UI. This means that both the Quote and the Signature have been generated.
Next, click on Trade. If things worked well, you should see your wallet successfully estimate gas, and pop up a transaction confirmation. Confirm the transaction, run the trade, and checked that everything worked out well.
Success!!
Congrats! You've successfully set up a Market Maker 🥳
How to send errors
The 10 steps above covered the "happy path" – what happens when everything goes right. But the real world is messy. So, invariably, we'll encounter some error cases. The Hashflow WS API is designed in a way to easily support errors, as long as they are sent in a specific format.
Every message that is in response to a different message has the option of returning an error instead of the desired payload. To do so, you send a message with the following format:
Let's explain these some more:
messageType: The type of message you're sending. You treat this field is the exact same as you would for a successful request.
message.error: The error you want to return. We currently support the following error cases based on message type.
For all messages:
'invalid_input'
,'internal_error'
,'compliance'
,'rate_limit'
When responds with
'rate_limit'
in your error message, you can specify an"expiryTimestampMs" (UTC millisecond timestamp)
that indicates until when this rate_limit would expire. We will then block any requests fall under given restrictions and not send messages to you until the timestamp has expired.
For quotes only:
insufficient_liquidity'
,'pair_not_supported'
,'market_conditions'
For signatures only:
'incorrect_payload'
,'payload_expired'
,'market_conditions'
,'signing_not_supported'
,'invalid_signature'
message.originalMessage: The body of the original inbound message you received. We need this to be able to map the failure to a request. You should only send the body here (we know which messageType it was based on the type of your response).
To give an example, if you received an rfq
message but you didn't want to give a quote because the trader had been blacklisted, you'd respond with
as long as you respond in this format, we'll be able to parse these messages correctly.
Last updated