申购与赎回
申购流程
申购过程涉及后端 API 调用和链上合约交互:
┌──────────┐ ① GET /earn/products ┌──────────────┐
│ 前端/用户 │ ───────────────────────▶ │ 公开接口 │
│ │ ◀─────────────────────── │ │
│ │ 产品列表 └──────────────┘
│ │
│ │ ② POST /earn/subscribe/prepare
│ │ ───────────────────────▶ ┌──────────────┐
│ │ ◀─────────────────────── │ 受保护接口 │
│ │ EIP-712 签名 └──────────────┘
│ │
│ │ ③ approve USDT
│ │ ───────────────────────▶ ┌──────────────┐
│ │ │ USDT 合约 │
│ │ ④ 调用 subscribe() └──────────────┘
│ │ ───────────────────────▶ ┌──────────────┐
│ │ │ Earn 合约 │
└──────────┘ └──────┬───────┘
│ ⑤ Subscribed 事件
▼
┌──────────────┐
│ 后端服务 │
│ 创建申购记录 │
│ 铸造 NFT │
└──────────────┘
第一步:获取 EIP-712 域信息
GET /earn/domain
响应
{
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" },
{ "name": "chainId", "type": "uint256" },
{ "name": "verifyingContract", "type": "address" }
],
"Subscribe": [
{ "name": "user", "type": "address" },
{ "name": "productId", "type": "uint256" },
{ "name": "amount", "type": "uint256" },
{ "name": "deadline", "type": "uint256" }
]
},
"primaryType": "Subscribe",
"domain": {
"name": "ZTDX Earn",
"version": "1",
"chainId": 421614,
"verifyingContract": "0x436dcB8a2478D636a6cC678AE7A2E4c5449cB3ba"
}
}
第二步:浏览产品
GET /earn/products
查询参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
status | string | 否 | 按状态过滤:created / subscribing / active / settled / ended / cancelled |
page | number | 否 | 页码,默认 1 |
page_size | number | 否 | 每页条数,默认 20 |
示例
GET /earn/products?status=subscribing&page=1&page_size=10
响应
{
"products": [
{
"id": "123204f6-5bde-4434-890a-8ca241f66067",
"chain_product_id": 1769524310,
"name": "USDT Earn 900% APY",
"description": null,
"annual_rate": "900.00%",
"period_rate": "0.00%",
"annual_rate_bps": 90000,
"period_rate_bps": 0,
"duration_seconds": 300,
"total_quota": "500000000.00",
"subscribed_amount": "500000000.00",
"available_quota": "0.00",
"subscription_rate": "100.00%",
"min_amount": "100000000.00",
"max_amount_per_user": "500000000.00",
"status": "subscribing",
"subscriber_count": 1,
"subscribe_start_time": "2026-01-27T14:33:00Z",
"subscribe_end_time": "2026-01-27T14:38:00Z",
"settle_time": "2026-01-27T14:43:00Z",
"is_subscribing": true,
"is_sold_out": false,
"time_remaining_seconds": 180
}
],
"total": 1,
"page": 1,
"page_size": 20
}
产品对象字段(ProductDetail)
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 产品 UUID |
chain_product_id | number | 链上产品 ID |
name | string | 产品名称 |
description | string | null | 产品描述 |
annual_rate | string | 年化收益率(百分比字符串) |
period_rate | string | 期间收益率(百分比字符串) |
annual_rate_bps | number | 年化利率(基点,1% = 100) |
period_rate_bps | number | 期间利率(基点) |
duration_seconds | number | 锁仓时长(秒) |
total_quota | string | 总申购上限(Wei 格式) |
subscribed_amount | string | 已申购总量(Wei 格式) |
available_quota | string | 剩余可申购额度(Wei 格式) |
subscription_rate | string | 申购进度百分比 |
min_amount | string | 单次最低申购额(Wei 格式) |
max_amount_per_user | string | 单用户最高申购额(Wei 格式) |
status | string | 产品状态 |
subscriber_count | number | 申购人数 |
subscribe_start_time | string | 申购开始时间(ISO 8601) |
subscribe_end_time | string | 申购截止时间(ISO 8601) |
settle_time | string | 结算时间(ISO 8601) |
is_subscribing | boolean | 当前是否在申购期 |
is_sold_out | boolean | 是否已售罄 |
time_remaining_seconds | number | null | 申购期剩余秒数(仅在申购期内有值) |
第三步:获取产品详情
GET /earn/products/:id
| 参数 | 类型 | 说明 |
|---|---|---|
id | string | 产品 UUID 或 chain_product_id(整数字符串) |
返回 ProductDetail 对象(字段同产品列表)。
第四步:申购准备(获取签名)
需要认证
此接口需在 HTTP Header 中携带有效 JWT Token:
Authorization: Bearer <JWT_TOKEN>
POST /earn/subscribe/prepare
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
product_id | string | 是 | 产品 UUID |
amount | string | 是 | 申购金额(人类可读 USDT,如 "100" = 100 USDT) |
{
"product_id": "123204f6-5bde-4434-890a-8ca241f66067",
"amount": "100"
}
响应
| 字段 | 类型 | 说明 |
|---|---|---|
chain_product_id | number | 链上产品 ID |
amount | string | 申购金额(Wei 格式,用于合约调用) |
deadline | number | 签名过期时间(Unix 时间戳,30 分钟后) |
signature | string | EIP-712 后端签名 |
contract_address | string | 理财合约地址 |
user_address | string | 用户钱包地址 |
{
"chain_product_id": 1769524310,
"amount": "100000000",
"deadline": 1772006400,
"signature": "0x1a2b3c...",
"contract_address": "0x436dcB8a2478D636a6cC678AE7A2E4c5449cB3ba",
"user_address": "0x29F721B203A9fC9c5DDe35A739d8b8E0E4605489"
}
申购限制
| 条件 | 错误码 |
|---|---|
产品非 subscribing 状态 | SUBSCRIPTION_CLOSED |
| 当前时间不在申购窗口内 | SUBSCRIPTION_CLOSED |
| 金额低于最低限额 | AMOUNT_TOO_LOW |
| 超出单用户最高限额 | AMOUNT_TOO_HIGH |
| 剩余额度不足 | QUOTA_EXCEEDED |
| 金额格式无效 | INVALID_AMOUNT |
| 金额为零或负数 | INVALID_AMOUNT |
第五步:链上申购
获取签名后,客户端需执行两笔 链上交易:
// 第一步:授权理财合约使用 USDT
await usdtContract.approve(contract_address, amount); // amount 为 Wei
// 第二步:调用 Earn 合约的 subscribe
await earnContract.subscribe(
chain_product_id, // 链上产品 ID
amount, // Wei 金额(来自响应)
deadline, // 来自响应
signature // 来自响应
);
利息计算示例
900% APY,锁仓 300 秒(≈5 分钟),申购 100 USDT:
利息 = 100 × 9.00 × (300 / 31536000) ≈ 0.0000855 USDT
赎回(领取)
产品进入 settled 状态后,用户可直接在链上领取本金 + 利息,无需调用后端接口。
领取本息
// 产品到期后领取(NFT 自动销毁)
await earnContract.claim(chain_product_id);
后端监听 Claimed 事件后自动更新申购记录:
nft_status→redeemedclaimed→true- 记录
claim_tx_hash
紧急退款
当产品处于 cancelled 状态时,用户可取回本金:
// 产品取消时的紧急退款
await earnContract.emergencyClaim(chain_product_id);
备注
领取和紧急退款均为纯链上操作。后端通过事件监听自动同步状态(轮询间隔约 12 秒)。申购记录可能需要 12–30 秒才能反映最新状态。
错误码
| 错误码 | HTTP 状态码 | 说明 |
|---|---|---|
SUBSCRIPTION_CLOSED | 400 | 产品未处于申购期(状态或时间窗口) |
AMOUNT_TOO_LOW | 400 | 金额低于产品最低申购额 |
AMOUNT_TOO_HIGH | 400 | 超出单用户最大申购限额 |
QUOTA_EXCEEDED | 400 | 产品剩余额度不足 |
INVALID_AMOUNT | 400 | 金额格式错误或为零/负数 |
PRODUCT_NOT_FOUND | 404 | 产品 ID 不存在 |
UNAUTHORIZED | 401 | 未认证或 Token 无效 |
错误响应格式
{
"error": "Product is not open for subscription",
"code": "SUBSCRIPTION_CLOSED"
}
代码示例
Python — Browse Products (Public)
import requests
BASE_URL = "https://api.ztdx.io"
# Get EIP-712 domain info
resp = requests.get(f"{BASE_URL}/earn/domain")
print("Domain:", resp.json())
# List subscribing products
resp = requests.get(f"{BASE_URL}/earn/products", params={"status": "subscribing", "page": 1, "page_size": 10})
data = resp.json()
for p in data["products"]:
print(f" {p['name']} — APY: {p['annual_rate']}, Quota: {int(p['available_quota']) / 1e6:.2f} USDT")
# Get product details
product_id = data["products"][0]["id"] if data["products"] else "example-uuid"
resp = requests.get(f"{BASE_URL}/earn/products/{product_id}")
print("Product detail:", resp.json())
Python — Prepare Subscription (JWT Required)
import requests
BASE_URL = "https://api.ztdx.io"
JWT_TOKEN = "your_jwt_token"
resp = requests.post(
f"{BASE_URL}/earn/subscribe/prepare",
headers={"Authorization": f"Bearer {JWT_TOKEN}", "Content-Type": "application/json"},
json={"product_id": "123204f6-5bde-4434-890a-8ca241f66067", "amount": "100"},
)
data = resp.json()
print(f"Chain Product ID: {data['chain_product_id']}")
print(f"Amount (Wei): {data['amount']}, Deadline: {data['deadline']}")
print(f"Signature: {data['signature']}")
# Use this signature to call earnContract.subscribe() on-chain
Error Codes
| Error Code | HTTP Status | Description |
|---|---|---|
SUBSCRIPTION_CLOSED | 400 | Product not in subscription period (status or time window) |
AMOUNT_TOO_LOW | 400 | Amount below product minimum |
AMOUNT_TOO_HIGH | 400 | Exceeds per-user maximum limit |
QUOTA_EXCEEDED | 400 | Insufficient remaining product quota |
INVALID_AMOUNT | 400 | Invalid amount format, zero, or negative |
PRODUCT_NOT_FOUND | 404 | Product ID does not exist |
UNAUTHORIZED | 401 | Not authenticated or invalid token |
Error Response Format
{
"error": "Product is not open for subscription",
"code": "SUBSCRIPTION_CLOSED"
}