Vai al contenuto

Publish a feed on testnet

This guide deploys a feed on DoubleZero testnet, starting from a host that is not yet connected to DoubleZero. See the Overview for what each component is.

The bond is posted on Solana devnet. The multicast group and the feed are created on the DoubleZero testnet ledger. Use the same keypair for both, because the ledger checks that the bond belongs to the key creating the group and the feed.

Run every command on your publishing host unless a step says otherwise.

Testnet only

The bond amounts and hold period in this guide apply to testnet only. Mainnet-beta will use different values.

By connecting to DoubleZero I agree to the DoubleZero Terms of Use

CLI or API

You can run Steps 3 to 6 with the DoubleZero CLI or with the DoubleZero builder API. Each command block has a tab for each. Choosing a tab switches every block on the page. Connecting and publishing use the doublezero CLI either way.

The API never holds your key. A request that changes state returns an unsigned instruction:

{
  "program_id": "DZtnuQ839pSaDMFG5q1ad2V95G82S5EC4RrB3Ndw2Heb",
  "accounts": [{"pubkey": "HTt...", "is_signer": true, "is_writable": true}],
  "data": "<base64>",
  "signers": ["HTt..."]
}

You sign the instruction with your key and send it in a transaction. Step 1 sets up a script that does both. Bond instructions go to Solana devnet, and the rest go to the DoubleZero ledger.

The API accepts 10 requests per 10 seconds from each IP address. See the Builder API reference for every endpoint and the signing steps in detail.

Step 1: Install the tools and create a keypair

Follow Setup to install the testnet doublezero package and configure your firewall for GRE and BGP.

Install doublezero-solana, which manages the bond:

sudo apt update && sudo apt install doublezero doublezero-solana

Install jq and Node.js 20 or later, then the signing script's one dependency:

sudo apt update && sudo apt install doublezero jq
mkdir -p ~/dz-sign && cd ~/dz-sign
npm init -y && npm install @solana/web3.js@1

Save this script as ~/dz-sign/sign.mjs:

// Signs an instruction from the DoubleZero builder API and sends it.
// Usage: curl ... | node sign.mjs <rpc-url> <keypair-file>
import { readFileSync } from "node:fs";
import {
  Connection,
  Keypair,
  PublicKey,
  Transaction,
  TransactionInstruction,
  sendAndConfirmTransaction,
} from "@solana/web3.js";

const [rpcUrl, keypairFile] = process.argv.slice(2);
const body = readFileSync(0, "utf8");

let answer = {};
try {
  answer = JSON.parse(body);
} catch {}
if (!answer.program_id) {
  console.error(`The API refused the request: ${body}`);
  process.exit(1);
}

const signer = Keypair.fromSecretKey(
  Uint8Array.from(JSON.parse(readFileSync(keypairFile, "utf8"))),
);
const builder = signer.publicKey.toBase58();
if (answer.signers.length !== 1 || answer.signers[0] !== builder) {
  console.error(`The API built this for ${answer.signers}, but the keypair is ${builder}`);
  process.exit(1);
}

const instruction = new TransactionInstruction({
  programId: new PublicKey(answer.program_id),
  keys: answer.accounts.map((account) => ({
    pubkey: new PublicKey(account.pubkey),
    isSigner: account.is_signer,
    isWritable: account.is_writable,
  })),
  data: Buffer.from(answer.data, "base64"),
});

const connection = new Connection(rpcUrl, "confirmed");
try {
  const signature = await sendAndConfirmTransaction(
    connection,
    new Transaction().add(instruction),
    [signer],
  );
  console.log(signature);
} catch (err) {
  console.error(err.message);
  process.exit(1);
}

The script reads the API's response on standard input. It checks that your key is the instruction's only signer, then builds a transaction with a recent blockhash, signs it as the fee payer, sends it, and prints the transaction signature. If the API refused the request, the script prints the API's error and sends nothing.

Any Solana SDK can do the same if you prefer another language.

Create a keypair:

doublezero keygen

The keypair is saved to ~/.config/doublezero/id.json. Back it up. Whoever holds it controls your bond and your feed.

The commands below use these variables. Group and feed codes must be unique across the network, so prefix them with your organization's name.

export KEY=~/.config/doublezero/id.json
export BUILDER=$(doublezero address)
export GROUP=acme-tob-fra        # multicast group code
export FEED=acme-tob             # feed code
export METRO=xfra                # metro code
export RATE=1Gbps                # committed rate
export KEY=~/.config/doublezero/id.json
export BUILDER=$(doublezero address)
export GROUP=acme-tob-fra        # multicast group code
export FEED=acme-tob             # feed code
export METRO=xfra                # metro code
export RATE_BPS=1000000000       # committed rate, bits per second
export API=https://subscription-api-testnet.doublezero.xyz
export SOLANA_RPC=https://api.devnet.solana.com
export LEDGER_RPC=https://doublezerolocalnet.rpcpool.com/8a4fd3f4-0977-449f-88c7-63d4b0f10f16

doublezero exchange list lists the metros. Choose the one closest to your upstream data. doublezero latency shows the latency from your host to each DoubleZero device.

Step 2: Get devnet 2Z, SOL, and ledger credits

Fund the same key on both Solana devnet and the DoubleZero testnet ledger.

Solana devnet

The DoubleZero devnet faucet sends 10 devnet 2Z and 0.1 devnet SOL to your key. That covers a bond at any tier and the Solana transaction fees.

curl -sS -X POST https://faucet-testnet.doublezero.xyz/fund \
  -H 'content-type: application/json' \
  -d "{\"pubkey\": \"$BUILDER\"}"

The faucet responds after the transfer confirms:

{"signature": "5x...", "amount_2z": "10", "amount_sol": "0.1"}

The faucet makes one payout per IP address and one per key every 24 hours, with a daily limit across all requests. Other responses:

Response Meaning
429 ip limited Your IP address received a payout in the last 24 hours.
429 pubkey limited Your key received a payout in the last 24 hours.
429 daily cap reached The faucet reached its daily limit. Retry after retry_after_secs.
504 not confirmed The transfer was sent but not yet confirmed. Check the returned signature before requesting again.

DoubleZero testnet ledger

Request ledger credits from the faucet's separate /fund-ledger endpoint. It sends 0.05 credits to your key to pay for creating the multicast group, feed, and publisher grant. The /fund request above funds only Solana devnet.

curl -sS -X POST https://faucet-testnet.doublezero.xyz/fund-ledger \
  -H 'content-type: application/json' \
  -d "{\"pubkey\": \"$BUILDER\"}"

The response confirms the transfer on the DoubleZero ledger:

{"signature": "5x...", "amount_sol": "0.05"}

Check that your ledger balance is positive before continuing:

doublezero balance

Step 3: Post a bond

Create a bond for your committed rate, then post 2Z to it. The bond is on Solana devnet.

Pass -u devnet to every bond command. --amount is in 2Z.

doublezero-solana bond init -u devnet -k $KEY --committed-rate $RATE
doublezero-solana bond post -u devnet -k $KEY --amount 1

Send both transactions to Solana devnet. amount is in base units: 1 2Z is 100000000.

curl -sS -X POST $API/builders/$BUILDER/bonds \
  -H 'content-type: application/json' \
  -d "{\"bond_index\": 0, \"committed_rate_bits_per_sec\": $RATE_BPS}" \
  | node ~/dz-sign/sign.mjs $SOLANA_RPC $KEY
curl -sS -X POST $API/builders/$BUILDER/bonds/0/post \
  -H 'content-type: application/json' \
  -d '{"amount": 100000000}' \
  | node ~/dz-sign/sign.mjs $SOLANA_RPC $KEY

Post at least the amount your tier requires. See Bond.

Check the bond:

doublezero-solana bond show -u devnet --builder $BUILDER
Address:      9Qx...
Builder:      HTt...
Bond index:   0
Committed:    1000000000 bits/sec
Bonded:       1
Required:     1
Covers:       true
Hold expires: 2027-04-24T15:00:00+00:00
Withdrawable: 0
curl -sS $API/builders/$BUILDER/bonds | jq
[
  {
    "bond_key": "9Qx...",
    "bond_index": 0,
    "bonded_2z_amount": 100000000,
    "required_2z_amount": 100000000,
    "committed_rate_bits_per_sec": 1000000000,
    "hold_expires_at": 1808578800,
    "funded": true
  }
]

Covers: true in the CLI, or "funded": true in the API, means the bond holds enough for its tier. Save the bond's address:

export BOND=<Address>
export BOND=$(curl -sS $API/builders/$BUILDER/bonds | jq -r '.[] | select(.bond_index == 0) | .bond_key')

Step 4: Create a multicast group

The protocol copies each funded bond to the DoubleZero ledger. Until the copy exists, creating a multicast group against the bond fails. The CLI reports BondMirrorMissing (custom program error 119), and the API answers bond <address> has no mirror on the ledger yet. Wait a minute and run the command again.

doublezero multicast group create --code $GROUP --max-bandwidth $RATE --bond-ref $BOND --wait
curl -sS -X POST $API/builders/$BUILDER/multicast-groups \
  -H 'content-type: application/json' \
  -d "{\"code\": \"$GROUP\", \"max_bandwidth_bits_per_sec\": $RATE_BPS, \"bond_index\": 0}" \
  | node ~/dz-sign/sign.mjs $LEDGER_RPC $KEY

The group's address depends on how many accounts the ledger holds. If another account is created between your request and your transaction, the ledger refuses the transaction. Run the command again.

Your key owns the group. View it:

doublezero multicast group get --code $GROUP
curl -sS $API/builders/$BUILDER/feeds | jq '.multicast_groups[] | select(.code == env.GROUP)'
export GROUP_KEY=$(curl -sS $API/builders/$BUILDER/feeds \
  | jq -r '.multicast_groups[] | select(.code == env.GROUP) | .multicast_group')

The multicast_ip field is the address your publisher sends to.

Step 5: Create the feed

The feed records the multicast group, the bond, the wire spec, and the committed rate. The spec ID names the wire spec your data follows. See Choose a spec.

doublezero feed create \
  --code $FEED --name "Acme top of book" --exchange $METRO --group $GROUP \
  --bond-ref $BOND --spec-id top-of-book@v3 --committed-rate $RATE

The API takes the metro's address, not its code.

export METRO_KEY=$(doublezero exchange list | awk -v code=$METRO '$3 == code { print $1 }')
curl -sS -X POST $API/builders/$BUILDER/feeds \
  -H 'content-type: application/json' \
  -d "$(jq -nc --arg code $FEED --arg exchange $METRO_KEY --arg group $GROUP_KEY --argjson rate $RATE_BPS \
    '{code: $code, name: "Acme top of book", exchange: $exchange, groups: [$group],
      spec_id: "top-of-book@v3", committed_rate_bits_per_sec: $rate, bond_index: 0}')" \
  | node ~/dz-sign/sign.mjs $LEDGER_RPC $KEY

The feed is created with status Active. Check it:

doublezero feed list --code $FEED
curl -sS $API/builders/$BUILDER/feeds | jq '.feeds[] | select(.code == env.FEED)'
export FEED_KEY=$(curl -sS $API/builders/$BUILDER/feeds | jq -r '.feeds[] | select(.code == env.FEED) | .feed_key')

Step 6: Grant your host publish access

Add your host's public IP address to the group's publisher allowlist:

export HOST_IP=$(curl -4 -s https://ifconfig.me)
doublezero multicast group allowlist publisher add --code $GROUP --client-ip $HOST_IP --user-payer me
export HOST_IP=$(curl -4 -s https://ifconfig.me)
curl -sS -X POST $API/builders/$BUILDER/multicast-groups/$GROUP_KEY/publishers \
  -H 'content-type: application/json' \
  -d "{\"client_ip\": \"$HOST_IP\", \"user_payer\": \"$BUILDER\"}" \
  | node ~/dz-sign/sign.mjs $LEDGER_RPC $KEY

Step 7: Connect as a publisher

doublezero connect multicast --publish $GROUP

After about 60 seconds, check the connection:

doublezero status

The output should show BGP Session Up with a User Type of Multicast. For a publisher, Doublezero IP differs from Tunnel Src. See Other multicast for example output.

Step 8: Publish

Configure your application to send to the group's multicast_ip from Step 4. Each wire spec uses two UDP ports, one for market data and one for reference data. You choose the ports and publish them for your subscribers. DoubleZero routes multicast traffic through the tunnel, so no other configuration is needed on the host.

The group now shows one publisher:

doublezero multicast group get --code $GROUP
curl -sS $API/builders/$BUILDER/feeds | jq '.multicast_groups[] | select(.code == env.GROUP) | .publishers'

Step 9: List your feed

A subscriber can buy a seat on your feed only after you list it. The listing sets the price of one seat for one month, and whether anyone may buy it.

Use the API for this step from either tab. You sign the request body with your key, so the API needs no other credential. The solana sign-offchain-message command signs it. Install the Solana CLI and jq if you do not have them:

sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"
sudo apt install jq
export API=https://subscription-api-testnet.doublezero.xyz
export FEED_KEY=$(curl -sS $API/builders/$BUILDER/feeds | jq -r '.feeds[] | select(.code == env.FEED) | .feed_key')

Choose a price for one seat for one month, in USDC. The API takes the price in micro-USDC, so multiply by 1,000,000. This example lists the feed at 1 USDC:

body='{"feed_key":"'$FEED_KEY'","price_per_seat_micro_usdc":1000000,"self_serve":true,"signed_at":'$(date +%s)'}'
curl -sS -X PUT $API/builders/$BUILDER/feeds/$FEED_KEY/listing \
  -H 'content-type: application/json' \
  -H "x-builder-signature: $(solana sign-offchain-message -k $KEY "$body")" \
  -d "$body"

With self_serve set to true, any DoubleZero key can buy a seat. To sell only to keys you choose, set it to false and name each key with its own price:

body='{"feed_key":"'$FEED_KEY'","buyers":[{"service_key":"<subscriber DZ ID>","price_per_seat_micro_usdc":1000000}],"signed_at":'$(date +%s)'}'
curl -sS -X PUT $API/builders/$BUILDER/feeds/$FEED_KEY/buyers \
  -H 'content-type: application/json' \
  -H "x-builder-signature: $(solana sign-offchain-message -k $KEY "$body")" \
  -d "$body"

Each request replaces the last one. The API refuses a body signed more than five minutes ago (422) and a body signed before the last change it accepted (409). Sign a new body each time.

Step 10: Buy a seat and confirm delivery

A host joins your feed only with a seat on it. Buy one from a second host, as a subscriber would. Run every command in this step on the second host.

Complete Setup, install the Solana CLI, jq and the signing script from Step 1, and create a keypair. Then set:

export KEY=~/.config/doublezero/id.json
export DZ_ID=$(doublezero address)
export API=https://subscription-api-testnet.doublezero.xyz
export SOLANA_RPC=https://api.devnet.solana.com
export FEED=acme-tob
export FEED_KEY=<the feed_key from Step 9>

Fund the key from the faucet with both requests in Step 2. The devnet SOL pays the payment's fee, and the ledger credits pay to connect. Then get devnet USDC for $DZ_ID from Circle's faucet, on Solana Devnet.

Buy one seat for one month. The key signs the body, and submission_id and item_id are new UUIDs:

body='{"service_start":"'$(date -u +%F)'","submission_id":"'$(cat /proc/sys/kernel/random/uuid)'","feeds":[{"feed_key":"'$FEED_KEY'","max_users":1,"months":1,"item_id":"'$(cat /proc/sys/kernel/random/uuid)'"}],"signed_at":'$(date +%s)'}'
curl -sS -X POST $API/subscribers/$DZ_ID/purchases \
  -H 'content-type: application/json' \
  -H "x-subscriber-signature: $(solana sign-offchain-message -k $KEY "$body")" \
  -d "$body" | tee invoice.json
export INVOICE=$(jq -r .id invoice.json)

The response is an invoice. In the first minute after a feed is listed, the purchase can answer 422 and name a payment account. Wait a minute and send the same request again. A repeat of the same body returns the same invoice.

Pay the invoice in USDC. The API returns an unsigned instruction, and the signing script sends it on Solana devnet:

curl -sS -X POST $API/subscribers/$DZ_ID/purchases/$INVOICE/pay \
  -H 'content-type: application/json' \
  -d '{"payer":"'$DZ_ID'"}' | tee pay.json | node ~/dz-sign/sign.mjs $SOLANA_RPC $KEY | tee paid.txt

Record the payment. The key signs this body too:

body='{"tx_signature":"'$(cat paid.txt)'","amount_micro_usdc":'$(jq .amount_micro_usdc pay.json)'}'
curl -sS -X POST $API/subscribers/$DZ_ID/purchases/$INVOICE/payments \
  -H 'content-type: application/json' \
  -H "x-subscriber-signature: $(solana sign-offchain-message -k $KEY "$body")" \
  -d "$body"

An outcome of recorded means the payment counts. An outcome of pending means the API waits for Solana to confirm it. Send the same request again after a few seconds.

The protocol grants the seat about ten seconds after it records the payment. provisioned_at shows when:

curl -sS $API/subscribers/$DZ_ID/purchases/$INVOICE | jq .provisioned_at

When it is set, join the feed:

doublezero connect multicast --subscribe-feed $FEED

The feed's datagrams arrive on doublezero1 from an address the host routes through its main interface. A host with strict reverse-path filtering drops them. Google Cloud images turn strict filtering on, so this step is likely needed only on Google Cloud. Check the value with sysctl net.ipv4.conf.all.rp_filter. If it is 1, set loose mode on the tunnel after each connect, because the client creates doublezero1 again each time:

sudo sysctl -w net.ipv4.conf.doublezero1.rp_filter=2

Capture the traffic:

sudo tcpdump -ni doublezero1 udp and dst host <multicast_ip>

Before you announce the feed, check it against its spec with dz-conformance. See Check your feed.

Next steps

See Operate your feed to halt, resume, or retire the feed, add multicast groups, and withdraw from the bond.