|
| 1 | +package nexcore |
| 2 | + |
| 3 | +import ( |
| 4 | + "crypto" |
| 5 | + "crypto/rand" |
| 6 | + "crypto/rsa" |
| 7 | + "crypto/sha256" |
| 8 | + "crypto/x509" |
| 9 | + "encoding/base64" |
| 10 | + "encoding/json" |
| 11 | + "encoding/pem" |
| 12 | + "fmt" |
| 13 | + "strconv" |
| 14 | + "strings" |
| 15 | + "sync" |
| 16 | + "time" |
| 17 | +) |
| 18 | + |
| 19 | +// WithdrawNamespace implements the v1 提币 API — 多链收款业务的资金出库端. |
| 20 | +// |
| 21 | +// 鉴权:RSA-PKCS1v15-SHA256 签名 + 4 个请求头 |
| 22 | +// |
| 23 | +// X-API-Key 账户级 API Key(控制台「账号 → API 密钥」) |
| 24 | +// X-Timestamp unix ms,与服务器时差 ≤ 60s |
| 25 | +// X-Nonce 一次性 nonce(uuid v4),5 分钟内不可重复 |
| 26 | +// X-Withdraw-Signature RSA-PKCS1v15-SHA256(caller_private_key, signString),Base64 |
| 27 | +// |
| 28 | +// signString = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY |
| 29 | +// 其中 BODY 为 HTTP body 原文(JSON 字符串原样,GET 请求为空字符串). |
| 30 | +// |
| 31 | +// 对应 /docs 文档 "提币 API" 章节的 4 个 endpoint(internal/handler/api_withdraw_v1.go): |
| 32 | +// |
| 33 | +// POST /api/v1/withdraw CreateWithdraw 发起提币 |
| 34 | +// GET /api/v1/withdraw/:id GetWithdraw 查询单笔状态 |
| 35 | +// GET /api/v1/balance/withdrawable GetWithdrawableBalance 查询可提余额 |
| 36 | +// GET /api/v1/fee/quote QuoteFee 费用预估 |
| 37 | +// |
| 38 | +// 另提供 VerifyCallback() 校验平台回调签名(用平台公钥). |
| 39 | +type WithdrawNamespace struct { |
| 40 | + c *Client |
| 41 | + |
| 42 | + // 私钥/公钥懒解析缓存,避免每次请求重复解码 PEM |
| 43 | + once sync.Once |
| 44 | + privKey *rsa.PrivateKey |
| 45 | + privParseErr error |
| 46 | + platformPubOnce sync.Once |
| 47 | + platformPub *rsa.PublicKey |
| 48 | + platformPubErr error |
| 49 | +} |
| 50 | + |
| 51 | +// parsePrivKey 解析配置的对接方私钥 PEM,带缓存. |
| 52 | +func (n *WithdrawNamespace) parsePrivKey() (*rsa.PrivateKey, error) { |
| 53 | + n.once.Do(func() { |
| 54 | + if n.c.cfg.WithdrawPrivateKeyPEM == "" { |
| 55 | + n.privParseErr = &Error{Message: "WithdrawPrivateKeyPEM not configured", Code: -1} |
| 56 | + return |
| 57 | + } |
| 58 | + block, _ := pem.Decode([]byte(n.c.cfg.WithdrawPrivateKeyPEM)) |
| 59 | + if block == nil { |
| 60 | + n.privParseErr = &Error{Message: "withdraw: invalid private key PEM", Code: -1} |
| 61 | + return |
| 62 | + } |
| 63 | + // 兼容 PKCS#1 和 PKCS#8 |
| 64 | + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { |
| 65 | + n.privKey = key |
| 66 | + return |
| 67 | + } |
| 68 | + if k, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { |
| 69 | + if rsaKey, ok := k.(*rsa.PrivateKey); ok { |
| 70 | + n.privKey = rsaKey |
| 71 | + return |
| 72 | + } |
| 73 | + n.privParseErr = &Error{Message: "withdraw: PKCS#8 key is not RSA", Code: -1} |
| 74 | + return |
| 75 | + } |
| 76 | + n.privParseErr = &Error{Message: "withdraw: cannot parse private key (neither PKCS#1 nor PKCS#8)", Code: -1} |
| 77 | + }) |
| 78 | + return n.privKey, n.privParseErr |
| 79 | +} |
| 80 | + |
| 81 | +// parsePlatformPub 解析平台公钥(回调验签用). |
| 82 | +func (n *WithdrawNamespace) parsePlatformPub() (*rsa.PublicKey, error) { |
| 83 | + n.platformPubOnce.Do(func() { |
| 84 | + if n.c.cfg.WithdrawPlatformPublicKeyPEM == "" { |
| 85 | + n.platformPubErr = &Error{Message: "WithdrawPlatformPublicKeyPEM not configured", Code: -1} |
| 86 | + return |
| 87 | + } |
| 88 | + block, _ := pem.Decode([]byte(n.c.cfg.WithdrawPlatformPublicKeyPEM)) |
| 89 | + if block == nil { |
| 90 | + n.platformPubErr = &Error{Message: "withdraw: invalid platform public key PEM", Code: -1} |
| 91 | + return |
| 92 | + } |
| 93 | + if key, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil { |
| 94 | + if rsaKey, ok := key.(*rsa.PublicKey); ok { |
| 95 | + n.platformPub = rsaKey |
| 96 | + return |
| 97 | + } |
| 98 | + n.platformPubErr = &Error{Message: "withdraw: PKIX key is not RSA", Code: -1} |
| 99 | + return |
| 100 | + } |
| 101 | + // 兼容 PKCS#1 |
| 102 | + if key, err := x509.ParsePKCS1PublicKey(block.Bytes); err == nil { |
| 103 | + n.platformPub = key |
| 104 | + return |
| 105 | + } |
| 106 | + n.platformPubErr = &Error{Message: "withdraw: cannot parse platform public key", Code: -1} |
| 107 | + }) |
| 108 | + return n.platformPub, n.platformPubErr |
| 109 | +} |
| 110 | + |
| 111 | +// Sign computes the RSA-PKCS1v15-SHA256 signature for a withdraw request. |
| 112 | +// |
| 113 | +// 业务方一般不需要直接调,SDK 内部 do 时自动调用.公开出来便于: |
| 114 | +// - 测试签名正确性 |
| 115 | +// - 自行实现非标场景(比如 curl 调试) |
| 116 | +func (n *WithdrawNamespace) Sign(method, path, timestamp, nonce, body string) (string, error) { |
| 117 | + priv, err := n.parsePrivKey() |
| 118 | + if err != nil { |
| 119 | + return "", err |
| 120 | + } |
| 121 | + signString := strings.ToUpper(method) + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body |
| 122 | + h := sha256.Sum256([]byte(signString)) |
| 123 | + sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) |
| 124 | + if err != nil { |
| 125 | + return "", &Error{Message: "withdraw: RSA sign failed: " + err.Error(), Code: -1} |
| 126 | + } |
| 127 | + return base64.StdEncoding.EncodeToString(sig), nil |
| 128 | +} |
| 129 | + |
| 130 | +// newNonce 生成 uuid v4(本地实现避免引入第三方依赖). |
| 131 | +func newNonce() (string, error) { |
| 132 | + b := make([]byte, 16) |
| 133 | + if _, err := rand.Read(b); err != nil { |
| 134 | + return "", err |
| 135 | + } |
| 136 | + // RFC 4122 v4 variant |
| 137 | + b[6] = (b[6] & 0x0f) | 0x40 |
| 138 | + b[8] = (b[8] & 0x3f) | 0x80 |
| 139 | + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil |
| 140 | +} |
| 141 | + |
| 142 | +// do 内部统一发请求 — 自动加 4 个鉴权头. |
| 143 | +// |
| 144 | +// body 必须传入"已序列化"的 JSON bytes(确保和签名串里的 BODY 字符串完全一致). |
| 145 | +// GET 请求 body 传 nil 即可. |
| 146 | +func (n *WithdrawNamespace) do(method, path string, body []byte, query map[string]any) (json.RawMessage, error) { |
| 147 | + if n.c.cfg.WithdrawAPIKey == "" { |
| 148 | + return nil, &Error{Message: "WithdrawAPIKey not configured", Code: -1} |
| 149 | + } |
| 150 | + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) |
| 151 | + nonce, err := newNonce() |
| 152 | + if err != nil { |
| 153 | + return nil, &Error{Message: "withdraw: gen nonce: " + err.Error(), Code: -1} |
| 154 | + } |
| 155 | + bodyStr := "" |
| 156 | + if body != nil { |
| 157 | + bodyStr = string(body) |
| 158 | + } |
| 159 | + sig, err := n.Sign(method, path, timestamp, nonce, bodyStr) |
| 160 | + if err != nil { |
| 161 | + return nil, err |
| 162 | + } |
| 163 | + return n.c.transport.do(method, path, &requestOpts{ |
| 164 | + BodyRaw: body, |
| 165 | + Query: query, |
| 166 | + Headers: map[string]string{ |
| 167 | + "X-API-Key": n.c.cfg.WithdrawAPIKey, |
| 168 | + "X-Timestamp": timestamp, |
| 169 | + "X-Nonce": nonce, |
| 170 | + "X-Withdraw-Signature": sig, |
| 171 | + }, |
| 172 | + }) |
| 173 | +} |
| 174 | + |
| 175 | +// CreateWithdraw 发起提币 — POST /api/v1/withdraw |
| 176 | +// |
| 177 | +// 下单后状态为 pending,等延迟到期由 worker 自动广播.期间可在控制台暂停 / 加速 / 取消. |
| 178 | +// |
| 179 | +// params := map[string]any{ |
| 180 | +// "chain": "tron", |
| 181 | +// "symbol": "USDT", |
| 182 | +// "amount": "100.5", |
| 183 | +// "to_address": "TXxxxxxxxx", |
| 184 | +// "memo": "withdraw to user #1024", // 可选 |
| 185 | +// "callback_url": "https://your-domain.com/cb", // 可选 |
| 186 | +// "request_id": "your-idempotency-uuid", // 可选,推荐传 |
| 187 | +// } |
| 188 | +// raw, err := client.Withdraw.CreateWithdraw(params) |
| 189 | +func (n *WithdrawNamespace) CreateWithdraw(params map[string]any) (json.RawMessage, error) { |
| 190 | + body, err := json.Marshal(params) |
| 191 | + if err != nil { |
| 192 | + return nil, &Error{Message: "withdraw: marshal body: " + err.Error(), Code: -1} |
| 193 | + } |
| 194 | + return n.do("POST", "/api/v1/withdraw", body, nil) |
| 195 | +} |
| 196 | + |
| 197 | +// GetWithdraw 查询单笔提币状态 — GET /api/v1/withdraw/:id |
| 198 | +// |
| 199 | +// 返回订单详情(可用来轮询状态,也建议优先用回调). |
| 200 | +func (n *WithdrawNamespace) GetWithdraw(id string) (json.RawMessage, error) { |
| 201 | + if id == "" { |
| 202 | + return nil, &Error{Message: "withdraw: id is required", Code: -1} |
| 203 | + } |
| 204 | + return n.do("GET", "/api/v1/withdraw/"+id, nil, nil) |
| 205 | +} |
| 206 | + |
| 207 | +// GetWithdrawableBalance 查询可提余额 — GET /api/v1/balance/withdrawable |
| 208 | +// |
| 209 | +// 返回该账户在每条链 × 每种资产下的「已归集待提现」余额. |
| 210 | +// 只有这部分可用于 API 提币. |
| 211 | +func (n *WithdrawNamespace) GetWithdrawableBalance() (json.RawMessage, error) { |
| 212 | + return n.do("GET", "/api/v1/balance/withdrawable", nil, nil) |
| 213 | +} |
| 214 | + |
| 215 | +// QuoteFee 费用预估 — GET /api/v1/fee/quote?chain=&symbol=&amount= |
| 216 | +// |
| 217 | +// 返回管理端为该 chain × symbol 配置的预扣费(OKX 式固定值). |
| 218 | +// |
| 219 | +// raw, err := client.Withdraw.QuoteFee("tron", "USDT", "100") |
| 220 | +func (n *WithdrawNamespace) QuoteFee(chain, symbol, amount string) (json.RawMessage, error) { |
| 221 | + if chain == "" || symbol == "" { |
| 222 | + return nil, &Error{Message: "withdraw: chain and symbol are required", Code: -1} |
| 223 | + } |
| 224 | + q := map[string]any{"chain": chain, "symbol": symbol} |
| 225 | + if amount != "" { |
| 226 | + q["amount"] = amount |
| 227 | + } |
| 228 | + return n.do("GET", "/api/v1/fee/quote", nil, q) |
| 229 | +} |
| 230 | + |
| 231 | +// VerifyCallback 验证平台回调签名. |
| 232 | +// |
| 233 | +// 用法(对接方收到回调时): |
| 234 | +// |
| 235 | +// sig := req.Header.Get("X-Platform-Signature") |
| 236 | +// body, _ := io.ReadAll(req.Body) |
| 237 | +// ts := req.Header.Get("X-Timestamp") |
| 238 | +// nonce := req.Header.Get("X-Nonce") |
| 239 | +// if err := client.Withdraw.VerifyCallback(req.Method, req.URL.Path, ts, nonce, body, sig); err != nil { |
| 240 | +// // 验签失败,拒绝处理 |
| 241 | +// } |
| 242 | +// |
| 243 | +// 验签算法与请求方向一致:RSA-PKCS1v15-SHA256(platform_public_key, signString). |
| 244 | +func (n *WithdrawNamespace) VerifyCallback(method, path, timestamp, nonce string, body []byte, base64Sig string) error { |
| 245 | + pub, err := n.parsePlatformPub() |
| 246 | + if err != nil { |
| 247 | + return err |
| 248 | + } |
| 249 | + sig, err := base64.StdEncoding.DecodeString(base64Sig) |
| 250 | + if err != nil { |
| 251 | + return &Error{Message: "withdraw: bad signature base64: " + err.Error(), Code: -1} |
| 252 | + } |
| 253 | + signString := strings.ToUpper(method) + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + string(body) |
| 254 | + h := sha256.Sum256([]byte(signString)) |
| 255 | + if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], sig); err != nil { |
| 256 | + return &Error{Message: "withdraw: signature verify failed", Code: -1} |
| 257 | + } |
| 258 | + return nil |
| 259 | +} |
| 260 | + |
0 commit comments