Creating Your First Intent

This guide will walk you through creating your first intent: a recurring swap intent.

This guide uses viem for wallet signatures and axios for HTTP requests. You may replace these with other equivalent packages of your choice, such as ethers.js or fetch respectively.

We've also opted to use dotenv to hide our secrets, as we will be using both an EOA private key and a Brink API key.

🚧

Private Key Security

Note: We at Brink DO NOT encourage storing private keys in .env files. This is for demonstration purposes only. Please use a more secure method of storing your private keys in production.

The steps outlined in this tutorial can be done either within a new codebase or within an existing project.

To view and play with the demo script that this guide is based on and more, please visit our public guides repository on GitHub.

Setup

We'll start by installing our packages and setting up the JavaScript code file. In the file, we will create one main() function that runs our script end-to-end. We'll also load our secrets from our .env file.

$ npm install viem axios dotenv
const axios = require('axios')
const viem = require('viem')
require('dotenv').config()

const main = async () => {
  // ...
}

main()

πŸ“˜

Brink API Key

This guide makes use of the Brink API which requires an API key. Please contact us on Discord to get yours!

1. Constructing the Intent

The intent we'll be creating in this guide is a recurring swap intent, AKA dollar-cost averaging (DCA). We want to swap 5,000 USDC for ETH (WETH) approximately once a week for 3 months or until we cancel the intent early. We'll also incentivize the solver with 2.5% of the swap.

The first step in creating this intent is using Brink's domain-specific language (DSL). The DSL structure for one intent consists of an object with multiple fields that define the guardrails of the intent, which include the actions, conditions and replay fields.

Brink Intents DSL

Inside of our main() function, we will add our Brink DSL object, declaring our recurring swap intent with it's parameters and conditions.

const axios = require('axios')
const viem = require('viem')
require('dotenv').config()

const main = async () => {
  const myRecurringIntent = {
    actions: [{
      type: 'marketSwap',
      owner: '0xc0ffee', // EOA public address of signer
      tokenInAmount: 500000000, // 500 USDC (6 decimals)
      tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
      tokenOut: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH
      fee: 2.5 // incentivize solver with 2.5% of the swap
    }],
    conditions: [{
      id: '1234567890', // unique ID for condition, perferably randomly generated
      type: 'interval',
      interval: 50_000, // ~50,000 blocks are built every 7 days
      maxIntervals: 12 // 7 days * 12 === 12 weeks or 3 months
    }],
    replay: {
      nonce: '123', // TODO, must make API request for this value, shown below
      runs: 'UNTIL_CANCELLED'
    }
  }
}

main()

The myRecurringIntent intent object is made up of 3 fields: actions, conditions, and replay.

The actions field is an array of objects that define the outcome(s) of our intent, in our case, a 500 USDC -> WETH market swap. We also set a 2.5% fee that the solver may take as an incentive to fulfill our intent.

Similarly, the conditions field is an array of objects that define the conditions that the actions may be executed against. Here, we expect the actions to run at a block interval of 50,000 blocks for 12 intervals to meet our "once a week for 3 months" intent condition.

Finally, the replay field defines the replayability for our intent. Since replayability is necessary for any recurring intent or transaction, we allow it by setting the runs field to UNTIL_CANCELLED, theoretically allowing our intent to be replayed forever (thankfully, our maxIntervals value would prevent our intent from actually being run forever).

We must also specify the replay.nonce value, which requires a quick request to the Brink API.

Fetching the Nonce Value

To fetch the correct nonce value, we must make a GET request to the /signers/<SIGNER>/nonces/v1 endpoint (replacing <SIGNER> with the public address of the EOA we'll be using to sign and submit this intent). Once we have the response data, we can plug it in to our intent object.

We'll start by putting our Brink API key into our .env file for security, then loading it into our code as a request header in axios with an x-api-key field.

BRINK_API_KEY=<my_api_key>
const axios = require('axios')
const viem = require('viem')
require('dotenv').config()

const main = async () => {
  const nonceRes = await axios.get('https://api.brink.trade/signers/0xc0ffee/nonces/v1', {
    headers: {
      'x-api-key': process.env.BRINK_API_KEY,
    }
  })

  const myRecurringIntent = {
    actions: [{
      type: 'marketSwap',
      tokenInAmount: 500000000,
      tokenIn: 'USDC',
      tokenOut: 'ETH',
      fee: 2.5 
    }],
    conditions: [{
      type: 'interval',
      interval: 50_000,
      maxIntervals: 12
    }],
    replay: {
      nonce: parseInt(nonceRes.data.nonces[0]), // nonce value from API response
      runs: 'UNTIL_CANCELLED'
    }
  }
}

main()

2. Compiling the Intents DSL

Now that we've defined our intent using the Brink Intents DSL, we must compile it into a Brink Declaration and construct the EIP-712 signature payload before signing it. Thankfully, there is a single Brink API endpoint that quickly compiles the DSL and provides the signature payload for us: /intents/compile/v1.

πŸ“˜

What is a Declaration?

A Declaration is a new concept introduced in the Brink protocol that allows for multiple Intents to be submitted in bulk with only one signature. Each time you sign an off-chain message (e.g. EIP-712 signature) on Brink, a Declaration is created, and may contain as many Intents as the user or developer would like.

A Segment (actions, conditions, replay values) is the lowest level of granularity in the Brink protocol. Segments make up Intents, and Intents make up Declarations. This empowers developers to create truly composable and complex intents quickly and easily.

In our compile request, we must define a few fields to properly compile the intent. Since this is a GET request, we will pass our data as query parameters.

Params for the /intents/compile/v1 request include:

  • chainId: The chain ID of the network you are using (we are using Ethereum Mainnet, 1).
  • signer: The address of the account that will be signing the intent.
  • signatureType: We'll be using the value EIP712 for this guide.
  • include: An array of extra data to include in the API response.
  • declaration: The intent object we created earlier (also accepts an array of multiple intents).

Using the params object in axios, we can define our parameters for this call as such:

const axios = require('axios')
const viem = require('viem')
require('dotenv').config()

const main = async () => {
  // ... nonce request

  const myRecurringIntent = { /* intent definition */ }

  const compileRes = await axios.get('https://api.brink.trade/intents/compile/v1', {
    headers: {
      'x-api-key': process.env.BRINK_API_KEY,
    },
    params: {
      chainId: 1,
      signer: '0xc0ffee',
      signatureType: 'EIP712',
      include: ['required_transactions'],
      declaration: JSON.stringify(myRecurringIntent)
    }
  })
}

main()

We must pass required_transactions as an item in the include array. By doing so, the Brink API will respond with any transactions that are required to be finalized before our intent can be fulfilled by solvers. In our case, a token approval transaction is required.

Once submitted, the /intents/compile/v1 response data will be structured as such:

// response from `/intents/compile/v1`
{
  declaration: { ... },
  eip712Data: {
    types: { ... },
    domain: { ... },
    value: { ... },
    hash: '0x<DECLARATION_HASH>'
  },
  eip1271Data: { message: 'Not implemented' },
  hash: '0x<DECLARATION_HASH>',
  requiredTransactions: [
    {
      owner: '<YOUR_PUBLIC_ADDRESS>',
      spender: '<BRINK_PROXY_ADDRESS>',
      currentAllowance: '0',
      requiredAllowance: '500000000',
      token: { ... },
      minTx: { ... },
      maxTx: { ... }
    }
  ]
}

This data will be put to use for the next 2 steps: approving your Brink Proxy to spend tokens, and signing the EIP-712 signature.

3. Approving Your Brink Proxy

Before signing and submitting our declaration, we must approve the signer's Brink Proxy contract to transfer the tokens involved in the intent. For more info on how the Proxy contracts work in the Brink protocol, please reach out on Discord.

We can get all info about the approval transaction in the requiredTransactions field of the API response above.

{
  //... other response fields
  requiredTransactions: [
    {
      owner: '<YOUR_PUBLIC_ADDRESS>',
      spender: '<BRINK_PROXY_ADDRESS>',
      currentAllowance: '0',
      requiredAllowance: '500000000',
      token: {
        address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC token address
        // ... other info
     },
      minTx: { ... },
      maxTx: { ... }
    }
  ]
}

To simplify this guide, an easy way to quickly approve tokens is by using the Etherscan "Write Contract" (sometimes "Write Proxy Contract") section of the token contract page.

From the owner account, you should approve the spender address to spend the tokens (the token contract address found at token.address). We highly recommend approving for an unlimited amount of tokens (max uint256 value), but the requiredAllowance value in the response is the minimum amount of tokens you must approve.

Approving Tokens Programmatically

If you'd like to run the approval transaction programmatically or within a web app, you may construct the transaction using either the minTx or the maxTx objects in the requiredTransactions response array. The minTx and maxTx contain the exact executable calldata for the approval transaction for both the minimum allowance amount and the maximum allowance amount respectively.

4. Signing the Declaration

Now that we've approved our Brink Proxy to spend our tokens, we can finally sign the intent (declaration). To do so, we must construct the EIP-712 typed data that we received from the /intents/compile/v1 endpoint. These values can be found in the eip712Data field of the response. We also must set the primaryType field to MetaDelegateCall.

We can prepare viem to sign by adding our private key to our .env file, then loading it into our code as a walletClient. Once our walletClient is created, we can construct and sign the EIP-712 signature using the signTypedData() method, passing in our values from the eip712Data field and our walletClient.account object.

# .env file
BRINK_API_KEY=<my_api_key>
SIGNER_PRIVATE_KEY=<my_private_key>

🚧

Private Key Security

Note: We at Brink DO NOT encourage storing private keys in .env files. This is for demonstration purposes only. Please use a more secure method of storing your private keys in production.

const axios = require('axios')
const viem = require('viem')
require('dotenv').config()

const main = async () => {
  // ... nonce request

  const myRecurringIntent = { /* intent declaration */ }

  const compileRes = await axios.get('https://api.brink.trade/intents/compile/v1', {
    // ... request config
  })

  const { eip712Data } = compileRes.data

  const walletClient = viem.createWalletClient({
    account: privateKeyToAccount(`0x${process.env.PRIVATE_KEY}`),
  })

  const declarationSignature = await walletClient.signTypedData({
    account: walletClient.account,
    types: eip712Data.types,
    domain: eip712Data.domain,
    message: eip712Data.value,
    primaryType: 'MetaDelegateCall'
  })
}

main()

5. Submitting the Intent

Now that we have our declaration signature using viem, we can finally submit it to the Brink API. To do so, we must make a POST request to the /intents/submit/v1 endpoint. The data we pass to this endpoint is similar to the data we passed to the /intents/compile/v1 endpoint, but it's passed as the POST request body, rather than query parameters.

The POST request body for the /intents/compile/v1 request includes:

  • chainId: The chain ID of the network you are using (we are using Ethereum Mainnet, 1).
  • signer: The address of the account that will be signing the intent.
  • signatureType: EIP712 same as before
  • declaration: The compiled declaration given to us from the compile request step.
  • signature: The EIP-712 signature hash we previously signed.
const axios = require('axios')
const viem = require('viem')
require('dotenv').config()

const main = async () => {
  // ... nonce request and intent declaration

  const compileRes = await axios.get('https://api.brink.trade/intents/compile/v1', {
    // ... request config
  })

  // ... walletClient from previous step

  const declarationSignature = await walletClient.signTypedData({
    // ... signTypedData config
  })

  const submitRes = await axios.post(
    'https://api.brink.trade/intents/submit/v1',
    {
      chainId: 1,
      signer: signerPublicAddress,
      signatureType: 'EIP712',
      declaration: compileRes.data.declaration, // compiled declaration from compile request
      signature: declarationSignature // signature hash from signTypedData
    },
    {
      headers: {
        'x-api-key': process.env.BRINK_API_KEY
      }
    }
  )
}

main()

Once the signed Brink Declaration is submitted to the Brink API, it will be added to the Brink Intentpool and the intent we created will be available for solvers to fulfill once the conditions are met.

Conclusion

Congratulations! You've successfully created your first Brink Intent and submitted it as a Declaration. Your final code file should look something like the code block below.

For a full, runable script, please visit script.js file in our public guides GitHub repo.

const axios = require('axios')
const viem = require('viem')
require('dotenv').config()

const main = async () => {
  const nonceRes = await axios.get('https://api.brink.trade/signers/0xc0ffee/nonces/v1', {
    headers: {
      'x-api-key': process.env.BRINK_API_KEY,
    }
  })

   const myRecurringIntent = {
    actions: [{
      type: 'marketSwap',
      owner: '0xc0ffee', // EOA public address of signer
      tokenInAmount: 500000000,
      tokenIn: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
      tokenOut: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH
      fee: 2.5 // incentivize solver with 2.5% of the swap
    }],
    conditions: [{
      id: '1234567890', // unique ID for condition, perferably randomly generated
      type: 'interval',
      interval: 50_000, // ~50,000 blocks are built every 7 days
      maxIntervals: 12 // 7 days * 12 === 12 weeks or 3 months
    }],
    replay: {
      nonce: parseInt(nonceRes.data.nonces[0]), // nonce value from API response
      runs: 'UNTIL_CANCELLED'
    }
  }

  const compileRes = await axios.get('https://api.brink.trade/intents/compile/v1', {
    headers: {
      'x-api-key': process.env.BRINK_API_KEY,
    },
    params: {
      chainId: 1,
      signer: '0xc0ffee',
      signatureType: 'EIP712',
      include: ['required_transactions'],
      declaration: JSON.stringify(myRecurringIntent)
    }
  })

  const { eip712Data } = compileRes.data

  const walletClient = viem.createWalletClient({
    account: privateKeyToAccount(`0x${process.env.PRIVATE_KEY}`),
  })

  const declarationSignature = await walletClient.signTypedData({
    account: walletClient.account,
    types: eip712Data.types,
    domain: eip712Data.domain,
    message: eip712Data.value,
    primaryType: 'MetaDelegateCall'
  })

  const submitRes = await axios.post(
    'https://api.brink.trade/intents/submit/v1',
    {
      chainId: 1,
      signer: signerPublicAddress,
      signatureType: 'EIP712',
      declaration: compileRes.data.declaration, // compiled declaration from compile request
      signature: declarationSignature // signature hash from signTypedData
    },
    {
      headers: {
        'x-api-key': process.env.BRINK_API_KEY
      }
    }
  )

  console.log(submitRes.data)
}

main()