-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpricing.js
More file actions
174 lines (157 loc) · 8.14 KB
/
Copy pathpricing.js
File metadata and controls
174 lines (157 loc) · 8.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
// ipfs-gate v1 — claim-based pricing engine (MB-hour).
// Pure functions, no I/O — fully unit-testable in isolation (the reason Stage 1a
// is the safe place to start). DESIGN LOCKED: PRICING-V1-DESIGN-NOTES.md.
//
// total_cost = billable_MB × billable_hrs × rate × copies
// billable_MB = max(1, ceil(bytes / MB_DIVISOR)) (decimal MB, §9)
// billable_hrs = max(PRICE_MIN_HOURS, ceil(hours)) (1-hour minimum)
// copies = min(copies_requested, NODE_COUNT) (capped at live nodes)
// rate = PRICE_RATE_PER_MB_HOUR (locked at purchase)
// ─── Config knobs (additive; all greenfield) ────────────────────────────────
const RATE_PER_MB_HOUR = parseFloat(process.env.PRICE_RATE_PER_MB_HOUR || '1');
const MIN_HOURS = parseInt(process.env.PRICE_MIN_HOURS || '1', 10);
const MB_DIVISOR = parseInt(process.env.MB_DIVISOR || '1000000', 10); // decimal MB — confirmed
const NODE_COUNT = parseInt(process.env.NODE_COUNT || '1', 10); // v1: config (1 Kubo node)
const REPLICATION_LEEWAY = parseInt(process.env.REPLICATION_LEEWAY || '2', 10); // min = max − leeway (Cluster)
const MIN_REFUND = parseFloat(process.env.MIN_REFUND || '0.05'); // below this, don't refund (dust)
// Universal precision floor — the v4call lesson: the gate must be able to actually
// charge/refund at whatever precision it quotes, or funds round to 0 and stick.
const RATE_FLOOR = parseFloat(process.env.RATE_FLOOR || '0.001');
// Stage-1b seam: documented now, consumed when backstops land. NOT used in 1a.
const BACKSTOP_CANCEL_FEE_PCT = parseFloat(process.env.BACKSTOP_CANCEL_FEE_PCT || '1');
const HOUR_MS = 60 * 60 * 1000;
// ─── Billable units ─────────────────────────────────────────────────────────
/** Decimal MB, rounded up, minimum 1. */
function billableMB(sizeBytes) {
const bytes = Number(sizeBytes);
if (!Number.isFinite(bytes) || bytes <= 0) {
throw Object.assign(new Error('size_bytes must be a positive number'), { code: 'bad_request' });
}
return Math.max(1, Math.ceil(bytes / MB_DIVISOR));
}
/** Hours, rounded up, minimum MIN_HOURS. */
function billableHours(hoursRequested) {
const h = Number(hoursRequested);
if (!Number.isFinite(h) || h <= 0) {
throw Object.assign(new Error('hours_requested must be a positive number'), { code: 'bad_request' });
}
return Math.max(MIN_HOURS, Math.ceil(h));
}
/**
* The LIVE node count (cohosting §3 / PRICING-V1 §4) — the cap on the copies
* selector. v1 reads config; multi-node swaps this single call site for a live
* cluster-peer-count query, and every cap/quote becomes dynamic with no rework.
*/
function getNodeCount() {
return Math.max(1, NODE_COUNT);
}
/** Copies clamped to [1, node_count] — nobody escrows for redundancy the gate can't deliver. */
function cappedCopies(copiesRequested, nodeCount = getNodeCount()) {
const c = Math.floor(Number(copiesRequested) || 1);
return Math.min(Math.max(1, c), Math.max(1, nodeCount));
}
/**
* Map a copies count to an IPFS-Cluster replication config (PRICING-V1 §4).
* `replication_factor_max = copies`; `min = max − leeway` (floored at 1) so a
* brief peer blip doesn't trigger a repin storm; `disable_repinning = false` so
* the cluster's own min-N self-heal runs (don't hand-roll it). Informational on a
* single-node v1 gate (copies is always 1); load-bearing once node_count > 1.
*/
function replicationConfig(copies, { leeway = REPLICATION_LEEWAY } = {}) {
const max = Math.max(1, Math.floor(Number(copies) || 1));
const min = Math.max(1, max - Math.max(0, leeway));
return { replication_factor_max: max, replication_factor_min: min, disable_repinning: false };
}
/** Round a coin amount to the gate's processable precision (RATE_FLOOR discipline). */
function roundCoins(amount) {
// RATE_FLOOR 0.001 → 3 decimal places. Derive places from the floor so the two stay in lock-step.
const places = Math.max(0, Math.round(-Math.log10(RATE_FLOOR)));
return parseFloat(Number(amount).toFixed(places));
}
// ─── Cost + refund ──────────────────────────────────────────────────────────
/**
* Quote a new claim. Returns the full breakdown so /reserve can show the user
* exactly how the number was reached.
* { billable_mb, billable_hrs, copies, rate, total }
*/
function calculateCost({ sizeBytes, hoursRequested, copies = 1, rate = RATE_PER_MB_HOUR, nodeCount = getNodeCount() }) {
const mb = billableMB(sizeBytes);
const hrs = billableHours(hoursRequested);
const cps = cappedCopies(copies, nodeCount);
const total = roundCoins(mb * hrs * rate * cps);
return { billable_mb: mb, billable_hrs: hrs, copies: cps, rate, total };
}
/**
* Pro-rata refund for an ACTIVE claim cancelled early.
* hours_used = max(1, ceil((now - start_ts) / 1h)) (min 1 hr consumed)
* hours_refunded = max(paid_hours - hours_used, 0)
* refund = hours_refunded × billable_MB × rate_locked × copies
* Returns { hours_used, hours_refunded, amount, dust } — amount is 0 (dust=true)
* when below MIN_REFUND. Rate is the claim's locked rate, never the live rate.
*/
function calculateRefund(claim, now = Date.now()) {
const startTs = Number(claim.start_ts);
const paidHours = Number(claim.paid_hours);
const rateLocked = Number(claim.rate_locked);
const copies = cappedCopies(claim.copies_requested);
const mb = billableMB(claim.size_bytes);
const hoursUsed = Math.max(1, Math.ceil((now - startTs) / HOUR_MS));
const hoursRefunded = Math.max(paidHours - hoursUsed, 0);
const raw = roundCoins(hoursRefunded * mb * rateLocked * copies);
if (raw < MIN_REFUND) {
return { hours_used: hoursUsed, hours_refunded: hoursRefunded, amount: 0, dust: true };
}
return { hours_used: hoursUsed, hours_refunded: hoursRefunded, amount: raw, dust: false };
}
/**
* Refund for a DORMANT backstop the pledger cancels before it ever activates.
* Full escrow back minus BACKSTOP_CANCEL_FEE_PCT (anti-churn; cohosting §3/§6).
* The fee applies ONLY to user-initiated dormant cancels — admin-forced voids
* pass feePct=0 (cohosting §7). Returns { amount, fee, dust }.
*/
function calculateDormantRefund(claim, feePct = BACKSTOP_CANCEL_FEE_PCT) {
const escrow = Number(claim.amount_paid);
if (!Number.isFinite(escrow) || escrow <= 0) return { amount: 0, fee: 0, dust: true };
const fee = roundCoins(escrow * (Math.max(0, feePct) / 100));
const amount = roundCoins(escrow - fee);
if (amount < MIN_REFUND) return { amount: 0, fee, dust: true };
return { amount, fee, dust: false };
}
/**
* Refund amount for a claim ended by an ADMIN force-action (cohosting §7).
* - innocent backstopper (CID ban voided an innocent third party's pledge)
* → FULL escrow back, no fee.
* - offender / banned-user's own claim, policy 'none' → 0 (forfeit).
* - offender / banned-user's own claim, policy 'prorata':
* active → pro-rata unused hours; dormant → full escrow (never ran).
* `claim.state` is the PRE-VOID state (active|dormant). Returns a number.
*/
function forcedRefundAmount(claim, { policy = 'prorata', innocent = false } = {}, now = Date.now()) {
const wasDormant = claim.state === 'dormant';
if (innocent) return calculateDormantRefund(claim, 0).amount; // full escrow, no fee
if (policy === 'none') return 0; // forfeit
if (wasDormant) return calculateDormantRefund(claim, 0).amount; // never metered → full back
return calculateRefund(claim, now).amount; // active offender → pro-rata
}
module.exports = {
billableMB,
billableHours,
cappedCopies,
getNodeCount,
replicationConfig,
roundCoins,
calculateCost,
calculateRefund,
calculateDormantRefund,
forcedRefundAmount,
// constants (exposed for server.js + tests)
RATE_PER_MB_HOUR,
MIN_HOURS,
MB_DIVISOR,
NODE_COUNT,
REPLICATION_LEEWAY,
MIN_REFUND,
RATE_FLOOR,
BACKSTOP_CANCEL_FEE_PCT,
HOUR_MS
};