@seasonkoh/webaz 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/money.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Money — 协议资金的【唯一】算术面(RFC-014 option A:整数最小单位)。
3
+ *
4
+ * 背景:此前金额用 JS float(SQLite REAL + number + Math.round(x*100)/100),除不尽的金额
5
+ * (33.33 @ 7% 等)会在【个体钱包】留浮点尘(staked=3.3299999999983)。聚合守恒安全(残差0,
6
+ * 靠 residual-absorption),但接真实 USDC(整数 base-unit)对账时会变成真裂缝。诊断见
7
+ * tests/test-money-precision-adversarial.ts。
8
+ *
9
+ * 表示:1 WAZ = 1,000,000 base-units(6 位小数,与 USDC base-units 对齐 → 链上对账 1:1)。
10
+ * 全部算术在【整数 base-units】上做(精确),只在显示层转回 2 位小数。
11
+ * 类型用 number 整数(安全到 ±2^53 ≈ 9.007e9 WAZ,模拟币足够);链上 USDC 边界 viem 已用 BigInt。
12
+ *
13
+ * 守恒利器:allocate() 用最大余数法把总额拆成整数桶且【精确求和 = 总额】——从源头消灭 dust,
14
+ * 且天然守恒(不增发/不丢)。mulRate() 单次舍入到整数单位。
15
+ *
16
+ * 用法(RFC-014 港口策略):资金热路径(engine.settle* / orders / 佣金 / dispute / skill …)
17
+ * 一律 toUnits 进 → 在 units 上 add/sub/mulRate/allocate → toUnits 出存库;显示层用 format。
18
+ * 全部港口完成后 schema 改 INTEGER(PR6),届时 toUnits/toDecimal 退化为存取边界。
19
+ */
20
+ export const MONEY_SCALE = 1_000_000; // 1 WAZ = 1e6 base-units(6 dp,USDC 对齐)
21
+ const MAX_SAFE = Number.MAX_SAFE_INTEGER; // 2^53 - 1 ≈ 9.007e15 units ≈ 9.007e9 WAZ
22
+ function assertUnits(u, ctx) {
23
+ if (!Number.isFinite(u) || !Number.isInteger(u))
24
+ throw new Error(`money[${ctx}]: 非整数 units: ${u}`);
25
+ if (Math.abs(u) > MAX_SAFE)
26
+ throw new Error(`money[${ctx}]: units 超出安全整数范围: ${u}`);
27
+ }
28
+ /** 十进制 WAZ(number/string)→ 整数 base-units(四舍五入到最近 unit)。存量 REAL 价格的入口。 */
29
+ export function toUnits(decimal) {
30
+ const n = typeof decimal === 'string' ? Number(decimal) : decimal;
31
+ if (!Number.isFinite(n))
32
+ throw new Error(`money.toUnits: 非有限数: ${decimal}`);
33
+ const u = Math.round(n * MONEY_SCALE);
34
+ assertUnits(u, 'toUnits');
35
+ return u;
36
+ }
37
+ /** 整数 base-units → 十进制 number(兼容/显示边界;返回 float,仅用于显示或与遗留 REAL 字段衔接)。 */
38
+ export function toDecimal(units) {
39
+ assertUnits(units, 'toDecimal');
40
+ return units / MONEY_SCALE;
41
+ }
42
+ /** 整数 base-units → 定点字符串(默认 2 位,WAZ 展示)。整数运算舍入,不靠 float toFixed。 */
43
+ export function format(units, dp = 2) {
44
+ assertUnits(units, 'format');
45
+ const neg = units < 0;
46
+ let abs = Math.abs(units);
47
+ const grain = Math.round(MONEY_SCALE / 10 ** dp); // dp=2 → 1e4 units 为一格
48
+ abs = Math.round(abs / grain) * grain; // 舍入到 dp 粒度(整数运算)
49
+ const whole = Math.floor(abs / MONEY_SCALE);
50
+ const fracUnits = abs - whole * MONEY_SCALE;
51
+ const fracStr = dp > 0 ? '.' + String(Math.floor(fracUnits / grain)).padStart(dp, '0') : '';
52
+ return (neg ? '-' : '') + String(whole) + fracStr;
53
+ }
54
+ export function add(a, b) { const r = a + b; assertUnits(r, 'add'); return r; }
55
+ export function sub(a, b) { const r = a - b; assertUnits(r, 'sub'); return r; }
56
+ export function sum(xs) { return xs.reduce((acc, x) => add(acc, x), 0); }
57
+ /** 单价 × 整数数量(精确)。 */
58
+ export function mulQty(unit, qty) {
59
+ if (!Number.isInteger(qty) || qty < 0)
60
+ throw new Error(`money.mulQty: qty 非非负整数: ${qty}`);
61
+ const r = unit * qty;
62
+ assertUnits(r, 'mulQty');
63
+ return r;
64
+ }
65
+ /** 金额 × 费率(rate 如 0.07);结果【单次】舍入到整数 base-units。守恒由 allocate 负责,勿用多次 mulRate 凑总额。 */
66
+ export function mulRate(amount, rate) {
67
+ if (!Number.isFinite(rate) || rate < 0)
68
+ throw new Error(`money.mulRate: rate 非法: ${rate}`);
69
+ assertUnits(amount, 'mulRate');
70
+ const r = Math.round(amount * rate);
71
+ assertUnits(r, 'mulRate');
72
+ return r;
73
+ }
74
+ /** clamp 到 [0, cap](cap 省略=只防负)。用于"不超过余额/不超过原佣金"等上限。 */
75
+ export function clamp(units, lo = 0, hi = MAX_SAFE) {
76
+ assertUnits(units, 'clamp');
77
+ return Math.max(lo, Math.min(hi, units));
78
+ }
79
+ /**
80
+ * 把 total 按整数权重拆成 N 桶,【精确求和 = total】(最大余数法)。
81
+ * - 守恒:Σ 输出 === total,无 dust、不增发/不丢。
82
+ * - 余数(floor 后差额)按小数部分从大到小逐 1 派发(确定性、稳定)。
83
+ * - 全 0 权重 → 全 0 桶(调用方决定 total 的去向,通常落 reserve)。
84
+ * 用于佣金/罚没/分润等"一笔总额分给多方"的场景,替代逐项 round 造成的不守恒。
85
+ */
86
+ export function allocate(total, weights) {
87
+ assertUnits(total, 'allocate');
88
+ if (weights.some(w => !Number.isFinite(w) || w < 0))
89
+ throw new Error(`money.allocate: 权重含负/非法`);
90
+ const W = weights.reduce((a, b) => a + b, 0);
91
+ if (W <= 0)
92
+ return weights.map(() => 0);
93
+ const raw = weights.map(w => (total * w) / W);
94
+ const floor = raw.map(Math.floor);
95
+ const used = floor.reduce((a, b) => a + b, 0);
96
+ let remainder = total - used; // 整数,0..N-1(total≥0 时)
97
+ const out = floor.slice();
98
+ const order = raw.map((r, i) => ({ i, frac: r - Math.floor(r) })).sort((a, b) => b.frac - a.frac || a.i - b.i);
99
+ for (let k = 0; remainder > 0 && k < order.length; k++, remainder--)
100
+ out[order[k].i] += 1;
101
+ // total 为负数的极端情况(理论上不该发生)兜底:把残余补到最后一桶,守恒优先
102
+ if (remainder !== 0)
103
+ out[out.length - 1] += remainder;
104
+ out.forEach((u, i) => assertUnits(u, `allocate[${i}]`));
105
+ return out;
106
+ }
@@ -0,0 +1,103 @@
1
+ const BASE = 'https://webaz.xyz';
2
+ // ACP availability 枚举(spec):in_stock | out_of_stock | pre_order | backorder | unknown
3
+ function availability(stock) {
4
+ const n = Number(stock);
5
+ if (!Number.isFinite(n))
6
+ return 'unknown';
7
+ return n > 0 ? 'in_stock' : 'out_of_stock';
8
+ }
9
+ // 绝对化图片 URL(相对路径 → https://webaz.xyz/...);已是 http(s) 的原样返回
10
+ function absolutizeImg(raw) {
11
+ if (typeof raw !== 'string' || !raw.trim())
12
+ return null;
13
+ const s = raw.trim();
14
+ if (/^https?:\/\//i.test(s))
15
+ return s;
16
+ return BASE + (s.startsWith('/') ? s : '/' + s);
17
+ }
18
+ // description 必须 plain text(spec)→ 去 HTML 标签 + 折叠空白 + 截 5000
19
+ function plainText(raw, max) {
20
+ if (typeof raw !== 'string')
21
+ return '';
22
+ return raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
23
+ }
24
+ export function buildAcpProductFeed(db, opts = {}) {
25
+ const limit = Math.min(Math.max(opts.limit ?? 2000, 1), 5000);
26
+ let rows = [];
27
+ try {
28
+ rows = db.prepare(`
29
+ SELECT p.id, p.title, p.description, p.price, p.currency, p.stock, p.category,
30
+ p.images, p.brand, p.model, p.return_days, p.product_type,
31
+ p.seller_id, u.name AS seller_name
32
+ FROM products p
33
+ LEFT JOIN users u ON u.id = p.seller_id
34
+ WHERE p.status = 'active'
35
+ ORDER BY p.created_at DESC
36
+ LIMIT ?
37
+ `).all(limit);
38
+ }
39
+ catch {
40
+ rows = [];
41
+ }
42
+ const products = rows.map((p) => {
43
+ let imgs = [];
44
+ try {
45
+ const arr = JSON.parse(p.images || '[]');
46
+ if (Array.isArray(arr))
47
+ imgs = arr.map(absolutizeImg).filter((x) => !!x);
48
+ }
49
+ catch { /* malformed → no images */ }
50
+ const returnDays = Number(p.return_days);
51
+ const hasReturn = Number.isFinite(returnDays) && returnDays > 0;
52
+ const item = {
53
+ item_id: p.id, // 稳定商品 ID(spec: max 100)
54
+ title: (p.title || '').slice(0, 150), // spec: max 150
55
+ description: plainText(p.description, 5000), // spec: plain text, max 5000
56
+ url: `${BASE}/#order-product/${p.id}`, // 商品详情页(SPA hash,200)
57
+ // price: spec = Number + ISO 4217 code。WAZ 非 ISO 4217 → 见 feed 级 _disclosures.currency
58
+ price: { amount: Number(p.price), currency: p.currency || 'WAZ' },
59
+ availability: availability(p.stock),
60
+ is_digital: p.product_type === 'digital',
61
+ seller_name: (p.seller_name || p.seller_id).slice(0, 70),
62
+ seller_url: `${BASE}/#u/${p.seller_id}`,
63
+ // 诚实门控:可发现 ≠ 可经 ACP 购买(ACP /complete 是卡+PSP,WebAZ 未接 —— RFC-015)
64
+ is_eligible_search: true,
65
+ is_eligible_checkout: false,
66
+ is_eligible_ads: false,
67
+ };
68
+ if (imgs.length) {
69
+ item.image_url = imgs[0];
70
+ if (imgs.length > 1)
71
+ item.additional_image_urls = imgs.slice(1).join(',');
72
+ }
73
+ if (p.brand)
74
+ item.brand = String(p.brand).slice(0, 70);
75
+ if (p.model)
76
+ item.mpn = String(p.model).slice(0, 70);
77
+ if (p.category)
78
+ item.product_category = String(p.category);
79
+ if (hasReturn) {
80
+ item.accepts_returns = true;
81
+ item.return_deadline_in_days = Math.round(returnDays);
82
+ }
83
+ return item;
84
+ });
85
+ return {
86
+ feed_version: 1,
87
+ generated_at: new Date().toISOString(),
88
+ source: 'WebAZ',
89
+ spec: {
90
+ based_on: 'OpenAI Agentic Commerce Protocol — product feed',
91
+ api_version_observed: '2025-09-12',
92
+ reference: 'https://developers.openai.com/commerce/specs/feed',
93
+ rfc: `${BASE}/docs/INTEGRATOR.md`,
94
+ },
95
+ _disclosures: {
96
+ phase: 'pre-launch',
97
+ currency: "Each item's price.currency is the protocol's SIMULATED pre-launch internal unit (WAZ, or legacy 'DCP'), NOT an ISO 4217 fiat currency. No real money is involved.",
98
+ checkout: 'is_eligible_checkout is false for every item: ACP checkout is not yet wired. Products are DISCOVERABLE via ACP, not yet PURCHASABLE via ACP. Native WebAZ checkout + escrow + state machine govern real purchases (see RFC-015).',
99
+ },
100
+ product_count: products.length,
101
+ products,
102
+ };
103
+ }