Links

API v3

Market Making on API v3 is JSON WebSocket based, similar to API v2.

1. Creating a Pool

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:
The HashflowFactory addresses on the supported chains are as such
Mainnets:
Testnets:
In order to create a pool, you will need two pieces of information:
  • name - this can be anything, but shorter names are preferred for display
  • signer- the 20-byte address derived from the Public Key of a signing 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

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:
{
messageType: 'priceLevels',
message: {
source?: string; // leave empty for all sources
baseToken: {
chain: { chainType: 'evm' | 'solana', chainId: number };
address: string
};
quoteToken: {
chain: { chainType: 'evm' | 'solana', chainId: number };
address: string
};
// non-cumulative order book
buyLevels: {
q: string; // quantity
p: string; // price
}[]; // maker buys baseToken
sellLevels: {
q: string;
p: string;
}[]; // maker buys quoteToken
}
}
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.
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:
{
baseToken: {
chain: { chainType: 'evm', chainId: 1 },
// We represent native ETH as 0x0.
address: '0x0000000000000000000000000000000000000000',
},
quoteToken: {
chain: { chainType: 'evm', chainId: 1 },
// USDC addrress on Ethereum
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
},
buyLevels: [
{ q: '0.1', p: '1600.00' },
{ q: '1', p: '1600.00' },
{ q: '0.5', p: '1599.00' }
],
sellLevels: [
{ q: '0', p: '1601.00' },
{ q: '1', p: '1601.00' },
{ q: '1', p: '1602.00' }
]
}
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:
{
"messageType": "rfqT";
"message": {
rfqId: string;
source: string;
nonce: number;
baseChain: { chainType: 'evm' | 'solana'; chainId: number; };
quoteChain: { chainType: 'evm' | 'solana'; chainId: number; };
baseToken: string;
quoteToken: string;
// The address of the account receiving quoteToken on quoteChain
trader: string;
effectiveTrader: string;
// Exactly one of the following two fields will be present.
baseTokenAmount?: string;
quoteTokenAmount?: string;
// Fees that will be charged, in basis points, up to 2 decimals.
feesBps: number;
// The USD price of the baseToken at the time of request. (e.g. '1500.05')
// This price will be used when computed the amount to be charged in fees.
baseTokenPriceUsd: string;
};
}
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 contains baseTokenAmount, then the quoteTokenAmount should be multiplied by (1 - (feesBps * 0.0001)) - this means that the trader receives less than they normally would
  • if the rfqT message contains quoteTokenAmount, then the baseTokenAmount should be divided by (1 - (feesBps * 0.00001)) - 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
{
"messageType": "rfqTQuote",
"message": {
rfqId: string;
quoteExpiry: number;
baseToken: string;
quoteToken: string;
baseTokenAmount: string;
quoteTokenAmount: string;
pool: string;
// For cross-chain trades only.
dstPool?: string;
externalAccount?: string;
// For cross-chain trades only.
dstExternalAccount?: string;
signature: string;
}
}
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)

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:
keccak256(
abi.encodePacked(
address(this),
quote.trader,
quote.effectiveTrader,
quote.externalAccount,
quote.baseToken,
quote.quoteToken,
quote.baseTokenAmount,
quote.quoteTokenAmount,
quote.nonce,
quote.quoteExpiry,
quote.txid,
block.chainid
)
)
Some clarifications:
  • address(this) is the address of the pool contract itself (the pool field in the quote)
  • externalAccount should be set to 0x00..0 if no external accounts are used
  • block.chainid is the Chain ID of the EVM chain (e.g. 1 for Ethereum, 137 for Polygon)
  • txid is the rfqId received in the rfqT 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:
keccak256(
abi.encodePacked(
'\x19Ethereum Signed Message:\n32',
keccak256(
abi.encodePacked(
address(this),
quote.trader,
quote.effectiveTrader,
quote.externalAccount,
quote.baseToken,
quote.quoteToken,
quote.baseTokenAmount,
quote.quoteTokenAmount,
quote.nonce,
quote.quoteExpiry,
quote.txid,
block.chainid
)
)
)
)
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):
import { solidityPackedKeccak256 } from 'ethers';
static hashRFQTQuote(
quoteData: UnsignedRFQTQuoteStruct,
chainId: number
): string {
return solidityPackedKeccak256(
[
'address',
'address',
'address',
'address',
'address',
'address',
'uint256',
'uint256',
'uint256',
'uint256',
'bytes32',
'uint256',
],
[
quoteData.pool,
quoteData.trader,
quoteData.effectiveTrader ?? quoteData.trader,
quoteData.externalAccount ?? ZERO_ADDRESS,
quoteData.baseToken,
quoteData.quoteToken,
quoteData.baseTokenAmount,
quoteData.quoteTokenAmount,
quoteData.nonce,
quoteData.quoteExpiry,
quoteData.txid,
chainId,
]
);
}

7b. Signing the Quote (cross-chain)

<work in progress>

8. Subscribe to Trades

We offer the option for makers to subscribe to trades on their pools for faster updates. If you want to be able to do so, please ask the Hashflow team to setup 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
{
"messageType": "subscribeToTrades",
"message": {
"chainType": string, // evm|solana
"chainId": number, // network id of pool
"pool": string // pool address '0x...', This must be a pool owned by Market Maker
}
}
If you subscribe to trades on a pool, this means you will start getting trades messages when our backend notices a new trade. These messages will have the following format:
{
"messageType": "trade",
"message": {
"rfqId"?: string;
"rfqSource": string;
"networkId": NetworkId;
"dstNetworkId"?: NetworkId;
"txid": string;
"blockNumber": number;
"blockHash": string;
"transactionHash": string;
"blockTimestamp": number;
"baseToken": string;
"baseTokenName": string;
"baseTokenNumDecimals": number;
"quoteToken": string;
"quoteTokenName": string;
"quoteTokenNumDecimals": number;
"baseTokenAmount": string;
"quoteTokenAmount": string;
"fees": string;
"baseTokenPriceUsd": string;
"pool": string;
"dstPool"?: string;
"trader": string;
"effectiveTrader"?: string;
"status": 'filled' | 'canceled';
"feesBps"?: number;
}
}
Once you've processed this trade message (updating internal records, hedging the trade, etc), you must to respond with a tradeAck message so that our backend knows you have received the message.
{
"messageType": "tradeAck",
"message": {
"txid": string // This is 32-byte HEX String
}
}

9. (If using External Accounts) Set allowance for Private Pool

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. 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:
{
"messageType": "<message>", // Same types as otherwise ("quote", "signature", etc)
"message": {
"error": "<failure>", // Only specific failures are recognized. See below.
"originalMessage": {
... // Original inbound message
}
}
}
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
{
"messageType": "quote",
"message": {
"error": "compliance",
"expiryTimestampMs" : 1681590420000, // set this field when your error is rate_limit
"originalMessage": {
"rfqId": "0x67..",
"rfqType": 1,
"source": "hashflow",
...
}
}
}
as long as you respond in this format, we'll be able to parse these messages correctly.