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:

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 secp256k1 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:

The Hashflow Program is written in Anchor and can be found at the following addresses:

NetworkProgram Address

Solana Devnet

HB3LQRRBZqZWkYXhFgrPbNvXzAhUvQfX2PmFTpcceGg2

Solana Mainnet

CRhtqXk98ATqo1R8gLg7qcpEMuvoPzqD5GNicPPqLMD

The Anchor IDL for the pool creation method is the following

{
  "name": "createPool",
  "accounts": [
    {
      "name": "owner",
      "isMut": true,
      "isSigner": true,
      "docs": [
        "Market Maker."
      ]
    },
    {
      "name": "pool",
      "isMut": true,
      "isSigner": false,
      "docs": [
        "Pool account."
      ]
    },
    {
      "name": "systemProgram",
      "isMut": false,
      "isSigner": false,
      "docs": [
        "System program."
      ]
    }
  ],
  "args": [
    {
      "name": "poolId",
      "type": "u64"
    },
    {
      "name": "quoteSignerPubKey",
      "type": {
        "array": [
          "u8",
          64
        ]
      }
    }
  ]
}

The pool creation method takes two parameters:

  • poolId - an arbitrary u64 integer that is used to derive the pool address (PDA)

  • quoteSignerPubKey - similar to EVM chains, this is the uncompressed Public Key of a secp256k1 signer (the first 04 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 pool

  • pool - the pool PDA address (details below on how to derive it)

  • systemProgram - the System Program 11111111111111111111111111111111

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:

#[account(
    init,
    payer = owner,
    space = Pool::LEN,
    seeds = [&Pool::SEED_PREFIX[..], &pool_id.to_le_bytes()[..]],
    bump
)]

The Pool account is defined as such

#[account]
/// Market Maker Pool data.
pub struct Pool {
    /// PDA bump.
    pub bump: u8,

    /// Unique Pool ID.
    pub pool_id: u64,

    /// Public Key part of Secp256k1 quote signature scheme.
    pub quote_signer_pub_key: [u8; 64],

    /// Market Maker key that owns the Pool.
    pub owner: Pubkey,
}

impl Pool {
    pub const LEN: usize = 8 +  // discriminator
        1 + // bump
        8 + // pool_id
        64 + // signer
        32 // owner
    ;

    pub const SEED_PREFIX: &'static [u8; 4] = b"pool";
}

In JavaScript / TypeScript, the @hashflow/contracts-solana package provides the full IDL, as well as program addresses.

const {
  HashflowSolanaIDL,
  HASHFLOW_PROGRAM_ADDRESS
} = require('@hashflow/contracts-solana');

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;  // for all sources put 'null' as value or do not send "source" field
  
    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.

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:

{
  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.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

{
  "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)

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:

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,
    ]
  );
}

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:

FieldBytes

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.

NetworkHashflow Chain IDWormhole Messenger

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:

keccak256(
    abi.encodePacked(
        '\x19Ethereum Signed Message:\n32',
        keccak256(
            abi.encodePacked(
                keccak256(
                    abi.encodePacked(
                        quote.srcChainId,
                        quote.dstChainId,
                        quote.srcPool,
                        // Pre-pended with 00 up to 32 bytes
                        quote.dstPool,
                        quote.srcExternalAccount,
                        // Pre-pended with 00 up to 32 bytes
                        quote.dstExternalAccount 
                    )
                ),
                // Pre-pended with 00 up to 32 bytes
                quote.dstTrader,
                quote.baseToken,
                // Pre-pended with 00 up to 32 bytes
                quote.quoteToken,
                quote.baseTokenAmount,
                quote.quoteTokenAmount,
                quote.quoteExpiry,
                quote.nonce,
                quote.txid,
                quote.xChainMessenger
            )
        )
    )
)

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

static hashXChainRFQTQuote(quoteData: UnsignedXChainRFQTQuoteStruct): string {
  const innerHash = solidityPackedKeccak256(
    ['uint16', 'uint16', 'address', 'bytes32', 'address', 'bytes32'],
    [
      quoteData.srcChainId,
      quoteData.dstChainId,
      quoteData.srcPool,
      quoteData.dstPool,
      quoteData.srcExternalAccount,
      quoteData.dstExternalAccount,
    ]
  );
  return solidityPackedKeccak256(
    [
      'bytes32',
      'bytes32',
      'address',
      'bytes32',
      'uint256',
      'uint256',
      'uint256',
      'uint256',
      'bytes32',
      'address',
    ],
    [
      innerHash,
      quoteData.dstTrader,
      quoteData.baseToken,
      quoteData.quoteToken,
      quoteData.baseTokenAmount,
      quoteData.quoteTokenAmount,
      quoteData.quoteExpiry,
      quoteData.nonce,
      quoteData.txid,
      quoteData.xChainMessenger,
    ]
  );
}

A few things to pay attention to:

  • srcChainId and dstChainId are Hashflow Chain IDs

  • All 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 becomes 0x000000000000000000000000E8bc44AE4bA6EDDB88C8c087fD9b479Dff729850

  • 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:

FieldBytes

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

{
    "messageType": "subscribeToTrades",
    "message": {
      "chain": { chainType: 'evm' | 'solana'; chainId: number; },
      // Pool address on the chain, e.g. '0x...'
      // This must be a pool owned by Market Maker
      "pool": string  
    }
}

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:

{
  "messageType": "trade",
  "message": {
    // Unique ID for the trade event.
    tradeEventId: string;
    
    baseChain: { chainType: 'evm' | 'solana'; chainId: number; };
    quoteChain: { chainType: 'evm' | 'solana'; chainId: number; };

    rfqId: string;

    blockNumber: number;
    transactionHash: string;
    blockTimestamp: number;

    baseToken: string;
    quoteToken: string;
    
    // Every quote an execute an amount that is <= the
    // baseTokenAmount provided by the Market Maker during RFQ.
    // These amounts are actual executed amounts on chain.
    baseTokenAmount: string;
    quoteTokenAmount: string;

    // e.g. '1500.3'
    baseTokenPriceUsd: string;
    // Fees to be charged for this trade.
    feesBps: number;

    pool: string;
    // Will be the same as `pool` if the trade is not cross-chain.
    dstPool: string;

    effectiveTrader?: string;
    
    // Re-orged trades will appear as `canceled`.
    tradeStatus: 'confirmed' | 'canceled';
  }
}

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:

{
  "messageType": "tradeAck",
  "message": {
    // The same as in the `trade` message.
    "tradeEventId": string
  }
}

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:


function updateXChainMessengerAuthorization(
    address xChainMessenger,
    bool authorized
) external;

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.

struct AuthorizedXChainPool {
    uint16 hashflowChainId;
    bytes32 pool;
}

function updateXChainPoolAuthorization(
    AuthorizedXChainPool[] calldata pools,
    bool authorized
) external;

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:

{
  "messageType": "<message>",  // Same types as otherwise ("rfqTQuote", 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": "rfqTQuote",
  "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.

Last updated