Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d0217b618 | |||
| 097c716714 | |||
| f7c5f678c4 |
@@ -13,10 +13,25 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
|||||||
return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
|
return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isEVMNetwork 判断是否是 EVM 网络
|
||||||
|
func isEVMNetwork(network string) bool {
|
||||||
|
switch network {
|
||||||
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
||||||
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
||||||
network = strings.ToLower(strings.TrimSpace(network))
|
network = strings.ToLower(strings.TrimSpace(network))
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
|
|
||||||
|
// evm 网络地址统一小写,tron 和 solana 保持原样
|
||||||
|
if isEVMNetwork(network) {
|
||||||
|
address = strings.ToLower(address)
|
||||||
|
}
|
||||||
|
|
||||||
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -374,6 +374,80 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
|||||||
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s hash=%s", net, tokenSym, walletAddr, tradeID, txHash)
|
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s hash=%s", net, tokenSym, walletAddr, tradeID, txHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TryProcessTronTRC20Transfer 处理 Tron 链上 TRC20-USDT 的 Transfer 入账。
|
||||||
|
func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Sugar.Errorf("[TRON-WS] TryProcessTronTRC20Transfer panic: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
walletAddr := strings.TrimSpace(toAddr)
|
||||||
|
if walletAddr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rawValue == nil || rawValue.Sign() <= 0 {
|
||||||
|
log.Sugar.Infof("[TRON-USDT][%s] skip non-positive or nil amount", walletAddr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||||
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
|
||||||
|
if amount <= 0 {
|
||||||
|
log.Sugar.Warnf("[TRON-USDT][%s] skip non-positive amount %.2f", walletAddr, amount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, walletAddr, "USDT", amount)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRON-USDT][%s] lock lookup: %v", walletAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tradeID == "" {
|
||||||
|
log.Sugar.Debugf("[TRON-USDT][%s] skip unmatched tx hash=%s amount=%.2f", walletAddr, txHash, amount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRON-USDT][%s] load order: %v", walletAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
log.Sugar.Warnf("[TRON-USDT][%s] skip tx %s because block time %d is before order create time %d", walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTron {
|
||||||
|
log.Sugar.Warnf("[TRON-USDT][%s] skip trade_id=%s network=%q", walletAddr, tradeID, order.Network)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(order.Token)) != "USDT" {
|
||||||
|
log.Sugar.Warnf("[TRON-USDT][%s] skip trade_id=%s token mismatch order=%s", walletAddr, tradeID, order.Token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: walletAddr,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
TradeId: tradeID,
|
||||||
|
Amount: amount,
|
||||||
|
BlockTransactionId: txHash,
|
||||||
|
}
|
||||||
|
err = OrderProcessing(req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||||
|
log.Sugar.Infof("[TRON-USDT][%s] skip resolved trade_id=%s hash=%s err=%v", walletAddr, tradeID, txHash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Errorf("[TRON-USDT][%s] OrderProcessing: %v", walletAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPaymentNotification(order)
|
||||||
|
log.Sugar.Infof("[TRON-USDT][%s] payment processed trade_id=%s hash=%s", walletAddr, tradeID, txHash)
|
||||||
|
}
|
||||||
|
|
||||||
func sendPaymentNotification(order *mdb.Orders) {
|
func sendPaymentNotification(order *mdb.Orders) {
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"🎉 <b>收款成功通知</b>\n\n"+
|
"🎉 <b>收款成功通知</b>\n\n"+
|
||||||
|
|||||||
@@ -539,7 +539,6 @@
|
|||||||
createdAt: "{{.CreatedAt}}",
|
createdAt: "{{.CreatedAt}}",
|
||||||
is_selected: "{{.IsSelected}}"
|
is_selected: "{{.IsSelected}}"
|
||||||
};
|
};
|
||||||
var PAYMENT_OPTIONS = null;
|
|
||||||
</script>
|
</script>
|
||||||
<script defer src="/static/payment.js"></script>
|
<script defer src="/static/payment.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+178
-128
@@ -1,10 +1,11 @@
|
|||||||
/* =============================================================
|
/* =============================================================
|
||||||
* GM Pay — payment.js
|
* GM Pay — payment.js
|
||||||
* 后端对接只需关注:
|
* 后端对接只需关注:
|
||||||
* 1. CONFIG.api.selectMethod — Step 1 确认接口(POST 币种/网络,返回收款地址)
|
* 1. CONFIG.api.supportedAssets — 获取支持的网络和币种
|
||||||
* 2. CONFIG.api.checkStatus — 轮询接口 URL
|
* 2. CONFIG.api.selectMethod — Step 1 确认接口(POST 币种/网络,返回收款地址)
|
||||||
* 3. CONFIG.api.statusMap — 状态码映射
|
* 3. CONFIG.api.checkStatus — 轮询接口 URL
|
||||||
* 4. index.html 底部 <script> 中 ORDER / PAYMENT_OPTIONS 变量
|
* 4. CONFIG.api.statusMap — 状态码映射
|
||||||
|
* 5. index.html 底部 <script> 中 ORDER 变量
|
||||||
* ============================================================= */
|
* ============================================================= */
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -23,6 +24,78 @@ const setHtml = (id, value) => {
|
|||||||
return el;
|
return el;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── API 错误码映射 ────────────────────────────────────────────
|
||||||
|
const API_ERRORS = {
|
||||||
|
400: { en: 'System error', zh: '系统错误', 'zh-hk': '系統錯誤', ja: 'システムエラー', ko: '시스템 오류', ru: 'Системная ошибка' },
|
||||||
|
401: { en: 'Signature / authentication failed', zh: '签名/认证失败', 'zh-hk': '簽名/認證失敗', ja: '署名/認証に失敗しました', ko: '서명/인증 실패', ru: 'Ошибка подписи / аутентификации' },
|
||||||
|
10001: { en: 'Wallet address already exists', zh: '钱包地址已存在', 'zh-hk': '錢包地址已存在', ja: 'ウォレットアドレスが既に存在します', ko: '지갑 주소가 이미 존재합니다', ru: 'Адрес кошелька уже существует' },
|
||||||
|
10002: { en: 'Order already exists', zh: '订单已存在', 'zh-hk': '訂單已存在', ja: '注文が既に存在します', ko: '주문이 이미 존재합니다', ru: 'Заказ уже существует' },
|
||||||
|
10003: { en: 'No wallet address available', zh: '无可用钱包地址', 'zh-hk': '無可用錢包地址', ja: '利用可能なウォレットアドレスがありません', ko: '사용 가능한 지갑 주소 없음', ru: 'Нет доступного адреса кошелька' },
|
||||||
|
10004: { en: 'Invalid payment amount', zh: '无效支付金额', 'zh-hk': '無效支付金額', ja: '無効な支払い金額', ko: '유효하지 않은 결제 금액', ru: 'Недопустимая сумма платежа' },
|
||||||
|
10005: { en: 'No available amount channel', zh: '无可用金额通道', 'zh-hk': '無可用金額通道', ja: '利用可能な金額チャンネルがありません', ko: '사용 가능한 금액 채널 없음', ru: 'Нет доступного канала суммы' },
|
||||||
|
10006: { en: 'Exchange rate calculation failed', zh: '汇率计算失败', 'zh-hk': '匯率計算失敗', ja: '為替レート計算に失敗しました', ko: '환율 계산 실패', ru: 'Ошибка расчёта курса' },
|
||||||
|
10007: { en: 'Block transaction already processed', zh: '区块交易已处理', 'zh-hk': '區塊交易已處理', ja: 'ブロックトランザクションは処理済みです', ko: '블록 트랜잭션이 이미 처리됨', ru: 'Транзакция блока уже обработана' },
|
||||||
|
10008: { en: 'Order not found', zh: '订单不存在', 'zh-hk': '訂單不存在', ja: '注文が見つかりません', ko: '주문 없음', ru: 'Заказ не найден' },
|
||||||
|
10009: { en: 'Request parameter parsing failed', zh: '请求参数解析失败', 'zh-hk': '請求參數解析失敗', ja: 'リクエストパラメータの解析に失敗', ko: '요청 매개변수 파싱 실패', ru: 'Ошибка разбора параметров запроса' },
|
||||||
|
10010: { en: 'Order status has changed', zh: '订单状态已变更', 'zh-hk': '訂單狀態已變更', ja: '注文ステータスが変更されました', ko: '주문 상태가 변경됨', ru: 'Статус заказа изменился' },
|
||||||
|
10011: { en: 'Sub-order quantity exceeded', zh: '子订单数量超限', 'zh-hk': '子訂單數量超限', ja: 'サブ注文数が超過しました', ko: '하위 주문 수량 초과', ru: 'Превышено количество подзаказов' },
|
||||||
|
10012: { en: 'Cannot switch network for sub-orders', zh: '不能对子订单切换网络', 'zh-hk': '不能對子訂單切換網絡', ja: 'サブ注文のネットワーク切替不可', ko: '하위 주문에 네트워크 전환 불가', ru: 'Нельзя переключить сеть для подзаказов' },
|
||||||
|
10013: { en: 'Order is not in pending payment status', zh: '订单不是待支付状态', 'zh-hk': '訂單不是待支付狀態', ja: '注文は支払い待ち状態ではありません', ko: '주문이 결제 대기 상태가 아님', ru: 'Заказ не в статусе ожидания оплаты' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 API 错误码返回当前语言的提示文字;未匹配时返回 null
|
||||||
|
* @param {number|string|null|undefined} code
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function getApiErrorMsg(code) {
|
||||||
|
if (code == null) return null;
|
||||||
|
const map = API_ERRORS[Number(code)];
|
||||||
|
if (!map) return null;
|
||||||
|
return map[currentLang] ?? map.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 业务异常(区别于网络异常),携带错误码 */
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(code, message) { super(message); this.code = code; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误码 → 终态面板映射;命中即跳转面板,不弹 toast */
|
||||||
|
const _TERMINAL_ERROR_CODES = {
|
||||||
|
10008: 'not-found', // 订单不存在
|
||||||
|
10010: 'expired', // 订单状态已变更
|
||||||
|
10013: 'not-found', // 订单不是待支付状态
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一 API 请求:发送 → 解析 JSON → 校验业务码 → 返回 data
|
||||||
|
* 业务异常抛 ApiError,网络/超时异常原样冒泡
|
||||||
|
*/
|
||||||
|
async function apiFetch(url, opts = {}) {
|
||||||
|
const { timeout = 10000, ...fetchOpts } = opts;
|
||||||
|
fetchOpts.headers = { Accept: 'application/json', ...fetchOpts.headers };
|
||||||
|
if (!fetchOpts.signal) fetchOpts.signal = AbortSignal.timeout(timeout);
|
||||||
|
const res = await fetch(url, fetchOpts);
|
||||||
|
const resp = await res.json().catch(() => null);
|
||||||
|
if (!res.ok || (resp?.code != null && resp.code !== 200)) {
|
||||||
|
const code = resp?.code ? Number(resp.code) : res.status;
|
||||||
|
throw new ApiError(code, getApiErrorMsg(code) ?? resp?.msg ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return resp?.data ?? resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理 API 异常:终态错误码 → 跳转面板;其余 → 弹 toast
|
||||||
|
* @returns {boolean} true = 终态,已跳转面板
|
||||||
|
*/
|
||||||
|
function handleApiError(err) {
|
||||||
|
const panel = (err instanceof ApiError) && _TERMINAL_ERROR_CODES[err.code];
|
||||||
|
if (panel) { _enterTerminalState(panel); return true; }
|
||||||
|
console.error('[API]', err);
|
||||||
|
showToast('⚠ ' + (err.message || t('network_timeout')));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 全局配置(可按项目调整)─────────────────────────────────
|
// ── 全局配置(可按项目调整)─────────────────────────────────
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
poll: {
|
poll: {
|
||||||
@@ -35,6 +108,8 @@ const CONFIG = {
|
|||||||
},
|
},
|
||||||
// ---- 后端接口 ----
|
// ---- 后端接口 ----
|
||||||
api: {
|
api: {
|
||||||
|
// 获取支持的网络和币种
|
||||||
|
supportedAssets: () => '/payments/gmpay/v1/supported-assets',
|
||||||
// 切换网络接口:POST { trade_id, token, network },返回完整订单对象
|
// 切换网络接口:POST { trade_id, token, network },返回完整订单对象
|
||||||
selectMethod: () => '/pay/switch-network',
|
selectMethod: () => '/pay/switch-network',
|
||||||
// 轮询接口:GET,返回 { data: { status: number } }
|
// 轮询接口:GET,返回 { data: { status: number } }
|
||||||
@@ -98,8 +173,7 @@ const _CHAIN_ALIASES = {
|
|||||||
|
|
||||||
const IMAGE_PREFIX = {
|
const IMAGE_PREFIX = {
|
||||||
chain: '/static/images/',
|
chain: '/static/images/',
|
||||||
tokenTrigger: 'https://cdn.jsdmirror.com/gh/atomiclabs/cryptocurrency-icons@1a63530/128/color/',
|
token: 'https://cdn.jsdmirror.com/gh/atomiclabs/cryptocurrency-icons@1a63530/128/color/',
|
||||||
tokenMenu: 'https://cdn.jsdmirror.com/gh/atomiclabs/cryptocurrency-icons@1a63530/128/color/',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function _resolveChain(name) {
|
function _resolveChain(name) {
|
||||||
@@ -132,7 +206,7 @@ function _networkTriggerHtml(network) {
|
|||||||
/** 选择器触发器显示:Token */
|
/** 选择器触发器显示:Token */
|
||||||
function _tokenTriggerHtml(token) {
|
function _tokenTriggerHtml(token) {
|
||||||
if (!token) return `<span class="text-sm font-semibold" style="color:var(--card-foreground)">--</span>`;
|
if (!token) return `<span class="text-sm font-semibold" style="color:var(--card-foreground)">--</span>`;
|
||||||
const src = `${IMAGE_PREFIX.tokenTrigger}${token.toLowerCase()}.png`;
|
const src = `${IMAGE_PREFIX.token}${token.toLowerCase()}.png`;
|
||||||
return `<span style="display:inline-flex;align-items:center;gap:7px">
|
return `<span style="display:inline-flex;align-items:center;gap:7px">
|
||||||
<img src="${src}" width="18" height="18" style="width:18px;height:18px;border-radius:50%;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'" />
|
<img src="${src}" width="18" height="18" style="width:18px;height:18px;border-radius:50%;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'" />
|
||||||
<span class="text-sm font-semibold" style="color:var(--card-foreground)">${token}</span>
|
<span class="text-sm font-semibold" style="color:var(--card-foreground)">${token}</span>
|
||||||
@@ -164,8 +238,8 @@ function applyTheme(t) {
|
|||||||
currentTheme = t;
|
currentTheme = t;
|
||||||
document.documentElement.setAttribute('data-theme', t);
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
localStorage.setItem('theme', t);
|
localStorage.setItem('theme', t);
|
||||||
$('icon-moon').style.display = t === 'light' ? 'block' : 'none';
|
const moon = $('icon-moon'); if (moon) moon.style.display = t === 'light' ? 'block' : 'none';
|
||||||
$('icon-sun').style.display = t === 'dark' ? 'block' : 'none';
|
const sun = $('icon-sun'); if (sun) sun.style.display = t === 'dark' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme(e) {
|
function toggleTheme(e) {
|
||||||
@@ -385,10 +459,7 @@ function setLang(lang) {
|
|||||||
o.classList.toggle('is-selected', o.dataset.lang === lang));
|
o.classList.toggle('is-selected', o.dataset.lang === lang));
|
||||||
closeAllSelects();
|
closeAllSelects();
|
||||||
// 重新渲染含翻译文案的动态行
|
// 重新渲染含翻译文案的动态行
|
||||||
if (ORDER?.tradeId) renderOrderId();
|
renderOrderCard();
|
||||||
if (ORDER?.amount && !ORDER.amount.startsWith('{{')) {
|
|
||||||
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -538,8 +609,6 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncStatusCardHeight() {} // 兼容旧调用,保留空桩
|
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// SECTION 7 — 步骤进度条
|
// SECTION 7 — 步骤进度条
|
||||||
@@ -564,22 +633,26 @@ function setStepBar(step) {
|
|||||||
// SECTION 8 — Step 1 — 币种 & 网络选择
|
// SECTION 8 — Step 1 — 币种 & 网络选择
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从后端获取支持的网络和币种,转换为 PAYMENT_OPTIONS 格式
|
||||||
|
* @returns {Array|null} 成功返回选项数组,失败返回 null
|
||||||
|
*/
|
||||||
|
async function fetchSupportedAssets() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch(CONFIG.api.supportedAssets());
|
||||||
|
if (!data?.supports?.length) return null;
|
||||||
|
return data.supports.flatMap(s => s.tokens.map(token => ({ token, network: s.network })));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[supportedAssets]', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _step1Token = null;
|
let _step1Token = null;
|
||||||
let _step1Opt = null;
|
let _step1Opt = null;
|
||||||
|
|
||||||
// 内置优先网络,按顺序匹配第一个可用选项
|
|
||||||
const _PREFERRED_NETWORKS = ['TRON', 'Solana'];
|
|
||||||
|
|
||||||
function initStep1() {
|
function initStep1() {
|
||||||
// 按内置优先顺序找到首个可用选项
|
_step1Opt = PAYMENT_OPTIONS[0];
|
||||||
_step1Opt = null;
|
|
||||||
for (const net of _PREFERRED_NETWORKS) {
|
|
||||||
_step1Opt = PAYMENT_OPTIONS.find(
|
|
||||||
o => (_resolveChain(o.network)?.label ?? o.network) === net
|
|
||||||
);
|
|
||||||
if (_step1Opt) break;
|
|
||||||
}
|
|
||||||
_step1Opt = _step1Opt ?? PAYMENT_OPTIONS[0];
|
|
||||||
_step1Token = _step1Opt.token;
|
_step1Token = _step1Opt.token;
|
||||||
_renderNetworkMenu();
|
_renderNetworkMenu();
|
||||||
_renderTokenMenu();
|
_renderTokenMenu();
|
||||||
@@ -597,7 +670,7 @@ function _renderTokenMenu() {
|
|||||||
const menu = $('dd-token-menu');
|
const menu = $('dd-token-menu');
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
menu.innerHTML = tokens.map(tok => {
|
menu.innerHTML = tokens.map(tok => {
|
||||||
const src = `${IMAGE_PREFIX.tokenMenu}${tok.toLowerCase()}.png`;
|
const src = `${IMAGE_PREFIX.token}${tok.toLowerCase()}.png`;
|
||||||
return `<div class="select-option menu-item${tok === _step1Token ? ' is-selected' : ''}"
|
return `<div class="select-option menu-item${tok === _step1Token ? ' is-selected' : ''}"
|
||||||
onclick="step1SetToken('${tok}')"
|
onclick="step1SetToken('${tok}')"
|
||||||
style="display:flex;align-items:center;gap:8px">
|
style="display:flex;align-items:center;gap:8px">
|
||||||
@@ -643,8 +716,28 @@ function step1SetNetwork(networkName) {
|
|||||||
closeAllSelects();
|
closeAllSelects();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 用后端返回的订单字段更新 ORDER */
|
||||||
|
function _applyOrderData(data, opt) {
|
||||||
|
ORDER.tradeId = data?.trade_id ?? ORDER.tradeId;
|
||||||
|
if (data?.trade_id) {
|
||||||
|
const _url = new URL(window.location.href);
|
||||||
|
_url.pathname = _url.pathname.replace(/\/[^/]+$/, '/' + encodeURIComponent(data.trade_id));
|
||||||
|
history.replaceState(null, '', _url.toString());
|
||||||
|
renderOrderCard();
|
||||||
|
}
|
||||||
|
ORDER.token = data?.token ?? opt.token;
|
||||||
|
ORDER.network = data?.network ?? opt.network;
|
||||||
|
ORDER.actualAmount = data?.actual_amount != null ? String(data.actual_amount) : ORDER.actualAmount;
|
||||||
|
ORDER.amount = data?.amount != null ? String(data.amount) : ORDER.amount;
|
||||||
|
ORDER.currency = data?.currency ?? ORDER.currency;
|
||||||
|
ORDER.receiveAddress = data?.receive_address ?? ORDER.receiveAddress;
|
||||||
|
if (data?.expiration_time != null) ORDER.expirationTime = String(data.expiration_time);
|
||||||
|
if (data?.created_at != null) ORDER.createdAt = String(data.created_at);
|
||||||
|
if (data?.redirect_url) ORDER.redirectUrl = data.redirect_url;
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmStep1() {
|
async function confirmStep1() {
|
||||||
const opt = _step1Opt || PAYMENT_OPTIONS[0];
|
const opt = _step1Opt;
|
||||||
const btn = $('btn-confirm-step1');
|
const btn = $('btn-confirm-step1');
|
||||||
const span = btn?.querySelector('span') ?? btn;
|
const span = btn?.querySelector('span') ?? btn;
|
||||||
const origText = span?.textContent;
|
const origText = span?.textContent;
|
||||||
@@ -653,45 +746,22 @@ async function confirmStep1() {
|
|||||||
if (btn) { btn.disabled = true; if (span) span.textContent = '…'; }
|
if (btn) { btn.disabled = true; if (span) span.textContent = '…'; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(CONFIG.api.selectMethod(), {
|
const data = await apiFetch(CONFIG.api.selectMethod(), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
trade_id: ORDER.tradeId,
|
trade_id: ORDER.tradeId,
|
||||||
token: opt.token.toLowerCase(),
|
token: opt.token.toLowerCase(),
|
||||||
network: opt.network.toLowerCase(),
|
network: opt.network.toLowerCase(),
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const resp = await res.json();
|
|
||||||
const data = resp?.data ?? resp;
|
|
||||||
|
|
||||||
// 2. 用后端返回的完整订单字段更新 ORDER,本地选项作为兜底
|
|
||||||
ORDER.tradeId = data?.trade_id ?? ORDER.tradeId;
|
|
||||||
// 同步新 trade_id 到 URL(路径末段),轮询将自动使用最新值
|
|
||||||
if (data?.trade_id) {
|
|
||||||
const _url = new URL(window.location.href);
|
|
||||||
_url.pathname = _url.pathname.replace(/\/[^/]+$/, '/' + encodeURIComponent(data.trade_id));
|
|
||||||
history.replaceState(null, '', _url.toString());
|
|
||||||
renderOrderId();
|
|
||||||
}
|
|
||||||
ORDER.token = data?.token ?? opt.token;
|
|
||||||
ORDER.network = data?.network ?? opt.network;
|
|
||||||
ORDER.actualAmount = data?.actual_amount != null ? String(data.actual_amount) : opt.actualAmount;
|
|
||||||
ORDER.amount = data?.amount != null ? String(data.amount) : ORDER.amount;
|
|
||||||
ORDER.currency = data?.currency ?? ORDER.currency;
|
|
||||||
ORDER.receiveAddress = data?.receive_address ?? opt.receiveAddress;
|
|
||||||
if (data?.expiration_time != null) ORDER.expirationTime = String(data.expiration_time);
|
|
||||||
if (data?.created_at != null) ORDER.createdAt = String(data.created_at);
|
|
||||||
if (data?.redirect_url) ORDER.redirectUrl = data.redirect_url;
|
|
||||||
|
|
||||||
|
_applyOrderData(data, opt);
|
||||||
setStepBar(2);
|
setStepBar(2);
|
||||||
slideTo('payment');
|
slideTo('payment');
|
||||||
initOrder();
|
initOrder();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[confirmStep1]', err);
|
handleApiError(err);
|
||||||
showToast('⚠ ' + (err.message || 'Request failed'));
|
|
||||||
} finally {
|
} finally {
|
||||||
if (btn) { btn.disabled = false; if (span && origText) span.textContent = origText; }
|
if (btn) { btn.disabled = false; if (span && origText) span.textContent = origText; }
|
||||||
}
|
}
|
||||||
@@ -702,10 +772,6 @@ async function confirmStep1() {
|
|||||||
// SECTION 9 — 订单渲染 & 倒计时
|
// SECTION 9 — 订单渲染 & 倒计时
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function truncateAddr(addr) {
|
|
||||||
return addr && addr.length > 10 ? `${addr.slice(0, 4)}...${addr.slice(-6)}` : addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAddr(addr) {
|
function formatAddr(addr) {
|
||||||
if (!addr || addr.length <= 10) return addr;
|
if (!addr || addr.length <= 10) return addr;
|
||||||
return `<span style="font-weight:800;color:var(--primary)">${addr.slice(0, 4)}</span>`
|
return `<span style="font-weight:800;color:var(--primary)">${addr.slice(0, 4)}</span>`
|
||||||
@@ -720,10 +786,14 @@ function renderRow(id, label, value) {
|
|||||||
<td style="color:var(--card-foreground);font-weight:500;word-break:break-all">${value}</td>`;
|
<td style="color:var(--card-foreground);font-weight:500;word-break:break-all">${value}</td>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOrderId() {
|
/** 填充订单信息卡片(订单号 + 法币金额行) */
|
||||||
|
function renderOrderCard() {
|
||||||
if (ORDER?.tradeId && !ORDER.tradeId.startsWith('{{')) {
|
if (ORDER?.tradeId && !ORDER.tradeId.startsWith('{{')) {
|
||||||
renderRow('display-order-id', t('order_id'), ORDER.tradeId);
|
renderRow('display-order-id', t('order_id'), ORDER.tradeId);
|
||||||
}
|
}
|
||||||
|
if (ORDER?.amount && !ORDER.amount.startsWith('{{')) {
|
||||||
|
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initOrder() {
|
function initOrder() {
|
||||||
@@ -732,14 +802,11 @@ function initOrder() {
|
|||||||
setText('display-amount', `${ORDER.actualAmount} ${ORDER.token}`);
|
setText('display-amount', `${ORDER.actualAmount} ${ORDER.token}`);
|
||||||
setHtml('field-address', formatAddr(ORDER.receiveAddress));
|
setHtml('field-address', formatAddr(ORDER.receiveAddress));
|
||||||
setHtml('display-network', networkTag(ORDER.network));
|
setHtml('display-network', networkTag(ORDER.network));
|
||||||
|
renderOrderCard();
|
||||||
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
|
|
||||||
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
|
|
||||||
}
|
|
||||||
renderOrderId();
|
|
||||||
|
|
||||||
const qrcodeEl = $('qrcode');
|
const qrcodeEl = $('qrcode');
|
||||||
if (qrcodeEl) {
|
if (qrcodeEl) {
|
||||||
|
qrcodeEl.innerHTML = ''; // 防止重复调用时叠加渲染
|
||||||
new QRCode(qrcodeEl, {
|
new QRCode(qrcodeEl, {
|
||||||
text: ORDER.receiveAddress,
|
text: ORDER.receiveAddress,
|
||||||
width: 176,
|
width: 176,
|
||||||
@@ -772,6 +839,8 @@ function _ratioColor(ratio) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initCountdown() {
|
function initCountdown() {
|
||||||
|
// 清除上一次的倒计时(initOrder 可能被重复调用)
|
||||||
|
if (_countdownInterval) { clearInterval(_countdownInterval); _countdownInterval = null; }
|
||||||
const parseTime = (raw) => new Date(/^\d+$/.test(String(raw)) ? Number(raw) : raw);
|
const parseTime = (raw) => new Date(/^\d+$/.test(String(raw)) ? Number(raw) : raw);
|
||||||
_expiresAt = parseTime(ORDER.expirationTime);
|
_expiresAt = parseTime(ORDER.expirationTime);
|
||||||
const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
|
const remaining = Math.max(0, Math.round((_expiresAt - Date.now()) / 1000));
|
||||||
@@ -797,10 +866,10 @@ function _tickCountdown() {
|
|||||||
const color = _ratioColor(_totalSeconds > 0 ? remaining / _totalSeconds : 0);
|
const color = _ratioColor(_totalSeconds > 0 ? remaining / _totalSeconds : 0);
|
||||||
const offset = CIRCUMFERENCE * (1 - (_totalSeconds > 0 ? remaining / _totalSeconds : 0));
|
const offset = CIRCUMFERENCE * (1 - (_totalSeconds > 0 ? remaining / _totalSeconds : 0));
|
||||||
|
|
||||||
$('countdown').textContent = timeStr;
|
const cdEl = $('countdown');
|
||||||
$('countdown').style.color = color;
|
const ringEl = $('ring');
|
||||||
$('ring').style.stroke = color;
|
if (cdEl) { cdEl.textContent = timeStr; cdEl.style.color = color; }
|
||||||
$('ring').style.strokeDashoffset = offset;
|
if (ringEl) { ringEl.style.stroke = color; ringEl.style.strokeDashoffset = offset; }
|
||||||
|
|
||||||
const inlineEl = $('countdown-inline');
|
const inlineEl = $('countdown-inline');
|
||||||
if (inlineEl) { inlineEl.textContent = timeStr; inlineEl.style.color = color; }
|
if (inlineEl) { inlineEl.textContent = timeStr; inlineEl.style.color = color; }
|
||||||
@@ -818,28 +887,28 @@ let _pollTimer = null;
|
|||||||
let _pollStopped = false;
|
let _pollStopped = false;
|
||||||
let _pollErrors = 0;
|
let _pollErrors = 0;
|
||||||
|
|
||||||
function checkOrderStatus() {
|
async function checkOrderStatus() {
|
||||||
if (_pollStopped || !ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) return;
|
if (_pollStopped || !ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) return;
|
||||||
|
|
||||||
fetch(CONFIG.api.checkStatus(ORDER.tradeId), {
|
try {
|
||||||
headers: { Accept: 'application/json' },
|
const data = await apiFetch(CONFIG.api.checkStatus(ORDER.tradeId), {
|
||||||
signal: AbortSignal.timeout(CONFIG.poll.timeout),
|
timeout: CONFIG.poll.timeout,
|
||||||
})
|
});
|
||||||
.then(res => {
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then(({ data }) => {
|
|
||||||
_pollErrors = 0;
|
_pollErrors = 0;
|
||||||
const { paid, expired } = CONFIG.api.statusMap;
|
const { paid, expired } = CONFIG.api.statusMap;
|
||||||
if (data?.status === paid) onPaymentSuccess();
|
if (data?.status === paid) onPaymentSuccess();
|
||||||
else if (data?.status === expired) showExpired();
|
else if (data?.status === expired) showExpired();
|
||||||
else _scheduleNextPoll();
|
else _scheduleNextPoll();
|
||||||
})
|
} catch (err) {
|
||||||
.catch(() => {
|
if (err instanceof ApiError) {
|
||||||
|
_pollErrors = 0;
|
||||||
|
if (!handleApiError(err)) _scheduleNextPoll();
|
||||||
|
} else {
|
||||||
|
// 网络 / 超时异常:累计错误次数
|
||||||
if (++_pollErrors >= CONFIG.poll.maxErrors) showTimeout();
|
if (++_pollErrors >= CONFIG.poll.maxErrors) showTimeout();
|
||||||
else _scheduleNextPoll();
|
else _scheduleNextPoll();
|
||||||
});
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _scheduleNextPoll() {
|
function _scheduleNextPoll() {
|
||||||
@@ -851,47 +920,42 @@ function stopPolling() {
|
|||||||
clearTimeout(_pollTimer);
|
clearTimeout(_pollTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryPolling() {
|
/** 重置轮询状态并立即发起一次请求 */
|
||||||
|
function _resumePolling() {
|
||||||
_pollErrors = 0;
|
_pollErrors = 0;
|
||||||
_pollStopped = false;
|
_pollStopped = false;
|
||||||
hideStatePanel();
|
clearTimeout(_pollTimer);
|
||||||
checkOrderStatus();
|
checkOrderStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retryPolling() {
|
||||||
|
hideStatePanel();
|
||||||
|
_resumePolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// SECTION 11 — 支付状态处理
|
// SECTION 11 — 支付状态处理
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function onPaymentSuccess() {
|
/** 进入终态面板:停止轮询,可选清除倒计时 / 禁用转账按钮 */
|
||||||
|
function _enterTerminalState(panel, { clearTimer = true, disableBtn = true } = {}) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
clearInterval(_countdownInterval);
|
if (clearTimer && _countdownInterval) { clearInterval(_countdownInterval); _countdownInterval = null; }
|
||||||
showStatePanel('success');
|
if (disableBtn) { const btn = $('btn-transferred'); if (btn) btn.disabled = true; }
|
||||||
|
showStatePanel(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaymentSuccess() {
|
||||||
|
_enterTerminalState('success');
|
||||||
if (ORDER?.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
|
if (ORDER?.redirectUrl && !ORDER.redirectUrl.startsWith('{{')) {
|
||||||
setTimeout(() => { window.location.href = ORDER.redirectUrl; }, CONFIG.redirect.delay);
|
setTimeout(() => { window.location.href = ORDER.redirectUrl; }, CONFIG.redirect.delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showExpired() {
|
function showExpired() { _enterTerminalState('expired'); }
|
||||||
stopPolling();
|
function showTimeout() { _enterTerminalState('timeout', { clearTimer: false, disableBtn: false }); }
|
||||||
clearInterval(_countdownInterval);
|
function showNotFound() { _enterTerminalState('not-found'); }
|
||||||
const btn = $('btn-transferred');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
showStatePanel('expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTimeout() {
|
|
||||||
stopPolling();
|
|
||||||
showStatePanel('timeout');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotFound() {
|
|
||||||
stopPolling();
|
|
||||||
clearInterval(_countdownInterval);
|
|
||||||
const btn = $('btn-transferred');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
showStatePanel('not-found');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -905,10 +969,7 @@ function handleTransfer() {
|
|||||||
if (!span) return;
|
if (!span) return;
|
||||||
span.textContent = t('verifying');
|
span.textContent = t('verifying');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
_pollErrors = 0;
|
_resumePolling();
|
||||||
_pollStopped = false;
|
|
||||||
clearTimeout(_pollTimer);
|
|
||||||
checkOrderStatus();
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
span.textContent = t('i_have_transferred');
|
span.textContent = t('i_have_transferred');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -924,7 +985,7 @@ function connectWallet() {
|
|||||||
// SECTION 13 — 初始化入口
|
// SECTION 13 — 初始化入口
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
setLang(detectLang());
|
setLang(detectLang());
|
||||||
|
|
||||||
if (!ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) {
|
if (!ORDER?.tradeId || ORDER.tradeId.startsWith('{{')) {
|
||||||
@@ -933,23 +994,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 在 Step 1 阶段就填充订单信息卡片
|
// 在 Step 1 阶段就填充订单信息卡片
|
||||||
|
renderOrderCard();
|
||||||
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
|
if (ORDER.amount && !ORDER.amount.startsWith('{{')) {
|
||||||
renderRow('display-fiat', t('order_amount'), `${ORDER.amount} ${ORDER.currency || ''}`);
|
|
||||||
setText('display-amount', `${ORDER.amount} ${ORDER.currency || ''}`);
|
setText('display-amount', `${ORDER.amount} ${ORDER.currency || ''}`);
|
||||||
}
|
}
|
||||||
renderOrderId();
|
|
||||||
|
|
||||||
// 确保 PAYMENT_OPTIONS 存在(服务端未注入时降级为内置默认选项)
|
// 从 API 获取支持的网络和币种
|
||||||
if (typeof PAYMENT_OPTIONS === 'undefined' || !Array.isArray(PAYMENT_OPTIONS) || !PAYMENT_OPTIONS.length) {
|
window.PAYMENT_OPTIONS = await fetchSupportedAssets();
|
||||||
window.PAYMENT_OPTIONS = [
|
if (!PAYMENT_OPTIONS?.length) return;
|
||||||
{ token: 'USDT', network: 'TRON', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
|
|
||||||
{ token: 'TRX', network: 'TRON', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
|
|
||||||
{ token: 'USDT', network: 'Solana', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
|
|
||||||
{ token: 'USDC', network: 'Solana', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
|
|
||||||
{ token: 'USDT', network: 'Ethereum', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
|
|
||||||
{ token: 'USDC', network: 'Ethereum', actualAmount: ORDER.actualAmount, receiveAddress: ORDER.receiveAddress },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// isselect=true 时跳过 Step 1,直接进入支付面板
|
// isselect=true 时跳过 Step 1,直接进入支付面板
|
||||||
const _isSelect = ORDER.is_selected && ORDER.is_selected !== 'false' && !ORDER.is_selected.startsWith('{{');
|
const _isSelect = ORDER.is_selected && ORDER.is_selected !== 'false' && !ORDER.is_selected.startsWith('{{');
|
||||||
@@ -968,5 +1020,3 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const walletBtn = $('btn-connect-wallet');
|
const walletBtn = $('btn-connect-wallet');
|
||||||
if (walletBtn) walletBtn.style.display = CONFIG.wallet.enabled ? '' : 'none';
|
if (walletBtn) walletBtn.style.display = CONFIG.wallet.enabled ? '' : 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-10
@@ -9,19 +9,13 @@ func Start() {
|
|||||||
log.Sugar.Info("[task] Starting task scheduler...")
|
log.Sugar.Info("[task] Starting task scheduler...")
|
||||||
go StartEthereumWebSocketListener()
|
go StartEthereumWebSocketListener()
|
||||||
go StartBscWebSocketListener()
|
go StartBscWebSocketListener()
|
||||||
//go StartPolygonWebSocketListener()
|
go StartPolygonWebSocketListener()
|
||||||
//go StartPlasmaWebSocketListener()
|
go StartPlasmaWebSocketListener()
|
||||||
|
go StartTronTrc20WebSocketListener()
|
||||||
|
|
||||||
c := cron.New()
|
c := cron.New()
|
||||||
// trc20钱包监听
|
|
||||||
_, err := c.AddJob("@every 5s", ListenTrc20Job{})
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
|
|
||||||
// solana钱包监听
|
// solana钱包监听
|
||||||
_, err = c.AddJob("@every 5s", ListenSolJob{})
|
_, err := c.AddJob("@every 5s", ListenSolJob{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
|
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func StartBscWebSocketListener() {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener("[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
runWsLogListener("[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func StartEthereumWebSocketListener() {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener("[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
runWsLogListener("[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runEvmWsLogListener(logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
func runWsLogListener(logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
||||||
const (
|
const (
|
||||||
minBackoff = 2 * time.Second
|
minBackoff = 2 * time.Second
|
||||||
maxBackoff = 60 * time.Second
|
maxBackoff = 60 * time.Second
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func StartPlasmaWebSocketListener() {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener("[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
runWsLogListener("[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func StartPolygonWebSocketListener() {
|
|||||||
storePolygonRecipientsFromWallets(w)
|
storePolygonRecipientsFromWallets(w)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
wsURL := "wss://polygon-bor-ws.publicnode.com"
|
wsURL := "wss://polygon-bor-rpc.publicnode.com"
|
||||||
query := ethereum.FilterQuery{
|
query := ethereum.FilterQuery{
|
||||||
Addresses: []common.Address{
|
Addresses: []common.Address{
|
||||||
polygonUsdtContract,
|
polygonUsdtContract,
|
||||||
@@ -60,7 +60,7 @@ func StartPolygonWebSocketListener() {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener("[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
runWsLogListener("[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-26
@@ -1,39 +1,116 @@
|
|||||||
package task
|
package task
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"context"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tron "github.com/assimon/luuu/crypto"
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/assimon/luuu/model/data"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/assimon/luuu/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/assimon/luuu/util/log"
|
||||||
|
"github.com/ethereum/go-ethereum"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListenTrc20Job struct{}
|
const tronUsdtContractBase58 = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||||
|
|
||||||
var gListenTrc20JobLock sync.Mutex
|
// TronGrid 的 JSON-RPC 使用 20-byte hex 地址(去掉 Tron 主网 0x41 前缀)。
|
||||||
|
var tronUsdtTrc20Contract = common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
|
||||||
|
|
||||||
func (r ListenTrc20Job) Run() {
|
type tronRecipientSnapshot struct {
|
||||||
gListenTrc20JobLock.Lock()
|
addrs map[string]struct{}
|
||||||
defer gListenTrc20JobLock.Unlock()
|
}
|
||||||
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
|
var tronWatchedRecipients atomic.Pointer[tronRecipientSnapshot]
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
|
func StartTronTrc20WebSocketListener() {
|
||||||
return
|
log.Sugar.Infof("[TRON-WS] listening contract base58=%s hex=%s", tronUsdtContractBase58, tronUsdtTrc20Contract.Hex())
|
||||||
}
|
|
||||||
if len(walletAddress) <= 0 {
|
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
|
||||||
log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses")
|
if err != nil {
|
||||||
return
|
log.Sugar.Errorf("[TRON-WS] Failed to get wallet addresses: %v", err)
|
||||||
}
|
return
|
||||||
log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress))
|
}
|
||||||
var wg sync.WaitGroup
|
storeTronRecipientsFromWallets(wallets)
|
||||||
for _, address := range walletAddress {
|
go func() {
|
||||||
log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address)
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
wg.Add(1)
|
for range ticker.C {
|
||||||
go service.Trc20CallBack(address.Address, &wg)
|
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
|
||||||
}
|
if err != nil {
|
||||||
wg.Wait()
|
log.Sugar.Warnf("[TRON-WS] refresh wallet addresses: %v", err)
|
||||||
log.Sugar.Debug("[ListenTrc20Job] Job completed")
|
continue
|
||||||
|
}
|
||||||
|
storeTronRecipientsFromWallets(w)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wsURL := "wss://api.trongrid.io/jsonrpc"
|
||||||
|
query := ethereum.FilterQuery{
|
||||||
|
Addresses: []common.Address{tronUsdtTrc20Contract},
|
||||||
|
Topics: [][]common.Hash{},
|
||||||
|
}
|
||||||
|
runWsLogListener("[TRON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
|
if len(vLog.Topics) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if vLog.Topics[0].String() != transferEventHash.String() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toAddr := tronHexToBase58(common.HexToAddress(vLog.Topics[2].Hex()))
|
||||||
|
//if !isWatchedTronRecipient(toAddr) {
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
|
||||||
|
amount := new(big.Int).SetBytes(vLog.Data)
|
||||||
|
if amount.Sign() <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Sugar.Infof("[TRON-WS] Detected TRC20 transfer to %s amount=%s tx=%s", toAddr, amount.String(), vLog.TxHash.Hex())
|
||||||
|
var blockTsMs int64
|
||||||
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
|
blockTsMs = time.Now().UnixMilli()
|
||||||
|
} else {
|
||||||
|
blockTsMs = int64(header.Time) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
service.TryProcessTronTRC20Transfer(toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeTronRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
||||||
|
m := make(map[string]struct{})
|
||||||
|
for _, w := range wallets {
|
||||||
|
addr := strings.TrimSpace(w.Address)
|
||||||
|
if addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[addr] = struct{}{}
|
||||||
|
}
|
||||||
|
tronWatchedRecipients.Store(&tronRecipientSnapshot{addrs: m})
|
||||||
|
return len(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWatchedTronRecipient(to string) bool {
|
||||||
|
snap := tronWatchedRecipients.Load()
|
||||||
|
if snap == nil || len(snap.addrs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := snap.addrs[strings.TrimSpace(to)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronHexToBase58(addr common.Address) string {
|
||||||
|
raw := append([]byte{tron.PrefixMainnet}, addr.Bytes()...)
|
||||||
|
return tron.EncodeCheck(raw)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user