diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f48b41..2b1d8a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 黑市"(前置:Web3 治理社区,1500):黑市派系在链上的实体;放在终端 Web3 页的"黑市"子页里,需要黑市声望 ≥ 10,镇压期间下线。 diff --git a/art/sprites.py b/art/sprites.py index 32ff337..7ccaa09 100644 --- a/art/sprites.py +++ b/art/sprites.py @@ -41,6 +41,9 @@ C = { 'n': (0x1E, 0x1B, 0x25), # 近黑 'x': (0x4A, 0x4E, 0x58), # 石墨亮边 '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_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_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) 对齐,只画变化的像素,不参与判格 OVERLAYS = { diff --git a/src/Decentralized.Domain/BmOrders.cs b/src/Decentralized.Domain/BmOrders.cs index 131152b..b5a73bc 100644 --- a/src/Decentralized.Domain/BmOrders.cs +++ b/src/Decentralized.Domain/BmOrders.cs @@ -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; 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), 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), diff --git a/src/Decentralized.Domain/ChainAddress.cs b/src/Decentralized.Domain/ChainAddress.cs new file mode 100644 index 0000000..e55f79a --- /dev/null +++ b/src/Decentralized.Domain/ChainAddress.cs @@ -0,0 +1,45 @@ +namespace Decentralized.Domain +{ + /// + /// 链上地址:"0x" + 6 位随机十六进制 + 2 位校验位。手输地址时输错一位,校验位几乎总能发现(约 1/256 恰好也合法,币就转进了没人用的地址)。 + /// + 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); + } + + /// 校验位:地址主体的 FNV-1a 散列取高 8 位(低位对单字符改动是一一对应的,永远不会碰巧合法)。 + 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(); + + /// 格式对(0x 开头、十六进制、长度对)。 + 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; + } + + /// 格式对且校验位对得上。 + 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); + } +} diff --git a/src/Decentralized/ModEntry.cs b/src/Decentralized/ModEntry.cs index 7ac01af..9de2dae 100644 --- a/src/Decentralized/ModEntry.cs +++ b/src/Decentralized/ModEntry.cs @@ -33,6 +33,7 @@ namespace Decentralized CoinCustomers.Unsubscribe(); BlackDelivery.Unsubscribe(); GpuInspectView.Remove(); + WalletDriver.Remove(); ModHook.OnGameLoadedLate -= Session.Load; ModHook.OnNewGameStarted -= OnNewGame; } diff --git a/src/Decentralized/ModFiles/Localization/en.csv b/src/Decentralized/ModFiles/Localization/en.csv index 4ac2565..9c86618 100644 --- a/src/Decentralized/ModFiles/Localization/en.csv +++ b/src/Decentralized/ModFiles/Localization/en.csv @@ -332,13 +332,9 @@ merchant.Luxury,Luxury black.kind.Goods,Goods merchant.Elite,Elite shop merchant.Personal,Private seller -black.pay.confirm,Confirm payment -black.pay.item,"{0} {1} credits" +black.pay.confirm,Invoice black.pay.to,"Payee address: {0}" -black.pay.input,Type the payee address -black.pay.go,Release payment -black.pay.cancel,Cancel -black.pay.bad,Address does not match; nothing paid. +black.pay.cancel,Void pay.mix,"Mixer {0} {1}" pay.swap,"Ponero desk {0} {1}" 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.out,Selling 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}" diff --git a/src/Decentralized/ModFiles/Localization/zh.csv b/src/Decentralized/ModFiles/Localization/zh.csv index 83d3549..951348e 100644 --- a/src/Decentralized/ModFiles/Localization/zh.csv +++ b/src/Decentralized/ModFiles/Localization/zh.csv @@ -331,13 +331,9 @@ merchant.Luxury,奢侈品 black.kind.Goods,商品 merchant.Elite,尖端店铺 merchant.Personal,个人卖家 -black.pay.confirm,确认付款 -black.pay.item,"{0} {1} 信用点等值" +black.pay.confirm,账单 black.pay.to,"收款地址:{0}" -black.pay.input,输入收款地址 -black.pay.go,放款 -black.pay.cancel,取消 -black.pay.bad,地址不符,没有付款。 +black.pay.cancel,作废 pay.mix,"混币器 {0} {1}" pay.swap,"Ponero 兑换处 {0} {1}" pay.listfee,"(上架费 ×{0})" @@ -355,3 +351,47 @@ black.platform.line,"平台现货 立即到货 收款地址 {0}" black.orders.in,买入 black.orders.out,卖出 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}" diff --git a/src/Decentralized/ModFiles/Textures/Items/ui_hw_bezel.png b/src/Decentralized/ModFiles/Textures/Items/ui_hw_bezel.png new file mode 100644 index 0000000..1408f44 Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/ui_hw_bezel.png differ diff --git a/src/Decentralized/ModFiles/Textures/Items/ui_hw_no.png b/src/Decentralized/ModFiles/Textures/Items/ui_hw_no.png new file mode 100644 index 0000000..8e98b23 Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/ui_hw_no.png differ diff --git a/src/Decentralized/ModFiles/Textures/Items/ui_hw_ok.png b/src/Decentralized/ModFiles/Textures/Items/ui_hw_ok.png new file mode 100644 index 0000000..f187c08 Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/ui_hw_ok.png differ diff --git a/src/Decentralized/ModFiles/Textures/Items/ui_hw_paper.png b/src/Decentralized/ModFiles/Textures/Items/ui_hw_paper.png new file mode 100644 index 0000000..2ee3bc6 Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/ui_hw_paper.png differ diff --git a/src/Decentralized/ModFiles/Textures/Items/ui_hw_shell.png b/src/Decentralized/ModFiles/Textures/Items/ui_hw_shell.png new file mode 100644 index 0000000..e8bcd17 Binary files /dev/null and b/src/Decentralized/ModFiles/Textures/Items/ui_hw_shell.png differ diff --git a/src/Decentralized/Session.cs b/src/Decentralized/Session.cs index 93a93b2..5233209 100644 --- a/src/Decentralized/Session.cs +++ b/src/Decentralized/Session.cs @@ -35,9 +35,11 @@ namespace Decentralized 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; } + /// 链上的区块高度:每签一笔交易往上走几块。 + public long Block = 1204331; /// 当天用币付款的成交额(信用点),夜间清零。 public double CoinSalesToday; } @@ -64,6 +66,8 @@ namespace Decentralized catch (Exception ex) { Log.Error($"读取存档数据失败,重新开始:{ex.Message}"); } if (Data == null) Data = new SaveData(); 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})"); } diff --git a/src/Decentralized/TerminalWindow.cs b/src/Decentralized/TerminalWindow.cs index 868b862..e7cc44f 100644 --- a/src/Decentralized/TerminalWindow.cs +++ b/src/Decentralized/TerminalWindow.cs @@ -16,12 +16,9 @@ namespace Decentralized private enum Page { Home, Bank, Poinbase, Web3 } 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 static EditPrice _edit; private static string _editPrice = ""; - private static string _addr = ""; private static string _amount = ""; private static string _message; @@ -36,8 +33,9 @@ namespace Decentralized { var page = p; 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.AddSeparator(); b.BeginScroll(540); @@ -54,7 +52,14 @@ namespace Decentralized 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(); } + + /// 钱包那边交易落地后刷新终端(开着的话),带上结果消息。 + 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) { @@ -241,7 +246,7 @@ namespace Decentralized private static void Web3Page(CustomUIBuilder b) { 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.Black && !Unlock.BlackUnlocked) _web3Tab = Web3Tab.Trade; @@ -251,7 +256,7 @@ namespace Decentralized if ((t == Web3Tab.Mixer && !Unlock.MixerUnlocked) || (t == Web3Tab.Black && !Unlock.BlackUnlocked)) continue; var tab = t; 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(); if (_web3Tab == Web3Tab.Mining) { ReportPage(b); return; } @@ -312,7 +317,7 @@ namespace Decentralized { var tab = t; 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(); 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)); c.AddLabel(L.T("black.price", offer.Price * count) + (count > 1 ? " ×" + count : "")); 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.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(); var items = BlackDelivery.Sellable(); 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 SellCards = new System.Collections.Generic.Dictionary(); - private static void ListAll() + private static void ListAll(string asset, double mult) { var d = Session.Data; int n = 0; @@ -437,7 +446,7 @@ namespace Decentralized string id = BlackDelivery.SellId(it); if (id == null) continue; 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; it.Destroy(); n++; @@ -447,13 +456,13 @@ namespace Decentralized Open(); } - private static void ListItem(GameItem it, double mult) + private static void ListItem(GameItem it, double mult, string asset) { var d = Session.Data; string id = BlackDelivery.SellId(it); if (id == null) { Open(); return; } 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; } it.Destroy(); _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.AddFlexibleSpace(); 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 void BeginPay(string address, string summary, int total, Action execute) + /// 开账单:收款地址、币种、金额(按信用点等值折成币),付款在硬件钱包里做。 + 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 }; - _addr = ""; + WalletWindow.Pending = new WalletWindow.Bill { Address = address, Summary = summary, Asset = asset, Amount = WalletWindow.RoundUp(amount), Execute = execute }; _message = null; Open(); } - /// 付款确认:手输收款地址才放款,输错不付。 + /// 待付账单:收款地址和金额,去钱包里转账;也可以作废。 private static void PayPage(CustomUIBuilder b) { - var p = _pay; + var p = WalletWindow.Pending; 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.BeginPanel(RightW, -1f, TextAnchor.MiddleCenter, 0f, 0f); - b.AddInput(L.T("black.pay.input"), _addr, s => _addr = s, "addr", s => _addr = s); - b.End(); + b.AddLabel(L.T("black.pay.amount", WalletWindow.Amt(p.Amount), p.Asset, Fmt.Credits(p.Amount * Session.Price(p.Asset)))); b.BeginRow(); - b.AddButton(L.T("black.pay.go"), () => - { - 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.AddButton(L.T("black.pay.wallet"), () => WalletWindow.OpenForBill(p)); + b.AddButton(L.T("black.pay.cancel"), () => { WalletWindow.Pending = null; _message = null; Open(); }); b.End(); } - private static void BuyOffer(BmOffer o, int count) + private static void BuyOffer(BmOffer o, int count, string asset) { var d = Session.Data; string item = o.Item, model = o.Model, fake = o.FakeOf; @@ -555,7 +565,7 @@ namespace Decentralized int bought = 0; 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)); bought++; } @@ -565,10 +575,10 @@ namespace Decentralized 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 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; } _message = L.T("black.ordered", ItemName(o.Item) + (o.Count > 1 ? " ×" + o.Count : ""), o.DueDay); Session.Save(); @@ -613,17 +623,18 @@ namespace Decentralized var d = Session.Data; int fee = e.Sale != null ? BlackMarket.RepriceFee(price) : BlackMarket.ListFee(price); _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, - () => { if (e.Sale != null) RepriceSale(e.Sale, mult); else ListItem(e.Item, mult); }); + string asset = _payAsset; + 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.End(); } - private static void RepriceSale(BmSale s, double mult) + private static void RepriceSale(BmSale s, double mult, string asset) { 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); Session.Save(); Open(); @@ -687,15 +698,17 @@ namespace Decentralized void Swap() { if (!ReadAmount(out double dv)) return; - string asset = _deskIn ? _deskAsset : Assets.Ponero; - BeginPay(d.Address("desk"), L.T("pay.swap", Fmt.Num(dv), asset), (int)Math.Round(dv * Session.Price(asset)), DeskSwap); + bool into = _deskIn; string other = _deskAsset; + 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; + _deskIn = into; _deskAsset = other; 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); 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)); 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; int day = StoreStation.GetDayCounter(); - if (d.Hot.Avail(_mixAsset) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; } - var dep = d.Mixer.Deposit(d.Hot, _mixAsset, v, _mixRounds, day, Session.Price); - _message = dep == null ? L.T("mixer.min", Tuning.MixerMin) : L.T("mixer.done", Fmt.Num(v), _mixAsset, dep.ReadyDay); + if (d.Hot.Avail(asset) + 1e-9 < v) { _message = L.T("msg.balance"); Open(); return; } + 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), asset, dep.ReadyDay); Session.Save(); Open(); } @@ -836,9 +853,22 @@ namespace Decentralized Open(); } + /// 交易:Poinbase 是托管账户,直接成交;DEX 是合约调用,要在硬件钱包上签名。 private static void Trade(Pool p, bool buy) { 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; 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; } diff --git a/src/Decentralized/WalletWindow.cs b/src/Decentralized/WalletWindow.cs new file mode 100644 index 0000000..0956fdb --- /dev/null +++ b/src/Decentralized/WalletWindow.cs @@ -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 +{ + /// + /// 硬件钱包:从终端打开的独立窗口,石墨机身、墨水屏、下面两个物理按键(左 ✕ 拒绝,右 ✓ 确认,签名要按住)。 + /// 链上的转账都从这里发:终端只开账单(收款地址、币种、金额),玩家在钱包里手输地址、核对、按住 ✓ 签名, + /// 然后看着交易进内存池、打包、确认。地址带校验位,输错一位几乎都会被拦下;碰巧合法的错地址没人用,币就没了。 + /// DEX 兑换是合约调用,没有收款地址,只要签名。 + /// + 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 } + + /// 终端开出的账单:转账(有收款地址)或合约调用(DEX 兑换,没有地址)。付清后执行。 + public sealed class Bill + { + public string Address, Asset, Summary; + public double Amount; + public bool Contract; + public Action Execute; + } + + /// 当前待付的账单(同一时间一张,新的覆盖旧的)。 + 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); + + /// 终端"打开钱包转账":账单的币种和金额填好,地址留给玩家手输。 + 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)); + } + + /// 换上硬件的样子:机身石墨色、屏幕是浅色墨水屏、屏上按钮做成黑底白字的触控键。 + 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(), "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(); + 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(); + if (content != null) content.childAlignment = TextAnchor.UpperCenter; + if (plate != null) plate.color = new Color(0, 0, 0, 0); + foreach (var t in screen.GetComponentsInChildren(true)) + { + if (t.GetComponentInParent