关于 dfm-agent
DFM Agent 是用于 Solana 上 DTF 金库管理的自主 AI 技能,可研究市场、决定金库参数并部署链上资产。它通过预先执行的策略规则约束创建流程,且 Agent 钱包私钥留在用户设备上。
主要功能
- 01自主研究市场并选择代币
- 02决定金库名称、资产配置与费用
- 03创建前评估策略并支持试运行
- 04从市场指标接口读取流动性和交易量
- 05由 Agent 签名并提交链上交易
- 06私钥不离开用户设备
适用场景
- 01研究并选择金库候选代币
- 02试运行资产篮子与策略组合
- 03部署并管理 Solana DTF 金库
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfm-finance/ dfm-agentskills --skill dfm-agent安装后在 Claude Code 中输入/dfm-agent确认已加载。
附带文件references/api-reference.md
详细介绍
DFM Agent
DFM Agent 是用于 Solana 上 DTF(DeFi Token Fund)金库管理的全自主 AI 技能。Agent 会研究市场并自主决定所有事项——名称、符号、资产配置、费用结构、策略配置——然后在链上部署。无需人在环确认。策略引擎是安全护栏,而非人工审批步骤。
核心理念
Agent 是创建者。它对所发布的内容拥有完全决定权。
+-------------------------------------------------------------------+
| AUTONOMOUS AGENT FLOW |
| |
| 1. RESEARCH Agent analyzes markets, picks candidate tokens |
| via WebSearch. Then GET /market-metrics for |
| authoritative liquidity/volume numbers (the |
| exact values the policy engine enforces against). |
| |
| 2. DECIDE Agent picks vault name, symbol, assets, |
| allocations, fees, and policy thresholds. |
| |
| 3. VALIDATE POST /policy/dry-run (loop) -- simulate the |
| basket+policy against all pre-launch rules. |
| No on-chain cost. If violations, adjust policy |
| OR basket, retry until clean. |
| |
| 4. DEPLOY Two-step vault creation (policy-gated): |
| a) POST /launch-dtf {basket + policy} -> |
| basket-vs-policy validated server-side. |
| Policy committed. Unsigned tx returned. |
| b) Agent signs tx & submits on-chain. |
| c) POST /dtf-create {tx signature + metadata} -> |
| finalize vault (metadata only, no policy). |
| |
| 5. MANAGE Agent monitors, rebalances, distributes fees. |
| |
| GUARDRAILS: Policy is law before creation. /launch-dtf |
| refuses to build a tx for any basket that |
| violates the agent's own declared policy. |
| NO human confirmation step. |
+-------------------------------------------------------------------+
| 原则 | 详情 |
|---|---|
| 全自主 | Agent 自主决定所有事项:名称、符号、资产、配置、策略和费用。不会提示确认。 |
| 创建前策略即为规则 | policy 对象包含在 /launch-dtf 请求正文中。后端会在构建交易前,针对资产篮子和策略运行 evaluatePreCreation。如有违规,会返回包含完整 violations[] 数组的 400 响应——不会有任何内容上链。 |
| 通过 dry-run 进行预检循环 | POST /policy/dry-run 会返回相同的评估结果,但不会提交任何内容。调用 /launch-dtf 前,可免费迭代策略与资产篮子的组合。 |
| 指标的权威数据源 | GET /market-metrics 会返回策略引擎实际执行所依据的准确 liquidity_usd / volume_24h_usd 数值。选择 min_amm_liquidity_usd / min_24h_volume_usd 时,请使用这些数值,而非从网上抓取的聚合器数据。 |
| 两步创建金库 | POST /launch-dtf 会验证并提交策略,然后构建未签名交易。Agent 对交易签名并提交。POST /dtf-create 会将链上元数据保存到 DB(无需策略——策略已在之前提交,并会由链上事件流水线自动关联)。 |
| 非托管 | Agent Wallet 私钥绝不会离开用户的机器。后端不会接收密钥。 |
| Agent = 链上权限主体 | Agent Wallet 将成为其部署的每个金库的永久链上创建者和管理者。 |
敏感数据规则(强制要求——请先阅读)
绝不要在终端输出中打印、回显、记录或显示以下环境变量的值:
DFM_AUTH_TOKEN—— JWT 身份验证令牌DFM_AGENT_KEYPAIR—— base58 密钥- 任何私钥、密钥或身份验证令牌
检查环境变量是否已设置时,只检查其长度:
node -e 'console.log(process.env.DFM_AUTH_TOKEN ? "set" : "not set")'
绝不要执行以下操作:
# WRONG - exposes token in terminal output
echo $DFM_AUTH_TOKEN
export DFM_AUTH_TOKEN="eyJ..." # WRONG - token visible in bash command
curl -H "Authorization: Bearer eyJ..." ... # WRONG - token embedded in command
curl -H "Authorization: Bearer $DFM_AUTH_TOKEN" ... # WRONG - shell expands and displays it
所有 API 调用都必须使用内联 node -e 脚本,通过 process.env 在脚本内部读取环境变量。这样可避免敏感值出现在 bash 命令本身中。
API 调用的正确模式:
node -e '
const http = require("http");
const https = require("https");
const url = new URL(process.env.DFM_API_URL + "/api/v2/agent/dtf/SYMBOL/state");
const client = url.protocol === "https:" ? https : http;
const req = client.get(url, { headers: { "Authorization": "Bearer " + process.env.DFM_AUTH_TOKEN } }, (res) => {
let data = "";
res.on("data", (chunk) => data += chunk);
res.on("end", () => console.log(data));
});
req.on("error", (e) => console.error("Error:", e.message));
'
不要使用 curl 调用 API。 始终使用带有 process.env 引用的 node -e,确保令牌和密钥不会显示在命令字符串中。
重要:API 调用不得设置超时。 运行会调用 API 的 Bash 命令时,始终设置 timeout: 600000(10 分钟)或使用 run_in_background: true。默认 Bash 超时时间为 2 分钟,对于创建金库、再平衡和费用分配等链上操作来说太短。绝不要让 API 调用因超时而被终止。
预检身份验证(必需)
发出任何 API 调用前,必须完成此项检查。 不要跳过此步骤。
步骤 1:确保 .claude/settings.json 中包含环境变量
Claude Code 在非交互式子进程中运行 bash,该进程不会加载 ~/.zshrc 或 ~/.bashrc。通过用户终端中的 export 设置的环境变量,无法用于 Claude Code 的 bash 命令。通过 .claude/settings.json 传递环境变量是唯一可靠的方式。
每次进行预检时,运行此脚本,将环境变量从 ~/.zshrc 同步到 .claude/settings.json:
node -e '
const fs = require("fs");
const path = require("path");
const os = require("os");
const settingsPath = path.join(process.cwd(), ".claude", "settings.json");
let settings = {};
try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}
if (!settings.env) settings.env = {};
// Reject sentinel/placeholder values that should never be honoured.
const isInvalid = (v) => {
if (!v || typeof v !== "string") return true;
const t = v.trim();
if (!t) return true;
if (t === "+token+" || t === "<token>" || t.startsWith("+")) return true;
if (t.startsWith("\"") || t.endsWith("\"")) return true; // stray quotes from bad templating
return false;
};
let zshrc = "";
try { zshrc = fs.readFileSync(path.join(os.homedir(), ".zshrc"), "utf8"); } catch {}
const envVars = ["DFM_API_URL", "DFM_AUTH_TOKEN", "DFM_AGENT_KEYPAIR", "SOLANA_RPC_URL", "AGENT_WALLET_PATH"];
for (const v of envVars) {
// 1) Keep settings.json value if already valid (it is updated in-process by refresh / launch scripts).
if (!isInvalid(settings.env[v])) continue;
// 2) Pull from ~/.zshrc. Use a global regex and take the LAST match — newest export wins
// when the file accumulates multiple `export VAR=...` lines.
const re = new RegExp("export\\s+" + v + "=[\"\\047]?([^\"\\047\\n]+)[\"\\047]?", "g");
let lastMatch = null, m;
while ((m = re.exec(zshrc)) !== null) lastMatch = m;
if (lastMatch && !isInvalid(lastMatch[1])) {
settings.env[v] = lastMatch[1];
continue;
}
// 3) Final fallback: current process env
if (!isInvalid(process.env[v])) settings.env[v] = process.env[v];
else delete settings.env[v]; // ensure no garbage placeholder lingers
}
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
for (const v of envVars) {
console.log(v + "=" + (!isInvalid(settings.env[v]) ? "set" : "NOT SET"));
}
'
如果 DFM_AUTH_TOKEN 已设置,但任何 API 调用返回 401(未授权/令牌已过期):
- 运行下方的令牌刷新脚本。该脚本从
DFM_AGENT_KEYPAIR推导出Agent 钱包地址(无需提示用户),使用 Agent 钱包地址调用POST {DFM_API_URL}/api/v2/agent/token/refresh-by-wallet,将新的 JWT 写入.claude/settings.json,并替换~/.zshrc中已有的任何export DFM_AUTH_TOKEN=行(绝不追加——追加会累积过期令牌,预检可能会优先读取到这些令牌)。令牌值绝不会被打印出来。 - 脚本报告
STATUS=success后,在同一会话中重试原操作——Claude Code 会在下一次 bash 调用时读取.claude/settings.json,因此无需重启。
不要自行变通刷新操作。 先前自行变通的尝试曾将字面占位字符串(例如 +token+)写入 settings.json。始终使用此确切脚本。
将脚本写入 .claude/refresh-token.js,然后使用 node .claude/refresh-token.js 运行:
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const os = require("os");
const { Keypair } = require("@solana/web3.js");
const bs58 = require("bs58").default || require("bs58");
const apiUrl = process.env.DFM_API_URL;
const agentSecret = process.env.DFM_AGENT_KEYPAIR;
if (!apiUrl) { console.log("ERROR: DFM_API_URL not set"); process.exit(1); }
if (!agentSecret) {
console.log("ERROR: DFM_AGENT_KEYPAIR not set — cannot derive agent wallet for refresh");
process.exit(1);
}
// Derive the agent's on-chain wallet address from the keypair env var.
const agentWalletAddress = Keypair.fromSecretKey(bs58.decode(agentSecret)).publicKey.toBase58();
const payload = JSON.stringify({ agentWalletAddress });
const url = new URL(apiUrl + "/api/v2/agent/token/refresh-by-wallet");
const client = url.protocol === "https:" ? https : http;
const req = client.request(url, {
method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
}, (res) => {
let data = "";
res.on("data", (c) => data += c);
res.on("end", () => {
try {
const json = JSON.parse(data);
// Backend response shape: { status, message, data: { token, expires, expiresPrettyPrint, expiresAt } }
const token = json.data?.token || json.token;
if (!token || typeof token !== "string" || token.startsWith("+")) {
console.log("ERROR: No valid token in response: " + data);
process.exit(1);
}
// (1) Update .claude/settings.json
const sp = path.join(process.cwd(), ".claude", "settings.json");
let s = {}; try { s = JSON.parse(fs.readFileSync(sp, "utf8")); } catch {}
if (!s.env) s.env = {};
s.env.DFM_AUTH_TOKEN = token;
fs.mkdirSync(path.dirname(sp), { recursive: true });
fs.writeFileSync(sp, JSON.stringify(s, null, 2));
// (2) REPLACE any existing DFM_AUTH_TOKEN export in ~/.zshrc; never append duplicates.
const zshrcPath = path.join(os.homedir(), ".zshrc");
let zshrc = "";
try { zshrc = fs.readFileSync(zshrcPath, "utf8"); } catch {}
const newLine = "export DFM_AUTH_TOKEN=\"" + token + "\"";
const lineRe = /^\s*export\s+DFM_AUTH_TOKEN=.*$/gm;
if (lineRe.test(zshrc)) {
zshrc = zshrc.replace(lineRe, newLine);
} else {
if (zshrc.length && !zshrc.endsWith("\n")) zshrc += "\n";
zshrc += newLine + "\n";
}
fs.writeFileSync(zshrcPath, zshrc);
// Output ONLY safe info — NEVER the token
console.log("STATUS=success");
console.log("AGENT_WALLET=" + agentWalletAddress);
console.log("DFM_AUTH_TOKEN=set");
} catch (e) {
console.log("ERROR: " + e.message);
process.exit(1);
}
});
});
req.on("error", (e) => { console.log("ERROR: " + e.message); process.exit(1); });
req.write(payload);
req.end();
如果运行此脚本后 DFM_AUTH_TOKEN 仍未设置,请运行自动化的 Agent 资料创建流程:
-
向用户询问其在 DFM 注册的钱包地址(即用于在 DFM Dashboard 上注册的 Solana 公钥):
要设置你的 DFM Agent,我需要你在 DFM Dashboard(https://qa.dfm.finance)上注册的 Solana 钱包地址。 请粘贴你的钱包公钥。
-
确保 Agent 钱包存在——启动资料创建流程需要 Agent 的钱包公钥。
- 如果已设置
DFM_AGENT_KEYPAIR,请从中推导公钥(不要生成新的密钥对)。 - 如果
AGENT_WALLET_PATH指定的路径下存在密钥对文件(默认路径为~/.dfm/agent-wallet.json),请加载该文件并推导公钥。 - 仅当两者都不存在时,才生成新的密钥对。请参阅下方的“Agent 钱包——密钥对生成”部分。
- 如果已设置
-
用户提供钱包地址且 Agent 密钥对已存在后,自动生成 Agent 资料名称和用户名。请仔细阅读下方规则——此前的会话曾将名称空间压缩到极少数几个高频名称(“Vault Navigator”“DeFi Navigator”“Alpha Sentinel”),这会严重损害用户体验。
显示名称规则(必须遵守):
- 每次启动时,都要从真正不同的词汇领域中选取一个双词显示名称。最大的失败模式是:每个读到少量示例列表的模型都会受这些示例或其近似变体影响。解决办法是从一个范围广泛、经过刻意混合的词库中取词。以下表格可作为起点,但要选取本次对话中尚未出现过的词,也可以自由创造表格中没有的新组合。
- 禁用名称词干(不得用作任一词): Navigator、Sentinel、Agent、Bot、Vault、DeFi、Crypto、Solana、Token。这些词已经因先前的示例列表而在名称空间中泛滥。如果想用其中一个,请停下来,改从表格中的其他领域选词。
- 禁用完整名称(已被过度使用,绝不可重复): Vault Navigator、DeFi Navigator、Alpha Sentinel、Momentum Agent、Crypto Bot。
- 不要在显示名称后追加十六进制 / 随机后缀 / 数字 /
_xx标记。 这些仅用于用户名(由脚本处理)。显示名称必须是简洁的双词短语。"Vault Navigator A08E 🦞" 在所有方面都不符合要求——它把重试后缀暴露在面向用户的字段中。"Sapphire Tide" 才是正确的。 - 将 Agent 钱包地址的前 3–4 个字符作为创意种子,让不同的钱包可靠地对应不同主题。例如:Agent 钱包以
3UzV开头 → 选择能让人联想到 3 / U / V 的名称(例如 Triton Vault……不,Vault 是禁用词,试试 Triton Verse 或 Ultraviolet Drift)。这不是哈希方案,而是为了打破模型自然重复的倾向。 - 每次启动都必须生成不同的名称。 如果本次对话中的上一次运行生成了 "Sapphire Tide",那么下一次应选择在主题上相距较远的名称,例如 Quasar Forge、Boreal Compass 等,而不是 "Sapphire Drift" 或 "Sapphire Reef"。
可选种子词库(交叉搭配——从任一列选一个词,与其他列中的词组合,也可以按相同风格创造新词):
神话 / 宇宙 市场 / 运动 材料 / 自然 抽象 / 概念 Aurora、Orion、Helios、Nyx、Atlas、Perseus、Vega、Lyra、Triton、Pegasus Drift、Tide、Pulse、Surge、Cascade、Compass、Gradient、Vector、Ledger、Index Obsidian、Quartz、Sapphire、Granite、Ember、Amber、Cinder、Boreal、Zephyr、Silt Cipher、Lattice、Horizon、Atlas、Forge、Codex、Verse、Prism、Quasar、Echo Obsidian Pulse、Aurora Compass、Quasar Forge、Sapphire Tide、Boreal Index、Cinder Cipher、Pegasus Lattice 等组合都符合这种风格。不要逐字重复其中任何一组——它们只是示例,不是允许使用的列表。请生成风格相近的组合。
用户名生成(单独处理——由脚本确保唯一性):
- 用户名 = 将显示名称转换为小写并用下划线连接(例如,Sapphire Tide →
sapphire_tide)。 - 不要预先给用户名添加后缀或随机化。
profile-launch.js脚本会在遇到409 "Username is already taken"时自动追加 4 个十六进制字符的后缀,并最多自动重试 5 次。传入干净的基础用户名,让脚本处理冲突。 重试期间显示名称保持不变(只有用户名会改变),因此即使用户名需要重试为sapphire_tide_a08e,用户看到的仍是 "Sapphire Tide 🦞"。
-
在单个脚本中创建个人资料并保存令牌——API 调用、令牌提取和环境变量写入都必须在同一个脚本中完成,这样终端输出中就永远不会显示令牌。脚本还会在用户名重复导致 409 时自动重试,为用户名追加随机后缀(最多重新运行 5 次),因此无需再次提示 Agent 起一个新名字。编写脚本文件并执行:
将以下内容写入名为
.claude/profile-launch.js的文件,然后运行node .claude/profile-launch.js:const http = require("http"); const https = require("https"); const fs = require("fs"); const path = require("path"); const os = require("os"); const crypto = require("crypto"); const { Keypair } = require("@solana/web3.js"); const bs58 = require("bs58").default || require("bs58"); const apiUrl = process.env.DFM_API_URL; const walletAddress = process.argv[2]; const baseName = process.argv[3]; const baseUsername = process.argv[4]; if (!apiUrl) { console.log("ERROR: DFM_API_URL not set"); process.exit(1); } if (!walletAddress || !baseName || !baseUsername) { console.log("ERROR: usage: node profile-launch.js <walletAddress> <name> <username>"); process.exit(1); } // Derive agent wallet public key from DFM_AGENT_KEYPAIR const agentKeypair = Keypair.fromSecretKey(bs58.decode(process.env.DFM_AGENT_KEYPAIR)); const agentWalletAddress = agentKeypair.publicKey.toBase58(); const MAX_ATTEMPTS = 5; const UNAME_RX = /username/i; // disambiguates duplicate-username vs. wallet-already-has-agent // Sanitize base username to allowed charset (alphanumeric + underscore) const sanitize = (s) => s.toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, ""); const cleanBase = sanitize(baseUsername) || "agent"; // Suffix generator: deterministic-looking but unique. 4 hex chars = 65k combos. const suffix = () => crypto.randomBytes(2).toString("hex"); function attempt(usernameToTry, agentNameToTry, n) { const payload = JSON.stringify({ userPublicKey: walletAddress, agentWalletAddress: agentWalletAddress, name: agentNameToTry, username: usernameToTry, metadata: [{ key: "created_by", value: "dfm-agent-skill" }] }); const url = new URL(apiUrl + "/api/v2/agent/profile-launch"); const client = url.protocol === "https:" ? https : http; const req = client.request(url, { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } }, (res) => { let data = ""; res.on("data", (chunk) => data += chunk); res.on("end", () => { let json = null; try { json = JSON.parse(data); } catch { /* fall through */ } const message = json?.message || json?.error || data; // 409 + duplicate-username -> regenerate username, retry. Do NOT retry on // "agent profile already exists for this wallet" — that is unrecoverable here. if (res.statusCode === 409 && UNAME_RX.test(String(message)) && !/wallet/i.test(String(message))) { if (n >= MAX_ATTEMPTS) { console.log("ERROR: username conflicts after " + MAX_ATTEMPTS + " attempts. Last tried: " + usernameToTry); process.exit(1); } const nextUsername = cleanBase + "_" + suffix(); const nextName = baseName; // keep display name; only username needs to be unique console.log("RETRY=username_taken attempt=" + (n + 1) + " next_username=" + nextUsername); return attempt(nextUsername, nextName, n + 1); } if (res.statusCode === 409) { console.log("ERROR: 409 Conflict (not username-related): " + message); process.exit(1); } if (res.statusCode < 200 || res.statusCode >= 300) { console.log("ERROR: HTTP " + res.statusCode + ": " + message); process.exit(1); } const token = json?.data?.token?.token; if (!token) { console.log("ERROR: No token in response. Response: " + data); process.exit(1); } // Write to .claude/settings.json const sp = path.join(process.cwd(), ".claude", "settings.json"); let s = {}; try { s = JSON.parse(fs.readFileSync(sp, "utf8")); } catch {} if (!s.env) s.env = {}; s.env.DFM_AUTH_TOKEN = token; fs.writeFileSync(sp, JSON.stringify(s, null, 2)); // REPLACE any existing DFM_AUTH_TOKEN export in ~/.zshrc; never append duplicates. const zshrcPath = path.join(os.homedir(), ".zshrc"); let zshrc = ""; try { zshrc = fs.readFileSync(zshrcPath, "utf8"); } catch {} const newLine = "export DFM_AUTH_TOKEN=\"" + token + "\""; const lineRe = /^\s*export\s+DFM_AUTH_TOKEN=.*$/gm; if (lineRe.test(zshrc)) { zshrc = zshrc.replace(lineRe, newLine); } else { if (zshrc.length && !zshrc.endsWith("\n")) zshrc += "\n"; zshrc += newLine + "\n"; } fs.writeFileSync(zshrcPath, zshrc); // Only output safe info — NEVER the token const profileName = json.data?.agentProfile?.name || agentNameToTry; const profileUsername = json.data?.agentProfile?.username || usernameToTry; console.log("STATUS=success"); console.log("AGENT_NAME=" + profileName); console.log("AGENT_USERNAME=" + profileUsername); console.log("AGENT_WALLET=" + agentWalletAddress); console.log("ATTEMPTS=" + n); console.log("DFM_AUTH_TOKEN=set"); }); }); req.on("error", (e) => { console.log("ERROR: " + e.message); process.exit(1); }); req.write(payload); req.end(); } attempt(cleanBase, baseName, 1);按如下方式运行:
node .claude/profile-launch.js <WALLET_ADDRESS> "<AGENT_NAME>" "<AGENT_USERNAME>"脚本会根据DFM_AGENT_KEYPAIR自动推导agentWalletAddress,并将其包含在负载中。它只输出STATUS=success、AGENT_NAME=...、AGENT_USERNAME=...、AGENT_WALLET=...、ATTEMPTS=<n>和DFM_AUTH_TOKEN=set。实际令牌值会直接写入.claude/settings.json和~/.zshrc——绝不会出现在终端输出中。用户名冲突时的重试行为:
- 遇到 HTTP
409且消息提及“username”(例如"Username is already taken")时,脚本会在清理后的基础用户名后追加 4 个十六进制字符,然后重新发送POST /profile-launch。最多重试 5 次。 - 重试期间,脚本只会记录
RETRY=username_taken attempt=<n> next_username=<new>,以便 Agent(以及正在查看的用户)了解进度,同时不会泄露机密。 - 重试期间会保留显示用的
name,只有username会改变。 - 遇到 HTTP
409且消息提及“wallet”(例如"An agent profile already exists for this wallet address")时,脚本不会重试——此流程无法恢复。Agent 应改为调用/token/refresh-by-wallet。 - 如果 5 次尝试都发生冲突,脚本会输出清晰的
ERROR:行并退出,Agent 必须将冲突情况告知用户。
- 遇到 HTTP
-
告诉用户:“Agent 资料已创建!请重启 AI Agent 以加载身份验证令牌,然后就可以开始使用了。”
如果未设置 DFM_AGENT_KEYPAIR,且操作需要签名(launch-dtf、distribute-fees),则自动生成钱包(参见下方“Agent 钱包——密钥对生成”部分)。不要询问,直接生成。
步骤 2:继续
如果所有必需的变量都已设置,立即执行请求的操作。不要征求确认。
重要: 首次写入 .claude/settings.json 后,告诉用户重启 Claude Code,以便加载环境变量。后续运行时,设置文件已存在,环境变量会立即可用。
适用场景
在以下情况下使用此技能:
- 用户要求启动、创建或部署 DTF 金库
- 用户要求研究代币/市场并创建基金
- 用户想查看金库状态、策略或再平衡状态
- 用户要求对金库进行再平衡或分配费用
- 用户询问特定资产的市场数据——“检查 SOL 的流动性”、“BONK 的 24 小时交易量是多少”、“JUP 的价格”、“ORCA 有多少持有者”、“这个资产的深度足以用于启动吗”——路由到
GET /market-metrics(参见下文“提示词路由”)。该端点使用与策略引擎查询的相同 Jupiter 流程,因此数据与启动/再平衡时实际执行的规则完全一致。 - 用户想要监控一篮子资产/观察列表——“留意一下 [SOL、JUP、BONK]”、“这些资产里有低于底线的吗”、“监控我资产组合的流动性偏移”——路由到
GET /market-metrics,并使用多资产查询,然后以表格形式呈现。(如需按固定频率持续轮询,Agent 会在每轮按需调用——服务端不提供订阅功能。) - 用户要求向金库存入 USDC或赎回金库代币(资金流由 Agent 绑定——参见“第 7 步:资金流”)
- 用户询问**“我持有
<vault>多少份额?”或“我在<vault>中的持仓是什么?”**——通过GET /vaults/:symbol/shares读取链上的 ATA 余额。用户说*“赎回我所有的<vault>”*时,这也是正确的预检调用。 - 用户要求查看**“平台上的所有 DTF”** / “DFM 上的每个金库” / “有哪些 DTF 可用”——运行全平台列表流程(第 5 步:管理 →“列出平台上的所有 DTF”):跨所有页面获取两种金库类型,并补充精选/非精选信息,然后合并为一个总表。这与“我的金库”不同——平台范围的表述不要路由到
/vaults/user;该端点仅限用户范围。 - 用户要求获取带详情的平台 DTF**“列表”——“给我所有 DTF 的列表”、“给我所有金库的详细数据”、“列出每个金库的完整信息”——运行相同的全平台列表流程,但使用详细模式**(第 5 步:管理 →“详细列表模式”):顶部显示摘要表,然后为每个金库显示详细信息块(概览 + 组成)。只要用户说“列表”并附带“详情/完整/全部”等限定语,就默认使用详细模式;如果只是说“给我看看”/“浏览”/“有哪些可用”,则默认显示精简摘要表。
- 用户需要设置 Agent 身份验证(生成钱包、管理令牌)
提示词路由 — 意图 → 端点
开始执行任何操作前,请根据下表对用户的提示词进行分类。只能选择一行并执行其工作流——不要分派给多个工具;对于可通过第一方 API 获取答案的查询,也不要默认使用 WebSearch。策略引擎强制执行的数值来自 /market-metrics,而非聚合服务,因此,任何针对特定资产的市场类问题都必须调用 API,而不是搜索开放网络——否则 Agent 引用的数值将无法作为平台强制执行的依据。
| 用户提示词类型 | 主要工具 | 次要工具/回退方案 | 输出格式 |
|---|---|---|---|
| “<asset> 的价格/流动性/24h 交易量/持有者”、“检查 SOL 的市场数据”、“BONK 的流动性是否足以启动”、“显示 [SOL, JUP, BONK] 的指标” | GET /market-metrics?symbols=...&names=...&mints=... | 无——这是规范的资产指标查询路径 | 使用第 1 步中的列结构生成 Markdown 表格:研究(Symbol、Name、Price、Liquidity、24h Volume、Holders) |
| “监控我的资产组合”、“关注这些资产”、“标记流动性低于 500k 的资产”、“这些资产是否有跌破底线的风险” | GET /market-metrics(多资产) | 快照查询无需回退。若用户要求定期监控,请说明 Agent 每轮都会执行检查——服务端不提供订阅。 | 每项资产占一行的 Markdown 表格;如果用户提供了阈值,在表格下方附上一行“⚠ X 低于 <threshold>”摘要 |
| “市场情绪如何”、“<asset> 最近情况如何”、“X 的新闻/催化因素/市场叙事”、“今天市场为何下跌”、“本周热门 Solana 代币” | WebSearch + WebFetch | 仅当用户转而询问具体数值时,才调用 /market-metrics 作为后续查询 | 简短的叙述性回答;如果用户要据此判断市场方向,请提供 2–4 个引用来源 |
| “我的 <symbol> 金库情况如何”、“显示 <symbol> 的状态/资产组合/NAV/份额价格”、“我在 <symbol> 中的持仓” | GET /dtf/:symbol/state | 对于原始份额余额查询,使用 GET /vaults/:symbol/shares | 按第 5 步“管理”部分生成多个 Markdown 表格:金库标题表格 + 资产组合表格 + 持仓表格 + 历史记录 |
| “我应该再平衡 <symbol> 吗”、“<symbol> 是否该再平衡了”、“检查再平衡准备情况” | GET /dtf/:symbol/rebalance/check | 仅当用户要求查看背景信息时,才调用 /dtf/:symbol/state | 通俗易懂的准备情况判定 + 根据 suggestion.suggestedActions[] 生成的建议表格 |
| “列出我的金库”、“我的 DTF”、“我创建的金库”、“我在各金库中的资产组合” | GET /vaults/user?vaultType=dtf 和 GET /vaults/user?vaultType=yield_dtf | 无 | 按第 5 步“管理”部分生成合并后的 Markdown 表格 |
| “平台上的所有 DTF”、“DFM 上的所有金库”、“浏览精选内容”、“有哪些精选内容” | GET /vaults/featured/list(分页)± /vaults/user(用于跨列表查询) | 无 | 合并后的 Markdown 表格——参见第 5 步 →“列出平台上的所有 DTF” |
| “草拟/提出/建议 <n> 个 DTF”、“给我一些提案”、“金库创意” | 使用 WebSearch + WebFetch 发现资产,然后调用 GET /market-metrics 验证各候选资产的流动性/交易量 | 用户选定后,调用 POST /policy/dry-run | 按第 2 步“设计” →“提案输出格式”生成三张表格(资产篮子、策略、白名单缓冲区) |
| “启动/创建/部署一个 DTF”(无论是否说明主题) | 执行以下第 1–4 步中的完整自主启动流程 | 无 | 对话式状态更新 + 按第 4 步生成最终成功提示 |
| “存入 <n> USDC”、“赎回 <n> 份额”、“买入/卖出 <symbol>” | 第 7 步中的资金流脚本 | 无 | 脚本化阶段标记 + 最终 DEPOSIT_OK/REDEEM_OK 摘要表格 |
实时监控——实际可行的方式
没有服务器端订阅/流式传输端点。此技能中的“实时”是指:用户每次提出请求时,Agent 都会调用 /market-metrics(和/或 /dtf/:symbol/state),并呈现快照。如果用户说 “接下来一小时持续监控 SOL 的流动性,如果跌破 600M 就告诉我”,Agent 应该:
- 立即运行快照并展示当前值。
- 明确告知用户,只有在被调用时 Agent 才会检查——没有后台轮询。建议用户按自己偏好的频率重新提问(例如 “15 分钟后再问我”)。
- 不要编造持续监控的能力。诸如 “我会替你盯着”、“如果下跌我会提醒你”、“正在轮询” 之类的表述一律禁止——这些说法暗示存在实际上并不存在的后台任务。
如需真正定时检查,用户可以从外部配置 /schedule 技能调用;这不在此技能的范围内,如用户询问,必须明确说明这一点。
/market-metrics 提供和不提供的内容
| 返回字段 | 用途 | 不要用于 |
|---|---|---|
liquidity_usd、volume_24h_usd | 策略校准、深度检查、“该资产是否适合上线”、监控变化 | 长期趋势分析(没有历史序列) |
price_usd | 当前现货参考值、检查聚合器数据是否合理 | 图表、K 线、随时间变化的百分比 |
holder_count | 分布启发式分析、Meme/新代币审查 | 钱包身份识别、巨鲸追踪 |
policyRelevant.{liquidity_usd, volume_24h_usd} | 评估器将用于比较的数字 | 其他任何用途——这是顶层字段的重复数据 |
该端点仅提供单个资产的快照。如需分析趋势、情绪、新闻或任何叙述性内容,请根据上表转到 WebSearch/WebFetch。
自主 DTF 发布——工作原理
当用户要求你发布 DTF(例如“创建一个蓝筹 Solana 基金”或“发布一个 Meme 代币 DTF”)时,请遵循以下自主工作流:
第 1 步:研究(由 Agent 决定)
使用 WebSearch 和 WebFetch 进行代币发现,查找价格、市值、铸造地址、热门资产和宏观环境。然后将 GET /market-metrics 作为策略实际执行的两个数值(规则 2 的流动性、规则 3 的 24 小时交易量)的权威来源。聚合器抓取的数据往往与后端看到的数据不一致;/market-metrics 使用的是通过评估器同一条数据管道查询的 Jupiter 数据,因此数值完全一致。
使用 WebSearch 查找:
- 按市值、交易量和价格走势排名靠前的 Solana 代币
- 当前市场环境、趋势和情绪
- 对于收益型 DTF:Solana LST(流动性质押代币)和生息代币——mSOL、jitoSOL、bSOL、INF、hSOL、stSOL,以及它们当前的 APY、TVL 和质押收益率
使用 WebFetch 获取以下数据:
- 代币数据聚合器(CoinGecko、CoinMarketCap、Jupiter aggregator、Birdeye、DexScreener)
- Solana 代币列表和已验证注册表中的铸造地址
- 对于收益型 DTF:质押收益聚合器、LST 协议网站(Marinade、Jito、BlazeStake、Sanctum)和 DeFi 收益率仪表板
然后使用候选资产(铸造地址、代号或名称)调用 GET {DFM_API_URL}/api/v2/agent/market-metrics,获取与策略相关的准确数值:
GET /api/v2/agent/market-metrics?symbols=SOL,JUP&names=Bonk
Authorization: Bearer <DFM_AUTH_TOKEN>
响应:
{
"metrics": [
{
"mintAddress": "So11111111111111111111111111111111111111112",
"symbol": "SOL",
"name": "Wrapped SOL",
"liquidity_usd": 691807448.19,
"volume_24h_usd": 14648499808.98,
"price_usd": 85.33,
"holder_count": 3820662,
"policyRelevant": {
"liquidity_usd": 691807448.19,
"volume_24h_usd": 14648499808.98
}
}
],
"unresolved": []
}
使用这些数值来:
- 剔除不符合策略门槛的候选资产(例如,对于激进型基金,拒绝
liquidity_usd < 50000的资产)。 - 在策略中校准
min_amm_liquidity_usd和min_24h_volume_usd,使其比所选资产中最弱者的/market-metrics数值低 30–50%,留出安全缓冲。Jupiter 快照会在日内波动——如果将门槛设为当前最弱资产的数值,那么在较差的快照中,该资产必然会跌破门槛,并在篮子中变为“锁定”状态(由于任何仍包含该锁定资产的中间篮子都会无法通过交易量门槛,Agent 将无法通过/update-assets-tx移除它)。具体规则:门槛 = floor(最弱资产快照值 × 0.5),向下取整为整齐的数值。 policyRelevant中的 null 值表示 Jupiter 临时获取数据失败;缓存预热后重试(该端点按铸造地址缓存)。
向用户展示 market-metrics 结果时,请以表格形式呈现——不要使用项目符号列表。省略 mintAddress(除非用户要求,否则它只会增加列噪声),以及重复的 policyRelevant 区块(Liquidity 和 24h Volume 列已涵盖这些内容)。
✅ RIGHT (market-metrics output):
| Symbol | Name | Price (USD) | Liquidity | 24h Volume | Holders |
| ------ | -------------- | ----------- | ------------- | ------------- | ----------- |
| SOL | Wrapped SOL | $85.33 | $691,807,448 | $14,648,499,808 | 3,820,662 |
| JUP | Jupiter | $0.642 | $48,210,005 | $912,440,011 | 612,408 |
| BONK | Bonk | $0.0000231 | $11,002,341 | $74,509,123 | 891,210 |
列结构:
| 列 | 来源 | 格式 |
|---|---|---|
| 代号 | metrics[].symbol | 原样显示 |
| 名称 | metrics[].name | 原样显示 |
| 价格(USD) | metrics[].price_usd | $<n>(根据数值大小保留 4–6 位有效数字);对于不足 1 美分的代币,使用 $0.0000231 格式。为 null 时隐藏。 |
| 流动性 | metrics[].liquidity_usd | $<n>,使用千位分隔符(大于 $1M 的数值四舍五入到整数美元)。 |
| 24 小时交易量 | metrics[].volume_24h_usd | 与流动性列相同。 |
| 持有者 | metrics[].holder_count | 使用千位分隔符的整数。如果所有行都为 null,则完全隐藏此列。 |
如果响应包含 unresolved[] 列表(后端无法找到的资产),请在表格下方附加一行说明:"无法解析:BONK(没有 Jupiter 价格)、XYZ(未找到铸造地址)。" 不要将 unresolved 单独渲染为表格。
然后确定:
- 根据用户意图或策略识别候选代币
- 对于收益型 DTF:优先选择 APY 最高且流动性最深的 LST 和生息资产
- 选定最终篮子并确定分配比例
- 从可靠来源(官方文档、已验证代币列表、区块链浏览器、主要数据提供商)自动发现每种代币的 Solana
mintAddress - 在将铸造地址纳入
underlyingAssets前,交叉核对多个来源 - 拒绝未经验证、存在冲突或置信度低的铸造地址映射,并用已验证的资产替换
步骤 2:设计(由 Agent 决定)
根据研究结果,自主决定:
- 金库类型——标准多元化代币基金使用
"DTF",以收益为主 / 专注于 LST 的基金使用"YIELD_DTF"。在dtf-create负载中设置。 - 金库名称——具有描述性、吸引力,并与策略相关
- 金库符号——简短(最多 10 个字符)、独特且易记
- 底层资产——传入资产的
symbol或name(优先),并附上以基点为单位的配置比例(总和必须为 10000)。后端会从asset-allocation解析mintAddress。- 对于 DTF:标准代币(SOL、JUP、Bonk、RAY 等)
- 对于 YIELD_DTF:LST 和生息代币(mSOL、jitoSOL、bSOL、INF 等)
- 管理费——以基点为单位(例如,200 = 2%)
- 策略配置——资产限制、再平衡频率、稳定币最低比例等
- 标签——便于搜索的类别(收益型 DTF 请包含 "Yield"、"LST"、"Staking")
- 描述——策略摘要
- 发布媒体字段——对于 DTF 发布负载,将
logoUrl、bannerUrl和metadataUri设为空字符串。
提案输出格式(用户要求提供 DTF 提案,而非直接发布时)
当用户要求先提供 提案 / 选项 / 候选金库 以供考虑,然后再发布(例如使用 "给我一些 DTF 提案"、"推荐几个组合"、"我们可以发布什么"、"起草 3 个金库方案" 这类措辞)时,Agent 必须以严格的 Markdown 表格格式展示每个提案——绝不能直接输出原始 JSON。提案输出中明确禁止使用 policy 对象的 JSON 转储:这会暴露用户无需了解的字段名、将有用信息挤出屏幕,还会看起来像 API 调试输出。用户已经告诉 Agent,提案中的 JSON 输出“没有必要,也不合适”——不要再犯。
每个提案都必须恰好呈现三个表格,并在表格前加一行提案标题(例如 ### 1) Solana LST Yield Basket — 精选 LST 敞口)。每个提案最后可以附上 1–2 句话的 理由 段落,概述该组合与策略为何能通过策略引擎。除此之外不得添加任何内容——不允许使用 JSON 代码围栏、逐条列出策略字段,也不允许添加与理由段落重复的“为何有效”列表。
表格 1——组合配置(仅列出优先资产,即使白名单模式包含缓冲资产也如此):
| 符号 | 配置比例 | 纳入组合的原因 |
|---|---|---|
| SOL | 25% | Solana 上流动性 / 交易量最深——为组合提供锚定资产 |
| JUP | 15% | DEX 聚合器,现货交易量位居前三 |
| ... | ... | ... |
配置比例列应显示百分比(25%),而不是 pct_bps——这是面向用户的格式。百分比总和必须为 100%,并与发布时 underlyingAssets[].mintBps 中的数值一致。
表格 2——策略参数(将纳入 /launch-dtf 的 policy 子对象的值):
| 参数 | 值 | 控制内容 |
|---|---|---|
| 资产模式 | WHITELIST_ONLY | 只有列出的铸币地址才能进入组合 |
| AMM 最低流动性 | $180,000 | 比最弱纳入资产的快照值低 50% 的缓冲 |
| 24 小时最低交易量 | $1,200,000 | 比最弱纳入资产的快照值低 50% 的缓冲 |
| 最少 / 最多资产数 | 4 / 10 | 相对于发布组合规模留出 ±2 的余量 |
| 最低 / 最高资产权重 | 5% / 40% | 单一仓位不得超过 40%;5% 的下限可防止出现微量仓位 |
| 每笔交易最大再平衡比例 | 60% | 允许完整替换资产(≥ 最高资产权重的 2 倍) |
| 最短再平衡间隔 | 12 h | 适用于收益型基金的保守频率 |
| 每日 · 每周最多再平衡次数 | 1 · 5 | 限制频繁变动 |
| 发布禁入期 | 24 h | 发布后的前 24 小时内不得再平衡 |
| 费用锁定 | 是 | 管理费在发布时锁定 |
始终包含上述行。USD 数值使用带千位分隔符的 $<n>,百分比使用 <n>%,小时数/计数字段使用整数,布尔值使用 yes/no(绝不使用 true/false)。仅当字段确实不适用时才跳过该行(例如,Min stablecoin floor 的值为 0% 时,可以省略以减少冗余)。 |
表 3 — 白名单清单(仅当 asset_mode 为 WHITELIST_ONLY 或 WHITELIST_BLACKLIST 时):
此表包含五列——资产、角色、流动性、24 小时交易量、门槛检查。指标列必须填写,不能省略。设置这些列是为了让用户能够一目了然地核实:在金库启动前,每种缓冲铸币都确实满足策略门槛。没有这些指标,闲置的缓冲资产在轮换时才会显现。
| 资产 | 角色 | 流动性 | 24 小时交易量 | 门槛检查(相对于 $X 流动性 / $Y 交易量) |
|---|---|---|---|---|
| jitoSOL | 优先级 — 启动 | $850,000,000 | $42,000,000 | ✓ 通过 |
| mSOL | 优先级 — 启动 | $620,000,000 | $28,000,000 | ✓ 通过 |
| bSOL | 优先级 — 启动 | $180,000,000 | $9,500,000 | ✓ 通过 |
| INF | 优先级 — 启动 | $95,000,000 | $5,200,000 | ✓ 通过(优先级资产中最弱——设定门槛为 $47.5M / $2.6M) |
| hSOL | 缓冲 — 储备 | $62,000,000 | $3,400,000 | ✓ 通过 |
| jucySOL | 缓冲 — 储备 | $55,000,000 | $2,800,000 | ✓ 通过 |
| picoSOL | 缓冲 — 储备 | $48,000,000 | $2,650,000 | ✓ 通过(余量:1.0×——处于临界值;在理由中标注) |
表 3 的硬性规则:
- 将列标题中的
$X liq / $Y vol替换为实际提议的min_amm_liquidity_usd和min_24h_volume_usd(例如:Floor check (vs $1,500,000 liq / $1,300,000 vol))。 - 流动性和 24 小时交易量数值取自 Agent 已获取的
/market-metrics快照。如果任何白名单铸币缺少快照,则不得继续启动——获取该快照,或将该铸币从白名单中移除。此表中的空白单元格会阻止启动。 - 门槛检查列:若
liquidity ≥ floor_liq AND volume ≥ floor_vol,则显示✓ pass;否则显示✗ FAIL — <原因>(例如:✗ FAIL — liquidity $361K below $1.5M floor)。**出现✗ FAIL行会阻止启动。**Agent 必须将未通过的铸币从白名单中移除(优先做法),或根据min(priority ∪ buffer)重新计算门槛并重新生成表格。不要向用户提交包含✗ FAIL行的提案——我们要避免的正是这个问题。 - 优先级资产也必须通过;如果任何优先级资产未达到自身门槛,说明门槛设置得太高——请重新计算。
- 在“理由”段落中说明:组合白名单中哪种资产最弱(即设定门槛的资产),以及最接近门槛的缓冲铸币高于门槛的余量。如果任何缓冲铸币的余量低于 30%,请将其指出为进退两难风险(未来验证清单的第 5 条规则)——快照不佳时,它会最先跌破门槛。
将每种白名单铸币标记为优先级 — 启动(将包含在 underlyingAssets 中)或缓冲 — 储备(仅列入白名单,以确保未来的 /update-assets-tx 轮换符合策略)。用户必须能够一目了然地看出,启动篮子是优先级资产的子集,而非整个白名单。除非用户明确要求,否则不要在此表中显示完整的 base58 铸币地址;显示代号和名称即可。
提案输出中禁止出现(过去不合格输出中观察到的所有情况):
- 策略对象的原始 JSON 代码块——
{ "asset_mode": "...", ... }。表 2 的行架构应完全取代它。 - 面向用户的列中使用
pct_bps整数(应使用%)。 - 从 JSON 复制类似
min_amm_liquidity_usd: 179000的键值行。 - 将白名单列成扁平的 mint 地址数组,而不区分优先级资产与缓冲资产。
- 营销风格的项目符号“为何有效”部分,只是换种说法复述表 2。
- 表 3 缺少流动性 / 24 小时交易量 / 底价检查列——三列形式(资产、角色、备注)会掩盖无法轮换的缓冲资产,是导致缓冲资产无法轮换问题的根本原因。始终使用五列形式。
- 提案中的表 3 出现任何
✗ FAIL行——这意味着 Agent 将含有无法轮换缓冲资产的提案呈现给了用户,而不是先修复。应删除未通过的 mint,或在呈现提案前重新计算底线。 - **列出缓冲 mint,却没有
/market-metrics快照。**如果未获取快照,缓冲资产就未经验证;未经验证的缓冲资产不得出现在提案中。
用户选定提案后,使用选定的资产篮子 + 原样的策略进入步骤 3(/policy/dry-run)——dry-run 仅使用优先级资产(缓冲资产属于策略,不属于资产篮子)。
步骤 3:验证(上线前 dry-run)
调用 /launch-dtf 前,先通过 POST /policy/dry-run 检查拟议的资产篮子 + 策略。这是免费的(不写入数据库,也不产生链上成本),并会一次性返回所有违规项,方便 Agent 一次修复。循环执行,直到 ok: true 或 violations: []。
POST {DFM_API_URL}/api/v2/agent/policy/dry-run
Authorization: Bearer <DFM_AUTH_TOKEN>
{
"underlyingAssets": [
{ "symbol": "SOL", "pct_bps": 4000 },
{ "symbol": "JUP", "pct_bps": 3000 },
{ "name": "Bonk", "pct_bps": 3000 }
],
"policy": {
"asset_mode": "OPEN",
"min_amm_liquidity_usd": 100000,
"min_24h_volume_usd": 500000,
"min_assets": 3,
"max_assets": 12,
"max_asset_pct": 4000,
"min_asset_pct": 500
}
}
响应无问题(继续步骤 4):
{ "ok": true, "policyCheck": { "ok": true, "violations": [] } }
检测到问题的响应——会同时返回所有违规项:
{
"ok": false,
"policyCheck": {
"ok": false,
"violations": [
{
"violationCode": "rule2MinAmmLiquidity",
"message": "Mint Bonk... has $30000 AMM liquidity; policy requires at least $100000.",
"details": { "mint": "Dez...", "observedUsd": 30000, "minUsd": 100000 }
},
{
"violationCode": "rule4MinMaxAssetCount",
"message": "Proposed 3 distinct assets; policy requires between 5 and 12.",
"details": { "distinctAssetCount": 3, "minAllowed": 5, "maxAllowed": 12 }
}
]
}
}
修复策略(按优先级排序):
rule2MinAmmLiquidity/rule3Min24hVolume→ 降低策略阈值(若该资产对策略至关重要),或替换该资产。不要将阈值设得高于/market-metrics对任何纳入资产报告的数值。rule4MinMaxAssetCount→ 调整资产篮子规模,或调整min_assets/max_assets边界。rule5MaxPctPerAsset/rule6MinPctPerAssetIfHeld→ 重新平衡pct_bps分配。rule1WhitelistBlacklist/assetModeViolation→ 修正asset_mode、asset_whitelist或asset_blacklist。
**每次修复后都要重新运行 dry-run。**只有 dry-run 返回无问题后,才能继续调用 /launch-dtf。实际操作中最多约需 3 次迭代;如果无法收敛,应在产生链上成本前向用户说明阻碍。
步骤 4:部署(两步流程——受策略约束)
4a. 构建未签名交易(含策略)
发送 POST {DFM_API_URL}/api/v2/agent/launch-dtf,提交金库创建载荷以及策略。后端会运行与 /policy/dry-run 相同的创建前评估,提交策略(未关联,按 vault_name + vault_symbol 作为键),并返回未签名交易。如果任何规则违规,接口会返回 400 和完整的 violations[]——不会构建交易,也不会有任何内容上链,因此 Agent 可以修正后重试。
{
"signerPublicKey": "<public key derived from DFM_AGENT_KEYPAIR>",
"vaultName": "Solana Blue Chips",
"vaultSymbol": "SOLBC",
"underlyingAssets": [
{ "symbol": "SOL", "mintBps": 4000 },
{ "symbol": "JUP", "mintBps": 3000 },
{ "name": "Bonk", "mintBps": 3000 }
],
"managementFees": 200,
"category": 0,
"threshold": 500,
"policy": {
"asset_mode": "OPEN",
"asset_whitelist": [],
"asset_blacklist": [],
"min_amm_liquidity_usd": 100000,
"min_24h_volume_usd": 500000,
"min_assets": 2,
"max_assets": 8,
"max_asset_pct": 4000,
"min_asset_pct": 500,
"min_stablecoin_pct": 0,
"max_rebalance_pct": 7500,
"min_rebalance_interval_hours": 4,
"max_rebalances_per_day": 3,
"max_rebalances_per_week": 14,
"launch_blackout_hours": 24,
"fee_locked": true,
"notes": "Auto-generated policy for blue chip strategy"
}
}
**预留空间的原因(请勿盲目照抄数值):**启动时的策略必须允许 Agent 未来需要执行的篮子迁移。请参阅下方的“面向未来的检查清单”——Agent 必须在发送 /launch-dtf 之前,根据拟定策略运行该检查清单。
成功响应(由全局 ResponseMiddleware 信封封装——每个成功的 Agent 端点,只要顶层没有 pagination 字段,都会采用此封装):
{
"status": "success",
"message": "OK",
"data": {
"onChain": {
"transaction": "base64-encoded-unsigned-versioned-transaction...",
"vaultIndex": 42,
"vaultPda": "7Xk...def",
"vaultMintPda": "9Rm...ghi"
},
"policyId": "665c..."
}
}
请从 body.data.onChain.transaction 读取未签名交易,而不是从 body.onChain.transaction 读取。 vaultIndex、vaultPda、vaultMintPda 和 policyId 也应通过相同的 body.data.* 路径读取。读取 body.onChain.transaction 会得到 undefined,即使响应实际正常,Agent 也会报告启动失败——出问题的只有读取路径。
策略违规响应(400)——由 GlobalExceptionFilter 封装。抛出的 BadRequestException 内容放在 error 下;violations[] 数组位于 body.error.violations,而非 body.violations:
{
"statusCode": 400,
"path": "/api/v2/agent/launch-dtf",
"timestamp": "2026-05-06T12:34:56.789Z",
"error": {
"message": "Proposed basket violates the supplied constitutional policy. Adjust the policy or the basket and try again.",
"ok": false,
"violationCode": "rule2MinAmmLiquidity",
"violations": [
{ "violationCode": "rule2MinAmmLiquidity", "message": "...", "details": {...} }
],
"error": "Bad Request",
"statusCode": 400
}
}
读取违规项: body.error.violations——每个条目的结构与 /policy/dry-run 的 policyCheck.violations 相同(因此可以复用违规代码翻译表)。请将 body.violations 视为 undefined;该路径对应的是未封装的 dry-run 结构,而非异常信封。
4b. 签名并将交易提交到链上
使用 Agent 的本地密钥对签名返回的交易,并将其提交到 Solana:
import { Keypair, VersionedTransaction, Connection } from "@solana/web3.js";
const bs58 = require("bs58").default || require("bs58");
const keypair = Keypair.fromSecretKey(bs58.decode(process.env.DFM_AGENT_KEYPAIR!));
const connection = new Connection(process.env.SOLANA_RPC_URL || "https://api.mainnet-beta.solana.com");
// Deserialize the unsigned transaction from the API response.
// `response` here is the parsed JSON body — the tx lives under data.onChain
// because the global ResponseMiddleware wraps the controller return value
// as { status, message, data: { onChain, policyId } }.
const txBytes = Buffer.from(response.data.onChain.transaction, "base64");
const tx = VersionedTransaction.deserialize(txBytes);
// Sign with the agent's keypair
tx.sign([keypair]);
// Submit on-chain
const signature = await connection.sendRawTransaction(tx.serialize(), {
skipPreflight: false,
preflightCommitment: "confirmed",
});
await connection.confirmTransaction(signature, "confirmed");
4c. 将金库元数据持久化到 DB
链上交易确认后,发送 POST {DFM_API_URL}/api/v2/agent/dtf-create,提交交易签名和仅元数据。策略已在 /launch-dtf 期间提交,并由链上事件流水线自动关联到新金库。此处不要发送策略字段——这些字段会被拒绝。
{
"transactionSignature": "<signature from step 4b>",
"vaultName": "Solana Blue Chips",
"vaultSymbol": "SOLBC",
"vaultType": "DTF",
"description": "Top-tier Solana ecosystem tokens",
"tags": ["Blue Chip", "Solana", "DeFi"],
"logoUrl": "",
"bannerUrl": "",
"noRebalance": false
}
成功响应:
{
"vault": [
{
"eventType": "VaultCreated",
"vault": {
"vaultName": "Solana Blue Chips",
"vaultSymbol": "SOLBC",
"vaultIndex": 42,
"status": "active"
}
}
],
"policyId": "665c..."
}
示例:WHITELIST 模式(用于精选 LST 收益基金)——策略字段移至 /launch-dtf。/dtf-create 仍然只携带元数据。**请注意,underlyingAssets(启动时包含的 3 个优先资产)与 asset_whitelist(6 个铸币地址——3 个优先资产 + 3 个为未来 /update-assets-tx 轮换预留的缓冲资产)之间存在差异。**缓冲铸币地址符合策略条件,但绝不会成为启动篮子的一部分。完整的优先资产 + 缓冲资产规则,请参阅策略字段决策指南中的“WHITELIST_ONLY”。
第 4a 步 /launch-dtf 请求体(精选 LST 基金的策略):
{
"signerPublicKey": "<agent pubkey>",
"vaultName": "Solana LST Yield",
"vaultSymbol": "SLSTY",
"underlyingAssets": [
{ "symbol": "mSOL", "mintBps": 4000 },
{ "symbol": "jitoSOL", "mintBps": 3500 },
{ "symbol": "bSOL", "mintBps": 2500 }
],
"managementFees": 150,
"category": 0,
"policy": {
"asset_mode": "WHITELIST_ONLY",
"asset_whitelist": [
"mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So",
"J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn",
"bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1",
"5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJm",
"he1iusmfkpAdwvxLNGV8Y1iSbj4rUy6yMhEA3fotn9A",
"BqYCcAd1ZtrtdwoAxmqLtXMSrW1DHPV6LWLjB2TQrtyT"
],
"asset_blacklist": [],
"min_amm_liquidity_usd": 500000,
"min_24h_volume_usd": 500000,
"min_assets": 2,
"max_assets": 8,
"max_asset_pct": 5000,
"min_asset_pct": 1000,
"min_stablecoin_pct": 0,
"max_rebalance_pct": 2000,
"min_rebalance_interval_hours": 12,
"max_rebalances_per_day": 1,
"max_rebalances_per_week": 5,
"launch_blackout_hours": 24,
"fee_locked": true,
"notes": "Whitelisted LST-only yield fund (3 priority + 3 buffer mints)"
}
}
第 4c 步 /dtf-create 请求体(仅元数据):
{
"transactionSignature": "<signature>",
"vaultName": "Solana LST Yield",
"vaultSymbol": "SLSTY",
"vaultType": "YIELD_DTF",
"description": "Diversified Solana liquid staking token yield fund",
"tags": ["Yield", "LST", "Staking", "Solana"],
"logoUrl": "",
"bannerUrl": "",
"noRebalance": false
}
策略字段决策指南
Agent 必须根据金库策略决定所有策略值。这些值应放入 /launch-dtf(第 4a 步)的 policy 子对象中,而非 /dtf-create。 每个阈值都应校准并为未来的篮子迁移预留空间——如果金库自身的策略导致篮子不可变,Agent 将无法补救。发送 /launch-dtf 前,请参阅下方的“面向未来的检查清单”。
| 策略类型 | max_asset_pct | min_asset_pct | min_amm_liquidity_usd | min_24h_volume_usd | max_rebalances_per_day | min_rebalance_interval_hours |
|---|---|---|---|---|---|---|
| 保守(蓝筹、指数) | 3000-4000 | 500-1000 | 500000 | 1000000 | 2 | 6 |
| 适中(混合、生态系统) | 4000-5000 | 500 | 100000 | 500000 | 3 | 4 |
| 激进(Meme、热门趋势) | 5000-6000 | 300 | 50000 | 100000 | 4 | 2 |
| 收益型(LST、质押、收益) | 4000-5000 | 500-1000 | 500000 | 500000 | 1 | 12 |
上述流动性/交易量下限是策略上限,而非目标值。始终还要根据流动性最弱的入选资产的实际 /market-metrics 快照,应用 0.5× 安全缓冲规则——取以下两者中较低的值:(策略表中的数值、最弱资产快照 × 0.5)。 |
始终设置:
asset_mode:根据金库策略选择:-
"OPEN"— 可添加任意资产,不受限制。适用于广泛市场/指数/激进策略。 -
"WHITELIST_ONLY"— 仅允许添加asset_whitelist中的资产。适用于精选基金(例如“仅限蓝筹资产”或“仅限 LST”)。白名单必须严格包含启动资产篮子:优先资产(将放入underlyingAssets的 4/5/6 个选项)加上 3–4 个符合类别条件的储备资产,以便 Agent 日后通过/update-assets-tx策略更新轮换加入。储备资产绝不会纳入初始启动——只有优先资产会放入underlyingAssets。设置储备资产是为了避免未来迁移资产篮子时需要扩大策略(这是禁止的)。关键:下限要依据优先资产与储备资产的合并白名单中最弱资产校准,而非仅依据优先资产篮子。 这是本技能中最容易出错的规则——过去的方案按最弱优先资产 × 0.5设定下限,之后又纳入流动性低于该下限的储备铸币。结果是储备铸币在金库启动时就被策略限制,无法使用。它在
asset_whitelist中(因此满足规则 1),但任何尝试将其轮换进资产篮子的操作都会违反规则 2/规则 3——它可见却无法轮换。金库最终只能在启动时纳入的优先资产之间轮换。正确流程(请严格按此执行):
- 选择优先资产;为每项资产获取
/market-metrics数据。 - 选择储备候选资产(来自同一类别的 3–4 个铸币);为每项资产获取
/market-metrics数据。不得跳过此步骤。 没有快照的储备铸币无法验证,因此不得加入白名单。 - 计算
weakest_liquidity = min(liquidity_usd over priority ∪ buffer)和weakest_volume = min(volume_24h_usd over priority ∪ buffer)。注意“priority ∪ buffer”——两组资产都要纳入计算。 - 将
min_amm_liquidity_usd = floor(weakest_liquidity × 0.5)和min_24h_volume_usd = floor(weakest_volume × 0.5)设为向下取整后的整洁数值。 - 验证每个储备铸币都达到计算出的下限(按上述方式计算后理应如此;若有任何一项未达标,说明计算有误,请重新计算)。
如果储备候选资产的快照远弱于优先资产(导致第 3 步算出的下限低得毫无意义),应将该候选资产从储备名单中剔除,而不是因此拉低下限。只有储备资产确实可以轮换加入时,储备名单才有用——如果某个“储备”铸币会把下限拉到低于最弱优先资产的一半,它就是一颗特洛伊木马:它虚报了金库并不具备的深度,还会过度降低下限,导致未来候选资产(Agent 可能通过策略更新加入的资产)无法受到足够严格的准入限制。 **具体规模:**优先资产数量 + 缓冲资产数量 ≤
max_assets。缓冲资产数量 ≥ 3(以确保有实际的轮换选项)。每种白名单铸币都必须达到建议的min_amm_liquidity_usd和min_24h_volume_usd门槛,并享有与优先资产相同的 0.5× 安全缓冲。 - 选择优先资产;为每项资产获取
-
"OPEN_BLACKLIST"— 允许所有资产,但asset_blacklist中的资产除外。需要排除特定高风险资产时使用。将要排除的铸币地址设为asset_blacklist的值。 -
"WHITELIST_BLACKLIST"— 仅允许白名单资产,并可额外排除黑名单资产。适用于明确排除特定资产的严格精选基金。对asset_whitelist应用与WHITELIST_ONLY相同的优先资产 + 3–4 个缓冲资产规则;仅将asset_blacklist用于明确排除特定类别。发布时仅包含优先资产。 -
**决策规则:**如果用户要求特定类别的基金(例如“LST 基金”“仅蓝筹资产”“前 5 大 DeFi 代币”),使用
WHITELIST_ONLY。如果用户要求广泛配置,则使用OPEN。如果用户提出“排除模因币”等类似要求,则使用OPEN_BLACKLIST。
-
min_assets:max(1, launch_basket_count − 1)。绝不要将min_assets设为launch_basket_count— 这会使资产篮子永远锁定在该规模,未来迁移时连单个资产都无法移除。(TOP3S 发布时设置为min_assets: 3, max_assets: 3— 永久锁定为 3 种资产的篮子,任何单资产置换都会超过每次更新上限。)max_assets:min(12, launch_basket_count + 3),硬性上限为12。始终留出增加 2–3 种资产的空间。max_rebalance_pct:对于资产数量 ≤4 的任何篮子,设为5000–7500(50–75%);只有资产数量 ≥6 且各资产权重已较小时,才可接受3000–5000。绝不要低于2 × max_asset_pct— 置换一种资产会同时变动被替换资产和新资产的权重,因此总变动幅度约为置换权重的两倍。将max_rebalance_pct设为低于2 × max_asset_pct,会导致任何单资产置换在结构上都无法实现。(TOP3S 发布时设置为max_rebalance_pct: 2500、max_asset_pct: 5500— 数学上会阻止每次 JUP→ETH 置换。)max_rebalances_per_week:max_rebalances_per_day * 7或更低launch_blackout_hours:24(防止前 24h 内重新平衡)fee_locked:对于指数型、收益型或精选型投资策略,如果管理费承诺是产品的一部分,则设为true。如果策略可能需要随着 TVL 增长而调整费用,则设为false(之后可以随时通过策略更新锁定费用;但如果在发布时锁定费用,则无法解锁)。notes:简要说明策略和策略配置的理由
面向未来的检查清单(在运行 /launch-dtf 之前)
Agent 必须对照拟定的 policy + 篮子,在心里逐项勾选。任何一项未通过,都必须调整策略并重新运行 dry-run——绝不能启动未通过此清单的金库。出错的代价是灾难性的:配置错误的宪法策略无法由 Agent 放宽,实际上会导致金库无法进行迁移。
- 资产数量余量 — 是否满足
max_assets ≥ launch_basket_count + 2且min_assets ≤ launch_basket_count − 1?如果否 → 放宽范围。 - 单次换仓可行性 — 是否满足
max_rebalance_pct ≥ 2 × max_asset_pct?换句话说:如果明天必须替换权重最大的资产,由此产生的配置变动是否在max_rebalance_pct限制内?如果否 → 提高max_rebalance_pct(首选)或降低max_asset_pct。 - 流动性/交易量缓冲(优先资产) — 对启动篮子中的每项资产(优先资产):是否满足
min_amm_liquidity_usd ≤ asset.liquidity_usd × 0.5且min_24h_volume_usd ≤ asset.volume_24h_usd × 0.5?如果否 → 将下限减半。快照会波动;下限必须能够承受日内 50% 的跌幅,避免锁定资产。 - 可移除性 — 对当前篮子中的每项资产,模拟移除该资产后的篮子:模拟篮子是否仍满足
min_assets、max_asset_pct和min_asset_pct?如果有任何资产无法移除,金库只需遇到一次糟糕的交易量快照,就可能永久陷入停滞。 - 避免死循环 — 如果任何已纳入资产的
/market-metrics快照距离拟定的min_24h_volume_usd或min_amm_liquidity_usd不足 30%,就进一步降低下限或替换该资产。徘徊在下限附近的资产最终会跌破下限;届时,任何包含该资产的中间篮子都无法通过交易量检查,导致迁移完全受阻。 - 缓冲资产可轮换性(仅限 WHITELIST 模式) — 对
asset_whitelist中不在启动篮子内的每项铸币资产(即每个缓冲区——储备铸币资产):该铸币资产的/market-metrics快照是否能以与优先资产相同的 0.5× 余量通过拟定的min_amm_liquidity_usd和min_24h_volume_usd下限?如果任何缓冲铸币资产未通过——哪怕只差 1 美元的交易量——它就会被策略困住:它留在白名单中,看似可以轮换,但规则 2/规则 3 会阻止任何试图将它加入篮子的/update-assets-tx。移除未通过的缓冲铸币资产(首选——这样可以保留较高的下限);或者,仅当 Agent 确实需要将该特定铸币资产保留为未来选项时,才降低两个下限,使该铸币资产以 0.5× 余量达标(这也会降低所有未来缓冲候选资产的门槛,因此务必谨慎权衡)。过去在实际使用中出现过因这一确切错误导致的失败:一个 Solana DeFi 金库以 150 万美元的流动性下限启动,却将 ORCA(36.1 万美元)/RENDER(54 万美元)放入缓冲区——两者从第一天起就无法轮换。一个 meme-beta 金库以 7 万美元的交易量下限启动,却将 DRIFT(4.9 万美元)放入缓冲区——无法轮换。
如果用户陈述的策略(例如“严格选取 3 项集中持仓”)似乎要求采用僵化策略,不要通过收紧策略范围来固化这种僵化。策略的意图应体现在篮子和 notes 中;策略的范围则应足够宽松,以便金库易于管理。
后端载荷规则
对于 /launch-dtf 载荷:
- 对于由 Agent 创建的金库,使用
category: 0(Manual)。 - 在
underlyingAssets中发送symbol或name(首选)。后端会从asset-allocation解析mintAddress。 - 严格限制:
underlyingAssets中绝不能包含 USDC(symbol: "USDC"或name: "USD Coin")。 - 如果候选列表中包含 USDC,请在发送
launch-dtf前将其移除,并替换为另一个符合条件的非 USDC 资产。 - 资产数量:
underlyingAssets中至少 1 个、最多 12 个资产。 超出此范围的载荷会被后端拒绝。 - 必须提供
policy对象。 包含 Agent 希望强制执行的每个字段。省略的字段默认值为 0/禁用。 - 后端会根据
policy评估underlyingAssets中的篮子(规则与/policy/dry-run相同)。如果任何规则未通过,/launch-dtf会返回400和violations[],且不会构建任何 tx。
对于 /dtf-create 载荷:
- 仅限元数据。 不要发送任何策略字段(
asset_mode、asset_whitelist、min_amm_liquidity_usd等)。策略已在/launch-dtf期间提交并自动关联。 - 仅保留:
transactionSignature、vaultName、vaultSymbol、vaultType、description、tags、logoUrl、bannerUrl、noRebalance。 - 对于标准基金,将
vaultType设为"DTF";对于收益/LST 基金,设为"YIELD_DTF"。 - 将
logoUrl、bannerUrl设为空字符串。 vaultName和vaultSymbol必须与launch-dtf中使用的值一致。
返回未签名交易的 API 调用签名辅助程序
launch-dtf 和 distribute-fees 都会返回经 Base64 编码的未签名 VersionedTransaction。使用以下模式进行签名和提交:
import { Keypair, VersionedTransaction, Connection } from "@solana/web3.js";
const bs58 = require("bs58").default || require("bs58");
async function signAndSendTransaction(
base64Tx: string,
keypair: Keypair,
connection: Connection
): Promise<string> {
const txBytes = Buffer.from(base64Tx, "base64");
const tx = VersionedTransaction.deserialize(txBytes);
tx.sign([keypair]);
const signature = await connection.sendRawTransaction(tx.serialize(), {
skipPreflight: false,
preflightCommitment: "confirmed",
});
await connection.confirmTransaction(signature, "confirmed");
return signature;
}
// Usage — note the `data.onChain.transaction` path. The /launch-dtf and /distribute-fees
// responses are wrapped by the global ResponseMiddleware as { status, message, data: { ... } },
// so the unsigned tx string lives under `body.data.onChain.transaction`. Reading
// `body.onChain.transaction` returns undefined and the call to Buffer.from will throw
// "first argument must be of type string" — that error is the misread, not a backend bug.
const keypair = Keypair.fromSecretKey(bs58.decode(process.env.DFM_AGENT_KEYPAIR!));
const connection = new Connection(process.env.SOLANA_RPC_URL || "https://api.mainnet-beta.solana.com");
const sig = await signAndSendTransaction(response.data.onChain.transaction, keypair, connection);
金库部署的关键错误处理规则:
-
交易已签名并在链上提交后,绝不要再次调用
launch-dtf。 链上金库创建不可撤销,且会消耗 USDC。如果dtf-create失败,只能使用相同的交易签名、金库名称和代号重试dtf-create。不要生成新的名称/代号,也不要再次调用launch-dtf。 -
如果
launch-dtf返回 400,且包含策略violations[](尚未进行任何链上提交):没有策略被提交,没有构建 tx,也没有产生链上费用。根据违规代码修正篮子或策略(与步骤 3 中 dry-run 的指引相同),然后重试launch-dtf。可以先运行/policy/dry-run,以低成本进行迭代。 -
如果
launch-dtf因其他原因失败(例如验证错误、未找到资产、USDC 不足):修正载荷并重试launch-dtf——由于尚未构建 tx,因此仍未产生链上费用。 -
如果签名/提交失败:可以重试
launch-dtf,以获取新的未签名交易。策略已在第一次调用时提交——必须复用完全相同的vaultName和vaultSymbol,以便关联现有的未关联策略(更改它们会以不同名称提交第二个策略)。 -
如果
dtf-create失败(成功在链上提交之后):只能使用完全相同的transactionSignature、vaultName和vaultSymbol重试dtf-create。绝不要更改这些值,也绝不要再次调用launch-dtf。策略已提交,且无论如何都会由链事件处理流水线关联。 -
保留交易签名:成功在链上提交后,保存签名,并在所有
dtf-create重试中复用它。这是链上金库与数据库记录之间的关联凭据。
第 5 步:管理(持续进行,自主执行)
启动后,Agent 会自主执行以下操作:
- 通过
GET {DFM_API_URL}/api/v2/agent/vaults/user?page=1&limit=10&vaultType=dtf&includeTvl=true列出用户的金库(支持分页;对于收益基金,将vaultType切换为yield_dtf) - 通过
GET {DFM_API_URL}/api/v2/agent/vaults/featured/list?page=1&limit=10&vaultType=dtf&includeTvl=true浏览平台精选金库 - 通过
GET {DFM_API_URL}/api/v2/agent/dtf/:symbol/state监控金库状态 - 通过
GET {DFM_API_URL}/api/v2/agent/dtf/:symbol/rebalance/check检查是否可以重新平衡 - 通过
POST {DFM_API_URL}/api/v2/agent/dtf/:symbol/rebalance执行重新平衡(由管理员钱包在后台执行) - 通过
POST {DFM_API_URL}/api/v2/agent/dtf/:symbol/distribute-fees分发累积的费用(返回由 Agent 签名的未签名交易)
**列出金库(/vaults/user、/vaults/featured/list):**两个端点都接受相同的四个查询参数——page、limit、vaultType(dtf | yield_dtf)、includeTvl。使用 /vaults/user 分页列出调用者自己的金库并按类型筛选(优先于旧版无分页的 /dtf/my-vaults);使用 /vaults/featured/list 展示平台精选金库。始终发送 includeTvl=true——两个端点都必须返回 totalValueLocked 和 sharePrice。持续递增 page,直到 pagination.hasNext 为 false(或 pagination.page >= pagination.totalPages)。
**将自然语言请求映射到 page:**按用户的原话将请求转换为 page 查询参数。
| 用户说 | 发送 |
|---|---|
| “显示我的金库”/“列出我的 DTF” | ?page=1&limit=10&...(始终从第 1 页开始) |
| “显示第 2 页”/“第 2 页” | ?page=2&limit=10&... |
| “精选金库的第 5 页” | ?page=5&limit=10&...(针对 /vaults/featured/list) |
| “下一页”/“再多显示一些” | ?page=<lastShown + 1>&...。如果上一次响应中的 pagination.hasNext === false,则拒绝请求并告知用户“已经是最后一页”。 |
| “上一页”/“返回” | ?page=<lastShown - 1>&...。如果 lastShown 已经是 1,则返回相同的第 1 页结果(不要让 page 小于 1)。 |
| “第一页”/“回到开头” | ?page=1&... |
| “最后一页” | 首先调用 page=1 读取 pagination.totalPages,然后调用 ?page=<totalPages>&... |
| “每页显示 25 条”/“每次获取 50 条” | 按给定值传递 limit(如果数值过大,则限制为 100 之类的合理上限)。更改 limit 时将 page 重置为 1。 |
跨轮次记住分页状态:此技能是无状态的——没有内置的“当前页”记忆。请在自己的对话上下文中跟踪上次返回的 pagination.page 和 pagination.totalPages,以便正确处理“下一页”/“上一页”请求。如果用户更改筛选条件(vaultType、search、limit),则重置为 page=1,因为结果集已更改,旧页码不再对应相同的数据。
获取页面后,向用户显示导航页脚,例如:“第 2 页,共 5 页——说‘下一页’查看更多,或说‘第 N 页’跳转。”使用 pagination.hasNext/pagination.hasPrev 决定提示哪些导航操作。切勿向用户隐藏分页信息——如果 pagination.totalPages > 1,就应告知用户还有更多页面。
响应结构(两个端点均为分页响应,不使用信封包装):
{
"data": [/* array of Vault objects */],
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1, "hasNext": false, "hasPrev": false }
}
金库列表位于 body.data——data 上直接包含一个扁平数组。遍历 body.data.map(...) 以处理金库;读取 body.pagination 获取导航信息。
常见错误——切勿按以下方式读取:
| 错误的访问方式 | 为什么会出错 | 正确的访问方式 |
|---|---|---|
body.data.vaults | 数组本身就是 body.data;这里没有嵌套的 vaults 键。(这是把它和旧版 /dtf/my-vaults 的结构混淆了,后者确实会包装为 { vaults: [...], total }。) | body.data |
body.data.data | 这两个端点都带有 pagination 字段,因此全局 ResponseMiddleware 不会将它们包装为 { status, message, data: ... }。这里没有双重 data。 | body.data |
body.vaults | 这些端点没有顶层 vaults 字段。 | body.data |
body.results / body.items | 这是通用 API 的惯常猜测,与此处的契约不符。 | body.data |
**响应为何未包装:**全局 ResponseMiddleware 拦截器会将任何带有顶层 pagination 字段的响应原样传递(不使用 { status, message, data } 信封)。技能的 call() 辅助函数会自动处理这一点——它会让分页响应保持原样。如果你在列表端点看到 body.status === "success",说明上游出了问题;应将其视为格式错误的响应并告知用户。
Vault 条目字段:body.data 中的每个 Vault 都包含 vaultName、vaultSymbol、vaultAddress、description、vaultIndex、tags[]、feeConfig.managementFeeBps、underlyingAssets[](每项都包含嵌套的 assetAllocation: { name, symbol, logoUrl } 和 pct_bps)、creator(详细资料对象:name、walletAddress、avatar、twitter_username)、category: { name }、totalValueLocked、sharePrice、vaultApy、performance7d,以及字符串类型的 nav 和 totalSupply。完整字段表见 references/api-reference.md § 12a。
端点与响应结构速查表(这两者之间的差异是“Vault 列表”错误最常见的来源):
| 端点 | 响应结构 | 状态 |
|---|---|---|
GET /vaults/featured/list | { data: Vault[], pagination: {...} } | 使用此端点——分页、当前版本。 |
GET /vaults/user | { data: Vault[], pagination: {...} } | 使用此端点——分页、当前版本。 |
GET /dtf/my-vaults | { vaults: Vault[], total: number } | 旧版 / 已弃用——结构不同。除非用户明确要求,否则不要让 Agent 读取此端点;凡是提到“我的 Vault”,都应路由到 /vaults/user。 |
向用户展示 Vault 列表时,应显示易读的字段——vaultName / vaultSymbol、description、以逗号分隔的资产篮子摘要(格式为 symbol pct%,将 pct_bps 除以 100)、格式化为 USD 的 totalValueLocked、以百分比显示的 performance7d、以 feeConfig.managementFeeBps / 100 计算的费用百分比,以及作为作者显示的 creator.name(或 creator.twitter_username)。跳过 _id、id、原始 pct_bps、内部 Mongo 字段和 daoconfig: null。nav 和 totalSupply 是可安全处理精度的字符串——进行任何计算前都要用 Number() 解析;对于新 Vault,应将 "0"、null 类型的 vaultApy 和 null 类型的 performance7d 视为“暂无数据”。
错误 vs 正确 — 金库列表输出
用户刚刚收到了以下关于 “列出我的 DTF” 的糟糕输出。以下所有括号内容 / 页脚片段都禁止出现:
❌ WRONG:
Here are the DTFs currently available from your DFM account context
(/vaults/user, paginated across all pages):
DTF (9)
AIPIN — Solana AI + DePIN Infrastructure
TASR — TA Structure Rotation
…
Yield DTF (4)
YLST — Solana Yield LST
…
Total: 13 vaults (9 DTF + 4 Yield DTF)
If you want, I can also return this with TVL/share price sorting
(highest TVL first) or only "platform featured" vaults.
上方的禁用片段:
- “from your DFM account context (/vaults/user, paginated across all pages)” — 泄露端点路径和分页内部细节。违反“用户输出中不得包含端点路径”规则。
- “(highest TVL first) or only 'platform featured' vaults” — 主动提供了用户未要求的推测性后续选项,还暴露了用户无需了解的独立“精选金库”界面。
同一列表的正确输出始终是 Markdown 表格——一句简短的引导语,然后是表格;仅当存在更多页面时,才添加分页页脚。金库列表明确禁止使用项目符号列表(参见《行为准则》中的“输出格式”规则)。
✅ RIGHT:
Here are your vaults — 13 in total (9 DTFs and 4 Yield DTFs).
| Type | Symbol | Name | TVL (USD) | 7d % | Fee |
| --------- | ---------- | --------------------------------- | --------- | ------ | ----- |
| DTF | AIPIN | Solana AI + DePIN Infrastructure | $1,240.50 | +3.41% | 1.50% |
| DTF | TASR | TA Structure Rotation | $890.10 | -0.12% | 2.00% |
| DTF | OBLETH | Open Blacklist ETH | $0.00 | — | 1.50% |
| DTF | WHL3 | TriGuard White | $0.00 | — | 1.50% |
| DTF | POP-DTF | Popeye Index | $14.80 | +13.01%| 1.50% |
| DTF | EXECX | Execution Stack X | $0.00 | — | 2.00% |
| DTF | BARBL | Barbell Degen | $0.00 | — | 2.00% |
| DTF | AGE-DTF | Agentonomy | $14.80 | +13.01%| 1.50% |
| DTF | ICM-DTF | ICM Prime | $0.00 | — | 1.50% |
| Yield DTF | YLST | Solana Yield LST | $0.00 | — | 1.00% |
| Yield DTF | NX6-YDTF | NEXUS-6 | $0.00 | — | 2.00% |
| Yield DTF | REA-YDTF | Real Bastion | $0.00 | — | 2.00% |
| Yield DTF | AUR-YDTF | Aurum Stake | $0.00 | — | 2.00% |
金库列表的列规范(强制):
| 列 | 来源 | 格式 |
|---|---|---|
Type | vaultType("dtf" → "DTF","yield_dtf" → "Yield DTF") | 首字母大写格式,不得使用原始枚举值 |
Symbol | vaultSymbol | 原样 |
Name | vaultName | 原样 |
TVL (USD) | totalValueLocked | $<n>.<2dp>,带千位分隔符;为 0 时显示 $0.00 |
7d % | performance7d | +<n>.<2dp>% / -<n>.<2dp>%;为 null 时显示 —(不要为新金库显示“0.00%”) |
Fee | feeConfig.managementFeeBps / 100 | <n>.<2dp>% |
可选列——仅在用户要求或与上下文相关时添加:Author(creator.name,或在 creator.twitter_username 前加 @)、APY(vaultApy,为 null 时显示 —)、Basket(按 pct_bps 排序的前 3 种资产,格式为 SYM 40% / SYM 30% / SYM 30%)。
与错误输出相比的变化:
- 严格使用表格格式——每个金库占一行。Agent 无权在列表中选择项目符号、编号列表或段落。只能使用 Markdown 表格。
- 不得包含端点路径。 使用“你的金库”代替“from your DFM account context (/vaults/user, ...)”。
- 不得提及“已跨所有页面分页”。 分页是内部机制。如果你获取了多个页面来整理答案,无需提及。如果
totalPages > 1且你只获取了第 1 页,则在表格下方添加一行页脚:“第 1 页,共 N 页——回复‘下一页’查看更多。”这是唯一可接受的分页说明,且仅在相关时使用。 - 不得主动提供后续选项。 不要以“如果你愿意,我也可以按……的方式返回”之类的话结尾。
- 新金库的 TVL = $0.00 / 7d =
———绝不编造数字;将null/"0"显示为$0.00/—。
列出平台上的所有 DTF(两种金库类型、所有页面、精选与非精选)
当用户要求查看平台上的所有 DTF时——例如 “列出 DFM 上的所有 DTF”、“展示平台上的每个金库”、“DFM 上有哪些 DTF”、“列出 DFM 上可用的金库”、“浏览所有 DTF”——Agent 通过组合两个独立端点,执行全平台列表流程(平台将它们作为不同界面提供——没有 isFeaturedVault 查询筛选器;不要虚构这样的筛选器):
| 端点 | 返回内容 | 用途 |
|---|---|---|
GET /vaults/user?vaultType=<type> | 通用——平台上指定类型的所有金库。 | “所有 DTF”基准列表。 |
GET /vaults/featured/list?vaultType=<type> | 仅精选——平台精选区展示的精选子集。 | 与基准列表交叉比对,标记哪些行是精选金库。 |
两个端点使用相同的 page / limit / vaultType / includeTvl 查询参数,并返回相同的分页 { data: Vault[], pagination: {...} } 结构(没有外层包装)。Agent 分别获取这两个端点的数据,将分页加载至完成,并在对话上下文中合并结果。
算法:
- 每个端点分别执行两次金库类型查询——分别使用
vaultType=dtf和vaultType=yield_dtf。端点每次只筛选一种类型。 - **每次查询都要翻页直至完成。**从
page=1开始,设置limit=50(限制更大,往返次数更少)。每次调用后,读取pagination.hasNext;若为true,则递增page并重复。直到hasNext === false才停止。始终发送includeTvl=true。 - 交叉引用 Featured 列——从
/vaults/featured/list的结果中构建一个包含vaultSymbol值的Set。对于/vaults/user中的每个金库,如果其符号在该集合中,则设置_featured = true,否则设置为false。 - 合并为一个输出表格——将金库类型和 Featured 标记作为列;
/vaults/user中每个唯一金库占一行。
**这两个端点都不存在 isFeaturedVault 查询参数。**不要附加 &isFeaturedVault=true 或 &isFeaturedVault=false——平台通过拆分为两个端点(全部金库与仅精选金库)来实现此功能。
内联 node -e 示例——总共执行四次查询(全部金库 × 两种金库类型,再加上精选金库 × 两种金库类型),合并后为面向用户的表格输出一个 JSON:
node -e '
const http = require("http");
const https = require("https");
// Generic paginate-to-completion against either listing endpoint.
async function fetchAll(path, vaultType) {
const all = [];
let page = 1;
while (true) {
const url = new URL(process.env.DFM_API_URL
+ path
+ `?page=${page}&limit=50&includeTvl=true&vaultType=${vaultType}`);
const client = url.protocol === "https:" ? https : http;
const r = await new Promise((resolve, reject) => {
const req = client.get(url, {
headers: { "Authorization": "Bearer " + process.env.DFM_AUTH_TOKEN }
}, (res) => {
let data = ""; res.on("data", (c) => data += c);
res.on("end", () => {
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
catch { resolve({ status: res.statusCode, body: data }); }
});
});
req.on("error", reject);
});
if (r.status !== 200) {
console.log("ERROR " + r.status + " from " + path + ": " + JSON.stringify(r.body));
return all;
}
all.push(...(r.body.data || []));
if (!r.body.pagination?.hasNext) break;
page += 1;
}
return all;
}
(async () => {
// Universal — every vault on the platform.
const dtfAll = await fetchAll("/api/v2/agent/vaults/user", "dtf");
const ydtfAll = await fetchAll("/api/v2/agent/vaults/user", "yield_dtf");
// Featured — curated subset surfaced on the platform.
const dtfFeatured = await fetchAll("/api/v2/agent/vaults/featured/list", "dtf");
const ydtfFeatured = await fetchAll("/api/v2/agent/vaults/featured/list", "yield_dtf");
// Build the featured-symbol set for the Featured column.
const featuredSymbols = new Set([
...dtfFeatured.map(v => v.vaultSymbol),
...ydtfFeatured.map(v => v.vaultSymbol),
]);
const rows = [
...dtfAll.map(v => ({ ...v, _type: "DTF", _featured: featuredSymbols.has(v.vaultSymbol) })),
...ydtfAll.map(v => ({ ...v, _type: "Yield DTF", _featured: featuredSymbols.has(v.vaultSymbol) })),
];
console.log(JSON.stringify({
totalDtf: dtfAll.length,
totalYieldDtf: ydtfAll.length,
featuredCount: featuredSymbols.size,
rows: rows.map(v => ({
type: v._type,
featured: v._featured,
symbol: v.vaultSymbol,
name: v.vaultName,
tvl: v.totalValueLocked,
perf7d: v.performance7d,
feeBps: v.feeConfig?.managementFeeBps,
apy: v.vaultApy,
})),
}));
})().catch(e => console.log("FATAL " + (e?.message || String(e))));
'
仅精选金库列表(当用户说 "显示精选金库"、"仅显示精选金库"、"平台上精选了哪些" 时)完全跳过全部金库查询——只调用 /vaults/featured/list?vaultType=dtf 和 /vaults/featured/list?vaultType=yield_dtf,分别翻页,并以相同的合并表格格式呈现。根据定义,输出中的每一行都是精选金库(无需 Featured 列;也可以为了视觉一致性,在每行的该列中显示 ⭐)。
输出模板(必须遵循——单个合并表格):
✅ RIGHT — platform-wide DTF listing:
Here are all DTFs on the platform — <total> in total (<dtfCount> DTFs and <yieldCount> Yield DTFs, of which <featuredCount> are platform-featured).
| Type | Featured | Symbol | Name | TVL (USD) | 7d % | Fee |
| --------- | -------- | ---------- | --------------------------------- | ---------- | ------- | ----- |
| DTF | ⭐ | AGE-DTF | Agentonomy | $14.80 | +13.01% | 1.50% |
| DTF | ⭐ | ICM-DTF | ICM Prime | $0.00 | — | 1.50% |
| DTF | | AIPIN | Solana AI + DePIN Infrastructure | $1,240.50 | +3.41% | 1.50% |
| DTF | | TASR | TA Structure Rotation | $890.10 | -0.12% | 2.00% |
| DTF | | POP-DTF | Popeye Index | $14.80 | +13.01% | 1.50% |
| DTF | | EXECX | Execution Stack X | $0.00 | — | 2.00% |
| DTF | | BARBL | Barbell Degen | $0.00 | — | 2.00% |
| DTF | | OBLETH | Open Blacklist ETH | $0.00 | — | 1.50% |
| DTF | | WHL3 | TriGuard White | $0.00 | — | 1.50% |
| Yield DTF | ⭐ | NX6-YDTF | NEXUS-6 | $0.00 | — | 2.00% |
| Yield DTF | ⭐ | REA-YDTF | Real Bastion | $0.00 | — | 2.00% |
| Yield DTF | ⭐ | AUR-YDTF | Aurum Stake | $0.00 | — | 2.00% |
| Yield DTF | | YLST | Solana Yield LST | $0.00 | — | 1.00% |
列结构(平台范围列表):
| 列 | 来源 | 格式 |
|---|---|---|
| 类型 | 派生值(vaultType → "DTF" / "Yield DTF") | 标题式大小写。 |
| Featured | 当金库符号出现在 /vaults/featured/list 结果集中时为 true;否则为 false。(交叉引用,不是响应字段。) | 精选时显示 ⭐;非精选时留空单元格(不能显示 — 或 false)。 |
| 符号 | vaultSymbol | 原样 |
| 名称 | vaultName | 原样 |
| TVL(美元) | totalValueLocked | $<n>.<2dp>,带千位分隔符;为 0 时显示 $0.00 |
| 7 天 % | performance7d | +/-<n>.<2dp>%;为 null 时显示 — |
| 费用 | feeConfig.managementFeeBps / 100 | <n>.<2dp>% |
**排序顺序(必须遵循):**先按类型分组(DTF 在前,Yield DTF 在后),再在每组内将精选金库排在非精选金库前,最后按 totalValueLocked 降序排列。这样每组中最重要的金库都会排在前面。
分页页脚:平台范围列表绝不显示分页页脚——Agent 已完成两种金库类型的全部翻页。用户所说的“所有 DTF”已经在单次回复中满足。如果合并列表过长、难以浏览(例如 50 行以上),请附加一行说明,提供筛选选项:"这就是平台上的全部金库。要我筛选出仅精选金库、仅 DTF,或按 TVL 筛选吗?"
此输出中禁止:
- 使用“来自
/vaults/featured/list”或提及任何端点。 - 使用“已遍历所有分页”——用户不需要知道数据是如何汇总的。
- 将精选与非精选金库拆分为两个表格——它们必须放在同一个表格中,并通过 Featured 列区分。
- 用户要求“全部”时只列出精选金库——用户明确要求两者都包含。
**用户后续提出筛选条件(“仅精选金库”/“仅 DTF”/“TVL 最高”)时:**按相同格式重新呈现合并表格,并筛选相应行。不要重新获取数据;完整数据已在当前对话上下文中。
详细列表模式——呈现响应中的每个字段
当用户以暗示其想获取每个金库完整数据的方式请求平台 DTF 的**“列表”时——例如 “给我所有 DTF 的列表”、“给我 DFM 上所有金库的详细数据”、“列出所有金库及其详情”、“显示每个金库的完整信息”、“有哪些 DTF 可用——给我完整数据”——应使用详细格式,而不是紧凑摘要表。详细格式是追加内容**,不是替代内容:先显示同一张紧凑摘要表,然后在表格下方为每个金库显示一个详情块。
会选择详细模式(而非上文仅显示摘要的模式)的触发表述:
| 表述 | 模式 |
|---|---|
| “显示所有 DTF”、“有哪些 DTF 可用”、“浏览金库”、“列出 DTF” | 仅显示摘要表(上文 §“列出所有平台 DTF”流程)。 |
| “给我列表”、“给我这份列表”、“给我详细数据”、“列出详情”、“显示每个金库的完整数据”、“把每个金库的所有信息都给我”、“所有 DTF 的详细列表” | 详细模式——顶部显示摘要表,随后显示各金库的详情块。 |
如果表述有歧义,优先选择详细模式——用户请求“列表”时,通常希望获取平台掌握的每个金库的数据,而不仅仅是代号和 TVL。
**算法:**与仅显示摘要的流程相同(执行四次 fetchAll,构建 featuredSymbols 集合并合并数据)。不同之处在于渲染方式——先输出摘要表,然后按相同的排序顺序遍历合并后的行,为每个金库渲染一个详情块。
输出模板(详细模式——摘要表 + 各金库详情块):
✅ RIGHT — detailed platform-wide DTF listing:
Here are all DTFs on the DFM platform — <total> in total (<dtfCount> DTFs and <yieldCount> Yield DTFs, of which <featuredCount> are platform-featured). Summary at the top, full details for each vault below.
| Type | Featured | Symbol | Name | TVL (USD) | 7d % | Fee |
| --------- | -------- | ---------- | --------------------------------- | ---------- | ------- | ----- |
| DTF | ⭐ | AGE-DTF | Agentonomy | $19.34 | 0.00% | 1.50% |
| DTF | ⭐ | ICM-DTF | ICM Prime | $11.97 | 0.00% | 1.50% |
| DTF | | TASR | TA Structure Rotation | $11.17 | 0.00% | 2.00% |
| ... | | ... | ... | ... | ... | ... |
---
**1. AGE-DTF — Agentonomy** ⭐
*AI agent tokens basket designed for narrative exposure to autonomous agents, agent launchpads, and "agent-driven markets". This is intentionally higher volatility and more reflexive than DeFi.*
Tags: AI-agents, agent-economy, high-beta, narrative-index, experimental
Overview:
| Field | Value |
| ------------- | ----------------- |
| Symbol | AGE-DTF |
| Type | DTF |
| Category | Manual |
| Vault index | 53 |
| Vault address | FdxU…tp8B |
| TVL | $19.34 |
| Share price | $1.7615 |
| NAV | $19.34 |
| Total supply | 10.981699 |
| Mgmt fee | 1.50% |
| 7d perf | 0.00% |
| APY | — |
| Creator | Tops (HzgvD…rUrM) |
Composition (7 assets):
| Symbol | Asset | Allocation |
| ------- | ----------------- | ---------- |
| VIRTUAL | Virtual Protocol | 40.00% |
| ZEREBRO | zerebro | 25.00% |
| RENDER | Render Token | 15.00% |
| NOS | Nosana | 12.00% |
| GRASS | Grass | 6.00% |
| IO | IO | 1.00% |
| DITH | Dither | 1.00% |
---
**2. ICM-DTF — ICM Prime** ⭐
*Solana's Internet Capital Markets stack in one index: issuance and attention markets plus the core rails for trading, leverage, and settlement.*
Tags: ICM, capital-markets, Solana, trading-rails, infra, high-beta
Overview:
| Field | Value |
| ------------- | ----------------- |
| Symbol | ICM-DTF |
| Type | DTF |
| Category | Manual |
| Vault index | 47 |
| Vault address | 3eH1…Vuqx |
| TVL | $11.97 |
| Share price | $0.9882 |
| NAV | $11.97 |
| Total supply | 12.111534 |
| Mgmt fee | 1.50% |
| 7d perf | 0.00% |
| APY | — |
| Creator | Tops (HzgvD…rUrM) |
Composition (9 assets):
| Symbol | Asset | Allocation |
| ------ | ------------- | ---------- |
| JUP | Jupiter | 18.00% |
| RAY | Raydium | 16.00% |
| PYTH | Pyth Network | 15.00% |
| ORCA | Orca | 12.00% |
| PUMP | Pump | 12.00% |
| DRIFT | Drift | 10.00% |
| KMNO | Kamino | 10.00% |
| META | MetaDAO | 5.00% |
| ORE | ORE | 2.00% |
---
(...repeat one detail block per vault, in the same sort order as the summary table...)
每个金库的详情块——字段架构:
| 区块元素 | 来源 | 格式 / 规则 |
|---|---|---|
| 标题 | vaultSymbol + vaultName | **<n>. <symbol> — <name>**;如果该金库是精选金库,则追加 ⭐。<n> 是合并排序后该行的索引。 |
| 描述行 | description | 使用斜体;渲染为单行。如果 description 为 null 或空,则完全省略此行。 |
| 标签行 | tags[] | Tags: <a>, <b>, <c>。如果 tags 为空或缺失,则完全省略此行。 |
| 概览 → 代号 | vaultSymbol | 原样显示 |
| 概览 → 类型 | 派生值(dtf → DTF,yield_dtf → Yield DTF) | 使用标题式大小写。 |
| 概览 → 类别 | category.name | 原样显示;如果为 null,则显示 —。 |
| 概览 → 金库索引 | vaultIndex | 整数;如果缺失,则显示 —。 |
| 概览 → 金库地址 | vaultAddress | 截断为 <前4位>…<后4位>(例如 FdxU…tp8B)。绝不显示完整地址。 |
| 概览 → TVL | totalValueLocked | $<n>.<2位小数>,千位使用分隔符;值为 0 或 null 时显示 $0.00。 |
| 概览 → 份额价格 | sharePrice | $<n>.<4位小数>;如果为 null,则显示 —。 |
| 概览 → NAV | nav(以字符串表示的十进制数——使用 Number() 解析) | $<n>.<2位小数>;如果为 null,则显示 —。 |
| 概览 → 总供应量 | totalSupply(以字符串表示的十进制数) | <n>.<6位小数>;如果为 null,则显示 —。 |
| 概览 → 管理费 | feeConfig.managementFeeBps / 100 | <n>.<2位小数>%。 |
| 概览 → 7 日表现 | performance7d | +/-<n>.<2位小数>%;如果为 null,则显示 —。值为 0 时显示 0.00%(而非 +0.00%)。 |
| 概览 → APY | vaultApy | <n>.<2位小数>%;如果为 null 或 0,则显示 —。 |
| 概览 → 创建者 | creator.name + creator.walletAddress | <name> (<前4位>…<后4位>)。如果 creator 为 null,则显示 —。绝不显示完整钱包地址、创建者的 _id、电子邮件、头像 URL、社交链接或 Twitter 用户名——这些均为内部信息。 |
| 资产构成标题 | underlyingAssets.length | 资产构成(<N> 项资产): |
| 资产构成行 → 代号 | underlyingAssets[].assetAllocation.symbol | 原样显示 |
| 资产构成行 → 资产 | underlyingAssets[].assetAllocation.name | 原样显示 |
| 资产构成行 → 配置比例 | underlyingAssets[].pct_bps / 100 | <n>.<2位小数>%。按 pct_bps 降序排列各行。 |
**金库之间的分隔符:**单独一行显示 ---。最后一个金库之后不加分隔符。
详细输出中的禁用内容(除摘要模式中的禁用内容外):
- Mongo 内部
_id字段(vault、资产配置、类别、创建者)——绝不显示。 - 完整钱包地址或 vault 地址——始终截断为
<first4>…<last4>。 logoUrl、avatar、twitter_username、email、socialLinks、useAvatarImage、useTwitterImage——内部信息/个人身份信息;绝不在聊天中呈现。- 响应中的
daoconfig、重复的_id、id——后端产物。 - 将
pct_bps显示为原始整数(“2600 bps”)——始终换算为百分比。 - 将
nav/totalSupply/sharePrice显示为未经换算的原始 6 位小数字符串(“960675”、“1.000016”)——始终按照上述架构解析并格式化。 - 使用“这是平台数据库中的数据”/“从 /vaults/user 获取”或任何后端内部措辞。
- 显示单独的详情区块却省略摘要表格——摘要始终置于最前。
**空响应/稀疏响应:**如果两个端点的 pagination.total === 0(完全没有 DTF),则不要显示表格或详情区块;只回复一行:“DFM 平台上目前还没有 DTF。” 如果只有一种 vault 类型返回了行,则从摘要标题行中省略空类型(“<dtfCount> DTFs and 0 Yield DTFs” → “<dtfCount> DTFs”)。
**长度限制:**如果合并后的集合包含超过 20 个 vault,则显示包含所有行的摘要表格,但将详情区块限制为排名靠前的 20 个(按排序顺序),并在底部追加一行说明:“以下为排名靠前的 20 个 vault 的完整详情。告诉我一个 symbol 或筛选条件(only featured、only Yield DTFs、top 5 by TVL),我可以展开任意子集。” 少于 20 个 vault 时:显示每个详情区块。
显示 /dtf/:symbol/state — 多表布局
vault 状态响应包含 4 个不同部分(vault、portfolio、userHoldings、rebalanceHistory.data[])。每个部分都放入各自带有清晰标签的 Markdown 表格中。开头用一行标题标明 vault。跳过数据为空或为 null 的部分。
✅ RIGHT (full state output):
**AIPIN — Solana AI + DePIN Infrastructure**
Vault overview:
| Field | Value |
| ------------ | --------------- |
| Symbol | AIPIN |
| Type | DTF |
| Status | active |
| TVL | $1,240.50 |
| Share price | $1.0823 |
| NAV | $1,240.50 |
| Total tokens | 1,146.231410 |
| Mgmt fee | 1.50% |
| 7d perf | +3.41% |
| APY | — |
Portfolio (current holdings):
| Symbol | Allocation | Balance | Value (USD) |
| ------- | ---------- | ------------ | ----------- |
| ZEREBRO | 35.00% | 142.318 | $434.18 |
| RENDER | 30.00% | 88.512 | $372.15 |
| ORCA | 20.00% | 211.044 | $248.10 |
| HNT | 15.00% | 67.901 | $186.07 |
Your holdings:
| Field | Value |
| ------------- | --------------- |
| Wallet | 5HvU…A7B8 |
| Token balance | 2.543171 AIPIN |
| Value | $2.7536 |
| Share | 0.22% |
Recent rebalances:
| Date (UTC) | Status | USDC in | USDC out | Duration |
| ------------------- | ---------- | --------- | --------- | -------- |
| 2026-04-21 14:02 | completed | $1,184.32 | $1,182.10 | 47s |
| 2026-04-08 09:17 | completed | $1,021.05 | $1,019.88 | 39s |
Vault 概览表 — 列架构:
| 字段 | 来源 | 格式 |
|---|---|---|
| Symbol | vault.vaultSymbol | 原样显示 |
| 类型 | vault.vaultType | "dtf" → "DTF","yield_dtf" → "Yield DTF" |
| 状态 | vault.status | 原样显示(小写) |
| TVL | vault.totalValueLocked | $<n>.<2 位小数>,使用千位分隔符 |
| 份额价格 | vault.sharePrice | $<n>.<4 位小数>(比 TVL 更精确) |
| NAV | vault.nav(以字符串表示的十进制数——先用 Number() 解析) | $<n>.<2 位小数> |
| 代币总数 | vault.totalTokens(以字符串表示的十进制数) | <n>.<6 位小数> |
| 管理费 | vault.feeConfig.managementFeeBps / 100 | <n>.<2 位小数>% |
| 7 日表现 | vault.performance7d | +/-<n>.<2 位小数>%,为 null 时显示 — |
| APY | vault.apy | <n>.<2 位小数>%,为 null/0 时显示 — |
投资组合表 — portfolio.assets[] 中的每项资产占一行:
| 列 | 来源 | 格式 |
|---|---|---|
| Symbol | assets[].symbol | 原样显示;此处绝不显示原始 mintAddress |
| 配置比例 | assets[].allocationBps / 100 | <n>.<2 位小数>% |
| 余额 | assets[].balance(原始 u64——除以 10^decimals) | <n>.<6 位小数> |
| 价值(USD) | assets[].valueUsd | $<n>.<2 位小数> |
按 allocationBps 降序排列各行,使占比最高的持仓位于顶部。
你的持仓表 — 仅当 userHoldings.tokenBalance > 0 时显示。将 walletAddress 截断为 <first4>…<last4>(例如 5HvU…A7B8)——绝不在用户输出中显示完整地址。
近期再平衡表 — 仅当 rebalanceHistory.data[] 非空时显示;最多显示最近的 5 行。将 startTime 转换为 UTC 时间 YYYY-MM-DD HH:mm。Duration 列为 executionDurationMs / 1000 四舍五入后的值,并加上后缀 s。隐藏 _id、vaultSymbol(标题中已显示)、sellPhaseTransactions 和 buyPhaseTransactions 数组——这些对摘要而言过于详细;仅在用户询问“兑换了哪些资产”时显示。
所有管理操作均为单次 API 调用。无需确认。
第 6 步:更新底层资产(四阶段流程)
当用户说 “更新底层资产”、“更改资产篮子”、“替换资产”、“重新平衡至 X”,或使用任何表示“替换金库的 underlyingAssets 分配”的表述时,运行此四阶段流程。四个阶段都必须自主运行,不需要逐阶段向用户确认。 第 4 阶段(重新平衡)必须在每次资产更新流程的末尾运行,不得例外:只更改资产篮子而不重新平衡会导致金库偏离目标(链上目标 ≠ 实际持仓),因此重新平衡是操作的一部分,而非可选的后续步骤。
┌──────────────────────────────────────────────────────────────────────────┐
│ UPDATE UNDERLYING — four-phase flow │
│ │
│ Phase 1: DECIDE THE NEW BASKET │
│ - WebSearch / WebFetch + GET /market-metrics → candidate assets │
│ - Match against the vault's existing constitutional policy │
│ (GET /dtf/:symbol/policy → asset_mode, whitelist, min liquidity) │
│ - Choose `mintBps` allocations summing to 10000 │
│ - Prefer `symbol` / `name` identifiers — backend resolves to mints │
│ │
│ Phase 2: BUILD ON-CHAIN TX (POLICY-GATED, SERVER-SIDE) │
│ POST /api/v2/agent/vaults/:symbol/update-assets-tx │
│ { signerPublicKey, underlyingAssets: [{ symbol, mintBps }, ...] } │
│ │
│ ├─ 400 + violations[] ─▶ Phase 1 with adjustments (loop) │
│ └─ 201 + base64 tx ─▶ Phase 3 │
│ │
│ Sign locally with DFM_AGENT_KEYPAIR → submit on-chain → confirm │
│ │
│ Phase 3: SYNC DB │
│ PATCH /api/v2/agent/vaults/:symbol/underlying-assets-by-mint │
│ { underlyingAssets: [{ mintAddress, pct_bps }, ...] } │
│ (Note: pct_bps, NOT mintBps — different field name) │
│ Auto-flushes agent:vaults:* caches. │
│ │
│ Phase 4: REBALANCE (STRICTLY MANDATORY — RUNS AUTOMATICALLY) │
│ POST /api/v2/agent/dtf/:symbol/rebalance │
│ { signerPublicKey } │
│ Admin wallet executes swaps server-side. No user confirmation gate │
│ — this phase ALWAYS runs after Phase 3 completes. A basket change │
│ without the rebalance leaves the vault drifting (on-chain target ≠ │
│ actual holdings), so the rebalance is part of the operation. │
└──────────────────────────────────────────────────────────────────────────┘
第 1 阶段 — 确定新的资产篮子
根据用户意图确定要添加、移除或重新平衡哪些资产。使用与启动时相同的研究工具:
- 使用
WebSearch/WebFetch发现代币(价格走势、市场叙事、市值)。 - 使用
GET /market-metrics?symbols=A,B,C获取权威的 Jupiter 流动性 / 24 小时交易量数据——与策略引擎根据规则 2 / 3 进行强制校验的数据相同。 - 使用
GET /dtf/:symbol/policy读取金库现有的asset_mode、asset_whitelist、asset_blacklist、min_amm_liquidity_usd、min_24h_volume_usd、max_asset_pct、min_asset_pct、min_stablecoin_pct。调整新的资产篮子,使其通过第 2 阶段的策略门控。策略固定不变;此流程中只能编辑资产篮子。
为每项资产选择 mintBps,确保所有值的总和恰好为 10000。如有需要,采用远离零方向的四舍五入,并将余数加到占比最大的分配项中。
可以传入 symbol 或 name,而非 mintAddress——后端会通过 asset-allocation 集合解析它们(与 /launch-dtf 使用相同的路径)。由服务器端解析成本更低,也无需 Agent 维护自己的铸币地址映射。
阶段 2 — 构建链上交易(受策略限制)
# Inline node script — never echo the JWT or the keypair to terminal
node -e '
const http = require("http");
const https = require("https");
const url = new URL(process.env.DFM_API_URL + "/api/v2/agent/vaults/" + process.argv[1] + "/update-assets-tx"); // process.argv[1] = vaultSymbol (e.g. ALPHA)
const client = url.protocol === "https:" ? https : http;
const { Keypair, VersionedTransaction, Connection } = require("@solana/web3.js");
const bs58 = require("bs58").default || require("bs58");
const keypair = Keypair.fromSecretKey(bs58.decode(process.env.DFM_AGENT_KEYPAIR));
const payload = JSON.stringify({
signerPublicKey: keypair.publicKey.toBase58(),
underlyingAssets: [
{ symbol: "SOL", mintBps: 5000 },
{ symbol: "JUP", mintBps: 3000 },
{ name: "Bonk", mintBps: 2000 }
]
});
const req = client.request(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
"Authorization": "Bearer " + process.env.DFM_AUTH_TOKEN
}
}, (res) => {
let data = ""; res.on("data", (c) => data += c);
res.on("end", async () => {
const json = JSON.parse(data);
if (res.statusCode === 201) {
// Sign locally + submit on-chain
const conn = new Connection(process.env.SOLANA_RPC_URL || "https://api.mainnet-beta.solana.com");
const tx = VersionedTransaction.deserialize(Buffer.from(json.transaction, "base64"));
tx.sign([keypair]);
const sig = await conn.sendRawTransaction(tx.serialize(), { preflightCommitment: "confirmed" });
await conn.confirmTransaction(sig, "confirmed");
console.log("ON_CHAIN_OK signature=" + sig + " vaultIndex=" + json.vaultIndex);
} else if (res.statusCode === 400 && json.ok === false) {
// Policy violation — every violated rule in violations[]
console.log("POLICY_VIOLATION");
for (const v of json.violations || []) {
console.log(" " + v.violationCode + ": " + v.message);
}
} else {
console.log("ERROR " + res.statusCode + ": " + data);
}
});
});
req.on("error", (e) => console.log("ERROR: " + e.message));
req.write(payload);
req.end();
' <vaultSymbol>
使用 timeout: 600000(10 分钟)运行——链上确认可能需要时间。
违反策略时(HTTP 400 + { ok: false, violations[] }): 阅读 violations[] 中的每一项,用一行“受阻原因”向用户总结违规代码,然后自动重试阶段 1 并进行调整:
| 违规代码 | 修改内容 |
|---|---|
rule2MinAmmLiquidity / rule3Min24hVolume | 移除违规资产,或将其替换为流动性更高的替代资产——金库的策略阈值固定,因此必须调整资产组合。 |
rule4MinMaxAssetCount | 提议的资产组合数量不符合 min_assets/max_assets。范围固定——要么调整资产组合,要么如果金库创建时设置了 min_assets == max_assets,请参阅下文的“结构性锁定策略”。 |
rule5MaxPctPerAsset | 降低该资产的 mintBps,并将权重重新分配给其他资产。 |
rule6MinPctPerAssetIfHeld | 提高该资产的权重,使其超过最低值,或将其从资产组合中完全移除。 |
rule7MinStablecoinFloor | 增加稳定币配置(必须是 asset-allocation 中已有且 asset_mode 允许的稳定币)。 |
rule1WhitelistBlacklist / assetModeViolation | 移除该资产(它在金库现有策略中已被列入黑名单,或未列入白名单)。 |
每次更新的变动上限(拒绝信息中提到 max_rebalance_pct) | 累计变动 = 每种铸造资产的 ` |
最多循环执行阶段 1 → 阶段 2 三次。如果尝试 3 次后仍失败,请向用户说明尚未解决的违规问题。
诊断结构性锁定策略: 如果 min_assets == max_assets 导致 rule4MinMaxAssetCount 持续触发,或者每次单资产交换都超过 max_rebalance_pct(测试条件:2 × max_asset_pct > max_rebalance_pct),或者某个已包含资产的交易量处于 min_24h_volume_usd 的 30% 范围内,且 rule3Min24hVolume 会阻止任何仍包含该资产的迁移,则说明金库在创建时配置有误,Agent 无法自行修复。向用户说明诊断结果和需要调整的具体限制,然后停止。不要对数学上无法修复的金库无限重试。
签名: 使用“预检认证”部分中的现有 signAndSend helper。签名后的交易会发送到 Solana RPC;请等待达到 confirmed 确认级别后再进入阶段 3。
阶段 3 — 同步数据库
链上交易确认后,调用 PATCH /api/v2/agent/vaults/:symbol/underlying-assets-by-mint 更新数据库记录。注意字段名称的区别:
- 阶段 2(
/update-assets-tx)使用mintBps(与链上指令一致)。 - 阶段 3(
/underlying-assets-by-mint)使用pct_bps(与数据库架构一致)。
数值相同,键名不同。在此处始终包含 mintAddress(此端点不解析符号/名称)。
node -e '
const http = require("http");
const https = require("https");
const url = new URL(process.env.DFM_API_URL + "/api/v2/agent/vaults/" + process.argv[1] + "/underlying-assets-by-mint"); // process.argv[1] = vaultSymbol (e.g. ALPHA)
const client = url.protocol === "https:" ? https : http;
const payload = JSON.stringify({
underlyingAssets: [
{ mintAddress: "So11111111111111111111111111111111111111112", pct_bps: 5000 },
{ mintAddress: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", pct_bps: 3000 },
{ mintAddress: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", pct_bps: 2000 }
]
});
const req = client.request(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
"Authorization": "Bearer " + process.env.DFM_AUTH_TOKEN
}
}, (res) => {
let data = ""; res.on("data", (c) => data += c);
res.on("end", () => console.log("DB_SYNC " + res.statusCode));
});
req.on("error", (e) => console.log("ERROR: " + e.message));
req.write(payload);
req.end();
' <vaultSymbol>
为什么需要两个阶段? 阶段 2 会修改链上状态;阶段 3 会让读取端点(/vaults/user、/vaults/featured/list)立即显示变更。链上事件管道最终确实会自行回填数据库,但 PATCH 可实现同步可见——用户期望在说“更新底层资产”后,立即通过“显示我的金库”看到新的资产组合。
阶段 4 — 再平衡(严格要求,自动执行)
阶段 4 始终作为每个资产更新流程的最后一步执行——无需用户确认,也没有拒绝选项,Agent 不会将其表述为可选操作。阶段 3 的 PATCH 返回 200 后,Agent 立即调用 /rebalance 并等待完成。流程开始时只向用户询问一次(“确认更新资产组合?”),这一次确认涵盖全部四个阶段,包括此阶段。再平衡是操作的一部分,不是后续询问。
**严格执行的原因:**阶段 2 会更改链上的资产组合目标(金库应持有的资产)。阶段 4 会将金库的实际持仓与这些新目标协调一致。跳过阶段 4 会导致金库持仓与目标持续偏离——链上目标 ≠ 实际持仓——而偏离期间的任何存款都会采用错误的配置。不存在无需再平衡而更改资产组合才是正确结果的使用场景。
调用:
POST {DFM_API_URL}/api/v2/agent/dtf/:symbol/rebalance,请求体为 { signerPublicKey }。
与独立再平衡流程使用相同的 /rebalance 端点。管理员钱包会在服务端执行兑换,因此 Agent 只需发送请求、等待响应并呈现结果。使用 Agent 在阶段 2 / 阶段 3 中已获取的 vaultSymbol(而不是 vaultId)。
响应处理:
200/201且ok: true→ 阶段 4 完成。记录再平衡签名,供最终摘要使用。如果存在policyCheck.flagged: true(执行后审查标记),则通过违规代码表进行转换,并将其作为非阻塞警告附加到面向用户的摘要中。再平衡仍已完成——被标记 ≠ 失败。- 任何错误响应 → 阶段 4 失败,即使阶段 1–3 已成功。不要重新运行阶段 2 或阶段 3(资产组合变更已在链上和数据库中完成)。向用户说明:“资产组合已更新,但再平衡步骤未能完成。金库的持仓仍与旧配置一致;告诉我‘rebalance
<symbol>’,即可仅重试这一步。” 结束本轮对话。
阶段 4 面向用户的输出中禁止使用:“确认现在执行吗?”、“你想再平衡吗?”、“要我继续进行再平衡吗?”或任何其他将再平衡描述为可选操作的措辞。阶段 4 不是问题。
所有阶段均成功后
向用户显示一行摘要:“已将 <vaultName> 的资产组合更新为 <asset1 pct1%, asset2 pct2%, ...> 并完成再平衡。链上签名 — 更新:<updateSig>,再平衡:<rebalanceSig>。”Agent 绝不能在面向用户的消息中透露端点名称、请求体结构或 HTTP 方法——只能说明结果。
当所需变更超过 max_rebalance_pct 时的多步迁移
某些资产组合迁移无法通过单次 /update-assets-tx 完成,因为总变动量(所有铸币资产的 |new_bps - old_bps| 之和)超过了金库的 max_rebalance_pct。此时,Agent 必须将迁移拆分为两次资产组合更新,并在两次更新之间强制等待冷却时间(该冷却时间由 rule9MinTimeBetweenRebalances 执行,受 min_rebalance_interval_hours 限制)。
触发此模式的情况:
- Agent 在发送
/update-assets-tx前计算总变动量。如果超过max_rebalance_pct,则应预先规划一个过渡资产组合,而不是让后端拒绝请求。 - 或者,后端返回
400,并带有rule8MaxPctRebalancedPerTx/ 单次更新变动上限——同样的响应,此时 Agent 需要规划拆分。
两步模式:
Step 1 (now): transitional basket — moves some weight, fits the cap.
├─ /update-assets-tx (Phase 2)
├─ sign + submit
├─ /underlying-assets-by-mint (Phase 3 — DB sync)
└─ /rebalance (Phase 4 — fan-in actually moves the holdings)
…wait at least min_rebalance_interval_hours…
Step 2 (later): final basket — moves the remaining weight.
├─ /update-assets-tx (Phase 2)
├─ sign + submit
├─ /underlying-assets-by-mint (Phase 3)
└─ /rebalance (Phase 4)
计算过渡篮子: 选择分配方案,使其满足以下条件:
- 汇总
|new_bps - old_bps|≤max_rebalance_pct - 500(低于上限 500 bps 的安全缓冲)。 - 过渡篮子位于当前分配与最终分配之间——只向最终分配部分调整,而不是任意的中间方案。
- 每项过渡资产都必须单独通过策略门槛(不要为了随后移除而引入某项资产)。
在开始第 1 阶段之前,就向用户提前说明计划,使用通俗易懂的语言。用户必须明白,他们同意的是两阶段迁移,而不是单次更新:
✅ RIGHT — multi-step plan announcement (table format):
The change you've requested is larger than this vault permits in a single
basket update. I'll do it in two stages, with a `<min_rebalance_interval_hours>`-hour
wait between them. Each stage updates the basket on-chain AND rebalances
the holdings to match — no separate rebalance step needed.
Stage 1 (now):
| Symbol | From | → To | Δ |
| ------- | ------- | ------- | -------- |
| HNT | 25.00% | → 0.00%| -25.00% |
| SOL | 0.00% | → 20.00%| +20.00% |
| RENDER | 30.00% | → 25.00%| -5.00% |
| ZEREBRO | 25.00% | → 30.00%| +5.00% |
| ORCA | 20.00% | → 20.00%| — |
| PUMP | 0.00% | → 5.00%| +5.00% |
Stage 2 (after cooldown clears):
| Symbol | From (post-Stage-1) | → To | Δ |
| ------- | ------------------- | ------- | -------- |
| SOL | 20.00% | → 30.00%| +10.00% |
| ZEREBRO | 30.00% | → 20.00%| -10.00% |
| RENDER | 25.00% | → 0.00%| -25.00% |
| ORCA | 20.00% | → 15.00%| -5.00% |
| PUMP | 5.00% | → 20.00%| +15.00% |
| BONK | 0.00% | → 15.00%| +15.00% |
Confirm to proceed with Stage 1 now?
第 1 阶段完成后(再平衡返回 200),记录冷却结束时间,并向用户清晰说明状态——绝不要粘贴规则代码:
✅ RIGHT — Stage 1 done, Stage 2 pending:
Stage 1 complete. The vault is now holding the transitional mix shown above.
Stage 2 needs to wait until the rebalance cooldown clears — earliest run is
about <H>h <M>m from now (around <YYYY-MM-DD HH:mm UTC>). Tell me
"continue Stage 2" when you're ready and I'll run it then.
阶段边界——第 1 阶段与第 2 阶段之间必须硬性停止。
这是多步迁移流程中违反次数最多的规则。第 1 阶段的再平衡返回 200 后,Agent 必须结束当前轮次,并发送上述“第 2 阶段待执行”消息。同一轮次中不得执行以下任何操作:
- ❌ 尝试对第 2 阶段/最终篮子调用
/update-assets-tx——这会被冷却期拦截,除了产生无用噪声外毫无作用。 - ❌ 对第 2 阶段的篮子调用
/rebalance/check,“看看会触发哪些标记”。 - ❌ 向用户说明第 2 阶段尝试的结果——即使只是说“告知您一下,我尝试了第 2 阶段,但要等到 才能执行”。开始时的计划说明已经告诉用户需要等待;通过失败尝试的叙述重复说明这一点只会产生噪声,而非信息。
- ❌ 在同一轮次中循环/重试/轮询,等待冷却期结束。用户可以通过说 “继续第 2 阶段” 来决定何时运行第 2 阶段。
Agent 的第 1 阶段完整轮次会在再平衡消息发出后结束。第 2 阶段将在单独的轮次中运行,由用户的下一条消息触发。
任一阶段面向用户的输出中禁止出现以下内容:
- 提及
rule9MinTimeBetweenRebalances、rule8MaxPctRebalancedPerTx或任何其他代码。 - 使用“冷却门槛触发”“策略强制要求间隔 4h”“max_rebalance_pct 为 8000 bps”之类的说法。
- 端点名称(
/update-assets-tx、/rebalance)。 - HTTP 状态码(
201、400、403)。 - 阶段编号(“Phase 1B”“Phase 2A”)——这些是技能内部编号。
- 使用 “被 400 拦截”、“flagged: true”、“审核标记” 之类的表述。
- 失败尝试的叙述——绝不要告诉用户“我尝试了第 2 阶段/第二次更新,但由于……被拦截”。如果 Agent 在内部尝试某项操作但失败了(无论是因为冷却期、策略还是其他原因),这都属于内部恢复;面向用户的消息仍必须使用清晰的“第 1 阶段已完成,第 2 阶段待执行”模板。用户已从计划说明中了解到需要等待;报告失败尝试只会产生无用噪声。
每次调用 /update-assets-tx 前,先进行考虑冷却期的预检——规则是:冷却期有效时,连尝试都不要尝试。
Agent 不应通过尝试调用 /update-assets-tx 并读取拒绝结果来“发现”冷却期。应预先确定冷却期,并据此决定不进行调用:
- 在本轮会话中,每当
/rebalance或/update-assets-tx成功后,都将时间戳存入对话记忆,记为lastRebalanceAt。 - 在任何
/update-assets-tx之前(第 1 阶段、第 2 阶段或全新的单步迁移),计算now - lastRebalanceAt。如果结果低于min_rebalance_interval_hours(从/dtf/:symbol/policy读取一次并一并存入),不要调用/update-assets-tx。用通俗英语向用户说明还需等待多久,然后结束本轮。 - 如果对话是全新的,且没有存入的
lastRebalanceAt,则在首次调用/update-assets-tx之前,调用一次GET /dtf/:vaultSymbol/rebalance/check。如果policyCheck.flagged: true,且reviewFlags中有与冷却时间相关的标记,则冷却期仍在生效——用通俗英语说明情况,告知用户,然后结束本轮。 - 多步迁移中最常见的情况:第 1 阶段的
/rebalance刚刚成功。Agent 已存入时间戳。同一轮中,第 2 阶段必定无法通过此预检,因此 Agent 甚至不会尝试——它会直接显示“第 1 阶段已完成,第 2 阶段待处理”的消息,然后结束本轮。这正是上述硬性停止规则的全部目的。
用户在下一轮说“继续第 2 阶段”时:
- 再次运行冷却预检。如果冷却期仍在生效(用户回来得太早),说明剩余等待时间并结束本轮——不要尝试执行。
- 重新获取
/dtf/:symbol/state,确认金库处于第 1 阶段之后的状态。如果状态不符合预期,告知用户并停止。 - 使用第 2 阶段的资产组合运行阶段 1–4。适用相同的错误/正确输出规则。
- 两个阶段都完成后的最终摘要:仅用一个结束段落写明两个阶段的签名,不要包含逐阶段调试日志。
错误与正确示例——资产更新操作摘要
真实示例:用户刚刚收到了 Agent 发来的以下不良跟踪记录。这是逐阶段调试日志——以下每个编号行以及每个 API/HTTP 片段都禁止出现:
❌ WRONG — every fragment below is banned:
Pre-flight (every operation batch)
- Synced/validated env via .claude/settings.json
(DFM_API_URL, DFM_AUTH_TOKEN, DFM_AGENT_KEYPAIR, …)
Phase 0: State + proposal alignment
- Fetched GET /api/v2/agent/dtf/AIPIN/state.
Phase 1A: Metrics/mint resolution
- Called GET /api/v2/agent/market-metrics?symbols=SOL,ZEREBRO,…
Phase 1B: Transitional basket on-chain build+submit
- Target: SOL 20% / ZEREBRO 25% / RENDER 20% / ORCA 20% / PUMP 15%.
- Called POST /api/v2/agent/vaults/AIPIN/update-assets-tx with mintBps.
- On-chain success signature: 47H2Vumu2C…
Phase 1C: Transitional basket DB sync
- First attempt failed: PATCH … → 403 ("Only the vault creator can perform this action").
- After your backend fix, retried: Same PATCH + same body → 200 success.
Phase 2A: Final basket on-chain build attempt
- Called POST /api/v2/agent/vaults/AIPIN/update-assets-tx.
- Blocked with 400: rule9MinTimeBetweenRebalances (4h cooldown not elapsed).
Phase 2B: Rebalance check (diagnostic)
- Called GET /api/v2/agent/dtf/AIPIN/rebalance/check.
- Returned ok: true, flagged: true with:
rule8MaxPctRebalancedPerTx (10000 bps movement vs 8000 max),
rule9MinTimeBetweenRebalances (cooldown remaining).
Phase 2C: Rebalance execution
- Called POST /api/v2/agent/dtf/AIPIN/rebalance with signer pubkey.
- HTTP 201, ok: true. Fees: upfront 0.005 SOL, actual 0.000335 SOL.
上述内容的问题(除了标准的“不得包含端点路径、HTTP 状态码、规则代码或 JSON 内部细节”之外):
- 这是逐阶段跟踪记录。 用户不需要知道 Agent 先后运行了阶段 1A、1B 和 1C。Agent 应报告结果,而不是步骤。
- 暴露了恢复过程的内部细节(“首次尝试失败……在你的后端修复后重试”)。用户的聊天不是事后分析渠道。
- 透露了费用内部细节(“预付 0.005 SOL,实际 0.000335 SOL”)。只有用户询问费用时才提及。
- 迁移状态不明确——“步骤 2……尚未完成”。Agent 必须明确说明下一步操作:要么说*“冷却期在 HH:mm 结束后,第 2 阶段会自动运行”,要么说“告诉我‘继续第 2 阶段’以完成迁移”*。绝不能让用户困惑。
同一跟踪记录的正确版本应采用上文的多步计划和阶段完成摘要模板——使用两条简洁的表格消息,不包含调试细节,并以明确提示用户下一步操作结尾。
底层更新的严重错误处理
/update-assets-tx返回 400 且包含violations[](任何签名操作之前):策略门禁未通过——链上没有发生任何变更。调整后重新执行阶段 1。免费。/update-assets-tx返回 400,且消息提示找不到资产("The following assets are not available in the platform: ..."):该符号/名称未能解析。选择其他资产,且其符号必须存在于asset-allocation中(可通过/market-metrics验证)。- 签名/链上提交失败:可以重试
/update-assets-tx,以获取新的区块哈希并重新进行策略检查(策略可能在此期间发生变化)。使用相同的signerPublicKey和相同的篮子——无需进行其他更改。 - 已提交到链上,但阶段 3(PATCH)失败:不要重新运行
/update-assets-tx(链上变更已经发生——不要重复更新)。使用相同的请求体重试 PATCH。如果 PATCH 持续失败,请向用户提供链上签名;链上事件管道最终会自行同步数据库。 /update-assets-tx返回 404 "No constitutional policy found":该金库是在 Agent 流程之外创建的(未调用/launch-dtf)。更新被阻止。请向用户说明这一情况——Agent 端没有可修复之处。- 阶段 3 成功后,阶段 4(重新平衡)失败:不要重新运行
/update-assets-tx或 PATCH(篮子变更已在链上和数据库中完成)。使用相同的请求体重试POST /dtf/:symbol/rebalance。如果持续失败,请告知用户:篮子已更新,但金库持仓仍与旧篮子一致——他们之后应通过“重新平衡金库”再次尝试重新平衡。 - 任何阶段返回 HTTP 403/"Only the vault creator can perform this action":Agent 钱包(
signerPublicKey)与金库的creatorAddress不匹配。**停止——不要重试,不要尝试其他金库符号或 ID,也不要遍历/vaults/user或/vaults/featured/list查找匹配项。**请向用户说明:*"该金库属于另一个钱包。只有创建者才能更新它。"*结束本轮对话。
第 7 步:资金流动——存入与赎回
当用户表示**“将 USDC 存入 SOLBC”、“购买 SOLBC 份额”、“赎回 1.5 SOLBC”、“卖出我的 SOLBC 代币”**,或使用任何表示“通过 Agent 钱包将资金转入或转出金库”的说法时,运行以下两种流程之一。
+--------------------------------------------------------------------+
| CAPITAL FLOWS — agent-bound deposit (2 steps) + redeem (5 steps) |
| |
| DEPOSIT (vault USDC fans out into underlyings): |
| 1. POST /vaults/:symbol/check-min-deposit (MANDATORY GATE) |
| -> abort flow on 400. Do NOT call deposit-tx if the |
| -> threshold is not met. |
| 2. POST /vaults/:symbol/deposit-tx |
| -> base64 unsigned VersionedTransaction |
| 3. agent signs locally + submits on-chain |
| 4. POST /deposit-transaction |
| -> agentSwap (vault USDC -> underlyings via Jupiter) + |
| depositTransaction (parses logs, persists 4 records) |
| |
| REDEEM (queue-serialised; underlyings fan in to vault USDC): |
| 1. POST /check-min-redeem (MANDATORY GATE) |
| -> reads `isValid` (returns 200 either way). Abort flow |
| -> when isValid=false. Do NOT call request-ticket. |
| 2. POST /redeem/request-ticket (queue entry) |
| 3. poll v1 GET /api/v1/tx-event-management/redeem/ticket-status |
| /:ticketId until isReady=true (agent has no clone) |
| 4. POST /redeem/execute/:ticketId (backend swap fan- |
| in: underlyings -> USDC inside the vault) |
| 5. POST /vaults/:symbol/redeem-tx |
| -> base64 unsigned finalizeRedeem tx |
| 6. agent signs locally + submits on-chain |
| 7. POST /redeem-transaction { transactionSignature, ticketId } |
| -> records redeem + auto-confirms ticket (non-fatal) |
| |
| IDENTITY: |
| Deposit-tx takes an explicit `userPublicKey` body field — the |
| depositor wallet may or may not be the agent. Records are |
| attributed via JWT (agentProfile + agentAddress). |
| Redeem-tx is bound to the agent wallet (JWT canonical, body |
| `agentWallet` accepted only as fallback). |
| |
| PRE-FLIGHT GATES ARE MANDATORY: |
| Both flows MUST start with the corresponding check. Skipping |
| them sends underspec'd amounts to the on-chain ix and produces |
| confusing failures (post-fee dust, 0-share mints, etc.). |
+--------------------------------------------------------------------+
选择流程:
| 用户意图 | 流程 | 备注 |
|---|---|---|
| “将 X USDC 存入 ” | 存入 | 默认将 userPublicKey 设置为 Agent 钱包,除非用户明确指定了其他存款人钱包。 |
| “从 赎回 X 个代币”/“卖出我的 ” | 赎回 | 始终使用 Agent 钱包(JWT)。Agent 必须已持有由其 agentProfile 标识的仓位。 |
| “向 添加流动性”/“增购 ” | 存入 | 与存入相同。 |
| “从 提现”/“将 变现” | 赎回 | 与赎回相同。 |
执行前检查(两种流程均适用):发出 build-tx 请求之前,向用户提供一行摘要(包括金库、金额和预期费用)。以下流程的子步骤之间不需要再次确认。
**强制最低金额门禁:**每次存入流程都必须以 POST /vaults/:symbol/check-min-deposit 开始,每次赎回流程都必须以 POST /check-min-redeem 开始。这些步骤不可省略——它们是后续流程的门禁:
- 如果拟存入金额低于阈值,
check-min-deposit会抛出400(Minimum deposit should be at least $<n> USDC)。400 时不要继续调用/deposit-tx——将最低金额原样告知用户,请用户提供更高的金额,然后重新执行门禁检查。 - 如果拟赎回金额低于阈值,
check-min-redeem会返回200,并将isValid设为false(不会抛出错误)。读取isValid,若为false则中止——检查未通过时,不要调用/redeem/request-ticket。 必须设置这些门槛的原因:跳过它们会放行金额过小的请求,导致令人困惑的链上失败(扣除手续费后变成尘埃金额、铸造零份额,以及在赎回时 Jupiter 会拒绝的分数兑换)。在验证器端点捕获问题,可在任何链上或队列交互发生之前,向用户提供清晰且可执行的错误信息。
阶段顺序约定(请先阅读——此技能中最常被违反的不变量):
两个流程的每个阶段都必须严格按顺序执行。Agent 必须先 await 上一阶段的 API 响应(并验证其状态),然后才能发起下一阶段的调用。以下脚本采用单个异步调用链编写,因此会自动遵守此规则——但如果 Agent 以多个独立的 node -e 调用运行各阶段,或并行运行任何操作,流程就会在无提示的情况下出错:
| 风险 | 出错情况 |
|---|---|
链上提交确认后跳过 /deposit-transaction | 存款交易已上链(份额已铸造给存款人),但 agentProfile 下没有记录 UserVaultPosition。之后用户无法通过 Agent 流程赎回,因为 /redeem-transaction 会抛出 No position found for agent in this vault。 |
/deposit-transaction 返回 200 之前就向用户报告存款成功 | 同上——链上操作已完成,但数据库操作尚未完成。用户以为自己持有份额;之后 Agent 的盈亏明细却看不到这笔存款。 |
isReady=true 之前调用 /redeem/execute/:ticketId | 后端会以 Failed to process ticket 拒绝请求,并自动取消票据——用户必须从头重新开始整个赎回流程。 |
/redeem/execute 完成兑换汇总之前就构建 /redeem-tx | 金库没有足够的 USDC 来结算,因此 finalizeRedeem 会在链上失败。已签名的价格会过期;Agent 必须重新调用 /redeem-tx 获取新价格。 |
链上 finalizeRedeem 确认之前调用 /redeem-transaction | 后端无法获取交易(Transaction not found);赎回已在链上完成,但没有数据库记录。请等待 confirmTransaction 解决后再调用。 |
成功标志是每个脚本打印的最后一行 *_OK JSON(DEPOSIT_OK { ... } 或 REDEEM_OK { ... })。任何更早的 PHASE_N_OK 日志都只是中间进度——在 stdout 中看到最后一行 OK 之前,不要向用户报告成功。如果脚本退出时没有打印最后一行 OK(或出现 MIN_*_FAIL、BUILD_ERROR、EXECUTE_ERROR、RECORD_ERROR、FATAL),请显示文档中规定的失败消息模板并停止。
工具:读取链上份额余额
对于“我持有多少 <vault> 份额?”/“我的持仓是多少?”/在执行 “赎回我的全部 <vault>” 之前的预检,请调用 GET /vaults/:symbol/shares。钱包地址从 JWT 中读取——除非用户明确询问其他钱包,否则不要传入 ?wallet=。该端点返回链上 ATA 余额(不会读取数据库)。
node -e '
const http = require("http");
const https = require("https");
const url = new URL(process.env.DFM_API_URL + "/api/v2/agent/vaults/" + process.argv[1] + "/shares");
const client = url.protocol === "https:" ? https : http;
const req = client.get(url, {
headers: { "Authorization": "Bearer " + process.env.DFM_AUTH_TOKEN }
}, (res) => {
let data = ""; res.on("data", (c) => data += c);
res.on("end", () => {
let parsed = JSON.parse(data || "{}");
// Unwrap the global ResponseMiddleware envelope
if (parsed && parsed.status && parsed.data) parsed = parsed.data;
if (res.statusCode !== 200) {
console.log("BALANCE_ERROR " + res.statusCode + ": " + JSON.stringify(parsed));
return;
}
if (!parsed.exists) {
console.log("BALANCE_OK exists=false sharesUi=0 — wallet has never held shares for this vault");
return;
}
console.log("BALANCE_OK " + JSON.stringify({
vaultSymbol: parsed.vaultSymbol,
sharesRaw: parsed.sharesRaw,
sharesUi: parsed.sharesUi,
ata: parsed.ata,
}));
});
});
req.on("error", (e) => console.log("ERROR: " + e.message));
' <vaultSymbol>
使用场景:
- “我持有多少 SOLBC 份额?”——调用一次,并显示
sharesUi和vaultSymbol。如果exists: false,请说 “你目前不持有任何 SOLBC 份额。” - “赎回我所有的 SOLBC”——先调用此接口读取
sharesUi,然后将该精确数值作为vaultTokenAmount传入赎回流程的/redeem/request-ticket。不要从 Agent 缓存的UserVaultPosition中读取——那是数据库记录,可能落后于链上实际情况(例如,之前的赎回未正确记录)。链上 ATA 才是事实来源。 - 任何赎回前的快速合理性检查——向用户显示
sharesUi并在继续之前确认。
7a. 存款流程(预检后的 2 个子步骤)
步骤 7a-0——强制预检:/check-min-deposit
在构建存款交易之前,始终先调用 POST {DFM_API_URL}/api/v2/agent/vaults/:symbol/check-min-deposit,并传入 { minDeposit: <UI USDC> }。如果返回 400,请勿继续——向用户显示最低金额提示,并要求用户输入更大的金额。下面第 7a-1 步中的整合脚本会在内部执行此检查,并在检查失败时中止。
第 7a-1 步 — 端到端存款(单个脚本,四个顺序阶段)
下面的脚本会在一个异步调用链中运行全部四个存款阶段——每个阶段都会等待前一阶段的 API 响应后再开始:
| 阶段 | 操作 | stdout 上的标识 |
|---|---|---|
| 1 | POST /vaults/:symbol/check-min-deposit(强制门槛检查) | PHASE_1_OK gate=passed |
| 2 | POST /vaults/:symbol/deposit-tx(构建未签名交易) | PHASE_2_OK vaultIndex=<n> |
| 3 | 使用 DFM_AGENT_KEYPAIR 在本地签名 + sendRawTransaction + confirmTransaction | PHASE_3_OK onChainSig=<sig> |
| 4 | POST /deposit-transaction(拆分并发起兑换 + 持久化 DB 记录——不具幂等性;每次调用都会重新分发一笔链上兑换) | DEPOSIT_OK { … }(最终成功——JSON 行) |
不要将这些阶段拆分为单独的 node -e 调用——请将整个调用链保留在单个脚本中,避免 Agent 在某个阶段尚未完成时意外继续。**在 stdout 上出现最终的 DEPOSIT_OK { … } JSON 行之前,不要向用户报告成功。**仅有 PHASE_3_OK(链上已确认)并不足够——没有 DEPOSIT_OK,就不会写入以 agentProfile 为键的 UserVaultPosition,用户之后赎回时会因 No position found for agent in this vault 而失败。
同样重要:如果阶段 4 失败,不要重试。 阶段 4(POST /deposit-transaction)不只是记录操作。每次调用都会发起一笔新的链上 agentSwap(通过 Jupiter 将金库 USDC 兑换为底层资产),并写入 DB 记录。如果阶段 4 超时、返回 5xx,或网络中断导致响应丢失,兑换可能已在服务端部分完成——再次调用会使用金库中剩余的 USDC 触发第二笔兑换,造成重复兑换并破坏 NAV。下方错误表中的技能规则明确禁止在 /deposit-transaction 返回任何非 200 响应时重试,包括看似暂时性的网关超时。在决定如何处理阶段 4 失败之前,请先阅读该规则。
阶段 4 请求体——每次调用都必须包含全部四个字段。 该端点会验证每个字段;如果发送不完整的请求体(例如仅包含 transactionSignature),就会返回 400,并附带类似 “vaultIndex should not be empty”、“amountInRaw must be a string” 的消息。不要将这些验证错误误认为合约已变更——这表示 Agent 构造的请求体不完整,而不是 API 需要新增字段。必需的结构固定如下:
| 字段 | 来源(阶段 2 响应) | 必需? | 类型 |
|---|---|---|---|
transactionSignature | 阶段 3 的 onChainSig(执行 sendRawTransaction + confirmTransaction 后) | 是 | 字符串(base58 签名) |
vaultIndex | build.body.vaultIndex | 是 | 整数(u32) |
etfSharePriceRaw | build.body.etfSharePriceRaw | 是 | 字符串(原始 u64,6 位小数) |
amountInRaw | build.body.amountInRaw | 是 | 字符串(原始 u64,6 位小数) |
slippage | 硬编码的 200(或传入用户的值) | 否 | 整数(50–500 bps) |
下面的脚本已将阶段 2 响应中的全部四个字段传入请求。切勿手动构造使用其他请求体结构的阶段 4 调用,尤其不要使用“只传签名来重试记录操作”这种方式——这正是导致客户端报告验证错误的原因。如果脚本的调用位置失败,正确做法不是手动使用更少的字段重试;请参阅错误表中关于 /deposit-transaction 返回非 200 响应时必须立即停止的规则。
node -e '
const http = require("http");
const https = require("https");
const { Keypair, VersionedTransaction, Connection } = require("@solana/web3.js");
const bs58 = require("bs58").default || require("bs58");
const keypair = Keypair.fromSecretKey(bs58.decode(process.env.DFM_AGENT_KEYPAIR));
const symbol = process.argv[1]; // e.g. ALPHA
const depositAmount = Number(process.argv[2]); // UI USDC, e.g. 5
function call(path, method, body) {
return new Promise((resolve, reject) => {
const url = new URL(process.env.DFM_API_URL + path);
const client = url.protocol === "https:" ? https : http;
const payload = body ? JSON.stringify(body) : null;
const headers = { "Authorization": "Bearer " + process.env.DFM_AUTH_TOKEN };
if (payload) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(payload);
}
const req = client.request(url, { method, headers }, (res) => {
let data = ""; res.on("data", (c) => data += c);
res.on("end", () => {
try {
let parsed = JSON.parse(data || "{}");
// Unwrap the global ResponseMiddleware envelope: { status, message, data }.
// Pagination responses (which carry a top-level `pagination` field) and
// exception responses (statusCode + error) are NOT wrapped; pass through.
if (
parsed && typeof parsed === "object" &&
parsed.status && Object.prototype.hasOwnProperty.call(parsed, "data") &&
!Object.prototype.hasOwnProperty.call(parsed, "pagination") &&
!Object.prototype.hasOwnProperty.call(parsed, "error")
) {
parsed = parsed.data;
}
resolve({ status: res.statusCode, body: parsed });
} catch { resolve({ status: res.statusCode, body: data }); }
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
(async () => {
// PHASE 1 — Mandatory gate
const gate = await call(
"/api/v2/agent/vaults/" + symbol + "/check-min-deposit",
"POST",
{ minDeposit: depositAmount }
);
if (gate.status !== 200) {
console.log("MIN_DEPOSIT_FAIL " + gate.status + ": " + (gate.body?.message || JSON.stringify(gate.body)));
return;
}
console.log("PHASE_1_OK gate=passed");
// PHASE 2 — Build the unsigned deposit tx (await response BEFORE signing)
const build = await call(
"/api/v2/agent/vaults/" + symbol + "/deposit-tx",
"POST",
{
userPublicKey: keypair.publicKey.toBase58(), // agent wallet as depositor (default)
depositAmount,
}
);
if (build.status !== 201) {
console.log("BUILD_ERROR " + build.status + ": " + JSON.stringify(build.body));
return;
}
console.log("PHASE_2_OK vaultIndex=" + build.body.vaultIndex);
// PHASE 3 — Sign locally + submit on-chain (await CONFIRMATION before recording)
const conn = new Connection(process.env.SOLANA_RPC_URL || "https://api.mainnet-beta.solana.com");
const tx = VersionedTransaction.deserialize(Buffer.from(build.body.transaction, "base64"));
tx.sign([keypair]);
const onChainSig = await conn.sendRawTransaction(tx.serialize(), { preflightCommitment: "confirmed" });
await conn.confirmTransaction(onChainSig, "confirmed");
console.log("PHASE_3_OK onChainSig=" + onChainSig);
// PHASE 4 — Swap + Record (await /deposit-transaction response before declaring success)
//
// ⚠️ THIS ENDPOINT IS NOT IDEMPOTENT AGAINST RETRIES. ⚠️
// Every call dispatches a fresh on-chain swap fan-out. Calling it twice with
// the same transactionSignature triggers a SECOND agentSwap that pulls more
// vault USDC into Jupiter — breaking NAV and stranding USDC in the admin
// wallet. If this phase times out or returns 5xx, see the failure-mode rule
// below: do NOT retry, do NOT re-run the script, do NOT call any deposit
// endpoint again with the same signature. Surface the on-chain signature to
// the user and stop. The chain-event pipeline + operator are the only safe
// recovery paths once Phase 4 is in an unknown state.
//
// Backend runs two server-side sub-operations on every call:
// (a) agentSwap — vault USDC -> underlyings via Jupiter (NOT idempotent)
// (b) depositTransaction — parses VaultDeposited logs, writes
// DepositTransaction + UserVaultPosition (keyed on agentProfile) +
// DepositRecord + History (DB-layer idempotency guard exists)
// Failure modes surfaced INSIDE the 200 response (swap.failedSwapsInfo,
// deposit.events[].vaultDepositUpdateError) are non-fatal and do NOT warrant
// a re-call. Any non-200 response (timeout, 5xx, network error) is a HARD
// STOP — the swap dispatch already happened server-side and the agent
// cannot tell from the client side how much of it completed.
// ALL FOUR fields are required — sending a partial body (e.g. just
// transactionSignature) returns 400 ("vaultIndex should not be empty",
// "amountInRaw must be a string", etc). Wire each from Phase 2's response.
const rec = await call("/api/v2/agent/deposit-transaction", "POST", {
transactionSignature: onChainSig, // from Phase 3 (on-chain submit)
vaultIndex: build.body.vaultIndex, // from Phase 2 (/deposit-tx response)
etfSharePriceRaw: build.body.etfSharePriceRaw, // from Phase 2 (/deposit-tx response)
amountInRaw: build.body.amountInRaw, // from Phase 2 (/deposit-tx response)
slippage: 200, // optional; default 200 bps
});
if (rec.status !== 200) {
// HARD STOP: do NOT retry. See the deposit error table for /deposit-transaction
// non-200 — every call dispatches a fresh agentSwap, retrying breaks NAV.
// Do NOT re-call with a "minimal" body either — the API contract is the
// five fields above, and validation errors here mean the body was wrong,
// not that the contract changed.
console.log("RECORD_ERROR " + rec.status + ": " + JSON.stringify(rec.body));
return;
}
// FINAL — emit consolidated JSON for the user-facing summary + memory.
// Agent must wait for THIS line before reporting deposit success.
const ev = rec.body.deposit?.events?.[0] || {};
const sharePriceDepositUsd = build.body.etfSharePriceRaw
? Number(build.body.etfSharePriceRaw) / 1e6
: null;
console.log("DEPOSIT_OK " + JSON.stringify({
event: ev.eventType,
vaultName: ev.vaultName,
vaultSymbol: ev.vaultSymbol,
grossUsdcRaw: ev.amount, // 6 decimals
entryFeeRaw: ev.entryFee, // 6 decimals
managementFeeRaw: ev.managementFee, // 6 decimals (typically 0 at deposit)
netUsdcRaw: ev.netAmount, // amount that actually became shares
sharesMintedRaw: ev.vaultTokensMinted, // 6 decimals
sharePriceDepositUsd, // share price baked into THIS deposit
onChainSig,
failedSwaps: rec.body.swap?.failedSwapsInfo || null,
recordError: ev.vaultDepositUpdateError || null,
}));
})().catch(e => console.log("FATAL " + (e?.message || String(e))));
' <vaultSymbol> <depositAmount>
使用 timeout: 600000(10 分钟)运行。每个阶段完成时都会输出四个 *_OK 标记;Agent 应跟踪这些标记,但只有在出现 DEPOSIT_OK { … } 时才能报告成功。如果出现其他任何终止行(MIN_DEPOSIT_FAIL、BUILD_ERROR、RECORD_ERROR、FATAL),则展示文档中规定的失败消息模板并停止。
⚠️ 必须立即停止 — PHASE_3_OK 之后出现 RECORD_ERROR 或 FATAL。 如果已记录 Phase 3,但脚本没有输出 DEPOSIT_OK,则链上存款已成功(份额已铸造给存款人),但 Phase 4 的服务端状态未知:agentSwap 可能已部分完成、已全部完成但未响应,或在任何交换发生前超时。不要重新运行脚本。不要使用相同签名再次调用 /deposit-transaction。不要调用任何其他存款端点来“修复”此问题。 再次调用 /deposit-transaction 会使用金库中剩余的 USDC 触发第二次 agentSwap,造成重复扣取并破坏 NAV。唯一安全的做法是向用户提供链上签名,并告知:“存款已上链 — 您的份额已铸造,并可在钱包中查看。我们无法确认平台的交换步骤;运营人员将进行核对。请不要在此处重试。” 然后结束本轮对话。此规则优先于任何测试框架层面的重试倾向。
存款完成消息 — 必须遵循的格式
与赎回消息规则对称。 总结存款时,Agent 必须从脚本的 JSON 输出中读取 entryFee 和 sharePriceDepositUsd,透明地向用户展示,并将它们记入对话记忆,以便后续赎回调用能够显示真实的盈亏明细,而不是猜测。
必须向用户展示的摘要(模板)。 使用换算后的 UI 值替换占位符 — 绝不能直接粘贴原始 u64,也绝不能在括号中同时列出原始值和 UI 值:
Deposit complete — <vaultName> (<vaultSymbol>).
• USDC deposited (gross): <grossUsdcUi> USDC
• Entry fee: <entryFeeUi> USDC
• USDC that purchased shares: <netUsdcUi> USDC
• Shares minted: <sharesMintedUi> <vaultSymbol>
• Share price at deposit: $<sharePriceDepositUsd>
• On-chain signature: <onChainSig>
其中,<grossUsdcUi> = grossUsdcRaw / 1e6(格式化为 6 位小数),<entryFeeUi> = entryFeeRaw / 1e6(格式化为 6 位小数 — 如果费用为 0,则显示为 0.000000,绝不能显示为 0 raw);其他每个 *Ui 占位符也都进行相同换算。原始 u64 值仅保留在脚本的 DEPOSIT_OK { … } JSON 中供内部使用 — 绝不能出现在聊天消息中。
如果存在 failedSwaps,则按原样追加:“<failedCount> 个底层交换失败,USDC 已退回金库 — 没有资金损失。” 不得编造其他失败说明。
如果存在 recordError,则按原样追加:“链上存款已成功,但数据库记录写入失败 — 链事件流水线将很快进行核对。无论如何,您的份额都已在链上铸造。”(这是唯一预先批准的“不得附加后端警告”规则例外 — 仅当链上操作确已成功,但数据库写入失败时使用,用户需要了解此状态。)不得扩展或改写此模板;上面的确切措辞是唯一可接受的形式。
供后续使用(对话上下文): 保存 { vaultSymbol, grossUsdcRaw, entryFeeRaw, sharesMintedRaw, sharePriceDepositUsd, onChainSig },以便同一用户之后从同一金库赎回时,赎回完成摘要能够包含存款端费用和真实的 NAV 变动计算。
7b. 赎回流程(预检后分为 4 个子步骤)
赎回流程的步骤更多,因为 (a) 它会进入队列,以串行化金库清算;(b) 交换汇集必须在链上执行 finalizeRedeem 之前完成,以确保金库有足够的 USDC 完成结算。
步骤 7b-0 — 必须预检:/check-min-redeem
请求票据前,始终先调用 POST {DFM_API_URL}/api/v2/agent/check-min-redeem,并传入 { minRedeem: <UI vault tokens> }。与 check-min-deposit 不同,此端点始终返回 200,并包含 { isValid, message }——应读取 isValid,而不是 HTTP 状态码。当 isValid: false 时,不要调用 /redeem/request-ticket;向用户显示最低门槛并停止。下方第 7b-5 步中的组合脚本会在内部执行此检查,并在检查失败时中止。
步骤 7b-1 — 请求赎回票据
调用 POST {DFM_API_URL}/api/v2/agent/redeem/request-ticket,并传入 { vaultSymbol, vaultTokenAmount, etfSharePriceRaw?, slippage? }。vaultTokenAmount 是原始 u64 字符串(6 位小数——先将 UI 代币数量乘以 1e6)。etfSharePriceRaw 为可选参数;省略时,后端会从链上读取。
返回:{ ticketId, position, estimatedWaitSeconds, isReady }。
步骤 7b-2 — 轮询直至就绪(使用 v1 端点)
agent 模块没有 /ticket-status 的对应端点。每隔 3-5 秒轮询一次 GET {DFM_API_URL}/api/v1/tx-event-management/redeem/ticket-status/:ticketId,并使用相同的 Authorization 标头。当 status.isReady === true 时停止。票据会在就绪约 3 分钟后过期——请勿拖延。
步骤 7b-3 — 执行 Swap 汇入
调用 POST {DFM_API_URL}/api/v2/agent/redeem/execute/:ticketId,并传入 { slippage? }。所有其他参数(vault、数量、份额价格)均从票据本身读取。后端会将票据状态转为 PROCESSING,运行 redeemAgentSwapAdmin(vault 底层资产 → USDC),并返回 Swap 签名。
执行失败时,后端会在重新抛出错误前自动取消票据——agent 无需调用 /cancel。向用户显示错误并停止。
步骤 7b-4 — 构建未签名的 finalizeRedeem 交易
调用 POST {DFM_API_URL}/api/v2/agent/vaults/:symbol/redeem-tx,并传入 { vaultTokenAmount }(UI vault 代币,例如 1.5)。agent 钱包从 JWT 中读取(请求正文中的 agentWallet 仅作为回退值)。
后端会解析 vault、派生 PDA、获取新的 KMS 签名份额价格、有条件地添加 4 条 ATA 创建指令(agent 的 vault 代币 + USDC、手续费接收方的 USDC、vault 管理员的 USDC),并生成 Ed25519 验证指令和 finalizeRedeem(...) 指令。返回 { transaction, vaultIndex, etfSharePriceRaw, priceTimestamp, vaultTokenAmountRaw, ... }。
步骤 7b-5 — 本地签名、提交、记录并自动确认票据
使用 DFM_AGENT_KEYPAIR 对返回的交易进行签名并在链上提交,然后调用 POST {DFM_API_URL}/api/v2/agent/redeem-transaction,并传入 { transactionSignature, vaultIndex, etfSharePriceRaw, signatureArray, slippage, ticketId }。
传入 ticketId 会触发后端在记录持久化后自动确认赎回队列票据。自动确认失败不影响主流程——若失败,响应中会包含 ticketConfirm: { ok: false, message },但不会中止流程。赎回已在链上完成并记录在数据库中。
端到端赎回(单个脚本,按顺序执行七个阶段)。 每个阶段都会等待 API 响应后再进入下一阶段:
| 阶段 | 操作 | stdout 中的标识 |
|---|---|---|
| 1 | POST /check-min-redeem(必需的前置检查;读取 isValid,而不是状态码) | PHASE_1_OK gate=passed |
| 2 | POST /redeem/request-ticket(加入队列) | PHASE_2_OK ticketId=<id> ready=<bool> |
| 3 | 轮询 v1 GET /redeem/ticket-status/:ticketId,直至 isReady=true(最长 5 分钟) | PHASE_3_OK ticket=ready |
| 4 | POST /redeem/execute/:ticketId(后端执行 Swap 汇入) | PHASE_4_OK swaps=<n> |
| 5 | POST /vaults/:symbol/redeem-tx(构建未签名的 finalizeRedeem) | PHASE_5_OK vaultIndex=<n> |
| 6 | 使用 DFM_AGENT_KEYPAIR 在本地签名 + sendRawTransaction + confirmTransaction | PHASE_6_OK onChainSig=<sig> |
| 7 | POST /redeem-transaction(记录并自动确认票据) | REDEEM_OK { … }(最终成功——JSON 行) |
在 stdout 出现最后一行 REDEEM_OK { … } 之前,不要向用户报告成功。PHASE_6_OK(链上已确认)还不够——没有 REDEEM_OK,持仓状态就会与链上赎回不同步,而且从未写入 History 行。 |
# End-to-end redeem — single script, seven sequential phases.
node -e '
const http = require("http");
const https = require("https");
const { Keypair, VersionedTransaction, Connection } = require("@solana/web3.js");
const bs58 = require("bs58").default || require("bs58");
const keypair = Keypair.fromSecretKey(bs58.decode(process.env.DFM_AGENT_KEYPAIR));
const symbol = process.argv[1];
const uiAmount = Number(process.argv[2]); // e.g. 1.5
function call(path, method, body) {
return new Promise((resolve, reject) => {
const url = new URL(process.env.DFM_API_URL + path);
const client = url.protocol === "https:" ? https : http;
const payload = body ? JSON.stringify(body) : null;
const headers = {
"Authorization": "Bearer " + process.env.DFM_AUTH_TOKEN
};
if (payload) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(payload);
}
const req = client.request(url, { method, headers }, (res) => {
let data = ""; res.on("data", (c) => data += c);
res.on("end", () => {
try {
let parsed = JSON.parse(data || "{}");
// Unwrap the global ResponseMiddleware envelope: { status, message, data }.
// Pagination responses (which carry a top-level `pagination` field) and
// exception responses (statusCode + error) are NOT wrapped; pass through.
if (
parsed && typeof parsed === "object" &&
parsed.status && Object.prototype.hasOwnProperty.call(parsed, "data") &&
!Object.prototype.hasOwnProperty.call(parsed, "pagination") &&
!Object.prototype.hasOwnProperty.call(parsed, "error")
) {
parsed = parsed.data;
}
resolve({ status: res.statusCode, body: parsed });
} catch { resolve({ status: res.statusCode, body: data }); }
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
(async () => {
const rawAmount = String(Math.round(uiAmount * 1e6));
// PHASE 1 — Mandatory gate: /check-min-redeem
// Returns 200 with isValid; do NOT trust HTTP status alone — read the boolean.
const gate = await call("/api/v2/agent/check-min-redeem", "POST", { minRedeem: uiAmount });
if (gate.status !== 200 || gate.body?.isValid !== true) {
console.log("MIN_REDEEM_FAIL: " + (gate.body?.message || JSON.stringify(gate.body)));
return;
}
console.log("PHASE_1_OK gate=passed");
// PHASE 2 — Request a redeem ticket (await response BEFORE polling)
const ticket = await call("/api/v2/agent/redeem/request-ticket", "POST", {
vaultSymbol: symbol,
vaultTokenAmount: rawAmount,
slippage: 200
});
if (ticket.status !== 200) { console.log("TICKET_ERROR " + ticket.status + ": " + JSON.stringify(ticket.body)); return; }
const { ticketId } = ticket.body;
console.log("PHASE_2_OK ticketId=" + ticketId + " ready=" + ticket.body.isReady);
// PHASE 3 — Poll v1 ticket-status until ready (max 5 minutes)
// Each poll awaits the response before sleeping for the next attempt.
// Do NOT call /redeem/execute until isReady=true.
const start = Date.now();
while (Date.now() - start < 5 * 60 * 1000) {
if (ticket.body.isReady) break;
await new Promise(r => setTimeout(r, 3000));
const s = await call("/api/v1/tx-event-management/redeem/ticket-status/" + ticketId, "GET");
if (s.body?.isReady) { ticket.body.isReady = true; break; }
if (["EXPIRED", "CANCELLED", "COMPLETED"].includes(s.body?.status)) {
console.log("TICKET_TERMINAL status=" + s.body.status); return;
}
}
if (!ticket.body.isReady) { console.log("TICKET_TIMEOUT"); return; }
console.log("PHASE_3_OK ticket=ready");
// PHASE 4 — Execute the backend swap fan-in (await 200 response BEFORE building tx)
// On failure here, the backend AUTO-CANCELS the ticket — do not call /cancel.
// Response shape (after envelope unwrap):
// { vaultIndex, vaultTokenAmount, swaps: [{ mint, input, sig }],
// vaultUsdcBalance, requiredUsdc, adjustedVaultTokenAmount,
// sharePriceAfter, totalValueUSDCraw, totalValueUSDActual, mode,
// ticketId, ticketStatus, message }
const exec = await call("/api/v2/agent/redeem/execute/" + ticketId, "POST", { slippage: 200 });
if (exec.status !== 200) { console.log("EXECUTE_ERROR " + exec.status + ": " + JSON.stringify(exec.body)); return; }
const swapSigs = (exec.body.swaps || []).map(s => s.sig).filter(Boolean);
console.log("PHASE_4_OK swaps=" + swapSigs.length + " sharePriceAfter=" + exec.body.sharePriceAfter);
// PHASE 5 — Build the unsigned finalizeRedeem tx (await response BEFORE signing)
// CRITICAL: if the backend clamped the amount during fan-in (adjustedVaultTokenAmount
// differs from the request), the on-chain finalizeRedeem ix MUST use the clamped
// amount or it will mismatch the USDC actually paid out and either fail or
// under-redeem. The script overrides uiAmount with the clamped value when present.
const adjustedRaw = exec.body.adjustedVaultTokenAmount;
const finalizeUiAmount =
adjustedRaw && /^\d+$/.test(String(adjustedRaw)) && Number(adjustedRaw) > 0
? Number(adjustedRaw) / 1e6
: uiAmount;
if (finalizeUiAmount !== uiAmount) {
console.log("ADJUSTED amount=" + uiAmount + " -> " + finalizeUiAmount + " (raw=" + adjustedRaw + ")");
}
const build = await call("/api/v2/agent/vaults/" + symbol + "/redeem-tx", "POST", {
vaultTokenAmount: finalizeUiAmount
});
if (build.status !== 201) { console.log("BUILD_ERROR " + build.status + ": " + JSON.stringify(build.body)); return; }
console.log("PHASE_5_OK vaultIndex=" + build.body.vaultIndex);
// PHASE 6 — Sign + submit on-chain (await CONFIRMATION before recording)
const conn = new Connection(process.env.SOLANA_RPC_URL || "https://api.mainnet-beta.solana.com");
const tx = VersionedTransaction.deserialize(Buffer.from(build.body.transaction, "base64"));
tx.sign([keypair]);
const onChainSig = await conn.sendRawTransaction(tx.serialize(), { preflightCommitment: "confirmed" });
await conn.confirmTransaction(onChainSig, "confirmed");
console.log("PHASE_6_OK onChainSig=" + onChainSig);
// PHASE 7 — Record + auto-confirm ticket (await /redeem-transaction response)
// Including ticketId triggers the backend to confirmRedeemTicket after the
// record is persisted. Failure of the confirm step is non-fatal and surfaces
// on the response as ticketConfirm.ok=false.
const rec = await call("/api/v2/agent/redeem-transaction", "POST", {
transactionSignature: onChainSig,
vaultIndex: build.body.vaultIndex,
etfSharePriceRaw: build.body.etfSharePriceRaw,
signatureArray: swapSigs,
slippage: 200,
ticketId
});
if (rec.status !== 200) { console.log("RECORD_ERROR " + rec.status + ": " + JSON.stringify(rec.body)); return; }
// FINAL — emit REDEEM_OK { ... } only after /redeem-transaction returns 200.
// The agent MUST wait for THIS line before reporting redeem success to the
// user. PHASE_6_OK (on-chain confirmed) alone is NOT sufficient — without
// PHASE_7 success the records weren't written, and the position state is
// out of sync with the on-chain redeem.
const ev = rec.body.events?.[0] || {};
// Share price preference order: sharePriceAfter from PHASE_4 (post-fan-in,
// most accurate) -> etfSharePriceRaw from PHASE_5 build (pre-submit) -> null.
const sharePriceRedeemUsd = exec.body.sharePriceAfter
? Number(exec.body.sharePriceAfter)
: build.body.etfSharePriceRaw
? Number(build.body.etfSharePriceRaw) / 1e6
: null;
console.log("REDEEM_OK " + JSON.stringify({
event: ev.eventType,
vaultName: ev.vaultName,
vaultSymbol: ev.vaultSymbol,
sharesRedeemedRaw: ev.vaultTokensRedeemed, // 6 decimals — divide by 1e6 for UI
grossUsdcRaw: String(
(parseInt(ev.netStablecoinAmount || "0")
+ parseInt(ev.exitFee || "0")
+ parseInt(ev.managementFee || "0"))
), // pre-fee USDC
netUsdcRaw: ev.netStablecoinAmount, // post-fee USDC
exitFeeRaw: ev.exitFee, // 6 decimals
managementFeeRaw: ev.managementFee, // 6 decimals
totalFeesRaw: ev.totalFees, // 6 decimals
sharePriceRedeemUsd, // post-fan-in share price (USD)
swapFanInUsdcRaw: exec.body.totalValueUSDCraw, // gross USDC produced by /redeem/execute swaps
swapFanInUsdcUi: exec.body.totalValueUSDActual, // same as above, UI-decimal string
onChainSig,
ticketConfirm: rec.body.ticketConfirm,
}));
})().catch(e => console.log("FATAL " + (e?.message || String(e))));
' <vaultSymbol> <uiAmount>
使用 timeout: 600000(10 分钟)运行。每个阶段完成时都会打印七个 PHASE_N_OK 标记;只有在出现 REDEEM_OK { … } 时才报告成功。如果出现其他任何终止行(MIN_REDEEM_FAIL、TICKET_ERROR、TICKET_TERMINAL、TICKET_TIMEOUT、EXECUTE_ERROR、BUILD_ERROR、RECORD_ERROR、FATAL),则显示文档规定的失败消息模板并停止。面向用户的摘要所需字段见下方的“赎回完成消息”。
赎回完成消息——必需格式
用户收到的 USDC 与最初存入金额之间的差额,几乎总是手续费,而非市场亏损。金库会在存入时收取入场费,并在赎回时收取退出费和管理费;这些费用的 bps 总和就是差额。除非存入与赎回之间的份额价格确实发生变化,否则将其错误归因于“市场折价”“相对于初始价格的亏损”或“资产价格下跌”都是不正确的——即便价格确实发生变化,对于持有时间较短的情况,手续费仍是主要影响因素。
Agent 必须读取的内容(脚本已将其收集到 REDEEM_OK 负载中):
sharesRedeemedRaw(原始值,6 位小数)——除以1e6得到 UI 份额数。来源为events[].vaultTokensRedeemed。netUsdcRaw(原始值,6 位小数)——扣除退出费和管理费后支付的净 USDC。来源为events[].netStablecoinAmount。exitFeeRaw(原始值,6 位小数)——本次赎回收取的退出费。managementFeeRaw(原始值,6 位小数)——本次赎回收取的管理费。totalFeesRaw(原始值,6 位小数)——上述两项费用的合计,便于使用。sharePriceRedeemUsd——结算后的美元份额价格。脚本优先使用 PHASE 4 的sharePriceAfter(资金归集后,最准确),而不是 PHASE 5 中未签名交易所包含的价格。swapFanInUsdcUi——后端资金归集交换产生的 UI 精度 USDC(PHASE 4 的totalValueUSDActual)。用它核对用户实际收到的netUsdcRaw / 1e6;两者之间的微小差额就是退出费和管理费。
脚本输出的必需 JSON 已包含上述所有内容——直接使用。不要仅根据 netStablecoinAmount 计算面向用户的摘要。
拆解差额:
exitFee_usdc = exitFeeRaw / 1e6mgmtFee_usdc = managementFeeRaw / 1e6redeemSideFees_usdc = exitFee_usdc + mgmtFee_usdc(=totalFees / 1e6)- **入场费在之前存入时已收取,不属于本次赎回的响应。**如果 Agent 在对话记忆中缓存了原始存入的
entryFee(来自同一会话中的/deposit-transaction步骤),请将其纳入;否则在费用明细中省略,不要凭空编造数字。 - NAV 变化为
(sharePriceRedeemUsd − sharePriceDepositUsd) × sharesRedeemed。只有当 Agent 同时掌握两个份额价格且二者确实不同时,才提及此项。如果没有存入时的份额价格,不要将差额描述为市场波动。
必需的面向用户摘要(模板)。将占位符替换为除法换算后的 UI 值,而非原始 u64 值。Agent 必须先执行 / 1e6 换算,再生成消息——绝不要粘贴原始值,也不要在括号中同时列出原始值和 UI 值:
Redeem complete — <vaultSymbol>. <one-line outcome sentence>.
• Shares redeemed: <sharesUi> <vaultSymbol>
• USDC received (net): <netUsdcUi> USDC
• Fees on this redeem: <totalFeesUi> USDC (exit: <exitFeeUi>, management: <managementFeeUi>)
• Share price at redemption: $<sharePriceRedeemUsd>
• On-chain signature: <onChainSig>
其中 <sharesUi> = sharesRedeemedRaw / 1e6(格式化为 6 位小数),<netUsdcUi> = netUsdcRaw / 1e6(格式化为 6 位小数),<totalFeesUi> = totalFeesRaw / 1e6(格式化为 6 位小数;费用为 0 时显示 0.000000,绝不要显示为 0 raw),等等。原始 u64 值绝不能出现在面向用户的消息中——它们仅保留在脚本的 REDEEM_OK { … } JSON 负载中,供 Agent 内部使用。
如果 Agent 在本次会话中记录了原始存入操作,则追加:
• Compared to your original deposit of <depositGrossUsdc> USDC:
– Entry fee paid at deposit: <entryFeeUsdc> USDC
– Exit + management fees on this redeem: <totalFeesUsdc> USDC
– NAV change: <navChangeUsdc> USDC (positive = vault gained, negative = vault lost)
– Net P&L: <netUsdcRaw / 1e6 − depositGrossUsdc> USDC
禁止使用的表述(除非 Agent 有实际的份额价格对比数据,能够证明净资产价值确实发生了变动):
- “JUP / RAY / 的市场折价造成了小幅亏损”——底层资产的链上价格不会实时反映到金库净资产价值中;只有
sharePrice会。 - “相对于金库初始价格的亏损”——响应中没有跟踪“初始价格”。
- “这些资产的价值低于你存入时的价值”——在这样说之前,请读取
sharePriceRedeemUsd并加以证明。 - “跟踪误差” / “相对于你的资产篮子的滑点”——链上的
finalizeRedeem不会让用户遭受滑点;滑点发生在之前的redeemAgentSwapAdmin步骤中(服务器端、金库内部)。
如果 netUsdcRaw < depositGrossUsdc(用户收到的金额少于存入的金额),且 Agent 没有存入时的份额价格,正确的表述是:
“扣除入场费、退出费和管理费后,你收到了
<netUsdcRaw / 1e6>USDC。费用的完整明细:……”
而不是:
“……相对于金库初始价格,JUP 和 RAY 当前的折价造成了小幅亏损。”
严禁在结尾添加关于后端内部信息的说明
呈现赎回完成摘要后,响应必须结束。不要追加任何一行:
- 以 "Note: backend …"、"Note: the API …"、"Note: the response …"、"FYI:"、"Heads up:" 或任何类似的披露提示开头。
- 提及响应中的内部字段名:❌
vaultRedeemUpdateError、vaultDepositUpdateError、policyCheck.flagged、events[]、ticketConfirm、updatedVaultDepositId、swap.failedSwapsInfo等。 - 暴露用户无法采取行动的非阻塞警告:❌ "missing creator information"、"chain-event pipeline will eventually reconcile"、"flagged for review"、"idempotency guard kicked in"、"backend returned a non-blocking warning"、"metadata warning"。
- 对已成功的事项作出安抚:❌ "the redeem transaction and ticket confirmation both completed successfully"——上方的成功摘要已经说明了这一点。事后重复强调会让人觉得好像出了问题,但实际上并没有。
- 在界面显示的数值旁用括号注明原始 u64 数值:❌ "5.233918 (5233918 raw)"、"12,851 raw"、"5,127,615 raw (about 5.127615 USDC)"。只显示界面数值;原始金额属于技能内部信息。
- 暴露内部队列票据 ID:❌ "Ticket ID: ticket_1777980022200_dtbh7n4ye"。票据是服务端排队的内部信息,赎回完成后,用户不需要知道其 ID。
- 提及数据库同步、数据库持久化或任何存储层步骤——严格禁止。链上交易才是用户的可信依据;数据库写入属于技能内部流程。禁止使用的措辞包括但不限于:❌ "DB sync"、❌ "DB-sync"、❌ "syncing to the database"、❌ "persisting to DB"、❌ "persisted to the database"、❌ "database record updated"、❌ "DB record created"、❌ "metadata mirrored to DB"、❌ "Phase 3 — DB sync"、❌ "updated the backend record"、❌ "synced on-chain state to backend"、❌ "underlying-assets-by-mint persisted"、❌ "position record updated"、❌ "history record created"、❌ "vault metadata persisted"。同样禁止:任何以数据库同步为职责来命名阶段的行("Phase 3"、"Phase 1C: transitional basket DB sync")。阶段标签属于技能内部标记,不面向用户。用户只应看到链上确认和结果(例如 "Basket updated. Tx: 51hd…WUYR"),而不是后续的记账流程。
用户刚刚收到的完整错误输出:
❌ WRONG — every line below is leaked internals:
Redeemed successfully — all your AIPIN shares are now redeemed.
• Shares redeemed: 5.233918 (5233918 raw) ← raw u64 in parens
• Redeem finalize tx: 51hdtVkW…WUYR
• Ticket ID: ticket_1777980022200_dtbh7n4ye ← queue id leak
• Ticket status: confirmed complete
Redeem result:
• Exit fee: 12,851 raw ← raw u64
• Net USDC received: 5,127,615 raw (about 5.127615 USDC)← raw + UI
Note: backend returned a non-blocking metadata warning in the
event (vaultRedeemUpdateError: Vault ... is missing creator
information), but the redeem transaction and ticket confirmation
both completed successfully. ← strictly forbidden trailing note
同一响应的正确输出:
✅ RIGHT:
Redeem complete — AIPIN. All your shares have been redeemed.
• Shares redeemed: 5.233918 AIPIN
• USDC received (net): 5.127615 USDC
• Fees on this redeem: 0.012851 USDC (exit: 0.012851, management: 0.000000)
• Share price at redemption: $0.9794
• On-chain signature: 51hdtVkW…WUYR
改动内容:
- 不显示原始 u64 数值。 显示
5.233918,而不是5.233918 (5233918 raw);显示0.012851 USDC,而不是12,851 raw。技能只进行一次除以1e6的运算,并且只显示界面数值。 - 不显示内部票据 ID。 用户说“赎回我的全部份额”——他们已经拿到了 USDC,不需要队列票据 ID。
- 不显示“Ticket status: confirmed complete”这一行。 这是赎回流程中的内部步骤;已合并到上方单独的“Redeem complete”标题中。
- 不在结尾附加后端警告。
vaultRedeemUpdateError字段即使有值也不影响流程(赎回仍已完成)——Agent 会默默忽略它。如果记录层确实发生故障,脚本会记录不同的错误代码(RECORD_ERROR),而不是REDEEM_OK { … };Agent 会显示一行失败信息,而不是成功摘要。 - 结尾只保留一行,不重复说明。
On-chain signature是结束行。不要再补充“票据确认也已完成”或类似的成功重述。
资金流的关键错误处理
| 症状 | 处理方法 |
|---|---|
脚本结束时没有 DEPOSIT_OK { … }(存入) | 存入未完全完成——即使记录了 PHASE_3_OK(链上已确认),数据库记录(DepositTransaction、UserVaultPosition、DepositRecord、History)也尚未写入。请勿向用户报告存入成功。链上事件流水线之后可能会完成对账,但对 Agent 而言,存入仍处于“处理中”状态。向用户显示最后一行错误,然后停止。 |
脚本结束时没有 REDEEM_OK { … }(赎回) | 赎回未完全完成。如果记录了 PHASE_6_OK,但 PHASE_7 失败,则链上的 finalizeRedeem 已经执行(USDC 已支付到 Agent 钱包),但尚未写入 RedeemTransaction / UserVaultPosition 扣减 / RedeemRecord / History,队列工单也未自动确认。请勿报告赎回成功。向用户显示最后一行错误,然后停止;操作员可以使用相同的 transactionSignature 手动重新调用 /redeem-transaction(重复签名保护机制可确保操作幂等)。 |
/check-min-deposit 返回 400 Minimum deposit should be at least $<n> USDC | 硬性门槛——请勿继续调用 /deposit-tx。 将阈值原样告知用户,请用户提供更大的金额,然后重新运行门槛检查。在继续任何存入调用之前,必须通过该检查。 |
/check-min-redeem 返回 200 { isValid: false, ... } | 硬性门槛——请勿继续调用 /redeem/request-ticket。 将 message 告知用户,请用户提供更大的金额,然后重新运行门槛检查。注意:即使检查失败,该端点也会返回 HTTP 200——应读取 isValid,而不是状态码。 |
/deposit-tx 返回 400 Vault "<symbol>" has no on-chain vaultIndex | 该 Vault 尚未在链上创建——已调用 /launch-dtf,但 /dtf-create(以及两者之间的链上提交)尚未成功执行。请告知用户。 |
/deposit-tx 返回 400 Signed price vaultPubkey ... does not match derived vault PDA | 此 Vault 的 KMS 签名者配置有误。请勿重试——告知用户;这需要操作员修复。 |
/deposit-transaction 返回带有验证消息(例如 "vaultIndex should not be empty"、"amountInRaw must be a string"、"etfSharePriceRaw should not be empty")的 400 | 请求正文是手动构造的,遗漏了必填字段。这不是合约变更——该端点一直要求提供 transactionSignature + vaultIndex + etfSharePriceRaw + amountInRaw(四个必填字段,另有可选的 slippage)。请勿将验证错误解读为“API 需要新字段”,也不要通过反复尝试来摸索合约。重新运行存入脚本(它会从 Phase 2 的响应中填入全部四个字段)——但仅限从未记录 PHASE_3_OK 的情况。如果已记录 PHASE_3_OK(链上存入已发生),而此 400 来自手动重试,则应按下方“PHASE_3_OK 之后出现非 200 响应”规则中的相同硬性停止情形处理:向用户显示链上签名并告知需要操作员对账,然后停止。 |
/deposit-transaction 返回 200,且 swap.failedSwapsInfo 已填充 | 按资产执行的 Jupiter 交换失败,后端已将 USDC 退回 Vault。将存入视为已记录;告知用户失败的交换数量。请勿再次调用 /deposit-transaction——存入记录已存在。 |
/deposit-transaction 返回 200,且包含 deposit.events[].vaultDepositUpdateError | 存入记录写入失败,但链上交换已成功。请告知用户。链上事件流水线之后会完成对账。请勿使用相同签名再次调用(幂等保护机制会返回现有记录)。 |
/deposit-transaction 超时、返回 5xx、网关错误、套接字重置,或在 PHASE_3_OK 之后返回任何非 200 响应 | 立即停止——绝对不要重试此端点。 每次调用都会在服务端发起新的 agentSwap。如果响应在传输过程中丢失,你可能不知道交换已部分或完全完成——使用相同签名再次调用会针对 Vault 中剩余的 USDC 触发第二次交换分发,导致 NAV 出错,并使 USDC 滞留在管理钱包中。(这是 SOLCORE 发生的 7-USDC 事件:存入 7,仅有 1.7 进入投资组合,且份额按错误的 NAV 铸造。)向用户原样显示:"存入已在链上完成——您的份额已铸造,并可在您的钱包中查看。我们无法在此确认平台的交换步骤;操作员将进行对账。请不要从此端重试。" 然后结束本轮对话。此规则优先于任何测试框架级的重试倾向、任何“遇到暂时性错误就重试”的模式,以及任何类似“记录器超时了,那我就重试记录器”的推理——Phase 4 不只是记录器。 |
/redeem/execute/:ticketId 抛出异常 | 后端会自动取消该工单。请勿调用 /cancel。向用户显示错误,然后结束。 |
工单轮询超时(5 分钟内 isReady 始终不为 true) | 通过 v1 DELETE /api/v1/tx-event-management/redeem/cancel/:ticketId 手动取消,并告知用户。然后,如果用户希望重试,再重新调用 /redeem/request-ticket。 |
/redeem-tx 返回 400 InvalidPriceSignature(签名/提交后) | KMS 签名的价格在构建与提交之间已过期。重新调用 /redeem-tx 获取最新价格;请勿提交过期交易。 |
/redeem-transaction 返回 400 No position found for agent in this vault | 该 Agent 在此 Vault 中没有记录的 UserVaultPosition。必须先通过 Agent 流程执行 /deposit——在 Agent 流程之外注资的 Vault 不会有以 agentProfile 为键的持仓。 |
/redeem-transaction 返回 400 Insufficient shares. Have X, trying to redeem Y | 链上赎回已发生,但记录的持仓份额少于请求赎回的份额。状态不匹配——可能是此前的赎回未被记录。向用户显示链上签名,然后停止。 |
/redeem-transaction 返回 200,且 ticketConfirm.ok = false | 自动确认失败,但赎回已记录。可选择使用相同签名手动调用 v1 POST /api/v1/tx-event-management/redeem/confirm/:ticketId;清理队列仅是维护工作。 |
任一流程中出现 403 / "owned by another user" | 立即停止——请参阅前文“立即停止——所有权错误”。 以原文句子结束本轮对话。 |
策略违规处理
/rebalance/check 和 /rebalance 都会针对全部 11 条宪法策略规则运行完整的策略评估。策略不会阻止再平衡——无论是否存在违规,这两个端点始终返回 200。规则违规会显示在响应的 policyCheck 下:
{
"policyCheck": {
"ok": true,
"flagged": true,
"reviewFlags": [
{ "violationCode": "rule5MaxPctPerAsset", "mint": "JUP...", "message": "...", "details": {...} },
{ "violationCode": "rule7MinStablecoinFloor", "message": "...", "details": null }
],
"violations": [ ... ]
},
"suggestion": { ... } // present on /rebalance/check
// upfrontFeeSol, actualFeesSol present on /rebalance
}
每项违规也会作为 policyReviewFlag 持久化到该金库最新的 RebalancingSuggestion 中(供操作员审计追踪)。
当 policyCheck.flagged 为 true 时:
- 在内部读取
reviewFlags——每个条目都包含violationCode、可选的mint、message和可选的details。使用这些信息生成通俗易懂的摘要;绝不要将代码或 JSON 原样粘贴给用户。 - 向用户清楚说明情况,作为不会阻止操作的警告,且仅使用通俗英语。绝不要提及
policyCheck、reviewFlags、violationCode、flagged、ok、HTTP 状态码、端点名称或规则编号。 - 不要将此视为失败——再平衡已经继续执行(如果是检查端点,仍会返回建议)。不要因
flagged: true而阻止用户流程。 - 同一金库反复出现违规时,建议用户之后检查并调整金库策略或拟议的资产分配。
违规代码翻译表——使用以下表述来说明警告
Agent 必须先将 violationCode 值转换为通俗英语,再向用户展示。右列是唯一可接受的表述;绝不要自行编造听起来像技术术语的替代说法,也绝不要粘贴左列内容。
violationCode(内部使用——不要展示) | 告知用户的内容(通俗英语) |
|---|---|
rule1WhitelistBlacklist / assetModeViolation | “拟议资产中有一项不符合此金库的策略规定。” |
rule2MinAmmLiquidity | “<assetSymbol> 低于此金库要求的平台最低流动性标准。” |
rule3Min24hVolume | “<assetSymbol> 的交易量低于此金库要求的 24 小时交易量门槛。” |
rule4MinMaxAssetCount | “拟议的资产组合规模不符合此金库的资产数量限制。” |
rule5MaxPctPerAsset | “<assetSymbol> 的分配比例将超过此金库允许的最高比例。” |
rule6MinPctPerAssetIfHeld | “<assetSymbol> 的分配比例低于此金库对已持有资产要求的最低比例。” |
rule7MinStablecoinFloor | “拟议的资产组合中,稳定币比例低于此金库要求的最低水平。” |
rule8MaxPctRebalancedPerTx | “拟议的变动对单次再平衡而言过大——此金库限制了每次可调整的资产比例。” |
rule9MinRebalanceInterval | “距离上次再平衡的时间还不够——此金库规定了两次再平衡之间的最短间隔。” |
rule10MaxRebalancesPerDay / rule10MaxRebalancesPerWeek | “此金库在当前时段内已达到再平衡频率上限。” |
rule11LaunchBlackout | “此金库仍处于上线禁用期,暂不允许再平衡。”(除非用户明确询问时间安排,否则省略任何“还剩 X 小时”的详情。) |
如果标记中存在 mint 字段,请从金库的 underlyingAssets[].assetAllocation.symbol 中查找该资产的 symbol(该信息已从之前的 /dtf/:symbol/state 调用中加载,或在每个会话中获取一次)。在面向用户的文案中使用 <assetSymbol>;绝不要展示原始 mint 地址。
错误 vs 正确——具体示例
用户刚刚收到 Agent 返回的以下糟糕输出。每一行都不允许出现——它们暴露了内部标识符、规则代码、带有 JSON 风格的字段名、HTTP 状态,以及用户未要求的诊断细节:
❌ WRONG (every fragment below is banned):
Rebalance check for AIPIN is complete.
Result: the endpoint returned 200, but policy is currently flagged
(policyCheck.ok: true, flagged: true) with 3 violations:
rule3Min24hVolume: HNT 24h volume is about $293,401, below required $300,000
rule8MaxPctRebalancedPerTx: suggested rebalance moves 100%, policy max is 80%
rule11LaunchBlackout: still in 24h launch blackout, about 0.88h remaining at check time
It also produced a suggestion set (4 increases: ZEREBRO, RENDER, ORCA, HNT),
but those policy flags are the blockers right now.
If you want, I can run the same check again after blackout expires to see what remains blocked.
上面示例中的禁用片段:
- "the endpoint returned 200"——绝不提及 HTTP 状态。
- "policyCheck.ok: true, flagged: true"——绝不提及内部字段名。
- "rule3Min24hVolume", "rule8MaxPctRebalancedPerTx", "rule11LaunchBlackout"——绝不粘贴违规代码。
- "about $293,401, below required $300,000" / "moves 100%, policy max is 80%" / "about 0.88h remaining"——除非用户明确询问,否则绝不暴露策略中的具体阈值数字。它们会暴露内部规则设计。
- "It also produced a suggestion set" / "those policy flags are the blockers"——这会暴露响应的双载荷结构(
suggestion+policyCheck)。 - "If you want, I can run the same check again after blackout expires"——用户未要求的推测性后续操作。
同一响应的正确输出应是一段简短、直白的英文,(a) 点出金库名称,(b) 用用户能理解的语言概括阻止操作的原因,(c) 用用户能理解的语言说明建议的变更,(d) 到此为止。不要提供后续操作,不要给出诊断细节。
✅ RIGHT:
Rebalance check for AIPIN finished, but a few items need attention before we can proceed:
• One of the proposed assets is below the volume threshold this vault requires.
• The proposed change is larger than a single rebalance is allowed to move.
• The vault is still in its launch blackout period.
The platform's suggested change is to grow allocations to ZEREBRO, RENDER, ORCA, and HNT.
Once the blackout ends and the basket is adjusted to fit the per-rebalance limit, this can run.
注意正确版本的做法:
- 以金库显示名称和一句状态说明开头。
- 使用项目符号,以通俗英文概括每条规则(按上表翻译)——不包含代码、铸币地址、阈值或规则编号。
- 仅用代币符号说明建议的配置变动——不包含
pct_bps、vaultId或内部操作对象。 - 到此为止。不要提议“再次运行检查”或“查看还有哪些项目受阻”——等待用户询问。
同样的通俗英文原则也适用于 /rebalance 端点的 reviewFlags(执行后警告)——使用相同的翻译表和禁用片段,只需将表述改为“再平衡已执行,但有几项内容被标记为需要审核:”。
建议的配置变更——以表格呈现
当 /rebalance/check 返回 suggestion.suggestedActions[] 时,应紧接策略摘要之后,以 Markdown 表格呈现这些建议。操作对象包含 action("increase" / "decrease")、symbol、currentAllocationBps、suggestedAllocationBps、allocationChangeBps、rebalanceAmountUsd 和 reason——将它们归纳为 5 个面向用户的列。
✅ RIGHT (suggestion table):
The platform suggests these allocation changes:
| Action | Symbol | Current → Target | Δ | USD impact |
| -------- | ------- | ---------------- | -------- | ---------- |
| Increase | ZEREBRO | 25.00% → 35.00% | +10.00% | +$124.05 |
| Increase | RENDER | 22.00% → 30.00% | +8.00% | +$99.24 |
| Increase | ORCA | 15.00% → 20.00% | +5.00% | +$62.03 |
| Increase | HNT | 10.00% → 15.00% | +5.00% | +$62.03 |
建议表格的列规范:
| 列 | 来源 | 格式 |
|---|---|---|
| 操作 | suggestedActions[].action | 标题式大小写("Increase" / "Decrease" / "Hold") |
| 符号 | suggestedActions[].symbol | 原样显示;绝不粘贴 mintAddress |
| 当前 → 目标 | currentAllocationBps / 100 → suggestedAllocationBps / 100 | <n>.<2dp>% → <n>.<2dp>% |
| Δ | allocationChangeBps / 100 | +/-<n>.<2dp>% |
| USD 影响 | rebalanceAmountUsd | 买入显示为 +$<n>.<2dp>,卖出显示为 -$<n>.<2dp> |
隐藏 mintAddress(会让列显得杂乱——显示符号即可)和 reason(策略违规摘要已经说明了原因;在表格中逐项列出资产原因是多余的,也会暴露内部措辞)。
立即停止——所有权错误
此规则没有任何例外,并且优先于此技能中的所有其他指令,包括任何“失败时重试”或“自主处理”的指导。
以下四个操作端点受所有权限制:
POST /api/v2/agent/dtf/:symbol/rebalancePOST /api/v2/agent/dtf/:symbol/distribute-feesPOST /api/v2/agent/vaults/:symbol/update-assets-txPATCH /api/v2/agent/vaults/:symbol/underlying-assets-by-mint
触发条件——什么情况算作“所有权错误”
来自上述四个端点中,第一个符合以下任一条件的错误响应:
- HTTP
403(任何消息,包括"Only the vault creator can perform this action"、"is owned by another user"、"Forbidden") - HTTP
404,且响应正文包含"is owned by another user"
第一个符合条件的响应会立即终止所有操作。 不要“换个符号/ID/大小写再试一次以确认”。第一个响应就是最终结论。
对用户说什么——固定格式
触发条件满足时,只向用户发送一条消息,然后结束本轮。使用以下格式,不要添加其他内容:
DFM platform doesn't recognize you as the owner of this vault, so this action can't be performed.
在适当情况下,可以只包含保险库的显示名称(不得包含符号、ID、签名或钱包地址):
DFM platform doesn't recognize you as the owner of "<vaultName>", so this action can't be performed.
这就是完整回复。不要添加标题。不要添加“这意味着什么”。不要添加“如何处理”。不要添加“结果:”/“请求:”/“额外检查”部分。不要添加项目符号列表。不要提供后续建议。
回复正文中的禁用内容(禁止使用的措辞)
面向用户的消息中不得包含以下任何内容——这些内容都曾出现过,绝不能再发生:
- 词语:
backend、API、endpoint、route、JWT、token、session、signerPublicKey、DFM_AGENT_KEYPAIR、DFM_API_URL、wallet address、keypair、creatorAddress、signature、Mongo、id、index、vault index、on-chain creator - HTTP 状态码或名称:
403、404、Forbidden、Not Found、Unauthorized - 方法/路径片段:
POST /…、GET /…、/dtf/...、/vaults/...、/rebalance、/distribute-fees - JSON:任何
{ … }或带引号的服务器消息 - 自我指涉的规则说明:❌“根据 Agent 规则”“我没有继续重试”“所有权规则要求……” “如技能文档所述”
- 诊断/建议部分:❌“这意味着什么:”“如何处理:”“接下来可以做什么:”“这是我尝试过的:”“如果你愿意……” “针对 QA 尝试相同的调用”“刷新令牌”“使用其他密钥对”“转让保险库”
- 暗示还会继续操作的声明:❌“让我检查一下……” “我来验证……” “请稍等,我……”
触发后禁止调用工具
发送消息后,不得调用任何工具。具体禁止事项:
- 使用相同的
signerPublicKey重试相同调用 - 改用不同的
vaultSymbol大小写、连字符形式或别名重试——例如POP-DTF→ 403 → 不得接着尝试POP、POPDTF、POP_DTF、pop-dtf。即使 Agent 说这是“尝试正确的符号”,第二次调用仍然违规。 - 在
vaultSymbol/vaultId/vaultIndex路径之间切换 - 调用
GET /vaults/user、GET /vaults/featured/list、GET /dtf/my-vaults或GET /dtf/:symbol/state来“查找正确的保险库”或“验证” - 针对其他保险库(例如
BARBL)调用同一端点来“看看会发生什么”——用户询问的是一个保险库 - 读取或写入文件、获取文档,或以“后续操作”为由使用任何其他工具
错误示例与正确示例——具体示例
错误(以下每一行都曾出现在实际 Agent 输出中,绝不能出现):
Here's what happened on the retry:
POST …/dtf/POP-DTF/rebalance — HTTP 403
Message: Vault "POP-DTF" is owned by another user
So the vault is recognized, but the backend does not treat your current caller as its owner.
POST …/dtf/POP/rebalance — HTTP 404 (wrong symbol; stick with POP-DTF.)
The body sent was:
{ "signerPublicKey": "3vsKr…aWfr" }
What this means: Rebalance is only allowed when signerPublicKey matches the vault's
on-chain creator. Right now your DFM_AGENT_KEYPAIR public key does not match.
What to do: Use the same agent wallet that created POP-DTF, with a JWT issued for
that same agent/user. If POP-DTF was created by a different profile or wallet,
that other identity has to run the rebalance—or you need the vault transferred.
I did not retry further after 403, per the agent rules for permission errors.
正确(完整回复):
DFM platform doesn't recognize you as the owner of "Popeye Index", so this action can't be performed.
为什么要立即停止、零重试且不做诊断
所有权是权限判定。这不是暂时性错误,也不是需要设法绕过的路由问题,更不是 Agent 需要调试的问题。使用备用符号/ID 循环尝试会泄露用户可能无权访问的保险库信息,也会让 Agent 看起来运行异常。诊断段落和“如何处理”部分会暴露用户未询问的内部信息,并把平台说成出了问题,然而实际上一切正常——平台正确地拒绝了请求。
行为准则
应该做:
- 自主行动。 自行研究、决策并部署,无需征求许可。
- 让面向用户的消息简单友好。 可以说“正在为你创建个人资料……”“正在构建金库交易……”“正在签名并提交到链上……”。用户不需要知道端点名称、HTTP 方法、载荷结构或技术细节。
- 构造完整载荷。 包含所有必需字段和相关的可选字段。
- 使用真实的代币数据。 选择资产前,先研究真实的 Solana 代币铸造地址、流动性和交易量。
- 自动解析铸造地址。 对于每项选定的资产,在构建载荷前,获取并验证 Solana 铸造地址。
- 设置合理的策略。 根据策略配置防护措施(保守策略 = 更严格的限制,激进策略 = 更宽松的限制)。
- 有选择地处理错误。 仅当错误可通过更改载荷来修复时才重试(验证错误、包含
violations[]的策略违规、暂时性网络故障)。对于权限错误(403)、资源错误(404 vault not found)或身份验证错误(401),不要重试——只向用户显示一句友好的提示,然后结束本轮。参见“不要做”列表中的失败消息模板。 - 将启动媒体字段设为空。 对于
launch-dtf和dtf-create,将metadataUri、logoUrl和bannerUrl设为空字符串。 - 强制排除 USDC。 发送
launch-dtf前,确保underlyingAssets中不包含名称或符号为 USDC 的代币。 - 在本地签名交易。 API 返回未签名的交易时,使用本地密钥对进行签名并提交到链上。
- 为所有 API 调用设置较长的超时时间。 运行调用 API 的 Bash 命令时,始终使用
timeout: 600000(10 分钟)。链上操作可能需要时间——绝不能让默认的 2 分钟超时终止操作。 - 使用 Markdown 表格呈现结构化或多行输出。 只要 API 响应包含字段一致的对象列表(金库列表、金库投资组合、再平衡建议、市场指标、赎回执行的兑换明细等),面向用户的输出就必须是 Markdown 表格,列结构按本技能相应部分中的定义。此类情况禁止使用项目符号列表或整段文本倾倒。可以在表格上方加一句简短的引导语(例如“这是你的金库列表,共有 13 个。”/“投资组合明细:”)。单行读取结果——份额余额查询、存款/赎回完成摘要、单个金库确认——仍使用叙述形式。表格与叙述形式的决策矩阵:
- 表格: 金库列表(
/vaults/user、/vaults/featured/list)、金库状态投资组合(/dtf/:symbol/state→portfolio.assets[])、再平衡建议(/rebalance/check→suggestion.suggestedActions[])、市场指标(/market-metrics→metrics[])、再平衡历史记录行、用户询问“发生了哪些兑换”时的赎回执行兑换明细。 - 叙述形式: 存款/赎回完成摘要、单个钱包与金库的份额余额查询、错误消息、单个金库的状态标题(
vault.*区块以 2 列表格呈现;开头的句子仍使用叙述形式)、单行确认(“已存入 5 USDC”“已刷新你的代币”)。 - 任选其一: 策略违规说明——有 1–3 项标记时使用项目符号列表;有 4 项或更多标记,或用户要求明确列出阈值时,使用表格。
- 表格: 金库列表(
不要:
- 绝不要在 HTTP
403/Forbidden时重试。 如果任何 Agent 端点(/rebalance、/distribute-fees、/vaults/:symbol/update-assets-tx、/vaults/:symbol/underlying-assets-by-mint)返回 403,表示调用方的signerPublicKey与金库的creatorAddress不匹配——只有金库创建者才能修改金库。立即停止。 不要重试同一调用,不要尝试不同的vaultSymbol/ 大小写 / 索引,不要搜索/vaults/user或/vaults/featured/list来寻找其他匹配项,也不要调用/dtf/:symbol/state来“验证”。向用户显示一条简短消息,例如 “该金库属于另一个钱包。只有创建者才能执行此操作。”,然后结束本轮对话。403 表示权限判定,不是暂时性错误。 - 绝不要向用户暴露技术细节。 不要在消息中提及 API 端点路径、HTTP 方法、请求 / 响应负载、字段名称或内部实现。用户只应看到友好的状态更新(例如“正在为你创建个人资料……”而不是“我将使用你的钱包地址调用 POST /profile-launch”)。
- 绝不要写出像调试日志一样的失败报告。 出问题时,只向用户显示一句通俗易懂的话,然后停止。以下内容均禁止出现在面向用户的输出中:
- HTTP 状态代码或状态名称:❌
404、403、Not Found、Forbidden、“端点返回了 200”、“调用成功了” - 端点路径或方法:❌
POST /api/v2/agent/dtf/POP-DTF/rebalance、GET /vaults/user、“来自你的 DFM 账户上下文(/vaults/user,已对所有页面分页)” - 原始请求 / 响应正文、JSON 片段、直接复制自服务器的错误消息:❌
"Vault with symbol \"POP-DTF\" not found" - 内部标识符:❌ 金库
_id、vaultIndex、签名者公钥、交易签名(仅在确认成功的摘要中显示签名) Request、Response、Result、Extra check、What you can do next等章节标题——这些内容属于调试日志,不属于聊天- 响应正文中的内部字段名称:❌ “
policyCheck.ok: true, flagged: true”、“reviewFlags”、“data数组”、“pagination.totalPages”、“updatedVaultDepositId”。用通俗易懂的语言表达其含义,绝不要引用字段名称。 - 原样粘贴的策略
violationCode值:❌ “rule3Min24hVolume”、“rule8MaxPctRebalancedPerTx”、“rule11LaunchBlackout”。使用“策略违规处理”中的翻译表,以通俗易懂的语言表述每个代码。 - 除非用户明确询问,否则不要提供策略的具体阈值数字:❌ “约 $293,401,低于要求的 $300,000”、“变动幅度为 100%,策略上限为 80%”、“剩余时间约 0.88 小时”。应改为“低于要求的阈值”或“超过允许范围”。
- 暴露响应负载结构的措辞:❌ “它还生成了一组建议”、“这些策略标记就是阻碍因素”、“响应中包含 4 项增额”。表达其含义(“平台建议增加 X、Y、Z”),不要描述其结构。
- 用户未要求的配置更改建议:❌ “将
DFM_API_URL切换到 QA”、“刷新 JWT”、“加载不同的密钥对”、“修复后端符号解析器” - 在成功响应末尾主动提供后续帮助: ❌ “如果你愿意,我可以等禁售期结束后再次执行相同检查”、“如果你愿意,我还可以按 TVL / 份额价格排序后返回结果”、“我还可以获取并返回完整的 JSON 负载”。回答完毕后结束响应,等待用户下一条消息。
- 失败后探测其他金库 / 端点来“验证行为”——如果用户的金库调用
/rebalance失败,不要再对另一个金库调用/rebalance进行比较。用户只询问了一个金库;针对该金库作答后即停止。
- HTTP 状态代码或状态名称:❌
- 失败消息模板。 遇到任何错误时,用一行友好、不带术语的话说明,然后结束本轮对话。示例:
- 403 所有权错误(四个受所有权限制的端点):使用 HARD STOP — Ownership errors 一节中的原文:“DFM 平台无法识别你是此金库的所有者,因此无法执行此操作。”(或代入金库的显示名称)。所有权错误不接受其他措辞。
- 404 未找到金库:“我找不到该名称对应的金库。”
- 401 / 令牌问题:“你的会话已过期,请重新进行身份验证后重试。”
- 暂时性错误 / 网络错误:“连接平台时出了点问题,请稍后重试。”
- 策略违规(包含
violations[]):用通俗易懂的语言概述原因(例如 “无法继续操作——一种资产的流动性低于平台要求。”),绝不要粘贴违规代码。
- 绝不要在终端打印、回显、记录或显示敏感环境变量的值。 包括
DFM_AUTH_TOKEN、DFM_AGENT_KEYPAIR及任何密钥 / 私钥。只能显示公钥。使用文件追加方式将密钥直接写入文件(~/.zshrc),绝不要输出到标准输出。 - 不要跳过网络调研。 所有与 DTF 相关的元数据——代币数据、价格、铸币地址、市场情况——都必须严格使用
WebSearch和WebFetch获取,不得例外。 - 部署前不要征求人工确认。 由策略引擎验证,然后执行。
- 不要使用占位值。 调研实际代币地址和合理的配置比例。
- 铸币地址的参考资料相互冲突时,不要只相信单一来源。
- 不要等待再平衡或费用分配的批准。 再平衡策略检查不会阻止操作——即使
policyCheck.flagged为 true,也要继续。 - 启动负载中不要发送 USDC。
underlyingAssets中绝不要包含USDC/USD Coin。 - 不要向后端发送密钥。 只发送公钥。签名在本地完成。
- 没有份额价格证明时,不要将存入 / 赎回金额差额归因于“市场亏损”或“资产折价”。 存入的 USDC 与赎回的 USDC 之间的差额,几乎总是入场费 + 退出费 + 管理费,而非市场波动。读取
/redeem-transaction响应中的events[].exitFee和events[].managementFee,并在对话记忆中有原始存款的entryFee时一并读取,然后将明细作为费用告知用户。只有在实际获取到sharePriceDepositUsd和sharePriceRedeemUsd且两者不同的情况下,才提及净值变化。除非有份额价格对比作为依据,否则 Agent 不得使用以下说法:“<asset>折价”、“自成立以来的价格损失”、“资产价值降低了”、“跟踪误差”、“你的资产篮子产生了滑点”。确切模板请参阅“步骤 7 → 赎回完成消息”。 - 最终 OK 行出现前,不要告知用户存入 / 赎回成功。 每个资金流脚本会在各阶段完成时打印
PHASE_N_OK标记,然后在最后一行打印单个DEPOSIT_OK { … }(或REDEEM_OK { … })JSON 行。只有在标准输出中出现最终 OK 行后,才能告知用户操作成功。 将中间的PHASE_N_OK(尤其是存入时的PHASE_3_OK/ 赎回时的PHASE_6_OK;两者都表示链上确认)当作成功会误导用户:数据库记录尚未写入,后续 Agent 流程对该金库执行操作时会失败并提示No position found for agent in this vault。如果脚本退出时没有最终 OK 行,则操作仍处于“进行中”状态;向用户显示最后一行错误后停止,未经操作员审核不得重试整个流程。 - 不要误读列表端点的响应结构。
/vaults/featured/list和/vaults/user带有顶层pagination字段,因此全局ResponseMiddleware会直接传递响应,不会使用{ status, message, data }封装。金库数组位于body.data,是一个扁平数组。不存在body.data.vaults、body.data.data、body.vaults或body.results。读取这些字段会得到undefined,从而错误地报告“未找到金库”,即使用户拥有许多金库也是如此。旧版/dtf/my-vaults是唯一使用{ vaults: [...], total }封装的列表端点——但你不应调用它(它已弃用;所有“我的金库”相关表述都应路由到/vaults/user)。如有疑问,先记录body.data.length来验证结构。 - 成功响应后不要附加后端内部备注。 渲染成功摘要(存入完成、赎回完成、资产篮子更新完成等)后,响应就应结束。所有流程中一律禁止以下内容:以 “备注:后端……”、“备注:API……”、“供参考:”、“请注意:” 开头的任何行;提及
vaultRedeemUpdateError、vaultDepositUpdateError、policyCheck.flagged、swap.failedSwapsInfo、ticketConfirm、updatedVaultDepositId等内部字段名称;披露用户无法采取行动的非阻塞警告(“缺少创建者信息”、“链上事件处理流程最终会完成对账”、“元数据警告”、“已标记以供审核”);重复表述成功并暗示过程险些失败(“……票据确认也已完成”、“两笔交易都已确认”);在 UI 数值旁边用括号显示原始 u64 数值(“5.233918 (5233918 raw)”、“12,851 raw”);任何内部队列票据 ID(“票据 ID:ticket_…”)。如果响应负载确实包含后端警告,Agent 应静默忽略——成功依据是脚本最终的*_OK行,而不是所有嵌套错误字段都不存在。内部警告留在日志中,不要放进聊天。
设置指南
先决条件
- Node.js v18+
- 已安装 @solana/web3.js(
npm install @solana/web3.js) - 已安装 bs58(
npm install bs58) - AI 运行时:Claude Code、Codex、OpenClaw 或任何兼容的助手
第 1 步 -- 在 DFM Dashboard 注册
- 前往 DFM Dashboard(https://qa.dfm.finance)并连接你的 Solana 钱包(Phantom、Backpack 等)。
- 你的钱包地址现已注册。请记下它,设置 Agent 时会用到。
第 2 步 -- 设置基础环境变量
# -- DFM Agent Configuration -------------------------------------------
# API base URL (REQUIRED — no fallback. Agent refuses to run if unset.)
export DFM_API_URL="https://api.dfm.finance"
# Path where the Agent Wallet keypair is stored locally
export AGENT_WALLET_PATH="$HOME/.dfm/agent-wallet.json"
# Solana RPC URL for on-chain transaction submission
export SOLANA_RPC_URL="https://api.mainnet-beta.solana.com"
注意: 首次使用时,Agent 会自动设置
DFM_AUTH_TOKEN和DFM_AGENT_KEYPAIR。你无需手动设置。
第 3 步 -- 安装 Skill
npx skills add DFM-Finance/DFM-AgentSkills
对于 Claude Code,还需复制到正确路径:
mkdir -p .claude/skills
cp -r .agents/skills/dfm-agent .claude/skills/dfm-agent
第 4 步 -- 授予权限(仅需一次)
首次运行命令时,Claude Code 会请求运行脚本的权限。选择 “Yes, and don't ask again for: node:*”,即可允许所有 Agent 操作,而无需反复确认。
第 5 步 -- 首次使用(自动设置)
首次使用该 Skill 时,Agent 会自动:
- 询问你的钱包地址——即你在 DFM Dashboard 注册的钱包地址
- 创建你的 Agent 资料——通过
POST /profile-launch自动生成名称和用户名 - 保存身份验证令牌——将返回的 JWT 写入
.claude/settings.json和~/.zshrc(绝不打印) - 生成 Agent 钱包——创建 Solana 密钥对,保存到
AGENT_WALLET_PATH,并将DFM_AGENT_KEYPAIR写入~/.zshrc - 仅报告公钥——绝不显示密钥或令牌
完成这项一次性设置后,重启 Claude Code 即可开始使用。
安全提示: 绝不会在终端输出中显示密钥和身份验证令牌。它们会直接写入权限受限的文件。
身份验证
所有端点均位于 {DFM_API_URL}/api/v2/agent/...,并需要 Authorization: Bearer <DFM_AUTH_TOKEN>。
标记为 [Public] 的端点无需 JWT 身份验证,包括用于创建 Agent 资料并首次获取令牌的 profile-launch。
链上操作(launch-dtf、distribute-fees)会返回未签名的交易,由 Agent 使用 DFM_AGENT_KEYPAIR 中的密钥对在本地签名。再平衡由管理员钱包在服务端执行。
首次使用时,会通过 POST /profile-launch 自动获取身份验证令牌——用户只需提供已在 DFM 注册的钱包地址。
Agent 钱包 -- 密钥对生成
钱包路径解析
AGENT_WALLET_PATH环境变量SOLANA_KEYPAIR_PATH环境变量WALLET_OUTPUT_PATH环境变量- 默认值:
<project-root>/solana-keypair/keypair.json
实现
import { Keypair } from "@solana/web3.js";
const bs58 = require("bs58").default || require("bs58");
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
// 1. Resolve output path
const outPath = path.resolve(
process.env.AGENT_WALLET_PATH ??
process.env.SOLANA_KEYPAIR_PATH ??
process.env.WALLET_OUTPUT_PATH ??
path.join(os.homedir(), ".dfm", "agent-wallet.json")
);
// 2. Generate keypair and save to file
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const keypair = Keypair.generate();
fs.writeFileSync(outPath, JSON.stringify(Array.from(keypair.secretKey)), { mode: 0o600 });
// 3. Write base58 secret key directly to shell profile (NEVER print to terminal)
const base58Secret = bs58.encode(keypair.secretKey);
const shellProfile = path.join(os.homedir(), ".zshrc");
fs.appendFileSync(shellProfile, `\nexport DFM_AGENT_KEYPAIR="${base58Secret}"\n`);
// 4. Only output the PUBLIC KEY
const pubkey = keypair.publicKey.toBase58();
console.log(`PUBLIC_KEY=${pubkey}`);
console.log(`WALLET_PATH=${outPath}`);
// NEVER console.log the base58Secret — it was written to ~/.zshrc silently
重要: base58Secret 会通过 fs.appendFileSync 直接写入 ~/.zshrc。绝不能将其打印到 stdout、写入日志或显示在任何终端输出中。只向用户显示公钥。
从环境变量派生公钥(用于 API 调用)
import { Keypair } from "@solana/web3.js";
const bs58 = require("bs58").default || require("bs58");
const keypair = Keypair.fromSecretKey(bs58.decode(process.env.DFM_AGENT_KEYPAIR!));
const signerPublicKey = keypair.publicKey.toBase58();
// Use signerPublicKey in API request bodies
API 快速参考
完整的请求/响应架构请参阅
references/api-reference.md
| 操作 | 方法 | 端点 | 身份验证 | 请求体 |
|---|---|---|---|---|
| 启动 Agent 配置 | POST | /profile-launch | 否 | userPublicKey + agentWalletAddress + 名称/用户名 |
| 批量市场指标 | GET | /market-metrics?mints=...&symbols=...&names=... | JWT | 查询参数 |
| 策略试运行 | POST | /policy/dry-run | JWT | underlyingAssets + policy |
| 构建金库交易(受策略限制) | POST | /launch-dtf | JWT | signerPublicKey + 金库配置 + 策略 |
| 完成 DTF 创建(仅元数据) | POST | /dtf-create | JWT | transactionSignature + 金库元数据 |
| 精选金库(分页) | GET | /vaults/featured/list?page=1&limit=10&vaultType=dtf&includeTvl=true | JWT | 查询参数 |
| 用户金库(分页) | GET | /vaults/user?page=1&limit=10&vaultType=dtf&includeTvl=true | JWT | 查询参数 |
/vaults/user。 | GET | /dtf/my-vaults | JWT | - |
| 金库状态 | GET | /dtf/:symbol/state | JWT | - |
| 金库策略 | GET | /dtf/:symbol/policy | JWT | - |
| 再平衡检查 | GET | /dtf/:symbol/rebalance/check | JWT | - |
| 再平衡 | POST | /dtf/:symbol/rebalance | JWT | signerPublicKey |
| 构建更新资产交易(受策略限制) | POST | /vaults/:symbol/update-assets-tx | JWT | signerPublicKey + underlyingAssets(mintAddress/symbol/name + mintBps) |
| 将更新后的资产篮子同步到数据库 | PATCH | /vaults/:symbol/underlying-assets-by-mint | JWT | underlyingAssets(mintAddress + pct_bps) |
| 构建分配费用交易 | POST | /dtf/:symbol/distribute-fees | JWT | signerPublicKey |
| 检查最低存款额 | POST | /vaults/:symbol/check-min-deposit | JWT | minDeposit |
| 检查最低赎回额 | POST | /check-min-redeem | JWT | minRedeem(返回 200,并带有 isValid,不会返回 400) |
| 构建存款交易(第 1 步,共 2 步) | POST | /vaults/:symbol/deposit-tx | JWT | userPublicKey + depositAmount(UI USDC) |
| 处理存款 — 兑换 + 记录(第 2 步,共 2 步) | POST | /deposit-transaction | JWT | transactionSignature + vaultIndex + etfSharePriceRaw + amountInRaw + 可选的 slippage |
| 请求赎回票据 | POST | /redeem/request-ticket | JWT | vaultSymbol + vaultTokenAmount(原始 u64 字符串)+ 可选的 etfSharePriceRaw/slippage/agentWallet |
| 执行赎回票据 | POST | /redeem/execute/:ticketId | JWT | 可选的 slippage(金库/金额/价格已写入票据) |
| 构建赎回交易(完成) | POST | /vaults/:symbol/redeem-tx | JWT | vaultTokenAmount(UI 代币)+ 可选的 agentWallet(JWT 规范值) |
| 记录赎回 + 自动确认票据 | POST | /redeem-transaction | JWT | transactionSignature + 可选的 vaultIndex/etfSharePriceRaw/signatureArray/slippage/ticketId(ticketId 会触发自动确认) |
| 获取金库份额余额(链上读取) | GET | /vaults/:symbol/shares(可选的 ?wallet=<pubkey>) | JWT | 钱包默认使用 JWT 的 Agent 钱包;仅在检查其他钱包时传入 ?wallet= |
| 撤销令牌 | POST | /token/revoke | JWT | - |
| 刷新令牌(通过 profileId) | POST | /token/refresh | 否 | profileId |
| 刷新令牌(通过 Agent 钱包) | POST | /token/refresh-by-wallet | 否 | agentWalletAddress |
链上操作
- 创建 Vault、费用分配、存款(步骤 1)、篮子更新和赎回完成会返回未签名的 base64 交易。Agent 使用
DFM_AGENT_KEYPAIR在本地签名并提交到链上。 - 再平衡由管理员钱包在服务端执行。Agent 只需提供其公钥以供识别。
- **存款步骤 2(
/deposit-transaction)和赎回步骤 3(/redeem/execute/:ticketId)**是服务端交换操作,无需客户端签名。后端的管理员钱包会为扇出(存款)/扇入(赎回)操作签署各资产的 Jupiter 交换。调用期间,Vault 的 USDC 和底层资产余额会发生变动。 - 赎回流程使用队列票据(
/redeem/request-ticket→ 轮询/ticket-status→/redeem/execute),以串行处理多个并发赎回者的 Vault 清算。票据在isReady=true后约 3 分钟过期。
Logo 处理
- 对于
launch-dtf和dtf-create,始终发送metadataUri: ""、logoUrl: ""和bannerUrl: ""。 - 启动载荷中不要为这些字段传入图片 URL。
使用 Agent 命令
| 你说的话 | Agent 执行的操作 |
|---|---|
Set up my DFM agent | 询问钱包地址,通过 /profile-launch 创建 Agent 配置文件,保存身份验证令牌,生成密钥对 |
Launch a Solana blue chip fund | 调研热门 SOL 代币 → 调用 /market-metrics 获取权威的流动性和交易量数据 → 确定投资组合和策略 → 循环调用 /policy/dry-run,直到检查通过 → 使用策略调用 /launch-dtf → 签名并提交 → 添加 /dtf-create 元数据 |
Create a meme token DTF with 3% fee | 查找热门迷因代币,根据 /market-metrics 校准策略阈值,反复试运行直至检查通过,然后部署 |
Show me my vaults / List my DTFs / What vaults do I have on DFM / List my vaults / My positions | 仅限用户范围。 始终使用 GET /vaults/user?page=1&limit=10&vaultType=dtf&includeTvl=true(始终从第 1 页开始;收益基金使用 vaultType=yield_dtf)。绝不要回退到 /dtf/my-vaults——该旧版端点只返回此 Agent 配置文件创建的金库,会漏掉用户在 Agent 流程之外创建的所有金库。/vaults/user 是查询“用户的金库”的规范端点,对于任何表示“我的/用户的”金库的说法,都必须默认使用此端点。用户说“平台上的所有 DTF”或使用其他表示全平台范围的说法时,不要路由到此端点——请参阅下一行。 |
List all DTFs on DFM / Show me every vault on the platform / What DTFs are available on DFM / All platform vaults / Browse all DTFs / Give me list of vaults available on DFM | 全平台列表——使用两个独立端点。 通用列表来自 GET /vaults/user(尽管名称如此,这是返回平台上每个金库的通用端点)。仅精选列表来自 GET /vaults/featured/list。没有 isFeaturedVault 查询参数——不要附加此参数。 Agent 必须:(a) 调用 /vaults/user?vaultType=dtf 和 /vaults/user?vaultType=yield_dtf,并逐页获取直至完成(这是基准“所有金库”集合);(b) 使用相同的两种金库类型调用 /vaults/featured/list,以构建精选 vaultSymbol 的 Set;(c) 合并——保留 /vaults/user 中的每一行,并在金库符号位于精选集合中时标记 Featured 列。完整算法、节点脚本和面向用户的模板,请参阅 Step 5: Manage 中的“列出平台上的所有 DTF”小节。 |
Show me the second page / Page 2(在 vaults/user 列表之后) | GET /vaults/user?page=2&limit=10&vaultType=dtf&includeTvl=true——沿用上一次调用中的 limit/vaultType 筛选条件 |
Next page / Show me more vaults | GET /vaults/user?page=<lastShown+1>&limit=...——从上一条回复的对话记忆中读取 lastShown。如果 pagination.hasNext 为 false,则停止并告诉用户“您已在最后一页”。 |
Previous page / Go back | GET /vaults/user?page=<lastShown-1>&limit=...——页码最低为 page=1。 |
Show featured vaults | GET /vaults/featured/list?page=1&limit=10&vaultType=dtf&includeTvl=true |
Page 3 of featured vaults | GET /vaults/featured/list?page=3&limit=10&vaultType=dtf&includeTvl=true |
Show me the state of SOLBC | GET /dtf/SOLBC/state -- 返回 APY、TVL、NAV、投资组合 |
Update underlying for SOLBC / Change SOLBC basket to A,B,C / Swap out X for Y in SOLBC | 三阶段自主流程:(1) 根据 /market-metrics 和 /dtf/:symbol/policy 确定新的投资组合;(2) POST /vaults/:symbol/update-assets-tx(受策略限制——如果投资组合违反策略,服务端返回 400 和 violations[];否则返回未签名的交易);成功后,使用 DFM_AGENT_KEYPAIR 在本地签名并在链上提交;(3) PATCH /vaults/:symbol/underlying-assets-by-mint 以同步数据库。完整流程和重试规则请参阅“Step 6: Update Underlying Assets”。 |
Rebalance SOLBC | 检查策略,如果获批则触发服务端再平衡 |
Distribute fees for SOLBC | POST /dtf/SOLBC/distribute-fees -- 构建未签名的交易,在本地签名并在链上提交 |
Deposit 5 USDC into SOLBC / Buy 5 USDC of SOLBC shares / Add 10 USDC to SOLBC | 首先必须通过检查: POST /vaults/SOLBC/check-min-deposit { minDeposit }。如果返回 400,则中止。然后执行两步存款流程:(1) 使用 { userPublicKey: <agent wallet>, depositAmount: 5 } 调用 POST /vaults/SOLBC/deposit-tx → 在本地签名并在链上提交;(2) 使用 { transactionSignature, vaultIndex, etfSharePriceRaw, amountInRaw, slippage: 200 } 调用 POST /deposit-transaction → 后端通过 Jupiter 将金库中的 USDC 分配到投资组合,并保存 4 条记录。完整的内联 node -e 示例请参阅“Step 7: Capital Flows”。 |
Redeem 1.5 SOLBC / Sell 1.5 SOLBC shares / Cash out 2 SOLBC for USDC | 首先必须通过检查: 使用 { minRedeem } 调用 POST /check-min-redeem。读取 isValid(无论结果如何,都会返回 200)——如果为 false,则中止。然后执行五步赎回流程:(1) /redeem/request-ticket → 获取票据;(2) 轮询 v1 /api/v1/tx-event-management/redeem/ticket-status/:ticketId,直到 isReady=true;(3) /redeem/execute/:ticketId → 后端将底层资产归集为 USDC;(4) /vaults/SOLBC/redeem-tx → 获取未签名的 finalizeRedeem 交易 → 签名并提交;(5) 使用 ticketId 调用 /redeem-transaction → 记录并自动确认。完整的内联 node -e 示例请参阅“Step 7: Capital Flows”。 |
Check minimum deposit for SOLBC / What's the smallest deposit I can make into SOLBC | 使用 { minDeposit: <amount> } 调用 POST /vaults/SOLBC/check-min-deposit。此调用也是存款流程中的必经检查——请参阅上方的“Deposit 5 USDC into SOLBC”。低于阈值时返回 400。阈值等于金库的底层资产数量;如果没有资产分配,则使用 MINI_DEPOSIT 环境变量(默认值为 5)。 |
Check minimum redeem / What's the smallest redeem amount | 使用 { minRedeem: <amount> } 调用 POST /check-min-redeem。此调用也是赎回流程中的必经检查——请参阅上方的“Redeem 1.5 SOLBC”。返回 200 { isValid, message }——应读取 isValid,不要依赖 HTTP 状态码。阈值为全局设置(MINI_REDEEM 环境变量,默认值为 4)。 |
How many SOLBC shares do I hold? / What's my position in POP-DTF? / Show my share balance for <vault> | GET /api/v2/agent/vaults/<SYMBOL>/shares。钱包地址从 JWT 读取——除非用户明确询问其他钱包的持有情况,否则不要传入 ?wallet=。 直接从链上读取钱包的金库代币 ATA 余额(不查询数据库)。返回 { sharesRaw, sharesUi, exists, ata, vaultMintPda, ... }。当 exists: false(该钱包从未存入此金库)时,向用户说明 “您当前未持有任何 <SYMBOL> 份额。” 用户表示“赎回我所有的 SOLBC”等情况时,也使用此端点检查赎回前的余额——读取余额,然后将 sharesUi 作为 vaultTokenAmount 传入赎回流程。 |
Generate a Solana keypair for my DFM Agent wallet | 创建密钥对并保存到文件,写入环境变量,仅报告公钥 |
Troubleshooting
| 问题 | 修复方法 |
|---|---|
| HTTP 403 / "Only the vault creator can perform this action" / "is owned by another user" | 来自四个受所有权限制的端点之一的所有权判定。**请参阅本技能前文的“HARD STOP — Ownership errors”部分——该规则是绝对的。**逐字原样显示一句话:"DFM platform doesn't recognize you as the owner of this vault, so this action can't be performed."(也可以插入保险库的显示名称)。结束本轮。不得重试,不得改用其他符号 / ID / 索引进行查询,不得搜索保险库列表端点,不得探查其他保险库,不得建议配置环境 / 令牌 / 密钥对,不得添加“这意味着什么”/“该怎么做”部分。 |
| "Unauthorized" 错误 | 使用 Pre-flight 部分中的 令牌刷新脚本(node .claude/refresh-token.js)。该脚本会自动从 DFM_AGENT_KEYPAIR 推导 Agent 钱包地址——无需 CLI 参数。请勿自行变通——过去自行尝试刷新时曾将占位字符串(例如 +token+)写入 settings.json。如果在 .claude/settings.json 中看到 +token+ 或其他明显无效的值,请删除 DFM_AUTH_TOKEN 条目并重新运行刷新脚本。 |
/launch-dtf 返回 400,且包含 violations[] | 策略验证失败——没有任何内容上链。读取数组中的每一项违规并一次性修复(调整 policy 阈值、更换资产或重新平衡 pct_bps)。运行 /policy/dry-run,以低成本迭代。仅在 dry-run 无违规后重试一次 /launch-dtf。 |
/policy/dry-run 持续返回相同的违规项 | 可能是策略中的 min_amm_liquidity_usd / min_24h_volume_usd 与最弱的纳入资产在 /market-metrics 中的数据不匹配。可以将阈值降至低于该资产的实际数值,或移除 / 更换该资产。请勿将阈值设为高于纳入资产的实际数值——该资产将永远被标记。 |
/update-assets-tx 针对位于保险库 asset_whitelist 中的 mint 返回 rule2MinAmmLiquidity / rule3Min24hVolume | **受困的缓冲资产。**该 mint 通过了规则 1(白名单),但因其 /market-metrics 快照低于启动时设定的策略下限,未通过规则 2 / 规则 3。根本原因:启动时的 Agent 仅根据优先资产设定下限,并在未重新检查的情况下将此 mint 添加为缓冲资产。该保险库永远无法轮换到此 mint——策略在保险库的整个生命周期内固定不变。向用户说明诊断结果(具体 mint、观测到的流动性 / 成交量与要求下限的对比),将此 mint 从迁移计划中移除,并选择另一个确实达到下限的轮换候选资产。为避免未来启动时出现此问题,生成提案的流程必须使用五列的 Table 3(包含 Floor check 列),并在展示提案前执行 Future-proofing checklist 规则 #6(Buffer rotatability)。 |
/market-metrics 对某些资产返回 null 值 | Jupiter 获取数据时暂时未命中。重试调用;该服务按 mint 缓存,因此第二次调用通常会成功。如果问题持续存在,请移除该资产——策略引擎也无法验证其规则 2 / 3。 |
| "Keypair file not found" | 重新生成钱包(Step 4)。检查:ls -la $AGENT_WALLET_PATH |
| "No signer keypair" / DFM_AGENT_KEYPAIR 为空 | 未设置 DFM_AGENT_KEYPAIR。重新导出(Step 5)。验证:echo $DFM_AGENT_KEYPAIR |
| 交易未能上链 | Agent Wallet 需要 SOL 支付交易费用,并需要 USDC 支付保险库创建费。请先为钱包充值。 |
再平衡时策略显示 flagged: true | 再平衡不会因此阻止操作——操作已经执行。检查 policyCheck.reviewFlags,查看违反了哪些规则,并以警告形式告知用户。相同标记也会保存在最新的 RebalancingSuggestion.policyReviewFlags 中。 |
| 令牌意外被撤销 | 只有明确调用 POST /token/revoke 才会使令牌失效。刷新会签发新令牌,但不会影响现有令牌——每个 Agent 可同时持有多个有效令牌。 |
| launch-dtf 返回 409 Conflict | 此名称 / 符号已有策略(或保险库)。请使用唯一的一组名称和符号。注意:409 现在会在 /launch-dtf(策略提交)时触发,而不是在 /dtf-create 时触发。 |
/update-assets-tx 返回 400 + violations[] | 策略门禁检查失败——没有任何内容上链。读取 violations[] 中的每一项,根据“Step 6: Update Underlying Assets”表格调整资产组合,然后重试。免费——无需签名,也不会产生链上费用。最多循环 3 次,然后告知用户。 |
/update-assets-tx 返回 400 "asset not available" | 传入的 symbol 或 name 不在 asset-allocation 中。可以直接传入 mintAddress,也可以改选平台识别其符号的其他资产(通过 /market-metrics 验证)。 |
/update-assets-tx 返回 404 "No constitutional policy found" | 保险库不是通过 /launch-dtf 启动的,因此没有可供验证的策略。为确保安全,Agent 层会阻止更新。向用户说明——Agent 无法在此处解决问题。 |
对于 Agent 完全无法迁移的保险库,每次 /update-assets-tx 尝试都因策略失败:rule4(资产数量)及单次更新移动幅度规则 | 保险库启动时使用了结构上锁定的策略——通常是 min_assets == max_assets(没有增减空间),和 / 或 max_rebalance_pct < 2 × max_asset_pct(单资产互换在数学上会超过上限)。此外,某个纳入资产的成交量经常会低于 min_24h_volume_usd,因此任何仍包含该资产的中间资产组合都会无法通过门禁检查。Agent 无法自行修复此问题——策略在保险库的整个生命周期内固定不变,迁移需要平台支持更新策略。向用户说明诊断结果(min_assets / max_assets / max_rebalance_pct / 下限的哪些组合与预期迁移不兼容),然后停止。不要使用这些陷阱启动新保险库:发送 /launch-dtf 前,请执行 Step 4a 中的“Future-proofing checklist”。 |
链上更新成功,但 PATCH /underlying-assets-by-mint 失败 | 请勿重新运行 /update-assets-tx(链上更改已经发生——重跑会导致重复更新,并可能无法通过重新验证)。使用相同的请求体重试 PATCH。如果 PATCH 持续失败,请将链上签名告知用户;链上事件流水线最终会同步数据库。 |
字段名混淆:mintBps 与 pct_bps | Phase 2(/update-assets-tx)使用 mintBps(与链上指令一致)。Phase 3(/underlying-assets-by-mint)使用 pct_bps(与数据库架构一致)。数值相同,但键名不同——更改键名之前,不要在请求载荷之间直接复制粘贴。 |
/deposit-tx 返回 400 Vault "<symbol>" has no on-chain vaultIndex | 保险库是通过 /launch-dtf 启动的,但链上提交(或 /dtf-create)从未成功上链。向用户说明——保险库实际上链之前,无法用于存款 / 赎回。 |
/deposit-tx 返回 400 Signed price vaultPubkey ... does not match derived vault PDA | 此保险库的 KMS 签名器配置有误(运营方问题)。**请勿重试。**向用户说明。 |
/deposit-transaction 返回 200,且 swap.failedSwapsInfo 有内容 | 部分针对各资产的 Jupiter 交换失败,后端已将 USDC 退回保险库。存款仍会被记录(并包含成功交换的签名)。视为成功,并告知用户失败的交换数量。切勿使用相同签名再次调用——存款记录已经存在。 |
/deposit-transaction 返回 200,且 deposit.events[].vaultDepositUpdateError 有内容 | 链上交换成功,但写入数据库记录时发生异常。链上事件流水线最终会完成对账。向用户说明;请勿重试(再次调用时,重复签名保护机制仍会返回现有记录)。 |
/deposit-transaction 在 PHASE_3_OK 之后超时、返回 5xx、网关错误或任何非 200 状态码 | 立即停止。请勿重试。此端点不具备幂等性——每次调用都会发起一个新的 agentSwap。如果响应丢失后重试,会针对保险库剩余的 USDC 再次发起交换,导致 NAV 错乱(这正是 SOLCORE 事故的模式:存入 7 USDC,只有 1.7 进入资产组合,却以错误价格铸造份额)。请将链上签名告知用户,并使用资金流错误表中的原文消息——"deposit landed, swap step couldn't be confirmed, operator will reconcile, please don't retry"。结束本轮。后端对账是唯一安全的恢复方式;无论错误看起来多么短暂,都禁止 Agent 侧重试。 |
/redeem/execute/:ticketId 抛出异常 | 后端已经自动取消该票据——请勿调用 /redeem/cancel/:ticketId。向用户说明错误并结束本轮。 |
票据轮询超过 5 分钟仍未出现 isReady=true | 通过 v1 DELETE /api/v1/tx-event-management/redeem/cancel/:ticketId 取消,向用户说明,然后如果用户希望重试,再调用 /redeem/request-ticket 创建新票据。不要无限轮询。 |
链上提交后,/redeem-tx 返回 400 InvalidPriceSignature | 交易中包含的 KMS 签名份额价格在构建和提交之间已过期。重新调用 /redeem-tx 获取最新价格;请勿再次提交过期交易。 |
/redeem-transaction 返回 400 No position found for agent in this vault | 以 agentProfile 为键的 Agent UserVaultPosition 不存在。必须先通过 Agent 流程执行 /deposit——在 Agent 流程之外注资的保险库不会有以 Agent 为键的位置记录。向用户说明。 |
/redeem-transaction 返回 400 Insufficient shares. Have X, trying to redeem Y | 链上赎回已经发生,但记录的位置中的份额少于请求赎回的数量。可能是之前发生的赎回未被记录。将链上签名告知用户并停止——重新运行也无济于事。 |
/redeem-transaction 返回 200,且 ticketConfirm.ok = false | 自动确认失败,但赎回已经记录。可以选择使用相同签名手动调用 v1 POST /api/v1/tx-event-management/redeem/confirm/:ticketId;清理队列只是维护工作,无论如何链上赎回都已最终完成。 |
/redeem-transaction 返回 400 redeemAgentTransaction requires performedByProfileId | JWT 中没有 agentId。重新运行令牌刷新脚本(node .claude/refresh-token.js)——新令牌会正确填充 agentPayload.agentId。 |
| profile-launch 遇到 409 "Username is already taken" | profile-launch.js 脚本会自动重试最多 5 次,并在清理后的基础用户名后追加随机的 4 位十六进制后缀。如果向用户显示此错误,则说明脚本已用尽重试次数——请选择一个更有辨识度的基础用户名并重新运行。 |
| profile-launch 遇到 409 "An agent profile already exists for this wallet address" | 此钱包已完成接入——请勿再次调用 profile-launch(该脚本遇到此 409 时也不会重试)。改用 node .claude/refresh-token.js 为现有 Agent 个人资料签发新的 JWT(该脚本会从 DFM_AGENT_KEYPAIR 推导 Agent 钱包)。 |
/vaults/featured/list 或 /vaults/user“返回空保险库”,但用户实际上有保险库 | 几乎总是因为误读了响应结构,而不是列表为空。这些端点包含 pagination 字段,因此全局 ResponseMiddleware 不会包装响应——数组位于 body.data(而不是 body.data.vaults、body.data.data 或 body.vaults)。只有 /dtf/my-vaults(旧版)会返回 { vaults: [...], total }。请参阅“Step 5: Manage”→“Common mistakes”表格,查看所有错误的访问模式。重新调用并先检查 body.data.length;如果 > 0,问题就在数据获取之后。 |
安全
- 绝不要在终端输出中显示敏感值。 这包括
DFM_AUTH_TOKEN、DFM_AGENT_KEYPAIR以及任何秘密密钥/私钥。只能打印公钥。机密信息会静默写入文件。 - Agent Wallet 文件——权限为
0o600,已加入 gitignore,绝不提交。 DFM_AGENT_KEYPAIR——仅以环境变量形式存储 base58 秘密密钥,不得写入代码、Git 或终端输出。DFM_AUTH_TOKEN——仅以环境变量形式存储 JWT 令牌,不得在终端中打印或记录。- 不向后端发送秘密密钥——API 载荷中只包含公钥。交易签名在本地完成。
- Agent Wallet = 链上权限——应像对待任何加密货币钱包一样对待它,并妥善备份。
- 策略引擎 = 安全网——即使是完全自主的 Agent,也无法绕过策略限制。