Hardware wallet window: invoices paid from PLEDGER, checksummed addresses, DEX signing, block confirmation animation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZHU MINCHAOandClaude Opus 5.5 committed 2026-11-03 22:29:42 +08:00
1 parent 5b7013f240
commit add61a6b5e
16 files changed
+768 -63

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
## 未发布 ## 未发布
### 第五阶段:硬件钱包(进行中)
- 硬件钱包 PLEDGER:终端顶栏"钱包"打开的独立窗口。像素画的圆角石墨机身、黑色屏幕边框、浅色墨水屏,下沿两个物理按键(左 × 拒绝 / 返回,右 ✓ 下一步,签名要按住 1.2 秒,显示游戏自带的按住圆环)。屏幕页:主钱包(地址、余额、合计、污点)、发送、核对、交易、已确认。
- 链上地址改成 "0x" + 6 位 + 2 位校验位(FNV-1a 高 8 位)。输错一位几乎都被拦下("地址校验失败"),约 1/256 碰巧合法;合法但没人用的地址,核对页提示"没有交易记录",签名后币转不回来。旧存档的商家和收款地址载入时换成新格式。
- 付款改成账单:黑市现货、商家订单、上架费、改价费、批量上架、混币器、Ponero 兑换在终端开账单(收款地址、币种、金额),"打开钱包转账"在钱包里预填币种和金额,地址要手输。付给有账单的地址才执行;币种不对、金额不足在发送页拦下;多付的部分不退;对有往来但没有账单的地址拒绝发送。账单不随切换终端页面作废,可以手动作废。
- DEX 兑换改成合约调用:在终端点买入 / 卖出后钱包弹出签名请求,按住 ✓ 签名后才成交,× 拒绝。Poinbase 是托管账户,仍直接成交。
- 签名后的动画:签名中 → 已广播(内存池)→ 打包进区块 #N → 确认 1/3、2/3、3/3,约 5 秒,可跳过;窗口关着也会走完。区块高度存档。
- 游戏内验证:DEX 10 CRT 兑 PSC(签名 → 确认 → CRT 4000 → 3990);黑市现货手枪:输错末位被校验位拦下,改对后付 156 CRT,库存 3 → 2,货进储藏区,污点按比例减少;向 0x12345699 转 1 CRT,提示转不回来,余额减 1;Ponero 兑换 10 PSC 得 18.8 Ponero,当天额度用完。没有在游戏里验证:混币器账单(测试存档没解锁混币器)、商家订单、上架 / 改价费账单(代码路径和现货相同)。
### 第四阶段:Web3 黑市与 Ponero(进行中) ### 第四阶段:Web3 黑市与 Ponero(进行中)
- 人脉网络新增"Web3 黑市"(前置:Web3 治理社区,1500):黑市派系在链上的实体;放在终端 Web3 页的"黑市"子页里,需要黑市声望 ≥ 10,镇压期间下线。 - 人脉网络新增"Web3 黑市"(前置:Web3 治理社区,1500):黑市派系在链上的实体;放在终端 Web3 页的"黑市"子页里,需要黑市声望 ≥ 10,镇压期间下线。
+53
View File
@@ -41,6 +41,9 @@ C = {
'n': (0x1E, 0x1B, 0x25), # 近黑 'n': (0x1E, 0x1B, 0x25), # 近黑
'x': (0x4A, 0x4E, 0x58), # 石墨亮边 'x': (0x4A, 0x4E, 0x58), # 石墨亮边
'z': (0x2D, 0x2F, 0x38), # 石墨暗 'z': (0x2D, 0x2F, 0x38), # 石墨暗
'P': (0xD8, 0xDA, 0xD0), # 墨水屏
'K': (0x3C, 0x3F, 0x48), # 石墨机身
'E': (0x5A, 0x5E, 0x6A), # 机身受光边
} }
@@ -617,7 +620,57 @@ for _tier in range(4):
SPRITES['gpu_probidia_f_t%d' % _tier] = (lambda t=_tier: gpu_front('G', 'g2', tier=t)) SPRITES['gpu_probidia_f_t%d' % _tier] = (lambda t=_tier: gpu_front('G', 'g2', tier=t))
SPRITES['gpu_pmd_f_t%d' % _tier] = (lambda t=_tier: gpu_front('R', 'r2', tier=t)) SPRITES['gpu_pmd_f_t%d' % _tier] = (lambda t=_tier: gpu_front('R', 'r2', tier=t))
def hw_button(symbol):
"""硬件钱包的物理按键(24×24):石墨外圈、左上受光的金属环,中间凹下去的按键面,刻着 ✓ 或 ✕。"""
c = Canvas(24, 24)
for y in range(24):
for x in range(24):
d = ((x - 11.5) ** 2 + (y - 11.5) ** 2) ** 0.5
if d <= 11.5:
c.put(x, y, 'x' if x + y < 23 else 'z') # 外圈:左上亮、右下暗
if d <= 9.5:
c.put(x, y, 'k' if x + y < 23 else 'n') # 凹槽:反过来,左上暗、右下亮一点
if d <= 8.5:
c.put(x, y, 'z') # 按键面
mark = 'G' if symbol == 'ok' else 'R'
if symbol == 'ok':
pts = [(7, 12), (8, 13), (9, 14), (10, 15), (11, 14), (12, 13), (13, 12), (14, 11), (15, 10), (16, 9)]
else:
pts = [(8 + k, 8 + k) for k in range(8)] + [(15 - k, 8 + k) for k in range(8)]
for (x, y) in pts:
c.put(x, y, mark); c.put(x, y + 1, mark)
outline(c)
return c
def rounded(size, r, fill, light=None, dark=None):
"""圆角矩形(九宫格用):圆角半径 r,外轮廓 O;可选左上一圈受光、右下一圈背光。"""
c = Canvas(size, size)
def inside(x, y, rr):
cx = min(max(x, rr), size - 1 - rr); cy = min(max(y, rr), size - 1 - rr)
return (x - cx) ** 2 + (y - cy) ** 2 <= rr * rr + rr * 0.6
for y in range(size):
for x in range(size):
if inside(x, y, r):
c.put(x, y, fill)
if light or dark:
for y in range(size):
for x in range(size):
if c.px[y][x] is None:
continue
edge = any(not (0 <= x + dx < size and 0 <= y + dy < size) or c.px[y + dy][x + dx] is None for dx, dy in ((2, 0), (-2, 0), (0, 2), (0, -2)))
if edge:
c.put(x, y, light if x + y < size - 1 else dark)
outline(c)
return c
UI_ICONS = {'ui_bank': ui_bank, 'ui_poinbase': ui_poinbase, 'ui_web3': ui_web3} UI_ICONS = {'ui_bank': ui_bank, 'ui_poinbase': ui_poinbase, 'ui_web3': ui_web3}
UI_ICONS['ui_hw_shell'] = lambda: rounded(32, 7, 'K', 'E', 'z')
UI_ICONS['ui_hw_bezel'] = lambda: rounded(16, 4, 'n')
UI_ICONS['ui_hw_paper'] = lambda: rounded(12, 2, 'P')
UI_ICONS['ui_hw_ok'] = lambda: hw_button('ok')
UI_ICONS['ui_hw_no'] = lambda: hw_button('no')
# 叠加图:和底图同尺寸,位置 (0,0) 对齐,只画变化的像素,不参与判格 # 叠加图:和底图同尺寸,位置 (0,0) 对齐,只画变化的像素,不参与判格
OVERLAYS = { OVERLAYS = {
+1 -1
View File
@@ -198,7 +198,7 @@ namespace Decentralized.Domain
double hi = t == MerchantType.Elite ? Tuning.BmFactorEliteMax : t == MerchantType.GpuNew ? Tuning.BmFactorNewMax : t == MerchantType.GpuUsed ? Tuning.BmFactorUsedMax : t == MerchantType.Bulk ? Tuning.BmFactorBulkMax : Tuning.BmFactorMax; double hi = t == MerchantType.Elite ? Tuning.BmFactorEliteMax : t == MerchantType.GpuNew ? Tuning.BmFactorNewMax : t == MerchantType.GpuUsed ? Tuning.BmFactorUsedMax : t == MerchantType.Bulk ? Tuning.BmFactorBulkMax : Tuning.BmFactorMax;
var s = new BmSeller var s = new BmSeller
{ {
Name = "0x" + rng.NextUInt().ToString("x8").Substring(0, 6), Name = ChainAddress.Make(rng.NextUInt()),
Scam = scam, Type = t, Factor = personal ? 1.0 : F(rng, lo, hi), Scam = scam, Type = t, Factor = personal ? 1.0 : F(rng, lo, hi),
Trades = fresh ? rng.Range(0, Tuning.BmFreshTradesMax) : rng.Range(Tuning.BmOldTradesMin, Tuning.BmOldTradesMax), Trades = fresh ? rng.Range(0, Tuning.BmFreshTradesMax) : rng.Range(Tuning.BmOldTradesMin, Tuning.BmOldTradesMax),
Rating = scam && !fresh ? rng.Range(Tuning.BmScamOldRatingMin, Tuning.BmScamOldRatingMax) : fresh ? rng.Range(Tuning.BmFreshRatingMin, Tuning.BmFreshRatingMax) : rng.Range(Tuning.BmOldRatingMin, Tuning.BmOldRatingMax), Rating = scam && !fresh ? rng.Range(Tuning.BmScamOldRatingMin, Tuning.BmScamOldRatingMax) : fresh ? rng.Range(Tuning.BmFreshRatingMin, Tuning.BmFreshRatingMax) : rng.Range(Tuning.BmOldRatingMin, Tuning.BmOldRatingMax),
+45
View File
@@ -0,0 +1,45 @@
namespace Decentralized.Domain
{
/// <summary>
/// 链上地址:"0x" + 6 位随机十六进制 + 2 位校验位。手输地址时输错一位,校验位几乎总能发现(约 1/256 恰好也合法,币就转进了没人用的地址)。
/// </summary>
public static class ChainAddress
{
public const int BodyLength = 6, CheckLength = 2, Length = 2 + BodyLength + CheckLength;
public static string Make(uint r)
{
string body = (r & 0xFFFFFF).ToString("x6");
return "0x" + body + Check(body);
}
/// <summary>校验位:地址主体的 FNV-1a 散列取高 8 位(低位对单字符改动是一一对应的,永远不会碰巧合法)。</summary>
public static string Check(string body)
{
uint h = 2166136261;
foreach (char c in body) { h ^= c; h *= 16777619; }
return (h >> 24).ToString("x2");
}
public static string Normalize(string s) => (s ?? "").Trim().ToLowerInvariant();
/// <summary>格式对(0x 开头、十六进制、长度对)。</summary>
public static bool WellFormed(string s)
{
s = Normalize(s);
if (s.Length != Length || !s.StartsWith("0x")) return false;
for (int i = 2; i < s.Length; i++)
if (!(s[i] >= '0' && s[i] <= '9' || s[i] >= 'a' && s[i] <= 'f')) return false;
return true;
}
/// <summary>格式对且校验位对得上。</summary>
public static bool Valid(string s)
{
s = Normalize(s);
return WellFormed(s) && Check(s.Substring(2, BodyLength)) == s.Substring(2 + BodyLength);
}
public static bool Same(string a, string b) => Normalize(a) == Normalize(b);
}
}
+1
View File
@@ -33,6 +33,7 @@ namespace Decentralized
CoinCustomers.Unsubscribe(); CoinCustomers.Unsubscribe();
BlackDelivery.Unsubscribe(); BlackDelivery.Unsubscribe();
GpuInspectView.Remove(); GpuInspectView.Remove();
WalletDriver.Remove();
ModHook.OnGameLoadedLate -= Session.Load; ModHook.OnGameLoadedLate -= Session.Load;
ModHook.OnNewGameStarted -= OnNewGame; ModHook.OnNewGameStarted -= OnNewGame;
} }
+46 -6
View File
@@ -332,13 +332,9 @@ merchant.Luxury,Luxury
black.kind.Goods,Goods black.kind.Goods,Goods
merchant.Elite,Elite shop merchant.Elite,Elite shop
merchant.Personal,Private seller merchant.Personal,Private seller
black.pay.confirm,Confirm payment black.pay.confirm,Invoice
black.pay.item,"{0} {1} credits"
black.pay.to,"Payee address: {0}" black.pay.to,"Payee address: {0}"
black.pay.input,Type the payee address black.pay.cancel,Void
black.pay.go,Release payment
black.pay.cancel,Cancel
black.pay.bad,Address does not match; nothing paid.
pay.mix,"Mixer {0} {1}" pay.mix,"Mixer {0} {1}"
pay.swap,"Ponero desk {0} {1}" pay.swap,"Ponero desk {0} {1}"
pay.listfee," (listing fee x{0})" pay.listfee," (listing fee x{0})"
@@ -356,3 +352,47 @@ black.platform.line,"Platform spot instant delivery payee {0}"
black.orders.in,Bought black.orders.in,Bought
black.orders.out,Selling black.orders.out,Selling
black.desk.pair,"PONERO [anonymous coin] price {0}" black.desk.pair,"PONERO [anonymous coin] price {0}"
black.pay.amount,"Amount: {0} {1} (about {2} credits)"
black.pay.wallet,Pay from wallet
tab.wallet,Wallet
hw.wallet.main,Main wallet
hw.balance,"{0} {1} ≈ {2}"
hw.total,"Total ≈ {0} credits"
hw.pending.bill,"Unpaid: {0}"
hw.pending.sign,"To sign: {0}"
hw.send,Send
hw.review,Signature request
hw.to,Payee address
hw.avail,"Available {1} {0}"
hw.amount,Amount
hw.bill,"Invoice: {0} {1} {2}"
hw.fill,Fill invoice amount
hw.next,✓ Next × Back
hw.err.format,Malformed address.
hw.err.checksum,Address checksum failed.
hw.err.self,That is this wallet's own address.
hw.err.asset,"The invoice asks for {0}."
hw.err.short,"Less than the invoice: {0} {1}."
hw.err.nobill,No unpaid invoice for this payee.
hw.review.h,Review
hw.contract,Contract call
hw.review.to,Payee address
hw.review.amount,"{0} {1} (about {2} credits)"
hw.review.bill,"Invoice: {0}"
hw.review.unknown,No transaction history at this address.
hw.review.over,"Overpaying {0} {1}; not refunded."
hw.hold,Hold ✓ to sign × Reject
hw.tx.h,Transaction
hw.tx.signing,Signing…
hw.tx.mempool,Broadcast; waiting in the mempool…
hw.tx.packed,"Included in block #{0}"
hw.tx.confirm,"Block #{0} confirmation {1}/{2}"
hw.skip,Skip
hw.done.h,Confirmed
hw.done.block,"Block #{0}"
hw.done.sent,"{0} {1} → {2}"
hw.done.void,Nobody uses this address. The coins are gone.
hw.back,Back
hw.rejected,Signature rejected on the wallet.
hw.wait,Confirm on the wallet.
hw.swap,"Swap {0} {1} → about {2} {3}"
1 # Probably Decentralized localization table: English (en)
332 black.kind.Goods
333 merchant.Elite
334 merchant.Personal
335 black.pay.confirm
black.pay.item
336 black.pay.to
337 black.pay.input black.pay.cancel
black.pay.go
black.pay.cancel
black.pay.bad
338 pay.mix
339 pay.swap
340 pay.listfee
352 black.orders.in
353 black.orders.out
354 black.desk.pair
355 black.pay.amount
356 black.pay.wallet
357 tab.wallet
358 hw.wallet.main
359 hw.balance
360 hw.total
361 hw.pending.bill
362 hw.pending.sign
363 hw.send
364 hw.review
365 hw.to
366 hw.avail
367 hw.amount
368 hw.bill
369 hw.fill
370 hw.next
371 hw.err.format
372 hw.err.checksum
373 hw.err.self
374 hw.err.asset
375 hw.err.short
376 hw.err.nobill
377 hw.review.h
378 hw.contract
379 hw.review.to
380 hw.review.amount
381 hw.review.bill
382 hw.review.unknown
383 hw.review.over
384 hw.hold
385 hw.tx.h
386 hw.tx.signing
387 hw.tx.mempool
388 hw.tx.packed
389 hw.tx.confirm
390 hw.skip
391 hw.done.h
392 hw.done.block
393 hw.done.sent
394 hw.done.void
395 hw.back
396 hw.rejected
397 hw.wait
398 hw.swap
+46 -6
View File
@@ -331,13 +331,9 @@ merchant.Luxury,奢侈品
black.kind.Goods,商品 black.kind.Goods,商品
merchant.Elite,尖端店铺 merchant.Elite,尖端店铺
merchant.Personal,个人卖家 merchant.Personal,个人卖家
black.pay.confirm,确认付款 black.pay.confirm,账单
black.pay.item,"{0} {1} 信用点等值"
black.pay.to,"收款地址:{0}" black.pay.to,"收款地址:{0}"
black.pay.input,输入收款地址 black.pay.cancel,作废
black.pay.go,放款
black.pay.cancel,取消
black.pay.bad,地址不符,没有付款。
pay.mix,"混币器 {0} {1}" pay.mix,"混币器 {0} {1}"
pay.swap,"Ponero 兑换处 {0} {1}" pay.swap,"Ponero 兑换处 {0} {1}"
pay.listfee,"(上架费 ×{0})" pay.listfee,"(上架费 ×{0})"
@@ -355,3 +351,47 @@ black.platform.line,"平台现货 立即到货 收款地址 {0}"
black.orders.in,买入 black.orders.in,买入
black.orders.out,卖出 black.orders.out,卖出
black.desk.pair,"PONERO [匿名币] 价格 {0}" black.desk.pair,"PONERO [匿名币] 价格 {0}"
black.pay.amount,"金额:{0} {1}(约 {2} 信用点)"
black.pay.wallet,打开钱包转账
tab.wallet,钱包
hw.wallet.main,主钱包
hw.balance,"{0} {1} ≈ {2}"
hw.total,"合计 ≈ {0} 信用点"
hw.pending.bill,"待付:{0}"
hw.pending.sign,"待签名:{0}"
hw.send,发送
hw.review,签名请求
hw.to,收款地址
hw.avail,"可用 {1} {0}"
hw.amount,金额
hw.bill,"账单:{0} {1} {2}"
hw.fill,填入账单金额
hw.next,✓ 下一步 × 返回
hw.err.format,地址格式不对。
hw.err.checksum,地址校验失败。
hw.err.self,这是本钱包的地址。
hw.err.asset,"账单要的是 {0}。"
hw.err.short,"金额不足账单:{0} {1}。"
hw.err.nobill,对方没有待付账单。
hw.review.h,核对
hw.contract,合约调用
hw.review.to,收款地址
hw.review.amount,"{0} {1}(约 {2} 信用点)"
hw.review.bill,"账单:{0}"
hw.review.unknown,这个地址没有交易记录。
hw.review.over,"多付 {0} {1},不退。"
hw.hold,按住 ✓ 签名 × 拒绝
hw.tx.h,交易
hw.tx.signing,签名中…
hw.tx.mempool,已广播,内存池等待打包…
hw.tx.packed,"已打包,区块 #{0}"
hw.tx.confirm,"区块 #{0} 确认 {1}/{2}"
hw.skip,跳过
hw.done.h,已确认
hw.done.block,"区块 #{0}"
hw.done.sent,"{0} {1} → {2}"
hw.done.void,这个地址没人用,币转不回来了。
hw.back,返回
hw.rejected,已在钱包上拒绝签名。
hw.wait,请在钱包上确认。
hw.swap,"兑换 {0} {1} → 约 {2} {3}"
1 # Probably Decentralized 本地化表:简体中文(zh)
331 black.kind.Goods
332 merchant.Elite
333 merchant.Personal
334 black.pay.confirm
black.pay.item
335 black.pay.to
336 black.pay.input black.pay.cancel
black.pay.go
black.pay.cancel
black.pay.bad
337 pay.mix
338 pay.swap
339 pay.listfee
351 black.orders.in
352 black.orders.out
353 black.desk.pair
354 black.pay.amount
355 black.pay.wallet
356 tab.wallet
357 hw.wallet.main
358 hw.balance
359 hw.total
360 hw.pending.bill
361 hw.pending.sign
362 hw.send
363 hw.review
364 hw.to
365 hw.avail
366 hw.amount
367 hw.bill
368 hw.fill
369 hw.next
370 hw.err.format
371 hw.err.checksum
372 hw.err.self
373 hw.err.asset
374 hw.err.short
375 hw.err.nobill
376 hw.review.h
377 hw.contract
378 hw.review.to
379 hw.review.amount
380 hw.review.bill
381 hw.review.unknown
382 hw.review.over
383 hw.hold
384 hw.tx.h
385 hw.tx.signing
386 hw.tx.mempool
387 hw.tx.packed
388 hw.tx.confirm
389 hw.skip
390 hw.done.h
391 hw.done.block
392 hw.done.sent
393 hw.done.void
394 hw.back
395 hw.rejected
396 hw.wait
397 hw.swap
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

+5 -1
View File
@@ -35,9 +35,11 @@ namespace Decentralized
public string Address(string key) public string Address(string key)
{ {
if (!Addresses.TryGetValue(key, out var a)) { a = "0x" + Rng.NextUInt().ToString("x8").Substring(0, 6); Addresses[key] = a; } if (!Addresses.TryGetValue(key, out var a) || !ChainAddress.Valid(a)) { a = ChainAddress.Make(Rng.NextUInt()); Addresses[key] = a; }
return a; return a;
} }
/// <summary>链上的区块高度:每签一笔交易往上走几块。</summary>
public long Block = 1204331;
/// <summary>当天用币付款的成交额(信用点),夜间清零。</summary> /// <summary>当天用币付款的成交额(信用点),夜间清零。</summary>
public double CoinSalesToday; public double CoinSalesToday;
} }
@@ -64,6 +66,8 @@ namespace Decentralized
catch (Exception ex) { Log.Error($"读取存档数据失败,重新开始:{ex.Message}"); } catch (Exception ex) { Log.Error($"读取存档数据失败,重新开始:{ex.Message}"); }
if (Data == null) Data = new SaveData(); if (Data == null) Data = new SaveData();
if (Data.Market == null) Data.Market = Market.CreateDefault(StoreStation.GetDayCounter()); if (Data.Market == null) Data.Market = Market.CreateDefault(StoreStation.GetDayCounter());
// 旧存档里的商家地址没有校验位:换成合法地址
foreach (var s in Data.Black.Sellers) if (!ChainAddress.Valid(s.Name)) s.Name = ChainAddress.Make(Data.Rng.NextUInt());
Log.Info($"状态已载入(第 {Data.Day} 天,PSC {Data.Market.Price:0.00})"); Log.Info($"状态已载入(第 {Data.Day} 天,PSC {Data.Market.Price:0.00})");
} }
+79 -49
View File
@@ -16,12 +16,9 @@ namespace Decentralized
private enum Page { Home, Bank, Poinbase, Web3 } private enum Page { Home, Bank, Poinbase, Web3 }
private static Page _page = Page.Home; private static Page _page = Page.Home;
private sealed class PendingPay { public string Address, Summary; public int Total; public Action Execute; }
private static PendingPay _pay;
private sealed class EditPrice { public GameItem Item; public BmSale Sale; public string Id; public long Ref; } private sealed class EditPrice { public GameItem Item; public BmSale Sale; public string Id; public long Ref; }
private static EditPrice _edit; private static EditPrice _edit;
private static string _editPrice = ""; private static string _editPrice = "";
private static string _addr = "";
private static string _amount = ""; private static string _amount = "";
private static string _message; private static string _message;
@@ -36,8 +33,9 @@ namespace Decentralized
{ {
var page = p; var page = p;
string name = L.T("tab." + p.ToString().ToLowerInvariant()); string name = L.T("tab." + p.ToString().ToLowerInvariant());
b.AddButton(page == _page ? $"[{name}]" : name, () => { _page = page; _message = null; _pay = null; _edit = null; Open(); }); b.AddButton(page == _page ? $"[{name}]" : name, () => { _page = page; _message = null; _edit = null; Open(); });
} }
b.AddButton(L.T("tab.wallet"), WalletWindow.Open);
b.End(); b.End();
b.AddSeparator(); b.AddSeparator();
b.BeginScroll(540); b.BeginScroll(540);
@@ -54,7 +52,14 @@ namespace Decentralized
b.Show(); b.Show();
} }
private static void Go(Page p) { _page = p; _message = null; _pay = null; _edit = null; Open(); } private static void Go(Page p) { _page = p; _message = null; _edit = null; Open(); }
/// <summary>钱包那边交易落地后刷新终端(开着的话),带上结果消息。</summary>
public static void Refresh(string message)
{
if (message != null) _message = message;
if (CustomUIManager.Instance != null && CustomUIManager.Instance.IsOpen(Id)) Open();
}
private static void Entry(CustomUIBuilder b, string icon, string name, string line1, string line2, Page target) private static void Entry(CustomUIBuilder b, string icon, string name, string line1, string line2, Page target)
{ {
@@ -241,7 +246,7 @@ namespace Decentralized
private static void Web3Page(CustomUIBuilder b) private static void Web3Page(CustomUIBuilder b)
{ {
var w = Session.Data.Hot; var w = Session.Data.Hot;
if (_pay != null) { PayPage(b); return; } if (WalletWindow.Pending != null && !WalletWindow.Pending.Contract) { PayPage(b); return; }
{ {
if (_web3Tab == Web3Tab.Mixer && !Unlock.MixerUnlocked) _web3Tab = Web3Tab.Trade; if (_web3Tab == Web3Tab.Mixer && !Unlock.MixerUnlocked) _web3Tab = Web3Tab.Trade;
if (_web3Tab == Web3Tab.Black && !Unlock.BlackUnlocked) _web3Tab = Web3Tab.Trade; if (_web3Tab == Web3Tab.Black && !Unlock.BlackUnlocked) _web3Tab = Web3Tab.Trade;
@@ -251,7 +256,7 @@ namespace Decentralized
if ((t == Web3Tab.Mixer && !Unlock.MixerUnlocked) || (t == Web3Tab.Black && !Unlock.BlackUnlocked)) continue; if ((t == Web3Tab.Mixer && !Unlock.MixerUnlocked) || (t == Web3Tab.Black && !Unlock.BlackUnlocked)) continue;
var tab = t; var tab = t;
string name = L.T("web3.sub." + t.ToString().ToLowerInvariant()); string name = L.T("web3.sub." + t.ToString().ToLowerInvariant());
b.AddButton(tab == _web3Tab ? $"[{name}]" : name, () => { _web3Tab = tab; _message = null; _pay = null; _edit = null; Open(); }); b.AddButton(tab == _web3Tab ? $"[{name}]" : name, () => { _web3Tab = tab; _message = null; _edit = null; Open(); });
} }
b.End(); b.End();
if (_web3Tab == Web3Tab.Mining) { ReportPage(b); return; } if (_web3Tab == Web3Tab.Mining) { ReportPage(b); return; }
@@ -312,7 +317,7 @@ namespace Decentralized
{ {
var tab = t; var tab = t;
string name = L.T("black.sub." + t.ToString().ToLowerInvariant()); string name = L.T("black.sub." + t.ToString().ToLowerInvariant());
b.AddButton(tab == _blackTab ? $"[{name}]" : name, () => { _blackTab = tab; _message = null; _pay = null; _edit = null; Open(); }); b.AddButton(tab == _blackTab ? $"[{name}]" : name, () => { _blackTab = tab; _message = null; _edit = null; Open(); });
} }
b.End(); b.End();
if (d.Black.Closed(day)) { b.AddLabel(L.T("black.rugged", d.Black.ClosedUntil - day + 1)); return; } if (d.Black.Closed(day)) { b.AddLabel(L.T("black.rugged", d.Black.ClosedUntil - day + 1)); return; }
@@ -384,7 +389,7 @@ namespace Decentralized
GoodHead(c, g, L.T("black.stock", offer.Qty)); GoodHead(c, g, L.T("black.stock", offer.Qty));
c.AddLabel(L.T("black.price", offer.Price * count) + (count > 1 ? " ×" + count : "")); c.AddLabel(L.T("black.price", offer.Price * count) + (count > 1 ? " ×" + count : ""));
c.AddFlexibleSpace(); c.AddFlexibleSpace();
c.AddButton(L.T("black.buy"), () => BeginPay(d.Address("platform"), ItemName(offer.Item) + (count > 1 ? " ×" + count : ""), offer.Price * count, () => BuyOffer(offer, count))); c.AddButton(L.T("black.buy"), () => { string asset = _payAsset; BeginPay(d.Address("platform"), ItemName(offer.Item) + (count > 1 ? " ×" + count : ""), asset, offer.Price * count, () => BuyOffer(offer, count, asset)); });
}); });
} }
@@ -404,7 +409,11 @@ namespace Decentralized
} }
b.End(); b.End();
b.AddLabel(L.T("mixer.balance", _payAsset, Fmt.Num(d.Hot.Avail(_payAsset))) + " " + L.T("black.sell.rating", d.Black.Rating) + " " + L.T("black.sell.slots", d.Black.ActiveListings, Tuning.BmMaxListings)); b.AddLabel(L.T("mixer.balance", _payAsset, Fmt.Num(d.Hot.Avail(_payAsset))) + " " + L.T("black.sell.rating", d.Black.Rating) + " " + L.T("black.sell.slots", d.Black.ActiveListings, Tuning.BmMaxListings));
b.AddButton(L.T("black.sell.all"), () => BeginPay(d.Address("platform"), L.T("black.sell.all") + L.T("pay.listfee", _sellMult.ToString("0.0", CultureInfo.InvariantCulture)), BlackDelivery.Sellable().Sum(it => BlackMarket.ListFee(BlackMarket.ListPriceOf(BlackDelivery.RefValue(it), _sellMult))), ListAll)); b.AddButton(L.T("black.sell.all"), () =>
{
string asset = _payAsset; double mult = _sellMult;
BeginPay(d.Address("platform"), L.T("black.sell.all") + L.T("pay.listfee", mult.ToString("0.0", CultureInfo.InvariantCulture)), asset, BlackDelivery.Sellable().Sum(it => BlackMarket.ListFee(BlackMarket.ListPriceOf(BlackDelivery.RefValue(it), mult))), () => ListAll(asset, mult));
});
b.AddSeparator(); b.AddSeparator();
var items = BlackDelivery.Sellable(); var items = BlackDelivery.Sellable();
if (items.Count == 0) { b.AddLabel(L.T("black.sell.none")); return; } if (items.Count == 0) { b.AddLabel(L.T("black.sell.none")); return; }
@@ -428,7 +437,7 @@ namespace Decentralized
private static readonly System.Collections.Generic.Dictionary<BmGood, GameItem> SellCards = new System.Collections.Generic.Dictionary<BmGood, GameItem>(); private static readonly System.Collections.Generic.Dictionary<BmGood, GameItem> SellCards = new System.Collections.Generic.Dictionary<BmGood, GameItem>();
private static void ListAll() private static void ListAll(string asset, double mult)
{ {
var d = Session.Data; var d = Session.Data;
int n = 0; int n = 0;
@@ -437,7 +446,7 @@ namespace Decentralized
string id = BlackDelivery.SellId(it); string id = BlackDelivery.SellId(it);
if (id == null) continue; if (id == null) continue;
var u = Items.IsGpu(it) ? Items.ToUnit(it) : null; var u = Items.IsGpu(it) ? Items.ToUnit(it) : null;
var s = d.Black.List(id, u?.Model, u?.FakeOf, u?.Health ?? 0, BlackDelivery.RefValue(it), _sellMult, d.Hot, _payAsset, StoreStation.GetDayCounter(), Session.Price); var s = d.Black.List(id, u?.Model, u?.FakeOf, u?.Health ?? 0, BlackDelivery.RefValue(it), mult, d.Hot, asset, StoreStation.GetDayCounter(), Session.Price);
if (s == null) break; if (s == null) break;
it.Destroy(); it.Destroy();
n++; n++;
@@ -447,13 +456,13 @@ namespace Decentralized
Open(); Open();
} }
private static void ListItem(GameItem it, double mult) private static void ListItem(GameItem it, double mult, string asset)
{ {
var d = Session.Data; var d = Session.Data;
string id = BlackDelivery.SellId(it); string id = BlackDelivery.SellId(it);
if (id == null) { Open(); return; } if (id == null) { Open(); return; }
var u = Items.IsGpu(it) ? Items.ToUnit(it) : null; var u = Items.IsGpu(it) ? Items.ToUnit(it) : null;
var s = d.Black.List(id, u?.Model, u?.FakeOf, u?.Health ?? 0, BlackDelivery.RefValue(it), mult, d.Hot, _payAsset, StoreStation.GetDayCounter(), Session.Price); var s = d.Black.List(id, u?.Model, u?.FakeOf, u?.Health ?? 0, BlackDelivery.RefValue(it), mult, d.Hot, asset, StoreStation.GetDayCounter(), Session.Price);
if (s == null) { _message = d.Black.ActiveListings >= Tuning.BmMaxListings ? L.T("black.sell.full", Tuning.BmMaxListings) : L.T("msg.balance"); Open(); return; } if (s == null) { _message = d.Black.ActiveListings >= Tuning.BmMaxListings ? L.T("black.sell.full", Tuning.BmMaxListings) : L.T("msg.balance"); Open(); return; }
it.Destroy(); it.Destroy();
_message = L.T("black.sell.listed", ItemName(id), s.ListPrice); _message = L.T("black.sell.listed", ItemName(id), s.ListPrice);
@@ -483,7 +492,12 @@ namespace Decentralized
c.AddLabel(L.T("black.price", BlackMarket.Pay(BlackMarket.OrderTotal(listing.Price, count), _mode)) + (count > 1 ? " ×" + count : "")); c.AddLabel(L.T("black.price", BlackMarket.Pay(BlackMarket.OrderTotal(listing.Price, count), _mode)) + (count > 1 ? " ×" + count : ""));
c.AddFlexibleSpace(); c.AddFlexibleSpace();
bool ok = _mode == PayMode.Escrow || seller.Advance; bool ok = _mode == PayMode.Escrow || seller.Advance;
c.AddButton(ok ? L.T("black.order") : L.T("black.noadvance"), () => { if (ok) BeginPay(seller.Name, ItemName(listing.Item) + (count > 1 ? " ×" + count : ""), BlackMarket.Pay(BlackMarket.OrderTotal(listing.Price, count), _mode), () => PlaceOrder(listing, count)); }); c.AddButton(ok ? L.T("black.order") : L.T("black.noadvance"), () =>
{
if (!ok) return;
string asset = _payAsset; var mode = _mode;
BeginPay(seller.Name, ItemName(listing.Item) + (count > 1 ? " ×" + count : ""), asset, BlackMarket.Pay(BlackMarket.OrderTotal(listing.Price, count), mode), () => PlaceOrder(listing, count, asset, mode));
});
}); });
} }
@@ -518,36 +532,32 @@ namespace Decentralized
private static string ItemName(string id) => BlackDelivery.ItemName(id); private static string ItemName(string id) => BlackDelivery.ItemName(id);
private static void BeginPay(string address, string summary, int total, Action execute) /// <summary>开账单:收款地址、币种、金额(按信用点等值折成币),付款在硬件钱包里做。</summary>
private static void BeginPay(string address, string summary, string asset, double credits, Action execute) =>
BillFor(address, summary, asset, credits / Session.Price(asset), execute);
private static void BillFor(string address, string summary, string asset, double amount, Action execute)
{ {
_pay = new PendingPay { Address = address, Summary = summary, Total = total, Execute = execute }; WalletWindow.Pending = new WalletWindow.Bill { Address = address, Summary = summary, Asset = asset, Amount = WalletWindow.RoundUp(amount), Execute = execute };
_addr = "";
_message = null; _message = null;
Open(); Open();
} }
/// <summary>付款确认:手输收款地址才放款,输错不付。</summary> /// <summary>待付账单:收款地址和金额,去钱包里转账;也可以作废。</summary>
private static void PayPage(CustomUIBuilder b) private static void PayPage(CustomUIBuilder b)
{ {
var p = _pay; var p = WalletWindow.Pending;
b.AddHeader(L.T("black.pay.confirm")); b.AddHeader(L.T("black.pay.confirm"));
b.AddLabel(L.T("black.pay.item", p.Summary, p.Total)); b.AddLabel(p.Summary);
b.AddLabel(L.T("black.pay.to", p.Address)); b.AddLabel(L.T("black.pay.to", p.Address));
b.BeginPanel(RightW, -1f, TextAnchor.MiddleCenter, 0f, 0f); b.AddLabel(L.T("black.pay.amount", WalletWindow.Amt(p.Amount), p.Asset, Fmt.Credits(p.Amount * Session.Price(p.Asset))));
b.AddInput(L.T("black.pay.input"), _addr, s => _addr = s, "addr", s => _addr = s);
b.End();
b.BeginRow(); b.BeginRow();
b.AddButton(L.T("black.pay.go"), () => b.AddButton(L.T("black.pay.wallet"), () => WalletWindow.OpenForBill(p));
{ b.AddButton(L.T("black.pay.cancel"), () => { WalletWindow.Pending = null; _message = null; Open(); });
if (!string.Equals((_addr ?? "").Trim(), p.Address, StringComparison.OrdinalIgnoreCase)) { _message = L.T("black.pay.bad"); Open(); return; }
_pay = null;
p.Execute();
});
b.AddButton(L.T("black.pay.cancel"), () => { _pay = null; _message = null; Open(); });
b.End(); b.End();
} }
private static void BuyOffer(BmOffer o, int count) private static void BuyOffer(BmOffer o, int count, string asset)
{ {
var d = Session.Data; var d = Session.Data;
string item = o.Item, model = o.Model, fake = o.FakeOf; string item = o.Item, model = o.Model, fake = o.FakeOf;
@@ -555,7 +565,7 @@ namespace Decentralized
int bought = 0; int bought = 0;
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
if (!d.Black.Buy(o, d.Hot, _payAsset, Session.Price)) break; if (!d.Black.Buy(o, d.Hot, asset, Session.Price)) break;
Items.Give(BlackDelivery.Make(item, model, fake, health)); Items.Give(BlackDelivery.Make(item, model, fake, health));
bought++; bought++;
} }
@@ -565,10 +575,10 @@ namespace Decentralized
Open(); Open();
} }
private static void PlaceOrder(BmListing l, int count) private static void PlaceOrder(BmListing l, int count, string asset, PayMode mode)
{ {
var d = Session.Data; var d = Session.Data;
var o = d.Black.Place(l, count, _mode, d.Hot, _payAsset, StoreStation.GetDayCounter(), Session.Price); var o = d.Black.Place(l, count, mode, d.Hot, asset, StoreStation.GetDayCounter(), Session.Price);
if (o == null) { _message = L.T("msg.balance"); Open(); return; } if (o == null) { _message = L.T("msg.balance"); Open(); return; }
_message = L.T("black.ordered", ItemName(o.Item) + (o.Count > 1 ? " ×" + o.Count : ""), o.DueDay); _message = L.T("black.ordered", ItemName(o.Item) + (o.Count > 1 ? " ×" + o.Count : ""), o.DueDay);
Session.Save(); Session.Save();
@@ -613,17 +623,18 @@ namespace Decentralized
var d = Session.Data; var d = Session.Data;
int fee = e.Sale != null ? BlackMarket.RepriceFee(price) : BlackMarket.ListFee(price); int fee = e.Sale != null ? BlackMarket.RepriceFee(price) : BlackMarket.ListFee(price);
_edit = null; _edit = null;
BeginPay(d.Address("platform"), ItemName(e.Id) + L.T(e.Sale != null ? "pay.repricefee" : "pay.listfee", mult.ToString("0.00", CultureInfo.InvariantCulture)), fee, string asset = _payAsset;
() => { if (e.Sale != null) RepriceSale(e.Sale, mult); else ListItem(e.Item, mult); }); BeginPay(d.Address("platform"), ItemName(e.Id) + L.T(e.Sale != null ? "pay.repricefee" : "pay.listfee", mult.ToString("0.00", CultureInfo.InvariantCulture)), asset, fee,
() => { if (e.Sale != null) RepriceSale(e.Sale, mult, asset); else ListItem(e.Item, mult, asset); });
}); });
b.AddButton(L.T("black.edit.cancel"), () => { _edit = null; _message = null; Open(); }); b.AddButton(L.T("black.edit.cancel"), () => { _edit = null; _message = null; Open(); });
b.End(); b.End();
} }
private static void RepriceSale(BmSale s, double mult) private static void RepriceSale(BmSale s, double mult, string asset)
{ {
var d = Session.Data; var d = Session.Data;
if (!d.Black.Reprice(s, mult, d.Hot, _payAsset, StoreStation.GetDayCounter(), Session.Price)) { _message = L.T("msg.balance"); Open(); return; } if (!d.Black.Reprice(s, mult, d.Hot, asset, StoreStation.GetDayCounter(), Session.Price)) { _message = L.T("msg.balance"); Open(); return; }
_message = L.T("black.sell.repriced", ItemName(s.Item), s.ListPrice); _message = L.T("black.sell.repriced", ItemName(s.Item), s.ListPrice);
Session.Save(); Session.Save();
Open(); Open();
@@ -687,15 +698,17 @@ namespace Decentralized
void Swap() void Swap()
{ {
if (!ReadAmount(out double dv)) return; if (!ReadAmount(out double dv)) return;
string asset = _deskIn ? _deskAsset : Assets.Ponero; bool into = _deskIn; string other = _deskAsset;
BeginPay(d.Address("desk"), L.T("pay.swap", Fmt.Num(dv), asset), (int)Math.Round(dv * Session.Price(asset)), DeskSwap); string asset = into ? other : Assets.Ponero;
_web3Tab = Web3Tab.Trade;
BillFor(d.Address("desk"), L.T("pay.swap", Fmt.Num(dv), asset), asset, dv, () => DeskSwap(dv, into, other));
} }
} }
private static void DeskSwap() private static void DeskSwap(double v, bool into, string other)
{ {
if (!ReadAmount(out double v)) return;
var d = Session.Data; var d = Session.Data;
_deskIn = into; _deskAsset = other;
int day = StoreStation.GetDayCounter(); int day = StoreStation.GetDayCounter();
double got = _deskIn ? d.Black.ToPonero(d.Hot, _deskAsset, v, day, Session.Price) : d.Black.FromPonero(d.Hot, _deskAsset, v, day, Session.Price); double got = _deskIn ? d.Black.ToPonero(d.Hot, _deskAsset, v, day, Session.Price) : d.Black.FromPonero(d.Hot, _deskAsset, v, day, Session.Price);
if (got < 0) { _message = L.T("black.desk.fail"); Open(); return; } if (got < 0) { _message = L.T("black.desk.fail"); Open(); return; }
@@ -757,18 +770,22 @@ namespace Decentralized
double rate = Mixer.RoundRate(v * Session.Price(_mixAsset)); double rate = Mixer.RoundRate(v * Session.Price(_mixAsset));
r.AddLabel(L.T("mixer.preview", Fmt.Num(v * Math.Pow(1 - Tuning.MixerFee, _mixRounds)), _mixAsset, (int)Math.Round((1 - Math.Pow(1 - rate, _mixRounds)) * 100), day + _mixRounds)); r.AddLabel(L.T("mixer.preview", Fmt.Num(v * Math.Pow(1 - Tuning.MixerFee, _mixRounds)), _mixAsset, (int)Math.Round((1 - Math.Pow(1 - rate, _mixRounds)) * 100), day + _mixRounds));
} }
r.AddButton(L.T("mixer.go"), () => { if (!ReadAmount(out double mv)) return; BeginPay(d.Address("mixer"), L.T("pay.mix", Fmt.Num(mv), _mixAsset), (int)Math.Round(mv * Session.Price(_mixAsset)), MixDeposit); }); r.AddButton(L.T("mixer.go"), () =>
{
if (!ReadAmount(out double mv)) return;
string asset = _mixAsset; int rounds = _mixRounds;
BillFor(d.Address("mixer"), L.T("pay.mix", Fmt.Num(mv), asset), asset, mv, () => MixDeposit(mv, asset, rounds));
});
}); });
} }
private static void MixDeposit() private static void MixDeposit(double v, string asset, int rounds)
{ {
if (!ReadAmount(out double v)) return;
var d = Session.Data; var d = Session.Data;
int day = StoreStation.GetDayCounter(); int day = StoreStation.GetDayCounter();
if (d.Hot.Avail(_mixAsset) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; } if (d.Hot.Avail(asset) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; }
var dep = d.Mixer.Deposit(d.Hot, _mixAsset, v, _mixRounds, day, Session.Price); var dep = d.Mixer.Deposit(d.Hot, asset, v, rounds, day, Session.Price);
_message = dep == null ? L.T("mixer.min", Tuning.MixerMin) : L.T("mixer.done", Fmt.Num(v), _mixAsset, dep.ReadyDay); _message = dep == null ? L.T("mixer.min", Tuning.MixerMin) : L.T("mixer.done", Fmt.Num(v), asset, dep.ReadyDay);
Session.Save(); Session.Save();
Open(); Open();
} }
@@ -836,9 +853,22 @@ namespace Decentralized
Open(); Open();
} }
/// <summary>交易:Poinbase 是托管账户,直接成交;DEX 是合约调用,要在硬件钱包上签名。</summary>
private static void Trade(Pool p, bool buy) private static void Trade(Pool p, bool buy)
{ {
if (!ReadAmount(out double v)) return; if (!ReadAmount(out double v)) return;
var w = Session.Data.Hot;
string pay = buy ? p.Quote : p.Base, get = buy ? p.Base : p.Quote;
if (w.Avail(pay) + 1e-9 < v) { _message = L.T(frozenPay(w, pay) ? "msg.frozen" : "msg.balance"); Open(); return; }
if (p.Venue != Venue.Dex) { DoTrade(p, buy, v); return; }
double quote = buy ? v / p.Price : v * p.Price;
_message = L.T("hw.wait");
Open();
WalletWindow.OpenForBill(new WalletWindow.Bill { Contract = true, Summary = L.T("hw.swap", Fmt.Num(v), pay, Fmt.Num(quote), get), Execute = () => DoTrade(p, buy, v) });
}
private static void DoTrade(Pool p, bool buy, double v)
{
var w = Session.Data.Hot; var w = Session.Data.Hot;
string pay = buy ? p.Quote : p.Base, get = buy ? p.Base : p.Quote; string pay = buy ? p.Quote : p.Base, get = buy ? p.Base : p.Quote;
if (w.Avail(pay) + 1e-9 < v) { _message = L.T(frozenPay(w, pay) ? "msg.frozen" : "msg.balance"); Open(); return; } if (w.Avail(pay) + 1e-9 < v) { _message = L.T(frozenPay(w, pay) ? "msg.frozen" : "msg.balance"); Open(); return; }
+464
View File
@@ -0,0 +1,464 @@
using System;
using System.Globalization;
using System.Linq;
using Decentralized.Domain;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Decentralized
{
/// <summary>
/// 硬件钱包:从终端打开的独立窗口,石墨机身、墨水屏、下面两个物理按键(左 ✕ 拒绝,右 ✓ 确认,签名要按住)。
/// 链上的转账都从这里发:终端只开账单(收款地址、币种、金额),玩家在钱包里手输地址、核对、按住 ✓ 签名,
/// 然后看着交易进内存池、打包、确认。地址带校验位,输错一位几乎都会被拦下;碰巧合法的错地址没人用,币就没了。
/// DEX 兑换是合约调用,没有收款地址,只要签名。
/// </summary>
public static class WalletWindow
{
public const string Id = "pd_wallet";
private const float WinW = 452f, WinH = 624f, ScreenW = 384f, ScreenH = 470f, Bezel = 6f;
private enum Screen { Home, Send, Review, Broadcast, Done }
/// <summary>终端开出的账单:转账(有收款地址)或合约调用(DEX 兑换,没有地址)。付清后执行。</summary>
public sealed class Bill
{
public string Address, Asset, Summary;
public double Amount;
public bool Contract;
public Action Execute;
}
/// <summary>当前待付的账单(同一时间一张,新的覆盖旧的)。</summary>
public static Bill Pending;
private sealed class Tx
{
public string To, Asset;
public double Amount;
public Bill Bill;
public long Block;
public float Start;
public bool Finished;
}
private static Screen _screen = Screen.Home;
private static string _to = "", _amount = "", _asset = Assets.PSC, _note;
private static Tx _tx;
private static readonly string[] SendAssets = { Assets.PSC, Assets.CRC, Assets.CRT, Assets.CRA, Assets.Ponero };
// 出块动画各阶段的结束时刻(秒):签名、内存池、打包、三次确认
private static readonly float[] Phases = { 0.8f, 2.0f, 3.0f, 3.7f, 4.4f, 5.1f };
private static readonly Color Ink = new Color(0.12f, 0.12f, 0.14f),
Paper = new Color(0.86f, 0.87f, 0.83f), Key = new Color(0.16f, 0.16f, 0.19f), KeyText = new Color(0.93f, 0.93f, 0.90f),
Dim = new Color(0.38f, 0.39f, 0.40f), Alert = new Color(0.62f, 0.16f, 0.12f);
public static bool IsOpen => CustomUIManager.Instance != null && CustomUIManager.Instance.IsOpen(Id);
/// <summary>终端"打开钱包转账":账单的币种和金额填好,地址留给玩家手输。</summary>
public static void OpenForBill(Bill bill)
{
Pending = bill;
if (_tx != null && !_tx.Finished) { Open(); return; }
_note = null;
if (bill.Contract) _screen = Screen.Review;
else
{
_screen = Screen.Send;
_to = "";
_asset = bill.Asset;
_amount = Amt(bill.Amount);
}
Open();
}
public static void Open()
{
if (Session.Data == null) return;
WalletDriver.Ensure();
var b = CustomUIManager.Window(Id, "PLEDGER");
if (b == null) return;
b.SetSize(WinW, WinH).SetDraggable(true).SetCloseOnEscape(true).UseMachineryStyle();
b.SetPosition(new Vector2(600f, 0f));
// 屏幕外面一圈黑色边框,里面是墨水屏
b.BeginPanel(ScreenW + Bezel * 2, ScreenH + Bezel * 2, TextAnchor.MiddleCenter, Bezel + 3f, 0f);
var bezel = b.Last.image;
b.BeginPanel(ScreenW, ScreenH, TextAnchor.UpperLeft, 14f, 6f);
var screen = b.CurrentContainer;
var paper = b.Last.image;
switch (_screen)
{
case Screen.Send: SendScreen(b); break;
case Screen.Review: ReviewScreen(b); break;
case Screen.Broadcast: BroadcastScreen(b); break;
case Screen.Done: DoneScreen(b); break;
default: HomeScreen(b); break;
}
b.AddFlexibleSpace();
if (!string.IsNullOrEmpty(_note)) { b.AddLabel(_note, "note"); }
b.End();
b.End();
// 机身下沿:左 ✕,右 ✓,左右对称
b.BeginRow(0f, TextAnchor.MiddleCenter);
b.AddIconButton(Items.ModSprite("ui_hw_no"), 60f, Reject);
b.BeginPanel(ScreenW + Bezel * 2 - 120f, 60f, TextAnchor.MiddleCenter, 0f, 0f);
var plate = b.Last.image;
b.End();
b.AddIconButton(Items.ModSprite("ui_hw_ok"), 60f, Confirm);
var ok = b.Last.root;
b.End();
var win = b.Show();
Log.Guard("硬件钱包外观", () => Skin(win, screen, bezel, paper, plate, ok));
}
/// <summary>换上硬件的样子:机身石墨色、屏幕是浅色墨水屏、屏上按钮做成黑底白字的触控键。</summary>
private static void Skin(CustomUIWindow win, RectTransform screen, Image bezel, Image paper, Image plate, GameObject ok)
{
if (win?.root == null) return;
// 自己画的像素九宫格:圆角石墨机身、黑色屏幕边框、墨水屏;标题栏和分隔线透明,标题当作机身上的刻字
Slice(win.root.GetComponent<Image>(), "ui_hw_shell", 10);
Slice(bezel, "ui_hw_bezel", 5);
Slice(paper, "ui_hw_paper", 3);
foreach (var name in new[] { "header", "rule" })
{
var img = win.root.transform.Find(name)?.GetComponent<Image>();
if (img != null) img.color = new Color(0, 0, 0, 0);
}
if (win.frame?.titleText != null)
{
var t = win.frame.titleText;
t.color = new Color(0.62f, 0.64f, 0.70f); t.fontStyle = FontStyles.Bold; t.characterSpacing = 18f; t.fontSize = 16f; t.horizontalAlignment = HorizontalAlignmentOptions.Center;
}
var content = win.frame?.contentRoot?.GetComponent<VerticalLayoutGroup>();
if (content != null) content.childAlignment = TextAnchor.UpperCenter;
if (plate != null) plate.color = new Color(0, 0, 0, 0);
foreach (var t in screen.GetComponentsInChildren<TextMeshProUGUI>(true))
{
if (t.GetComponentInParent<Button>() != null || t.GetComponentInParent<TMP_InputField>() != null) continue;
t.color = t.gameObject.name.Contains("_note") ? Alert : t.gameObject.name.Contains("_dim") ? Dim : Ink;
}
foreach (var btn in screen.GetComponentsInChildren<Button>(true))
{
var img = btn.GetComponent<Image>();
if (img != null) img.color = Key;
foreach (var t in btn.GetComponentsInChildren<TextMeshProUGUI>(true)) t.color = KeyText;
}
// 签名要按住 ✓:在按键上挂一个按住计时
if (ok != null && _screen == Screen.Review && ok.GetComponent<HoldToSign>() == null) ok.AddComponent<HoldToSign>();
}
/// <summary>把本 mod 的像素图做成九宫格贴到 Image 上:边角 border 像素不拉伸,一个像素放大成 3 个界面单位。</summary>
private static void Slice(Image img, string key, int border)
{
var src = Items.ModSprite(key);
if (img == null || src == null) return;
if (!Sliced.TryGetValue(key, out var sp))
{
src.texture.filterMode = FilterMode.Point;
sp = Sprite.Create(src.texture, src.rect, new Vector2(0.5f, 0.5f), src.pixelsPerUnit, 0, SpriteMeshType.FullRect, new Vector4(border, border, border, border));
Sliced[key] = sp;
}
img.sprite = sp;
img.type = Image.Type.Sliced;
img.pixelsPerUnitMultiplier = 100f / (3f * sp.pixelsPerUnit);
img.color = Color.white;
}
private static readonly System.Collections.Generic.Dictionary<string, Sprite> Sliced = new System.Collections.Generic.Dictionary<string, Sprite>();
public static string Amt(double v) => v.ToString(v >= 100 ? "0.##" : "0.####", CultureInfo.InvariantCulture);
/// <summary>账单金额按钱包显示的精度往上取整,照着填就够付。</summary>
public static double RoundUp(double v) => v >= 100 ? Math.Ceiling(v * 100 - 1e-6) / 100 : Math.Ceiling(v * 10000 - 1e-6) / 10000;
private static Wallet W => Session.Data.Hot;
private static string Me => Session.Data.Address("me");
private static void HomeScreen(CustomUIBuilder b)
{
var w = W;
b.AddHeader(L.T("hw.wallet.main"));
b.AddLabel(Me, "addr_dim");
b.AddSeparator();
bool any = false;
foreach (var kv in w.Balances.OrderBy(k => k.Key))
{
any = true;
b.AddLabel(L.T("hw.balance", kv.Key, Amt(kv.Value), Fmt.Credits(kv.Value * Session.Price(kv.Key))));
}
if (!any) b.AddLabel(L.T("wallet.empty"));
b.AddSeparator();
b.AddLabel(L.T("hw.total", Fmt.Credits(w.Value(Session.Price))));
if (w.Taint > 0.5) b.AddLabel(L.T("wallet.taint", Fmt.Credits(w.Taint)));
if (Pending != null) b.AddLabel(Pending.Contract ? L.T("hw.pending.sign", Pending.Summary) : L.T("hw.pending.bill", Pending.Summary));
b.AddSpace(6f);
b.BeginRow();
b.AddButton(L.T("hw.send"), () => { _screen = Screen.Send; _note = null; Open(); });
if (Pending != null && Pending.Contract) b.AddButton(L.T("hw.review"), () => { _screen = Screen.Review; _note = null; Open(); });
b.End();
}
private static void SendScreen(CustomUIBuilder b)
{
b.AddHeader(L.T("hw.send"));
b.AddLabel(L.T("hw.to"));
b.AddInput("0x", _to, s => _to = s, "to", s => _to = s);
b.BeginRow();
foreach (var a in SendAssets)
{
var asset = a;
b.AddButton(asset == _asset ? $"[{asset}]" : asset, () => { _asset = asset; Open(); });
}
b.End();
b.AddLabel(L.T("hw.avail", _asset, Amt(W.Avail(_asset))), "avail_dim");
b.AddLabel(L.T("hw.amount"));
b.AddInput("0", _amount, s => _amount = s, "amount", s => _amount = s);
if (Pending != null && !Pending.Contract)
{
b.AddLabel(L.T("hw.bill", Pending.Summary, Amt(Pending.Amount), Pending.Asset), "bill_dim");
b.AddButton(L.T("hw.fill"), () => { _asset = Pending.Asset; _amount = Amt(Pending.Amount); Open(); });
}
b.AddLabel(L.T("hw.next"), "hint_dim");
}
private static bool ReadAmount(out double v) =>
double.TryParse((_amount ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out v) && v > 0;
/// <summary>从发送页进核对页之前的检查:地址格式、校验位、余额、收款方是否在等这笔钱。</summary>
private static string CheckSend()
{
string to = ChainAddress.Normalize(_to);
if (!ChainAddress.WellFormed(to)) return L.T("hw.err.format");
if (!ChainAddress.Valid(to)) return L.T("hw.err.checksum");
if (ChainAddress.Same(to, Me)) return L.T("hw.err.self");
if (!ReadAmount(out double v)) return L.T("msg.amount");
if (W.Avail(_asset) + 1e-9 < v) return L.T(W.Frozen.ContainsKey(_asset) ? "msg.frozen" : "msg.balance");
var bill = Match(to);
if (bill != null)
{
if (bill.Asset != _asset) return L.T("hw.err.asset", bill.Asset);
if (v + 1e-9 < bill.Amount) return L.T("hw.err.short", Amt(bill.Amount), bill.Asset);
}
else if (Known(to)) return L.T("hw.err.nobill");
return null;
}
private static Bill Match(string to) => Pending != null && !Pending.Contract && ChainAddress.Same(Pending.Address, to) ? Pending : null;
/// <summary>有过往来的地址(平台、商家、混币器、兑换处):没有账单时对方不收。</summary>
private static bool Known(string to)
{
var d = Session.Data;
return d.Addresses.Values.Any(a => ChainAddress.Same(a, to)) || d.Black.Sellers.Any(s => ChainAddress.Same(s.Name, to));
}
private static void ReviewScreen(CustomUIBuilder b)
{
b.AddHeader(L.T("hw.review.h"));
if (Pending != null && Pending.Contract)
{
b.AddLabel(L.T("hw.contract"));
b.AddLabel(Pending.Summary);
}
else
{
ReadAmount(out double v);
string to = ChainAddress.Normalize(_to);
b.AddLabel(L.T("hw.review.to"));
b.AddLabel("<size=26><b>" + to + "</b></size>");
b.AddLabel(L.T("hw.review.amount", Amt(v), _asset, Fmt.Credits(v * Session.Price(_asset))));
var bill = Match(to);
if (bill != null) b.AddLabel(L.T("hw.review.bill", bill.Summary));
else b.AddLabel(L.T("hw.review.unknown"), "warn_note");
if (bill != null && v > bill.Amount + 1e-6) b.AddLabel(L.T("hw.review.over", Amt(v - bill.Amount), _asset), "over_note");
}
b.AddSpace(8f);
b.AddLabel(L.T("hw.hold"), "hold_dim");
}
private static void BroadcastScreen(CustomUIBuilder b)
{
b.AddHeader(L.T("hw.tx.h"));
b.AddLabel(StatusText(), "status");
b.AddProgressBar(Progress(), "progress");
b.AddSpace(10f);
b.AddButton(L.T("hw.skip"), Finish);
}
private static void DoneScreen(CustomUIBuilder b)
{
var tx = _tx;
b.AddHeader(L.T("hw.done.h"));
if (tx != null)
{
b.AddLabel(L.T("hw.done.block", tx.Block));
if (tx.To != null) b.AddLabel(L.T("hw.done.sent", Amt(tx.Amount), tx.Asset, tx.To));
if (tx.Bill != null) b.AddLabel(tx.Bill.Summary);
else if (tx.To != null) b.AddLabel(L.T("hw.done.void"), "void_note");
}
b.AddSpace(8f);
b.AddButton(L.T("hw.back"), () => { _screen = Screen.Home; _note = null; _tx = null; Open(); });
}
private static float Elapsed => _tx == null ? 0 : Time.unscaledTime - _tx.Start;
private static float Progress() => Mathf.Clamp01(Elapsed / Phases[Phases.Length - 1]);
private static string StatusText()
{
float t = Elapsed;
if (_tx == null) return "";
if (t < Phases[0]) return L.T("hw.tx.signing");
if (t < Phases[1]) return L.T("hw.tx.mempool");
if (t < Phases[2]) return L.T("hw.tx.packed", _tx.Block);
int conf = t < Phases[3] ? 1 : t < Phases[4] ? 2 : 3;
return L.T("hw.tx.confirm", _tx.Block, conf, 3);
}
/// <summary>左键 ✕:拒绝签名 / 返回上一页。</summary>
private static void Reject()
{
_note = null;
switch (_screen)
{
case Screen.Review:
if (Pending != null && Pending.Contract) { Pending = null; _screen = Screen.Home; TerminalWindow.Refresh(L.T("hw.rejected")); }
else _screen = Screen.Send;
break;
case Screen.Send: _screen = Screen.Home; break;
case Screen.Done: _screen = Screen.Home; _tx = null; break;
default: break;
}
Open();
}
/// <summary>右键 ✓:发送页上是"下一步";核对页上要按住(见 <see cref="HoldToSign"/>),单击只提示。</summary>
private static void Confirm()
{
switch (_screen)
{
case Screen.Home: _screen = Screen.Send; _note = null; break;
case Screen.Send:
_note = CheckSend();
if (_note == null) _screen = Screen.Review;
break;
case Screen.Review: _note = L.T("hw.hold"); break;
case Screen.Done: _screen = Screen.Home; _tx = null; _note = null; break;
default: return;
}
Open();
}
/// <summary>按住 ✓ 满时间:签名并广播。</summary>
internal static void Sign()
{
if (_screen != Screen.Review) return;
var d = Session.Data;
d.Block += 1 + d.Rng.Range(0, 3);
if (Pending != null && Pending.Contract)
_tx = new Tx { Bill = Pending, Block = d.Block, Start = Time.unscaledTime };
else
{
string err = CheckSend();
if (err != null) { _note = err; _screen = Screen.Send; Open(); return; }
ReadAmount(out double v);
string to = ChainAddress.Normalize(_to);
_tx = new Tx { To = to, Asset = _asset, Amount = v, Bill = Match(to), Block = d.Block, Start = Time.unscaledTime };
}
if (_tx.Bill != null) Pending = null;
_screen = Screen.Broadcast;
_note = null;
Open();
}
/// <summary>动画走完(或点了跳过):交易落地。付账单的执行终端那边的动作;没人用的地址,币就没了。</summary>
internal static void Finish()
{
var tx = _tx;
if (tx == null || tx.Finished) return;
tx.Finished = true;
var d = Session.Data;
Log.Guard("钱包交易落地", () =>
{
if (tx.Bill != null && !tx.Bill.Contract)
{
// 多付的部分对方不退
double extra = tx.Amount - tx.Bill.Amount;
if (extra > 1e-9) Wallet.Transfer(d.Hot, new Wallet(), tx.Asset, Math.Min(extra, d.Hot.Avail(tx.Asset)), Session.Price);
}
if (tx.Bill != null) tx.Bill.Execute();
else if (tx.To != null)
{
Wallet.Transfer(d.Hot, new Wallet(), tx.Asset, Math.Min(tx.Amount, d.Hot.Avail(tx.Asset)), Session.Price);
Log.Info($"转进没人用的地址 {tx.To}:{tx.Amount} {tx.Asset}");
TerminalWindow.Refresh(null);
}
});
Session.Save();
_screen = Screen.Done;
_to = "";
if (IsOpen) Open();
}
/// <summary>每帧推进出块动画:只改状态文字和进度条,不重建窗口。</summary>
internal static void Tick()
{
if (_tx == null || _tx.Finished || _screen != Screen.Broadcast) return;
if (Elapsed >= Phases[Phases.Length - 1]) { Finish(); return; }
if (!IsOpen) return;
var m = CustomUIManager.Instance;
m.GetElement(Id, "status")?.SetText(StatusText());
m.GetElement(Id, "progress")?.SetProgress(Progress());
}
}
/// <summary>常驻的驱动:每帧推进钱包的出块动画(窗口关着也照样走完)。</summary>
public sealed class WalletDriver : MonoBehaviour
{
private static WalletDriver instance;
public static void Ensure()
{
if (instance != null) return;
var go = new GameObject("pd_wallet_driver");
DontDestroyOnLoad(go);
instance = go.AddComponent<WalletDriver>();
}
public static void Remove()
{
if (instance != null) Destroy(instance.gameObject);
instance = null;
}
private void Update() => Log.Guard("钱包动画", WalletWindow.Tick);
}
/// <summary>✓ 键上的按住计时:按满 1.2 秒签名,鼠标挪开或松手就重来。进度用游戏自带的按住圆环显示。</summary>
public sealed class HoldToSign : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IPointerExitHandler
{
private const float Seconds = 1.2f;
private float held = -1f;
public void OnPointerDown(PointerEventData e) => held = 0f;
public void OnPointerUp(PointerEventData e) => Stop();
public void OnPointerExit(PointerEventData e) => Stop();
private void Stop()
{
if (held >= 0f) CustomUIManager.Instance?.HideHoldRing();
held = -1f;
}
private void Update()
{
if (held < 0f) return;
held += Time.unscaledDeltaTime;
CustomUIManager.Instance?.ShowHoldRing(held / Seconds);
if (held >= Seconds) { Stop(); Log.Guard("签名", WalletWindow.Sign); }
}
private void OnDisable() => Stop();
}
}
@@ -789,6 +789,25 @@ internal static class Program
Check(personalListings > 100 && lo < 0.65 && hi > 1.05, $"个人卖家每件价格系数分散:{lo:0.00}–{hi:0.00}"); Check(personalListings > 100 && lo < 0.65 && hi > 1.05, $"个人卖家每件价格系数分散:{lo:0.00}–{hi:0.00}");
}); });
Test("链上地址校验位", () =>
{
var rng = new Rng(5);
int caught = 0, typos = 0;
for (int i = 0; i < 2000; i++)
{
string a = ChainAddress.Make(rng.NextUInt());
Check(ChainAddress.Valid(a) && ChainAddress.Valid(a.ToUpperInvariant().Replace("0X", "0x")) && ChainAddress.Valid(" " + a + " "), "生成的地址合法,大小写、空格不影响:" + a);
int pos = 2 + rng.Range(0, ChainAddress.BodyLength + ChainAddress.CheckLength - 1);
char c = a[pos];
char t = "0123456789abcdef"[(Convert.ToInt32(c.ToString(), 16) + 1 + rng.Range(0, 14)) % 16];
string typo = a.Substring(0, pos) + t + a.Substring(pos + 1);
typos++;
if (!ChainAddress.Valid(typo)) caught++;
}
Check(caught > typos * 0.98 && caught < typos, $"输错一位几乎都被校验位拦下,极少数碰巧合法:{caught}/{typos}");
Check(!ChainAddress.Valid("0x12345") && !ChainAddress.Valid("0xzzzzzzzz") && !ChainAddress.WellFormed("12345678ab"), "格式不对的地址不合法");
});
Console.WriteLine($"\n{_passed} 通过,{_failed} 失败"); Console.WriteLine($"\n{_passed} 通过,{_failed} 失败");
return _failed == 0 ? 0 : 1; return _failed == 0 ? 0 : 1;
} }