如何使用 DFlow 在 Solana 上交易 Kalshi 预测市场

这篇文章详细介绍了如何通过 DFlow 将受 CFTC 监管的 Kalshi 预测市场整合到 Solana 生态系统中。它提供了一个 TypeScript CLI 的构建指南,涵盖了预测市场的完整生命周期,包括发现市场、获取价格、购买 SPL 代币形式的预测头寸、追踪持仓以及兑换赢得的代币。

概述

预测市场允许用户对体育、选举结果、经济指标、文化事件等现实世界的事件结果进行押注。押注被构建为“是”或“否”合约,如果选择的结果发生,则进行支付。Kalshi 是美国第一个受 CFTC 监管的预测市场交易所,这意味着在 Kalshi 上的交易是合法的、受交易所监管的事件合约,而不是离岸或灰色市场投机。

Kalshi 运营在链下,但通过与 DFlow 的集成,这些市场现在可以直接从 Solana 应用程序访问。Solana 开发者可以将 DFlow 的 API 集成到他们的 dApp 中,以原生提供预测市场功能,其中头寸表示为真实的 SPL 代币,可与 DeFi 的其他部分组合。

DFlow 是一个 Solana 原生交易基础设施层,为去中心化应用程序提供流动性路由、订单执行和代币化服务。DFlow 的 Metadata API 将 Kalshi 受监管的事件合约桥接到 Solana 生态系统,将“是/否”头寸代币化为 SPL 代币,使任何 Solana 应用程序都可以在无需从头构建交易所基础设施的情况下提供预测市场交易。Trade API 构建并返回可签名(ready-to-sign)的 Solana 交易,用于购买结果代币或兑换赢取的奖励。

本指南将通过构建一个 TypeScript CLI 来涵盖完整的交易生命周期:发现活跃的英超联赛 (EPL) 足球市场、获取“是/否”价格、下达交易、跟踪未平仓头寸,以及在结算后兑换赢取的奖励。

你将做什么

在本指南中,你将构建一个 TypeScript CLI,它将引导你完成 Solana 预测市场交易的完整生命周期:

  • 使用 DFlow Metadata API 查找预测市场
  • 获取“是/否”定价
  • 使用 DFlow Trade API 购买“是”或“否”结果代币
  • 通过将钱包代币余额映射到预测市场来跟踪未平仓头寸
  • 在市场结算后将赢取的结果代币兑换为 USDC

你将需要什么

  • 一个用于发送已签名交易的 Quicknode Solana 端点 — 注册 以开始
  • 一个带有少量 SOL(用于交易费用)和 USDC(用于购买结果代币)的钱包
  • Node.js 20 或更高版本
  • 生产环境用的 DFlow API 密钥

本指南使用以下软件包和工具:

依赖项 版本
Node 24.8.0
typescript 5.7.3
tsx 4.20.3
@solana/kit 6.1.0

Kalshi 预测市场如何运作

Kalshi 将可交易合约组织成一个四级层次结构:类别 → 系列 → 事件 → 市场

类别是范围最广的分组(SportsEconomicsPolitics)。每个类别都有一个或多个标签,进一步对其内容进行分类(SoccerBasketball)。标签是你在搜索 API 时筛选到特定领域的依据。

系列是类别中命名、重复的合约模板。它定义了适用于其产生的所有市场的规则、解决来源和费用结构。例如,KXEPLGAME 系列涵盖了本赛季所有英超联赛 (EPL) 比赛结果市场。

事件代表系列中的一个真实世界事件。一个事件将该事件的所有二元市场归类在一起。

市场是事件中的一个独立的“是/否”合约。每个市场都有一个 yesMintnoMint 的 SPL 代币,代表 Solana 上的头寸。市场在结果正式确定时解决。获胜方每份合约支付 1.00 美元,而失败方则一文不值地到期。

DFlow 如何将 Kalshi 桥接到 Solana

DFlow 的关键创新是 并发流动性程序 (CLP),这是一个 Solana 原生框架,它将链下 Kalshi 流动性与链上 Solana 用户连接起来:

  1. 交易者在链上表达交易意图(类似于限价订单)。
  2. 流动性提供者观察并以有竞争力的价格填充意图。
  3. 协议铸造代表所购买预测头寸的 SPL 代币。
  4. 当市场解决时,赢取的代币通过相同的 CLP 兑换回其稳定币支付。

由于头寸是真实的 Solana SPL 代币,而不是合成表示,它们可以被借出、借入、用作抵押品,或在去中心化交易所 (DEX) 上交易。

DFlow 提供了两个 API 来处理这些代币:

API 基础 URL(开发) 用途
Metadata API https://dev-prediction-markets-api.dflow.net 发现市场、获取定价、检查结算
Trade API https://dev-quote-api.dflow.net 构建交易以购买结果代币或兑换赢取的奖励

开发端点

这两个开发端点都是开放的,开发期间无需 API 密钥。适用速率限制。对于生产环境,请联系 DFlow 获取专用 API 密钥。“开发”是指未经验证的 API 访问层级,而不是 Solana 开发网。所有请求都在 Solana 主网测试版上执行。

发现预测市场

在下达交易之前,你需要确定要交易市场的系列代码,然后找到特定的事件和结果铸币。DFlow 的 Metadata API 通过一组发现端点暴露了完整的 Kalshi 层次结构。

本指南中,我们将处理英超联赛 (EPL) 比赛市场。同样的发现步骤适用于任何其他类别 — 政治、经济、娱乐等。

我们不会编写代码以编程方式搜索正确的系列,而是手动完成发现过程。只需三个 API 调用,一旦你有了系列代码,它将在整个赛季保持不变。

步骤 1:获取可用标签

curl https://dev-prediction-markets-api.dflow.net/api/v1/tags_by_categories

预期响应(为简洁起见,省略了其他类别):

{
  "tagsByCategories": {
    "Climate and Weather": ["..."],
    "Companies": ["..."],
    "Crypto": ["..."],
    "Economics": ["..."],
    "Elections": null,
    "Entertainment": ["..."],
    "Financials": ["..."],
    "Mentions": ["..."],
    "Politics": ["..."],
    "Science and Technology": ["..."],
    "Social": null,
    "Sports": [
      "Soccer",
      "Basketball",
      "Baseball",
      "Football",
      "Hockey",
      "Olympics",
      "Golf",
      "Tennis",
      "Esports",
      "MMA",
      "Motorsport",
      "Rugby",
      "Cricket",
      "Lacrosse",
      "Boxing",
      "Darts",
      "Chess"
    ]
  }
}

Sports 类别包含一个 Soccer 标签。在下一步中将这两个值作为 categorytags 传递。

步骤 2:列出所有足球系列

curl "https://dev-prediction-markets-api.dflow.net/api/v1/series?category=Sports&tags=Soccer"

响应包含多个系列。扫描 title 字段直到找到 “English Premier League Game” 并记下其 ticker

{
  "series": [
    {...},
    {
      "ticker": "KXEPLGAME",
      "frequency": "custom",
      "title": "English Premier League Game",
      "category": "Sports",
      "tags": ["Soccer"],
      "settlementSources": [
        { "name": "ESPN", "url": "https://www.espn.com/" },
        { "name": "Fox Sports", "url": "https://www.foxsports.com/" }
      ],
      "contractUrl": "...",
      "contractTermsUrl": "...",
      "productMetadata": {},
      "feeType": "quadratic_with_maker_fees",
      "feeMultiplier": 1.0,
      "additionalProhibitions": ["..."]
    }
  ]
}

代码是 KXEPLGAME。这是本指南要使用的系列。其他系列(其他足球联赛)将出现在完整响应中。

步骤 3:获取活跃的 EPL 事件

有了系列代码,你的应用程序在运行时只需这一个调用即可获取所需的一切:

curl https://dev-prediction-markets-api.dflow.net/api/v1/events?seriesTickers=KXEPLGAME&status=active&withNestedMarkets=true

我们需要传递两个参数以获取所需的结果:

  • status=active:将结果限制为当前开放交易的事件。没有它,响应将包括已确定、已完成和已暂停的你无法参与的事件。
  • withNestedMarkets=true:将每个事件的市场(包括 yesMintnoMint、实时定价和账户数据)直接嵌入响应中。一个调用返回显示和交易所需的一切,无需每个市场进行后续请求。

预期响应(实际响应包括所有活跃的 EPL 赛程):

{
"events": [
    {...},
    {
      "ticker": "KXEPLGAME-26FEB18WOLARS",
      "seriesTicker": "KXEPLGAME",
      "title": "Wolverhampton vs Arsenal",
      "subtitle": "WOL vs ARS (Feb 18)",
      "status": "active",
      "markets": [
        {
          "ticker": "KXEPLGAME-26FEB18WOLARS-ARS",
          "eventTicker": "KXEPLGAME-26FEB18WOLARS",
          "title": "Wolverhampton vs Arsenal Winner?",
          "yesSubTitle": "Arsenal",
          "noSubTitle": "Arsenal",
          "status": "active",
          "result": "",
          "openTime": 1770382800,
          "closeTime": 1772654400,
          "yesBid": "0.7600",
          "yesAsk": "0.7700",
          "noBid": "0.2300",
          "noAsk": "0.2400",
          "accounts": {
            "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH": {
              "marketLedger": "C5sDtQq8iAZHqMGdH382uCT2yZTDAphEhVsMnGKAWDBT",
              "yesMint": "GPGgr29ektC4ZB2TsPpbWmmNiCtNjq3vospE8cLweH5U",
              "noMint": "7fLxMYQdQRdTCVTDXzxaimt6rjXSpgogWYiBXVE78roi",
              "isInitialized": true,
              "redemptionStatus": "pending"
            },
            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": {
              "marketLedger": "ERm2CMDxUJduckmBkZU28SBzzGycZzcdUbiRmzqRaoA",
              "yesMint": "EahtAm7FJfTrGdEFvsQbztsadM6ohMqyPcLk1Q6YCRDx",
              "noMint": "4kXWe1ofHoihJfSzTAT7Yecm5vsEuBFEKmrtLzuASLU8",
              "isInitialized": true,
              "redemptionStatus": "pending"
            }
          }
        },
        {
          "ticker": "KXEPLGAME-26FEB18WOLARS-WOL",
          "eventTicker": "KXEPLGAME-26FEB18WOLARS",
          "title": "Wolverhampton vs Arsenal Winner?",
          "yesSubTitle": "Wolverhampton",
          "noSubTitle": "Wolverhampton",
          "status": "active",
          "result": "",
          "openTime": 1770382800,
          "closeTime": 1772654400,
          "yesBid": "0.0700",
          "yesAsk": "0.0800",
          "noBid": "0.9200",
          "noAsk": "0.9300",
          "accounts": {
            "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH": {
              "marketLedger": "ADBWy7L2VzSAfVima4ybNqse7zDGBqac1B6fGkYSEpVc",
              "yesMint": "Hy8XLnGcyvmbxf3SBBQbkws5SDa46eF2AWhJLQXiuSci",
              "noMint": "2Tw3tAE3is7TiCqrcS7DxXJ2UAeEsq7dbMDc8Nya57eg",
              "isInitialized": true,
              "redemptionStatus": "pending"
            },
            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": {
              "marketLedger": "5UHoukpeVPQbmSUaAPWnkXEKZMrjSmwTqqaD8eXmvKNn",
              "yesMint": "CA7FMbzNTfeR7jkLzF113bBJupKwq98cixaQtc3b3frb",
              "noMint": "D7ibW7tu2kvzfbDS78gF5i9UZTye7pqTP63yxYd43No3",
              "isInitialized": true,
              "redemptionStatus": "pending"
            }
          }
        },
        {
          "ticker": "KXEPLGAME-26FEB18WOLARS-TIE",
          "eventTicker": "KXEPLGAME-26FEB18WOLARS",
          "title": "Wolverhampton vs Arsenal Winner?",
          "yesSubTitle": "Tie",
          "noSubTitle": "Tie",
          "status": "active",
          "result": "",
          "openTime": 1770382800,
          "closeTime": 1772654400,
          "yesBid": "0.1500",
          "yesAsk": "0.1600",
          "noBid": "0.8400",
          "noAsk": "0.8500",
          "accounts": {
            "CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH": {
              "marketLedger": "BfdCBQiiNbxxWNgs1dYpNgsfXC5SDH19RmratW2hTxPG",
              "yesMint": "6Ka59wvyvppd2v7D1LGfjKMb2LJ36KcnCpHZU2uP2EPB",
              "noMint": "48CyDoWdsEL62gD81My4wHYybrsyH8dnHRHWJcaUHvtA",
              "isInitialized": true,
              "redemptionStatus": "pending"
            },
            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": {
              "marketLedger": "GGViDLxL6RRQ4zTydGoiL6NnLugxyDGraydUBAQfo9iX",
              "yesMint": "4qeSi2JVCbE9VQt1uzTJTpJSKdMFRsqWuvf3UL9fGa2P",
              "noMint": "7GA2eFoEupkSqJaeKrHgS516dipVDfmQ4ZQ2BFdZpdb3",
              "isInitialized": false,
              "redemptionStatus": null
            }
          }
        }
      ]
    },
    "..."
  ],
  "cursor": null
}

每个事件包含三个市场,每个结果一个(-ARS-WOL-TIE)。每个市场有两个 accounts 条目,以结算铸币为键:CASH CASH...CASH(由 Phantom 发行的美元支持的稳定币)和 USDC EPjF...Dt1v。每个结算账户下的 yesMintnoMint 地址是交易后你的钱包将持有的 SPL 代币铸币。

本指南在构建买入和兑换订单时使用 USDC 作为结算铸币。

构建示例应用程序

现在你已经了解了 Kalshi 市场的结构以及 DFlow 如何在 Solana 上呈现它们,是时候编写代码了。在本节中,你将构建一个小的 TypeScript CLI,其中包含用于获取系列中活跃事件、购买结果代币、查看未平仓头寸以及在结算后兑换赢取的奖励的脚本。

设置钱包

你需要一个 Solana 密钥对来签署交易。使用 Solana CLI 为本指南创建一个专用钱包:

solana-keygen new --outfile ~/dflow-wallet.json

为钱包充值:

  • 少量 SOL(约 0.01 SOL)以支付交易费用
  • 一些 USDC 以购买结果代币

需要真实资金

所有交易,包括在“开发”(未验证)API 端点上的交易,都在 Solana 主网测试版上执行,使用真实 USDC。请使用专用钱包,并且只充值你准备好在测试期间花费的金额。

初始化项目

创建一个新的项目目录并使用 npm 初始化它:

mkdir dflow-prediction-markets && cd dflow-prediction-markets
npm init -y
mkdir src

安装依赖项:

npm install @solana/kit
npm install --save-dev typescript tsx @types/node

配置环境

在编写任何代码之前,请设置你的环境变量。在项目根目录中创建 .env 文件:

.env

METADATA_API_URL=https://dev-prediction-markets-api.dflow.net
TRADE_API_URL=https://dev-quote-api.dflow.net
DFLOW_API_KEY= # 生产环境需要,开发环境留空
QUICKNODE_RPC_URL=https://docs-demo.solana-mainnet.quiknode.pro/abcd1234
KEYPAIR_PATH=/path/to/your/wallet/dflow-wallet.json
SERIES_TICKER=KXEPLGAME
USDC_MINT=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
变量 描述
METADATA_API_URL DFlow 元数据 API 基础 URL,用于市场发现和定价。
TRADE_API_URL DFlow 交易 API 基础 URL,用于购买和兑换结果代币。
DFLOW_API_KEY 开发端点留空。生产环境中需要作为 x-api-key 头。
QUICKNODE_RPC_URL 你的 Quicknode 主网测试版端点。在此处注册 获取。
KEYPAIR_PATH 你的 Solana 密钥对 JSON 路径。此钱包签署交易,并且必须持有 SOL 作为费用以及 USDC 以购买结果代币。
SERIES_TICKER 要监控的 Kalshi 系列。对于 EPL 比赛市场,设置为 KXEPLGAME
USDC_MINT 用于从市场账户中选择正确铸币地址的 USDC 铸币。

创建类型文件

在编写任何脚本之前,创建 src/types.ts。本指南中的每个脚本都从该文件导入,因此一次性定义类型可以使代码在发现、购买、头寸和兑换过程中保持一致且完全类型安全。

src/types.ts

export interface MarketAccountInfo {
  yesMint: string;
  noMint: string;
  isInitialized?: boolean;
  redemptionStatus: string;
  scalarOutcomePercent: number | null;
}

export interface Market {
  ticker: string;
  title: string;
  yesSubTitle?: string;
  closeTime?: number | null;
  status?: string;
  result?: string | null;
  yesBid: string | null;
  yesAsk: string | null;
  noBid: string | null;
  noAsk: string | null;
  accounts: Record<string, MarketAccountInfo>;
}

export interface Event {
  ticker: string;
  title: string;
  subtitle: string | null;
  markets: Market[];
}

export interface EventsResponse {
  events: Event[];
  cursor: number | null;
}

export interface OrderResponse {
  outAmount: string;
  executionMode: 'sync' | 'async';
  transaction: string;
  lastValidBlockHeight: number;
  revertMint?: string;
}

export interface OrderStatusResponse {
  status: 'pending' | 'expired' | 'failed' | 'open' | 'pendingClose' | 'closed';
  outAmount: number;
  reverts?: { signature: string }[];
}

创建实用程序文件

有几个函数在所有四个脚本中共享:加载钱包、构建请求头、进行类型化 API 调用、签署和发送交易、轮询订单状态以及解码代币账户数据。将它们集中在一个共享模块中意味着每个脚本都专注于自己的逻辑。

创建 src/utils.ts

src/utils.ts

import {
  createKeyPairFromBytes,
  getAddressFromPublicKey,
  getAddressDecoder,
  address,
  sendTransactionWithoutConfirmingFactory,
  assertIsTransactionWithinSizeLimit,
  getTransactionDecoder,
  signTransaction,
  getSignatureFromTransaction,
} from '@solana/kit';
import type { createSolanaRpc } from '@solana/kit';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type { OrderResponse, OrderStatusResponse } from './types';

type SolanaRpc = ReturnType<typeof createSolanaRpc>;

export function getHeaders(): Record<string, string> {
  const h: Record<string, string> = { 'Content-Type': 'application/json' };
  const apiKey = process.env.DFLOW_API_KEY;
  if (apiKey) h['x-api-key'] = apiKey;
  return h;
}

export async function loadWallet() {
  const keypairPath = process.env.KEYPAIR_PATH || path.join(os.homedir(), '.config', 'solana', 'id.json');
  const secretKey = Uint8Array.from(JSON.parse(fs.readFileSync(keypairPath, 'utf-8')));
  const keyPair = await createKeyPairFromBytes(secretKey);
  const address = await getAddressFromPublicKey(keyPair.publicKey);
  return { keyPair, address };
}

export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
  const res = await fetch(url, { headers: getHeaders(), ...options });
  if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);
  return res.json() as Promise<T>;
}

export async function signAndSend(
  order: OrderResponse,
  keyPair: Awaited<ReturnType<typeof createKeyPairFromBytes>>,
  rpc: SolanaRpc,
): Promise<string> {
  const txBytes = Buffer.from(order.transaction, 'base64');
  const transaction = getTransactionDecoder().decode(txBytes);
  const signedTx = await signTransaction([keyPair], transaction);
  const signature = getSignatureFromTransaction(signedTx);
  assertIsTransactionWithinSizeLimit(signedTx);
  const sendTx = sendTransactionWithoutConfirmingFactory({ rpc });
  await sendTx(signedTx, { commitment: 'confirmed', skipPreflight: false });
  return signature as string;
}

export async function waitForOrder(
  sig: string,
  lastValidBlockHeight: number,
  tradeApiUrl: string,
): Promise<OrderStatusResponse> {
  while (true) {
    const status = await fetchJson<OrderStatusResponse>(
      `${tradeApiUrl}/order-status?signature=${sig}&lastValidBlockHeight=${lastValidBlockHeight}`
    );
    console.log(`  Status: ${status.status}`);
    if (['closed', 'expired', 'failed'].includes(status.status)) return status;
    await new Promise((r) => setTimeout(r, 2_000)); // Wait before next poll to avoid rate-limiting
  }
}

export function parseMintAndBalance(accountData: [string, string]): { mint: string; amount: bigint } {
  const [base64Data] = accountData;
  const data = Buffer.from(base64Data, 'base64');
  const mint = getAddressDecoder().decode(data.subarray(0, 32));
  const amount = data.readBigUInt64LE(64);
  return { mint, amount };
}

// SPL Token programs
export const TOKEN_PROGRAM      = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
export const TOKEN_2022_PROGRAM = address('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');

export async function getWalletTokenAccounts(rpc: SolanaRpc, walletAddress: string) {
  const [{ value: legacyAccounts }, { value: token2022Accounts }] = await Promise.all([
    rpc.getTokenAccountsByOwner(address(walletAddress), { programId: TOKEN_PROGRAM },      { encoding: 'base64' }).send(),
    rpc.getTokenAccountsByOwner(address(walletAddress), { programId: TOKEN_2022_PROGRAM }, { encoding: 'base64' }).send(),
  ]);
  return [...legacyAccounts, ...token2022Accounts];
}

获取活跃的 EPL 事件

创建 src/events.ts 来调用 /events 端点并打印一个即将到来的 EPL 事件及其 USDC “是/否”市场的表格:

src/events.ts

import { fetchJson } from './utils';
import type { EventsResponse } from './types';

const METADATA_API  = process.env.METADATA_API_URL;
const SERIES_TICKER = process.env.SERIES_TICKER;
const USDC_MINT     = process.env.USDC_MINT;

// Helpers
function toUsd(price: string | null): string {
  if (!price) return '  N/A  ';
  return `$${Number(price).toFixed(2)}`;
}

function closeDate(ts: number): string {
  return new Date(ts * 1000).toLocaleString(undefined, {
    month: 'short', day: 'numeric', year: 'numeric',
    hour: '2-digit', minute: '2-digit',
  });
}

// Main
async function listEvents() {
  const url = `${METADATA_API}/api/v1/events?seriesTickers=${SERIES_TICKER}&status=active&withNestedMarkets=true`;
  const { events } = await fetchJson<EventsResponse>(url);

  if (events.length === 0) {
    console.log(`No active events found for series "${SERIES_TICKER}".`);
    return;
  }

  console.log(`\n${SERIES_TICKER} — ${events.length} active event(s)\n`);

  for (const event of events) {
    // closeTime lives on the markets, not the event itself
    const closeTime = event.markets[0]?.closeTime;

    console.log(`┌─ ${event.title}`);
    console.log(`│  Ticker:  ${event.ticker}`);
    if (event.subtitle) console.log(`│  Subtitle: ${event.subtitle}`);
    if (closeTime) console.log(`│  Closes:  ${closeDate(closeTime)}`);
    console.log('│');

    for (const market of event.markets) {
      // Prefer USDC accounts; fall back to first account available
      const acct = market.accounts[USDC_MINT] ?? Object.values(market.accounts)[0];

      console.log(`│  ▸ YES label: ${market.yesSubTitle}`);
      console.log(`│    Ticker:   ${market.ticker}`);
      console.log(`│    YES mint: ${acct?.yesMint ?? 'not initialized'}`);
      console.log(`│    NO  mint: ${acct?.noMint  ?? 'not initialized'}`);
      console.log(`│    Price (ask):  YES ${toUsd(market.yesAsk)}   NO ${toUsd(market.noAsk)}`);
      console.log('│');
    }

    console.log('└─────────────────────────────────────────────────────\n');
  }
}

listEvents().catch(console.error);

运行事件脚本以获取系列中活跃事件的列表:

tsx --env-file=.env src/events.ts

预期输出(这仅显示返回的第一个事件,但实际结果将包括所有活跃事件):

KXEPLGAME — 31 active event(s)

┌─ Wolverhampton vs Arsenal
│  Ticker:  KXEPLGAME-26FEB18WOLARS
│  Subtitle: WOL vs ARS (Feb 18)
│  Closes:  Mar 4, 2026, 02:00 PM
│
│  ▸ YES label: Arsenal
│    Ticker:   KXEPLGAME-26FEB18WOLARS-ARS
│    YES mint: EahtAm7FJfTrGdEFvsQbztsadM6ohMqyPcLk1Q6YCRDx
│    NO  mint: 4kXWe1ofHoihJfSzTAT7Yecm5vsEuBFEKmrtLzuASLU8
│    Price (ask):  YES $0.78   NO $0.23
│
│  ▸ YES label: Wolverhampton
│    Ticker:   KXEPLGAME-26FEB18WOLARS-WOL
│    YES mint: CA7FMbzNTfeR7jkLzF113bBJupKwq98cixaQtc3b3frb
│    NO  mint: D7ibW7tu2kvzfbDS78gF5i9UZTye7pqTP63yxYd43No3
│    Price (ask):  YES $0.08   NO $0.93
│
│  ▸ YES label: Tie
│    Ticker:   KXEPLGAME-26FEB18WOLARS-TIE
│    YES mint: 4qeSi2JVCbE9VQt1uzTJTpJSKdMFRsqWuvf3UL9fGa2P
│    NO  mint: 7GA2eFoEupkSqJaeKrHgS516dipVDfmQ4ZQ2BFdZpdb3
│    Price (ask):  YES $0.17   NO $0.85
└─────────────────────────────────────────────────────

┌─ [Other Events]
│ List of the other active EPL events
│ ...
└─────────────────────────────────────────────────────

记下你想要交易的市场(yesMintnoMint)的 tickeryesMintnoMint 值,以供下一步使用。

购买“是/否”代币

每个 EPL 事件都暴露了三个独立的二元市场,每个市场对应一个可能的结果:阿森纳获胜(-ARS)、狼队获胜(-WOL)或比赛平局(-TIE)。

每个市场都是一个独立的“是/否”问题。购买阿森纳是与购买狼队否不同。是-阿森纳持有人在平局时会输,而否-狼队持有人会赢。平局会支付给是-平局持有人,但也会支付给否-阿森纳和否-狼队持有人。总是选择与你想要投机的确切结果相匹配的市场。

市场 代币 阿森纳赢 狼队赢 平局
阿森纳会赢吗? -ARS ✅ 赢 ❌ 输 ❌ 输
阿森纳会赢吗? -ARS ❌ 输 ✅ 赢 ✅ 赢
狼队会赢吗? -WOL ❌ 输 ✅ 赢 ❌ 输
狼队会赢吗? -WOL ✅ 赢 ❌ 输 ✅ 赢
比赛会以平局结束吗? -TIE ❌ 输 ❌ 输 ✅ 赢
比赛会以平局结束吗? -TIE ✅ 赢 ✅ 赢 ❌ 输

购买结果代币是一个三步过程:

  1. 从 DFlow 的 Trade API 请求订单
  2. 签署返回的交易
  3. 将已签名的交易发送到 Solana

创建 src/buy.ts 脚本以购买“是”或“否”结果代币:

src/buy.ts

// src/buy.ts
// 使用 USDC 作为输入购买“是”或“否”结果代币
import {
  createSolanaRpc,
  Signature,
} from '@solana/kit';
import type { OrderResponse } from './types';
import { loadWallet, fetchJson, signAndSend, waitForOrder } from './utils';

const TRADE_API = process.env.TRADE_API_URL;
const RPC_URL   = process.env.QUICKNODE_RPC_URL;
const USDC_MINT = process.env.USDC_MINT;

const rpc = createSolanaRpc(RPC_URL);

// Main
async function buyOutcomeToken(
  outcomeMint: string, // yesMint 或 noMint 来自市场元数据
  usdcAmount: number   // 要花费的 USDC 数量 (例如 1 = $1.00)
) {
  const wallet = await loadWallet();

  // USDC 有 6 位小数:1.00 美元 = 1,000,000 基本单位。
  // DFlow /order `amount` 参数是 INPUT 数量(要花费的 USDC)。
  const amountBaseUnits = usdcAmount * 1_000_000;

  console.log(`\n花费 ${usdcAmount} USDC 购买结果代币`);
  console.log(`  输入 (USDC):  ${USDC_MINT}`);
  console.log(`  输出 (铸币): ${outcomeMint}`);
  console.log(`  钱包地址:        ${wallet.address}\n`);

  // 步骤 1:从 DFlow Trade API 请求订单
  const params = new URLSearchParams({
    inputMint: USDC_MINT,
    outputMint: outcomeMint,
    amount: String(amountBaseUnits),
    userPublicKey: wallet.address,
    slippageBps: 'auto',
    dynamicComputeUnitLimit: 'true',
    prioritizationFeeLamports: '5000',
  });

  const order = await fetchJson<OrderResponse>(`${TRADE_API}/order?${params}`);
  console.log(`订单已接收 — 模式: ${order.executionMode}, 预期输出: ${order.outAmount} 基本单位`);

  // 步骤 2:签署并发送交易
  const signature = await signAndSend(order, wallet.keyPair, rpc);

  console.log(`\n交易已提交: ${signature}`);
  console.log(`  浏览器: https://explorer.solana.com/tx/${signature}`);

  // 步骤 3:对于异步订单,轮询确认填充
  if (order.executionMode === 'async') {
    const result = await waitForOrder(signature, order.lastValidBlockHeight, TRADE_API);
    if (result.status === 'closed') {
      console.log(`\n✅ 订单已填充!收到 ${result.outAmount / 1_000_000} 结果代币`);
    } else if (result.status === 'expired') {
      console.log(`\n⚠️ 订单在填充前已过期。未收到代币。`);
    } else {
      console.log(`\n⚠️ 订单以状态 ${result.status} 结束`);
    }
  } else {
    // 同步:轮询签名状态直到确认
    for (let i = 0; i < 30; i++) {
      await new Promise((r) => setTimeout(r, 1_000));
      const { value: statuses } = await rpc.getSignatureStatuses([signature as Signature]).send();
      const s = statuses[0];
      if (s?.confirmationStatus === 'confirmed' || s?.confirmationStatus === 'finalized') {
        if (s.err) {
          console.error(`\n❌ 交易在链上失败:`, s.err);
        } else {
          console.log(`\n✅ 同步订单已确认!`);
        }
        break;
      }
    }
  }
}

const [outcomeMint, amountStr] = process.argv.slice(2);
if (!outcomeMint || !amountStr) {
  console.error('用法: npm run buy -- <outcome-mint-address> <usdc-amount>');
  process.exit(1);
}

buyOutcomeToken(outcomeMint, Number(amountStr)).catch(console.error);

使用你之前记录的 yesMint(或 noMint)以及你想要花费的 USDC 数量运行脚本:

tsx --env-file=.env src/buy.ts 4qeSi2JVCbE9VQt1uzTJTpJSKdMFRsqWuvf3UL9fGa2P 1

预期输出:

花费 1 USDC 购买结果代币
  输入 (USDC):  EPjFW...Dt1v
  输出 (铸币): 4qeS...Ga2P
  钱包地址:        F6Yt...8kM9

订单已接收 — 模式: async, 预期输出: 11000000 基本单位

交易已提交: 3WmN...nD8s
  浏览器: https://explorer.solana.com/tx/3WmND...nD8s
  状态: pending
  状态: closed

✅ 订单已填充!收到 11 结果代币

跟踪未平仓头寸

用户的预测市场头寸作为 SPL 代币余额存在于其钱包中。要显示它们,需要获取所有代币账户,识别哪些铸币是结果代币,并将每个代币映射到其市场数据。

创建 src/positions.ts

src/positions.ts

// src/positions.ts
// 列出钱包所有未平仓的预测市场头寸
import { createSolanaRpc } from '@solana/kit';
import type { Market } from './types';
import { loadWallet, fetchJson, parseMintAndBalance, getWalletTokenAccounts } from './utils';

const METADATA_API = process.env.METADATA_API_URL;
const RPC_URL      = process.env.QUICKNODE_RPC_URL;

const rpc = createSolanaRpc(RPC_URL);

// Main
async function getPositions(walletAddress: string) {
  const tokenAccounts = await getWalletTokenAccounts(rpc, walletAddress);
  const heldMints: string[] = [];
  const mintToBalance: Record<string, bigint> = {};

  for (const { account } of tokenAccounts) {
    const { mint, amount } = parseMintAndBalance(account.data as [string, string]);
    if (amount > 0n) {
      heldMints.push(mint);
      mintToBalance[mint] = amount;
    }
  }

  if (heldMints.length === 0) {
    console.log('钱包中未找到 SPL 代币。');
    return;
  }

  console.log(`\n正在检查 ${heldMints.length} 个代币以获取预测市场头寸...\n`);

  const positions: { mint: string; side: 'YES' | 'NO'; market: Market; balance: bigint }[] = [];

  // 对于每个持有的铸币,通过 DFlow Metadata API 检查它是否是结果代币
  for (const mint of heldMints) {
    let market: Market;
    try {
      market = await fetchJson<Market>(`${METADATA_API}/api/v1/market/by-mint/${mint}`);
    } catch { continue; }
    const allAccts = Object.values(market.accounts);
    const side = allAccts.some(a => a.yesMint === String(mint)) ? 'YES' : 'NO';
    positions.push({ mint, side, market, balance: mintToBalance[mint]! });
  }

  if (positions.length === 0) {
    console.log('未找到预测市场头寸。');
    return;
  }

  console.log(`找到 ${positions.length} 个未平仓头寸:\n`);

  for (const pos of positions) {
    const acct = Object.values(pos.market.accounts)[0];
    console.log(`市场:     ${pos.market.ticker}`);
    console.log(`标题:      ${pos.market.title}`);
    console.log(`方向:       ${pos.side}`);
    console.log(`余额:    ${(Number(pos.balance) / 1_000_000).toFixed(6)} 代币`);
    console.log(`状态:     ${pos.market.status}${pos.market.result ? ` (结果: ${pos.market.result})` : ''}`);
    const isOpen = acct?.redemptionStatus === 'open';
    const sideWon =
      (pos.side === 'YES' && pos.market.result === 'yes') ||
      (pos.side === 'NO'  && pos.market.result === 'no');
    const isScalar = !pos.market.result && acct?.scalarOutcomePercent != null;
    const redeemable = isOpen && (sideWon || isScalar);
    const redeemLabel = redeemable
      ? '✅ 是'
      : !pos.market.result
        ? '⏳ 待定 (尚未确定)'
        : sideWon
          ? '⏳ 已赢 — 尚未开放兑换'
          : '❌ 已输';
    console.log(`可兑换: ${redeemLabel}`);
    console.log();
  }
}

loadWallet()
  .then(({ address: walletAddress }) => getPositions(walletAddress))
  .catch(console.error);

运行脚本以获取市场头寸:

tsx --env-file=.env src/positions.ts

预期输出:

正在检查 4 个代币以获取预测市场头寸...

找到 1 个未平仓头寸:

市场:     KXEPLGAME-26FEB18WOLARS-TIE
标题:      Wolverhampton vs Arsenal Winner?
方向:       YES
余额:    5.000000 代币
状态:     active
可兑换: ⏳ 待定 (尚未确定)

一旦比赛结束且市场最终确定,重新运行脚本将反映已结算的结果。如果你支持了正确的结果,头寸将显示为可兑换:

正在检查 4 个代币以获取预测市场头寸...

找到 1 个未平仓头寸:

市场:     KXEPLGAME-26FEB18WOLARS-TIE
标题:      Wolverhampton vs Arsenal Winner?
方向:       YES
余额:    5.000000 代币
状态:     finalized (结果: yes)
可兑换: ✅ 是

兑换赢取代币

市场确定后,赢取的结果代币可以兑换为其稳定币支付。用于购买的相同 /order 端点也处理兑换。DFlow 检测到输入是结果代币,并自动构建兑换交易。

在尝试兑换之前,必须满足三个条件:

  1. 市场状态为 determinedfinalized
  2. 结算铸币的 redemptionStatusopen(在 market.accounts 中检查)
  3. 结果代币与获胜方匹配(result === "yes" → 使用 yesMintresult === "no" → 使用 noMint

创建 src/redeem.ts

src/redeem.ts

import { createSolanaRpc } from '@solana/kit';
import type { Market, OrderResponse } from './types';
import { loadWallet, fetchJson, signAndSend, waitForOrder, parseMintAndBalance, getWalletTokenAccounts } from './utils';

const METADATA_API = process.env.METADATA_API_URL;
const TRADE_API    = process.env.TRADE_API_URL;
const RPC_URL      = process.env.QUICKNODE_RPC_URL;
const USDC_MINT    = process.env.USDC_MINT;

const rpc = createSolanaRpc(RPC_URL);

// Main
async function redeemOutcomeTokens(outcomeMint: string) {
  const wallet = await loadWallet();

  const tokenAccounts = await getWalletTokenAccounts(rpc, wallet.address);
  let rawBalance = 0n;
  for (const { account } of tokenAccounts) {
    const { mint, amount } = parseMintAndBalance(account.data as [string, string]);
    if (mint === outcomeMint) { rawBalance = amount; break; }
  }
  if (rawBalance === 0n) {
    console.error('在你的钱包中未找到此铸币的余额。');
    process.exit(1);
  }
  const tokenAmount = Number(rawBalance);

  // 步骤 1:验证代币是否可兑换
  const market = await fetchJson<Market>(`${METADATA_API}/api/v1/market/by-mint/${outcomeMint}`);
  const acct = Object.values(market.accounts)[0];
  if (!acct) throw new Error('未找到此市场的账户信息。');

  const isWinningMint =
    (market.result === 'yes' && acct.yesMint === outcomeMint) ||
    (market.result === 'no' && acct.noMint === outcomeMint);
  const isScalar = !market.result && acct.scalarOutcomePercent !== null;

  if (!isWinningMint && !isScalar) {
    console.log(`\n⚠️ 此结果代币无法兑换。`);
    console.log(`  市场结果: ${market.result ?? '尚未确定'}`);
    console.log(`  市场状态: ${market.status}`);
    return;
  }

  if (acct.redemptionStatus !== 'open') {
    console.log(`\n⚠️ 兑换窗口尚未开放。`);
    console.log(`  redemptionStatus: ${acct.redemptionStatus}`);
    return;
  }

  console.log(`\n正在从市场 ${market.ticker} 兑换 ${tokenAmount} 个代币`);

  if (isScalar && acct.scalarOutcomePercent !== null) {
    const yesPct = (acct.scalarOutcomePercent / 100).toFixed(2);
    const noPct = ((10_000 - acct.scalarOutcomePercent) / 100).toFixed(2);
    console.log(`  标量市场 — YES 支付: ${yesPct}%  NO 支付: ${noPct}%`);
  }

  // 步骤 2:请求兑换订单(与购买使用相同的 /order 端点)
  const params = new URLSearchParams({
    inputMint: outcomeMint,
    outputMint: USDC_MINT,
    amount: String(tokenAmount),
    userPublicKey: wallet.address,
  });

  const order = await fetchJson<OrderResponse>(`${TRADE_API}/order?${params}`);

  // 步骤 3:签署并发送
  const signature = await signAndSend(order, wallet.keyPair, rpc);

  console.log(`\n交易已提交: ${signature}`);
  console.log(`  浏览器: https://explorer.solana.com/tx/${signature}`);

  // 步骤 4:监控直到关闭
  const result = await waitForOrder(signature, order.lastValidBlockHeight, TRADE_API);
  if (result.status === 'closed') {
    console.log(`\n✅ 兑换完成!收到 ${result.outAmount / 1_000_000} USDC`);
  } else {
    console.log(`\n⚠️ 兑换以状态 ${result.status} 结束`);
  }
}

const [mint] = process.argv.slice(2);
if (!mint) {
  console.error('用法: npm run redeem -- <outcome-mint-address>');
  process.exit(1);
}

redeemOutcomeTokens(mint).catch(console.error);

在市场确定且兑换窗口开放后运行它:

tsx --env-file=.env src/redeem.ts 4qeSi2JVCbE9VQt1uzTJTpJSKdMFRsqWuvf3UL9fGa2P

预期输出:

正在从市场 KXEPLGAME-26FEB18WOLARS-TIE 兑换 5000000 个代币

交易已提交: 2sF5df4KwTsKx4V8Qg3GLAQFRDRtctnVUaQbB9n3cjyvyHxKKr6RPHWu38HRvMozd7HGi5JvbLw4amXMQeYvLuL5
  浏览器: https://explorer.solana.com/tx/2sF5df4KwTsKx4V8Qg3GLAQFRDRtctnVUaQbB9n3cjyvyHxKKr6RPHWu38HRvMozd7HGi5JvbLw4amXMQeYvLuL5
  状态: pending
  状态: closed

✅ 兑换完成!收到 5 USDC

这就是完整的流程!

你已经从通过 DFlow Metadata API 发现活跃市场,一直到将赢取的奖励兑换为 USDC,其中购买结果代币和跟踪头寸完全在 Solana 上处理。每个步骤都只是一个 API 调用或已签名的交易。无需链下交易基础设施。

生产环境清单

生产应用强制要求 KYC

任何允许最终用户通过 DFlow 交易预测市场的应用程序都必须集成 Proof 进行身份验证。Proof 是 DFlow 指定的 KYC 提供商,并且是上线的前提条件。Kalshi 受 CFTC 监管的地位意味着用户验证是一项合规要求,而不是一个可选功能。

在将预测市场应用程序部署到主网用户之前,请完成此清单:

API 密钥和端点

  • 将开发端点 (dev-quote-api.dflow.netdev-prediction-markets-api.dflow.net) 替换为 DFlow 提供的生产 URL 和 API 密钥
  • 在每个请求中通过 x-api-key 头传递 API 密钥
  • 应用具有指数退避机制的、感知速率限制的重试逻辑

市场生命周期边缘情况

  • 市场可能会暂时暂停;在显示“交易”CTA 之前检查 status
  • closeTime 标记交易结束时间。醒目显示此信息,以避免用户在市场关闭后尝试购买
  • result 在市场关闭后可能需要时间填充;轮询直到 status 达到 determined

常见问题

DFlow 的异步预测市场交易与常规 Solana 互换有何不同?

与标准原子互换(一次交易,即时确认)不同,DFlow 上的预测市场订单使用异步并发流动性程序 (CLP) 模型。你提交一笔交易表达“交易意图”,一个或多个流动性提供者在单独的交易中填充它。轮询 GET /order-status?signature=<txSig> 直到状态达到 closed,以确认你的结果代币已被铸造。

我可以使用任何代币购买“是/否”结果代币,还是必须是 USDC?

你可以从任何 Solana 现货代币开始。DFlow 的 /order 端点会自动将你的输入通过中间互换路由到市场的结算铸币(USDC 或 CASH),然后获取结果代币。但是,直接使用 USDC 或 CASH 作为输入是最快的路径。它跳过了额外的互换环节,并节省了大约 50 毫秒的延迟。

如果我支持了失败方,我的结果代币会发生什么?

失败的结果代币在结算后一文不值地到期。只有赢取方可以兑换全部结算价值(每枚代币 1.00 美元 = DFlow 内部尺度的 10,000)。在标量市场中,YES 和 NO 代币都可以根据 scalarOutcomePercent 字段以按比例的支付额度兑换。在尝试兑换交易之前,请务必确认 redemptionStatus 已开放。

市场状态为 determined 与 finalized 之间有什么区别?

determined 意味着 Kalshi 已确定获胜结果,但结算尚未完全传播。finalized 意味着市场已完全结算,所有头寸均已支付。一旦市场达到 determined 且结算铸币的 redemptionStatusopen,DFlow 就会将结果代币标记为可兑换。你应该等待 redemptionStatus: "open",而不是仅仅依赖市场状态。

我需要 API 密钥才能开始使用 DFlow 吗?

开发环境不需要。开发 Trade API (https://dev-quote-api.dflow.net) 和开发 Metadata API (https://dev-prediction-markets-api.dflow.net) 都无需验证即可工作。开发端点适用速率限制。对于生产流量,你需要一个 API 密钥(作为 x-api-key 头传递)。

如果我发布一个预测市场应用程序给用户,需要遵守哪些合规要求?

由于头寸由 Kalshi(一个 CFTC 监管的交易所)支持,任何生产应用程序都必须集成 Proof(一个 KYC/身份验证提供商)以满足 Kalshi 的合规要求。DFlow 的文档明确指出这是上线前的硬性要求。未能在上线前集成 KYC 合规性可能会违反 Kalshi 的服务条款。

总结

你已经构建了一个完整的 Solana 端到端预测市场流程。从 DFlow Metadata API 开始,你导航了系列 → 事件 → 市场层次结构以找到活跃的 EPL 合约,获取了事件和“是/否”价格,通过一笔已签名交易购买了结果代币,将钱包余额映射回市场头寸,并在结算后将赢取的代币兑换为稳定币。

这里的相同模式适用于 DFlow 暴露的任何 Kalshi 支持的市场:体育、经济或政治。你现在拥有坚实的基础来构建一个以 Solana 为核心的完整预测市场应用程序。

资源

我们❤️反馈!

如果你有任何反馈或新的主题请求,请告诉我们。我们很乐意听取你的意见。

  • 原文链接: quicknode.com/guides/sol...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
QuickNode
QuickNode
江湖只有他的大名,没有他的介绍。