Files
probably-decentralized/src/Decentralized/WalletWindow.cs
T

526 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Decentralized.Domain;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Decentralized
{
/// <summary>
/// 硬件钱包 PLEDGER:每个钱包一台,从终端的钱包页打开,各是一个独立窗口。石墨机身、墨水屏、下面两个物理按键
/// (左 × 拒绝 / 返回,右 ✓ 下一步,签名要按住)。链上的转账都从这里发:终端只开账单(收款地址、币种、金额),
/// 玩家在钱包里手输地址、核对、按住 ✓ 签名,然后看着交易进内存池、打包、确认。地址带校验位,输错一位几乎都会被拦下;
/// 碰巧合法的错地址没人用,币就没了。DEX 兑换是合约调用,没有收款地址,只要签名。
/// 收款方:有账单的地址(执行账单)、自己的另一个钱包、Poinbase 充值地址(只收 CRC / PSC);其余地址币转进去就回不来。
/// </summary>
public static class WalletWindow
{
private const string IdPrefix = "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<Wallet> 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;
}
/// <summary>一台硬件钱包的屏幕状态。</summary>
private sealed class Device
{
public string Address;
public Screen Screen = Screen.Home;
public string To = "", Amount = "", Asset = Assets.PSC, Note;
public Tx Tx;
public string Id => IdPrefix + Address;
public Wallet Wallet => Session.Data.Own(Address);
}
private static readonly Dictionary<string, Device> Devices = new Dictionary<string, Device>();
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),
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 string NameOf(Wallet w)
{
int i = Session.Data.Wallets.IndexOf(w);
return i == 0 ? L.T("hw.wallet.main") : L.T("hw.wallet.n", i + 1);
}
private static Device Dev(Wallet w)
{
if (!Devices.TryGetValue(w.Address, out var dev)) Devices[w.Address] = dev = new Device { Address = w.Address };
return dev;
}
private static bool IsOpen(Device dev) => CustomUIManager.Instance != null && CustomUIManager.Instance.IsOpen(dev.Id);
/// <summary>读档 / 新游戏后清掉上一局的设备状态并关窗。</summary>
public static void Reset()
{
foreach (var dev in Devices.Values)
if (IsOpen(dev)) CustomUIManager.Instance.CloseWindow(dev.Id);
Devices.Clear();
Pending = null;
}
/// <summary>终端"打开钱包转账" / DEX 签名请求:在当前连接的钱包上打开。转账填好币种和金额,地址留给玩家手输。</summary>
public static void OpenForBill(Bill bill)
{
Pending = bill;
var dev = Dev(Session.Data.Hot);
if (dev.Tx != null && !dev.Tx.Finished) { Open(dev); return; }
dev.Note = null;
if (bill.Contract) dev.Screen = Screen.Review;
else
{
dev.Screen = Screen.Send;
dev.To = "";
dev.Asset = bill.Asset;
dev.Amount = Amt(bill.Amount);
}
Open(dev);
}
public static void Open(Wallet w) { if (w != null) Open(Dev(w)); }
private static void Open(Device dev)
{
if (Session.Data == null || dev.Wallet == null) return;
WalletDriver.Ensure();
var b = CustomUIManager.Window(dev.Id, "PLEDGER");
if (b == null) return;
int idx = Session.Data.Wallets.IndexOf(dev.Wallet);
b.SetSize(WinW, WinH).SetDraggable(true).SetCloseOnEscape(true).UseMachineryStyle();
b.SetPosition(new Vector2(600f + 28f * idx, -24f * idx));
// 屏幕外面一圈黑色边框,里面是墨水屏
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 (dev.Screen)
{
case Screen.Send: SendScreen(b, dev); break;
case Screen.Review: ReviewScreen(b, dev); break;
case Screen.Broadcast: BroadcastScreen(b, dev); break;
case Screen.Done: DoneScreen(b, dev); break;
default: HomeScreen(b, dev); break;
}
b.AddFlexibleSpace();
if (!string.IsNullOrEmpty(dev.Note)) b.AddLabel(dev.Note, "note");
b.End();
b.End();
// 机身下沿:左 ×,右 ✓,左右对称
b.BeginRow(0f, TextAnchor.MiddleCenter);
b.AddIconButton(Items.ModSprite("ui_hw_no"), 60f, () => Reject(dev));
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(dev));
var ok = b.Last.root;
b.End();
var win = b.Show();
Log.Guard("硬件钱包外观", () => Skin(win, screen, bezel, paper, plate, ok, dev));
}
/// <summary>换上硬件的样子:机身、屏幕边框、墨水屏是自己画的像素九宫格,屏上按钮做成黑底白字的触控键。</summary>
private static void Skin(CustomUIWindow win, RectTransform screen, Image bezel, Image paper, Image plate, GameObject ok, Device dev)
{
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 && dev.Screen == Screen.Review && ok.GetComponent<HoldToSign>() == null) ok.AddComponent<HoldToSign>().Device = dev.Address;
}
/// <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 Dictionary<string, Sprite> Sliced = new 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 void Go(Device dev, Screen s) { dev.Screen = s; dev.Note = null; Open(dev); }
private static void HomeScreen(CustomUIBuilder b, Device dev)
{
var w = dev.Wallet;
b.AddHeader(NameOf(w));
b.AddLabel(w.Address, "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"), () => Go(dev, Screen.Send));
if (Pending != null && Pending.Contract && w == Session.Data.Hot) b.AddButton(L.T("hw.review"), () => Go(dev, Screen.Review));
b.End();
}
private static void SendScreen(CustomUIBuilder b, Device dev)
{
var w = dev.Wallet;
b.AddHeader(L.T("hw.send"));
b.AddLabel(L.T("hw.to"));
b.AddInput("0x", dev.To, s => dev.To = s, "to", s => dev.To = s);
b.BeginRow();
foreach (var a in SendAssets)
{
var asset = a;
b.AddButton(asset == dev.Asset ? $"[{asset}]" : asset, () => { dev.Asset = asset; Open(dev); });
}
b.End();
b.AddLabel(L.T("hw.avail", dev.Asset, Amt(w.Avail(dev.Asset))), "avail_dim");
b.AddLabel(L.T("hw.amount"));
b.AddInput("0", dev.Amount, s => dev.Amount = s, "amount", s => dev.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"), () => { dev.Asset = Pending.Asset; dev.Amount = Amt(Pending.Amount); Open(dev); });
}
b.AddLabel(L.T("hw.next"), "hint_dim");
}
private static bool ReadAmount(Device dev, out double v) =>
double.TryParse((dev.Amount ?? "").Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out v) && v > 0;
private static bool IsPoinbase(string to) => ChainAddress.Same(to, Session.Data.Address("poinbase"));
/// <summary>从发送页进核对页之前的检查:地址格式、校验位、余额、收款方收不收。</summary>
private static string CheckSend(Device dev)
{
var w = dev.Wallet;
string to = ChainAddress.Normalize(dev.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, w.Address)) return L.T("hw.err.self");
if (!ReadAmount(dev, out double v)) return L.T("msg.amount");
if (w.Avail(dev.Asset) + 1e-9 < v) return L.T(w.Frozen.ContainsKey(dev.Asset) ? "msg.frozen" : "msg.balance");
var bill = Match(to);
if (bill != null)
{
if (bill.Asset != dev.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 (IsPoinbase(to)) { if (dev.Asset != Assets.CRC && dev.Asset != Assets.PSC) return L.T("hw.err.poinbase"); }
else if (Session.Data.Own(to) == null && 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, Device dev)
{
b.AddHeader(L.T("hw.review.h"));
if (Pending != null && Pending.Contract)
{
b.AddLabel(L.T("hw.contract"));
b.AddLabel(Pending.Summary);
}
else
{
ReadAmount(dev, out double v);
string to = ChainAddress.Normalize(dev.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), dev.Asset, Fmt.Credits(v * Session.Price(dev.Asset))));
var bill = Match(to);
var own = Session.Data.Own(to);
if (bill != null) b.AddLabel(L.T("hw.review.bill", bill.Summary));
else if (own != null) b.AddLabel(L.T("hw.review.own", NameOf(own)));
else if (IsPoinbase(to)) b.AddLabel(L.T("hw.review.poinbase"));
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), dev.Asset), "over_note");
}
b.AddSpace(8f);
b.AddLabel(L.T("hw.hold"), "hold_dim");
}
private static void BroadcastScreen(CustomUIBuilder b, Device dev)
{
b.AddHeader(L.T("hw.tx.h"));
b.AddLabel(StatusText(dev), "status");
b.AddProgressBar(Progress(dev), "progress");
b.AddSpace(10f);
b.AddButton(L.T("hw.skip"), () => Finish(dev));
}
private static void DoneScreen(CustomUIBuilder b, Device dev)
{
var tx = dev.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));
var own = tx.To != null ? Session.Data.Own(tx.To) : null;
if (tx.Bill != null) b.AddLabel(tx.Bill.Summary);
else if (own != null) b.AddLabel(L.T("hw.review.own", NameOf(own)));
else if (tx.To != null && IsPoinbase(tx.To)) b.AddLabel(L.T("hw.review.poinbase"));
else if (tx.To != null) b.AddLabel(L.T("hw.done.void"), "void_note");
}
b.AddSpace(8f);
b.AddButton(L.T("hw.back"), () => { dev.Tx = null; Go(dev, Screen.Home); });
}
private static float Elapsed(Device dev) => dev.Tx == null ? 0 : Time.unscaledTime - dev.Tx.Start;
private static float Progress(Device dev) => Mathf.Clamp01(Elapsed(dev) / Phases[Phases.Length - 1]);
private static string StatusText(Device dev)
{
float t = Elapsed(dev);
if (dev.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", dev.Tx.Block);
int conf = t < Phases[3] ? 1 : t < Phases[4] ? 2 : 3;
return L.T("hw.tx.confirm", dev.Tx.Block, conf, 3);
}
/// <summary>左键 ×:拒绝签名 / 返回上一页。</summary>
private static void Reject(Device dev)
{
dev.Note = null;
switch (dev.Screen)
{
case Screen.Review:
if (Pending != null && Pending.Contract) { Pending = null; dev.Screen = Screen.Home; TerminalWindow.Refresh(L.T("hw.rejected")); }
else dev.Screen = Screen.Send;
break;
case Screen.Send: dev.Screen = Screen.Home; break;
case Screen.Done: dev.Screen = Screen.Home; dev.Tx = null; break;
default: break;
}
Open(dev);
}
/// <summary>右键 ✓:发送页上是"下一步";核对页上要按住(见 <see cref="HoldToSign"/>),单击只提示。</summary>
private static void Confirm(Device dev)
{
switch (dev.Screen)
{
case Screen.Home: dev.Screen = Screen.Send; dev.Note = null; break;
case Screen.Send:
dev.Note = CheckSend(dev);
if (dev.Note == null) dev.Screen = Screen.Review;
break;
case Screen.Review: dev.Note = L.T("hw.hold"); break;
case Screen.Done: dev.Screen = Screen.Home; dev.Tx = null; dev.Note = null; break;
default: return;
}
Open(dev);
}
/// <summary>按住 ✓ 满时间:签名并广播。</summary>
internal static void Sign(string address)
{
if (!Devices.TryGetValue(address, out var dev) || dev.Screen != Screen.Review || dev.Wallet == null) return;
var d = Session.Data;
d.Block += 1 + d.Rng.Range(0, 3);
if (Pending != null && Pending.Contract)
dev.Tx = new Tx { Bill = Pending, Block = d.Block, Start = Time.unscaledTime };
else
{
string err = CheckSend(dev);
if (err != null) { dev.Note = err; dev.Screen = Screen.Send; Open(dev); return; }
ReadAmount(dev, out double v);
string to = ChainAddress.Normalize(dev.To);
dev.Tx = new Tx { To = to, Asset = dev.Asset, Amount = v, Bill = Match(to), Block = d.Block, Start = Time.unscaledTime };
}
if (dev.Tx.Bill != null) Pending = null;
dev.Screen = Screen.Broadcast;
dev.Note = null;
Open(dev);
}
/// <summary>动画走完(或点了跳过):交易落地。付账单的执行终端那边的动作;转给自己的钱包或 Poinbase 的按比例带上污点;没人用的地址,币就没了。</summary>
private static void Finish(Device dev)
{
var tx = dev.Tx;
if (tx == null || tx.Finished) return;
tx.Finished = true;
var d = Session.Data;
var w = dev.Wallet;
Log.Guard("钱包交易落地", () =>
{
if (w == null) return;
double amt = tx.To != null ? Math.Min(tx.Amount, w.Avail(tx.Asset)) : 0;
if (tx.Bill != null && !tx.Bill.Contract)
{
// 多付的部分对方不退
double extra = amt - tx.Bill.Amount;
if (extra > 1e-9) Wallet.Transfer(w, new Wallet(), tx.Asset, extra, Session.Price);
}
if (tx.Bill != null) { tx.Bill.Execute(w); return; }
if (tx.To == null) return;
var own = d.Own(tx.To);
Wallet target = own ?? (IsPoinbase(tx.To) ? d.Poinbase : new Wallet());
Wallet.Transfer(w, target, tx.Asset, amt, Session.Price);
if (own == null && target != d.Poinbase) Log.Info($"转进没人用的地址 {tx.To}:{amt} {tx.Asset}");
TerminalWindow.Refresh(null);
if (own != null) RefreshDevice(own);
});
Session.Save();
dev.Screen = Screen.Done;
dev.To = "";
if (IsOpen(dev)) Open(dev);
}
/// <summary>别的交易让这个钱包的余额变了:开着的话刷新主页。</summary>
public static void RefreshDevice(Wallet w)
{
if (w == null || !Devices.TryGetValue(w.Address, out var dev) || !IsOpen(dev)) return;
if (dev.Screen == Screen.Home || dev.Screen == Screen.Send) Open(dev);
}
/// <summary>每帧推进出块动画:只改状态文字和进度条,不重建窗口。</summary>
internal static void Tick()
{
foreach (var dev in Devices.Values.ToList())
{
if (dev.Tx == null || dev.Tx.Finished || dev.Screen != Screen.Broadcast) continue;
if (Elapsed(dev) >= Phases[Phases.Length - 1]) { Finish(dev); continue; }
if (!IsOpen(dev)) continue;
var m = CustomUIManager.Instance;
m.GetElement(dev.Id, "status")?.SetText(StatusText(dev));
m.GetElement(dev.Id, "progress")?.SetProgress(Progress(dev));
}
}
}
/// <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 string Device;
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(Device)); }
}
private void OnDisable() => Stop();
}
}