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