ckb-session-kit 0.0.0-stage → 1.0.0
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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +558 -3
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/lock.d.ts +70 -0
- package/dist/lock.js +106 -0
- package/dist/onchain.d.ts +212 -0
- package/dist/onchain.js +561 -0
- package/dist/paywall.d.ts +31 -0
- package/dist/paywall.js +116 -0
- package/dist/place.d.ts +104 -0
- package/dist/place.js +151 -0
- package/dist/session.d.ts +63 -0
- package/dist/session.js +54 -0
- package/dist/store.d.ts +26 -0
- package/dist/store.js +181 -0
- package/dist/subscription.d.ts +221 -0
- package/dist/subscription.js +622 -0
- package/dist/transfer.d.ts +19 -0
- package/dist/transfer.js +33 -0
- package/package.json +70 -6
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import { relativeBlocksSince, TESTNET_LEGACY_DEPLOYMENTS, sessionLockCellDep, sessionLockScript, } from "./lock.js";
|
|
3
|
+
import { assertPlainXudt, assertSigningLock, collect, keyCellFloor, matureCells, ownerSweep, SessionBalanceError, signAndSend, tokenAmount, tokenData, } from "./onchain.js";
|
|
4
|
+
import { SessionPolicyError } from "./session.js";
|
|
5
|
+
const tokenOf = (sub) => sub.token && ccc.Script.from(sub.token.type);
|
|
6
|
+
export function subscriptionLock(deployment, sub) {
|
|
7
|
+
const token = tokenOf(sub);
|
|
8
|
+
return sessionLockScript(deployment, {
|
|
9
|
+
ownerLockHash: sub.subscriberLockHash,
|
|
10
|
+
sessionLockHash: sub.chargerLockHash,
|
|
11
|
+
// In a token subscription no CKB may leave: the charger pays the fee from its own cell.
|
|
12
|
+
maxPerTx: token ? 0n : sub.price,
|
|
13
|
+
minInterval: sub.periodBlocks,
|
|
14
|
+
...(token && { token: { typeHash: token.hash(), maxPerTx: sub.price } }),
|
|
15
|
+
recipientLockHash: sub.payToLockHash,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Throws unless the three roles and the numbers make a subscription the lock can enforce as meant. */
|
|
19
|
+
function assertTerms(sub) {
|
|
20
|
+
if (sub.price <= 0n)
|
|
21
|
+
throw new SessionPolicyError("price must be positive");
|
|
22
|
+
// min_interval 0 means no rate limit at all: the charger could take the price every block.
|
|
23
|
+
if (sub.periodBlocks <= 0n)
|
|
24
|
+
throw new SessionPolicyError("periodBlocks must be positive");
|
|
25
|
+
relativeBlocksSince(sub.periodBlocks);
|
|
26
|
+
if (sub.payToLockHash === sub.chargerLockHash) {
|
|
27
|
+
// With a recipient set, outputs under the key's lock may not grow (error 17).
|
|
28
|
+
throw new SessionPolicyError("payTo must differ from the charger: a charge to the charger's own lock is refused (error 17)");
|
|
29
|
+
}
|
|
30
|
+
if (sub.chargerLockHash === sub.subscriberLockHash)
|
|
31
|
+
throw new SessionPolicyError("the charger must not be the subscriber");
|
|
32
|
+
if (sub.payToLockHash === sub.subscriberLockHash)
|
|
33
|
+
throw new SessionPolicyError("payTo must not be the subscriber");
|
|
34
|
+
}
|
|
35
|
+
async function isAnyoneCanPay(lock, client) {
|
|
36
|
+
const acp = await client.getKnownScript(ccc.KnownScript.AnyoneCanPay);
|
|
37
|
+
return lock.codeHash === acp.codeHash && lock.hashType === acp.hashType;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The memo an opening commits to, binding the subscription to one account at the
|
|
41
|
+
* creator: a hash, so the account id never appears on-chain and can double as a
|
|
42
|
+
* secret the subscriber's browser presents to the creator.
|
|
43
|
+
*/
|
|
44
|
+
export function subscriptionMemo(account) {
|
|
45
|
+
return ccc.hashCkb(ccc.bytesFrom(`ckb-session-kit subscribe ${account}`, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Plain (untyped) cells under a subscription's lock, with or without data: the
|
|
49
|
+
* charger may write data into a change cell, and such a cell must still be found,
|
|
50
|
+
* charged and swept (review L2).
|
|
51
|
+
*
|
|
52
|
+
* In a token subscription, the cells typed with exactly its token and holding at
|
|
53
|
+
* least the 16-byte amount (the lock keeps a charge's change at exactly 16).
|
|
54
|
+
* Plain CKB sent to the same lock holds no tokens and cannot pay a price; it is
|
|
55
|
+
* left to `recoverSessions`.
|
|
56
|
+
*/
|
|
57
|
+
async function subscriptionCells(client, lock, token) {
|
|
58
|
+
const cells = [];
|
|
59
|
+
for await (const cell of client.findCellsByLock(lock, null, true)) {
|
|
60
|
+
const type = cell.cellOutput.type;
|
|
61
|
+
if (token ? type?.eq(token) && ccc.bytesFrom(cell.outputData).length >= 16 : !type)
|
|
62
|
+
cells.push(cell);
|
|
63
|
+
}
|
|
64
|
+
return cells;
|
|
65
|
+
}
|
|
66
|
+
/** The token units in token cells (cells typed with `token`, with at least 16 data bytes). */
|
|
67
|
+
function tokensIn(cells, token) {
|
|
68
|
+
return cells.reduce((s, c) => (c.type?.eq(token) && ccc.bytesFrom(c.data).length >= 16 ? s + tokenAmount(c.data) : s), 0n);
|
|
69
|
+
}
|
|
70
|
+
const U128_MAX = (1n << 128n) - 1n;
|
|
71
|
+
/**
|
|
72
|
+
* Cells whose token amounts add up without overflowing a u128, in order; the rest
|
|
73
|
+
* are left out. Anyone can plant a cell holding 2^128-1 units of a free token
|
|
74
|
+
* under a subscription's lock, and xUDT refuses a transaction whose inputs
|
|
75
|
+
* overflow, so taking every cell at once would block charges and the cancel
|
|
76
|
+
* (review, 27 Oct). Only the planter's own tokens are left behind.
|
|
77
|
+
*/
|
|
78
|
+
function fitting(cells, token) {
|
|
79
|
+
if (!token)
|
|
80
|
+
return cells;
|
|
81
|
+
const kept = [];
|
|
82
|
+
let sum = 0n;
|
|
83
|
+
for (const c of cells) {
|
|
84
|
+
const units = c.cellOutput.type?.eq(token) && ccc.bytesFrom(c.outputData).length >= 16 ? tokenAmount(c.outputData) : 0n;
|
|
85
|
+
if (sum + units > U128_MAX)
|
|
86
|
+
continue;
|
|
87
|
+
sum += units;
|
|
88
|
+
kept.push(c);
|
|
89
|
+
}
|
|
90
|
+
return kept;
|
|
91
|
+
}
|
|
92
|
+
/** Whether `p` spends a cell under `lockHash`: who took part in the transaction. */
|
|
93
|
+
const spends = (p, lockHash) => p.inputs.some((c) => c?.cellOutput.lock.hash() === lockHash);
|
|
94
|
+
/** A cell of `token` under `payTo`'s anyone-can-pay lock to top up; random, so payers at once rarely collide. */
|
|
95
|
+
async function tokenTarget(client, payToLock, token) {
|
|
96
|
+
const targets = (await collect(client, payToLock, token)).filter((c) => !!c.cellOutput.type);
|
|
97
|
+
const target = targets[Math.floor(Math.random() * targets.length)];
|
|
98
|
+
if (!target) {
|
|
99
|
+
throw new SessionBalanceError("payTo has no cell of this token to top up: create one (any amount, even 0) first");
|
|
100
|
+
}
|
|
101
|
+
return target;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The subscriber opens a subscription, in one transaction and one signature: the
|
|
105
|
+
* first period's price goes to `payTo` at once (a new cell cannot be charged for a
|
|
106
|
+
* whole period, so otherwise the first period would be free; review H1), and one
|
|
107
|
+
* cell holding `budget` shannons goes under the session lock. No key cell: the
|
|
108
|
+
* creator already owns cells under its charger key. `budget` of the cell's own
|
|
109
|
+
* size (153 CKB) plus n prices pays n more periods; what is left comes back on
|
|
110
|
+
* cancel. With a price of 153 CKB or more, the last 153 can be charged too.
|
|
111
|
+
*
|
|
112
|
+
* `account` (the subscriber's id at the creator) is committed in the witness of
|
|
113
|
+
* the subscriber's own input, which the subscriber's signature covers; the
|
|
114
|
+
* creator checks it with `verifySubscription`, so nobody else can present this
|
|
115
|
+
* subscription as theirs (review H3).
|
|
116
|
+
*
|
|
117
|
+
* `payTo` should be an anyone-can-pay address with a live cell, so a charge of
|
|
118
|
+
* any size tops it up; otherwise each charge creates a new cell and the price must
|
|
119
|
+
* be at least that cell's size (61 CKB for a plain address). Keep a plain cell in
|
|
120
|
+
* the wallet: cancelling needs one to prove ownership.
|
|
121
|
+
*
|
|
122
|
+
* With `token`, the subscription is paid in that plain xUDT and `price` is in
|
|
123
|
+
* token units: the cell holds `token.amount` units plus its own size in CKB
|
|
124
|
+
* (282 CKB; `budget` may add more, which stays until cancel), and the first price
|
|
125
|
+
* tops up `payTo`'s cell of the token. `payTo` must then be anyone-can-pay and
|
|
126
|
+
* already hold a cell of the token: no CKB may leave a token subscription, so a
|
|
127
|
+
* charge cannot pay for a new token cell.
|
|
128
|
+
*/
|
|
129
|
+
export async function openSubscription(subscriber, deployment, opts) {
|
|
130
|
+
const client = subscriber.client;
|
|
131
|
+
if (TESTNET_LEGACY_DEPLOYMENTS.some((d) => ccc.hexFrom(d.codeHash) === ccc.hexFrom(deployment.codeHash))) {
|
|
132
|
+
// The 8 Oct code lacks errors 16-18 (review M5); no new subscription goes under an old lock.
|
|
133
|
+
throw new SessionPolicyError("this is an old deployment of the session lock: open subscriptions under the current one");
|
|
134
|
+
}
|
|
135
|
+
const token = opts.token && ccc.Script.from(opts.token.type);
|
|
136
|
+
if (token && opts.token) {
|
|
137
|
+
await assertPlainXudt(token, client);
|
|
138
|
+
if (opts.token.amount < 0n)
|
|
139
|
+
throw new SessionBalanceError("token amount must not be negative");
|
|
140
|
+
}
|
|
141
|
+
else if (opts.budget === undefined) {
|
|
142
|
+
throw new SessionBalanceError("budget is required: the shannons the subscription cell holds");
|
|
143
|
+
}
|
|
144
|
+
const subscriberLock = (await subscriber.getRecommendedAddressObj()).script;
|
|
145
|
+
await assertSigningLock(subscriberLock, client);
|
|
146
|
+
const [chargerLock, payToLock] = await Promise.all([opts.charger, opts.payTo].map(async (a) => (await ccc.Address.fromString(a, client)).script));
|
|
147
|
+
// A charger lock that signs nothing would let anyone trigger charges (review M2).
|
|
148
|
+
await assertSigningLock(chargerLock, client);
|
|
149
|
+
const sub = {
|
|
150
|
+
subscriberLockHash: subscriberLock.hash(),
|
|
151
|
+
chargerLockHash: chargerLock.hash(),
|
|
152
|
+
payToLockHash: payToLock.hash(),
|
|
153
|
+
price: opts.price,
|
|
154
|
+
periodBlocks: opts.periodBlocks,
|
|
155
|
+
...(token && { token: { type: { codeHash: token.codeHash, hashType: token.hashType, args: token.args } } }),
|
|
156
|
+
};
|
|
157
|
+
assertTerms(sub);
|
|
158
|
+
const acp = await isAnyoneCanPay(payToLock, client);
|
|
159
|
+
const newCell = ccc.CellOutput.from({ lock: payToLock }, "0x").capacity;
|
|
160
|
+
if (token && !acp) {
|
|
161
|
+
throw new SessionPolicyError("a token subscription pays into payTo's anyone-can-pay cell of the token: no CKB may leave it to make a new one");
|
|
162
|
+
}
|
|
163
|
+
if (!token && opts.price < newCell && !acp) {
|
|
164
|
+
throw new SessionPolicyError(`each charge creates a cell for payTo, which needs ${ccc.fixedPointToString(newCell)} CKB: ` +
|
|
165
|
+
"raise the price or use an anyone-can-pay address");
|
|
166
|
+
}
|
|
167
|
+
const lock = subscriptionLock(deployment, sub);
|
|
168
|
+
// Two live cells under the same lock are one group with two rate-limited cells:
|
|
169
|
+
// two charges per period (review M1).
|
|
170
|
+
if ((await subscriptionCells(client, lock, token)).length) {
|
|
171
|
+
throw new SessionPolicyError("a subscription with exactly these terms is already open: cancel it first");
|
|
172
|
+
}
|
|
173
|
+
const tx = ccc.Transaction.from({
|
|
174
|
+
outputs: [{ lock, capacity: opts.budget ?? 0n, ...(token && { type: token }) }],
|
|
175
|
+
outputsData: [token && opts.token ? tokenData(opts.token.amount) : "0x"],
|
|
176
|
+
});
|
|
177
|
+
// CCC raises an output to its occupied capacity; a budget below that is a mistake.
|
|
178
|
+
if (opts.budget !== undefined && tx.outputs[0].capacity !== opts.budget) {
|
|
179
|
+
throw new SessionBalanceError(`budget must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
|
|
180
|
+
}
|
|
181
|
+
// The first period, paid now.
|
|
182
|
+
if (token) {
|
|
183
|
+
const target = await tokenTarget(client, payToLock, token);
|
|
184
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
|
|
185
|
+
tx.addOutput({ lock: payToLock, type: token, capacity: target.cellOutput.capacity }, tokenData(tokenAmount(target.outputData) + opts.price));
|
|
186
|
+
await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay, ccc.KnownScript.XUdt);
|
|
187
|
+
await tx.completeInputsByUdt(subscriber, token);
|
|
188
|
+
const extra = (await tx.getInputsUdtBalance(client, token)) - tx.getOutputsUdtBalance(token);
|
|
189
|
+
if (extra > 0n)
|
|
190
|
+
tx.addOutput({ lock: subscriberLock, type: token }, tokenData(extra));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const target = acp ? (await collect(client, payToLock))[0] : undefined;
|
|
194
|
+
if (target) {
|
|
195
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
|
|
196
|
+
tx.addOutput({ lock: payToLock, capacity: target.cellOutput.capacity + opts.price });
|
|
197
|
+
await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
tx.addOutput({ lock: payToLock, capacity: opts.price });
|
|
201
|
+
if (tx.outputs[1].capacity !== opts.price) {
|
|
202
|
+
throw new SessionBalanceError(`payTo has no cell to top up, and a new one needs ${ccc.fixedPointToString(newCell)} CKB`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
await tx.completeInputsByCapacity(subscriber);
|
|
207
|
+
if (opts.account !== undefined) {
|
|
208
|
+
let mine = -1;
|
|
209
|
+
for (let i = 0; i < tx.inputs.length && mine < 0; i++) {
|
|
210
|
+
await tx.inputs[i].completeExtraInfos(client);
|
|
211
|
+
if (tx.inputs[i].cellOutput?.lock.eq(subscriberLock))
|
|
212
|
+
mine = i;
|
|
213
|
+
}
|
|
214
|
+
if (mine < 0)
|
|
215
|
+
throw new SessionBalanceError("no input of the subscriber to carry the account memo");
|
|
216
|
+
tx.setWitnessArgsAt(mine, { inputType: subscriptionMemo(opts.account) });
|
|
217
|
+
}
|
|
218
|
+
await tx.completeFeeBy(subscriber);
|
|
219
|
+
let funded = sub;
|
|
220
|
+
const txHash = await signAndSend(subscriber, tx, async (hash) => {
|
|
221
|
+
funded = { ...sub, openTx: hash };
|
|
222
|
+
await opts.beforeSend?.(funded);
|
|
223
|
+
});
|
|
224
|
+
return { txHash, subscription: funded };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The live cells of a subscription, their balance (shannons) and token balance
|
|
228
|
+
* (units; 0 without a token), and how many blocks until one can be charged.
|
|
229
|
+
*/
|
|
230
|
+
export async function findSubscriptionCells(client, deployment, sub) {
|
|
231
|
+
const lock = subscriptionLock(deployment, sub);
|
|
232
|
+
const token = tokenOf(sub);
|
|
233
|
+
const cells = await subscriptionCells(client, lock, token);
|
|
234
|
+
const balance = cells.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
235
|
+
const tokenBalance = token ? tokensIn(cells.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) : 0n;
|
|
236
|
+
if (!cells.length)
|
|
237
|
+
return { lock, cells, balance, tokenBalance, nextChargeIn: undefined };
|
|
238
|
+
const tip = await client.getTip();
|
|
239
|
+
let nextChargeIn;
|
|
240
|
+
for (const c of cells) {
|
|
241
|
+
const born = (await client.getTransaction(c.outPoint.txHash))?.blockNumber;
|
|
242
|
+
if (born === undefined || born === null)
|
|
243
|
+
continue; // not committed yet
|
|
244
|
+
const age = tip - ccc.numFrom(born);
|
|
245
|
+
const wait = age >= sub.periodBlocks ? 0n : sub.periodBlocks - age;
|
|
246
|
+
if (nextChargeIn === undefined || wait < nextChargeIn)
|
|
247
|
+
nextChargeIn = wait;
|
|
248
|
+
}
|
|
249
|
+
return { lock, cells, balance, tokenBalance, nextChargeIn };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The creator's charger key takes one price from the subscription and pays it to
|
|
253
|
+
* `payTo` (topping up its anyone-can-pay cell with `topUp`). The fee comes from
|
|
254
|
+
* one of the charger's own plain cells, which goes back to it smaller. Throws a
|
|
255
|
+
* `SessionBalanceError` while no cell is a full period old, or once what is left
|
|
256
|
+
* cannot cover a price and still leave a cell behind.
|
|
257
|
+
*
|
|
258
|
+
* In a token subscription the price is in token units and always tops up
|
|
259
|
+
* `payTo`'s cell of the token (`topUp` is implied). The change keeps every
|
|
260
|
+
* shannon that came in (no CKB may leave) and the tokens less one price, in
|
|
261
|
+
* exactly 16 data bytes.
|
|
262
|
+
*/
|
|
263
|
+
export async function chargeSubscription(charger, deployment, sub, opts) {
|
|
264
|
+
const client = charger.client;
|
|
265
|
+
assertTerms(sub);
|
|
266
|
+
const token = tokenOf(sub);
|
|
267
|
+
const chargerLock = (await charger.getRecommendedAddressObj()).script;
|
|
268
|
+
if (chargerLock.hash() !== sub.chargerLockHash)
|
|
269
|
+
throw new SessionPolicyError("this signer is not the subscription's charger");
|
|
270
|
+
const { script: payToLock } = await ccc.Address.fromString(opts.payTo, client);
|
|
271
|
+
if (payToLock.hash() !== sub.payToLockHash)
|
|
272
|
+
throw new SessionPolicyError("payTo is not the subscription's payout address");
|
|
273
|
+
const state = await findSubscriptionCells(client, deployment, sub);
|
|
274
|
+
if (!state.cells.length)
|
|
275
|
+
throw new SessionBalanceError("no subscription cell: cancelled, or not committed yet");
|
|
276
|
+
if (state.nextChargeIn !== 0n) {
|
|
277
|
+
throw new SessionBalanceError(state.nextChargeIn === undefined
|
|
278
|
+
? "the subscription's cell is not committed yet"
|
|
279
|
+
: `not a full period yet: next charge in ${state.nextChargeIn} blocks`);
|
|
280
|
+
}
|
|
281
|
+
const tx = ccc.Transaction.from({ cellDeps: [sessionLockCellDep(deployment)] });
|
|
282
|
+
if (token) {
|
|
283
|
+
if (!(await isAnyoneCanPay(payToLock, client)))
|
|
284
|
+
throw new SessionPolicyError("a token subscription needs an anyone-can-pay payTo");
|
|
285
|
+
const target = await tokenTarget(client, payToLock, token);
|
|
286
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
|
|
287
|
+
tx.addOutput({ lock: payToLock, type: token, capacity: target.cellOutput.capacity }, tokenData(tokenAmount(target.outputData) + sub.price));
|
|
288
|
+
await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay, ccc.KnownScript.XUdt);
|
|
289
|
+
}
|
|
290
|
+
else if (opts.topUp) {
|
|
291
|
+
if (!(await isAnyoneCanPay(payToLock, client)))
|
|
292
|
+
throw new SessionPolicyError("topUp needs an anyone-can-pay payTo");
|
|
293
|
+
const targets = await collect(client, payToLock);
|
|
294
|
+
const target = targets[Math.floor(Math.random() * targets.length)];
|
|
295
|
+
if (!target)
|
|
296
|
+
throw new SessionBalanceError("payTo has no anyone-can-pay cell to top up");
|
|
297
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: target.outPoint }));
|
|
298
|
+
tx.addOutput({ lock: payToLock, capacity: target.cellOutput.capacity + sub.price });
|
|
299
|
+
await tx.addCellDepsOfKnownScripts(client, ccc.KnownScript.AnyoneCanPay);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
tx.addOutput({ lock: payToLock, capacity: sub.price });
|
|
303
|
+
if (tx.outputs[0].capacity !== sub.price) {
|
|
304
|
+
throw new SessionBalanceError(`a charge to a new cell must be at least ${ccc.fixedPointToString(tx.outputs[0].capacity)} CKB`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Every mature cell, merged into one change: the rate limit is per cell, so
|
|
308
|
+
// leaving one out would let it pay another price in the same period (code review, 26 Oct).
|
|
309
|
+
const taken = fitting(await matureCells(client, state.cells, sub.periodBlocks), token);
|
|
310
|
+
const ckbIn = taken.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
311
|
+
const since = relativeBlocksSince(sub.periodBlocks);
|
|
312
|
+
tx.inputs.unshift(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
|
|
313
|
+
if (token) {
|
|
314
|
+
const tokenIn = tokensIn(taken.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token);
|
|
315
|
+
if (tokenIn < sub.price) {
|
|
316
|
+
throw new SessionBalanceError(`the subscription holds ${tokenIn} token units: not enough for a price of ${sub.price}`);
|
|
317
|
+
}
|
|
318
|
+
// All the CKB back (max_per_tx is 0), the tokens less one price, in exactly 16 bytes.
|
|
319
|
+
tx.addOutput({ lock: state.lock, type: token, capacity: ckbIn }, tokenData(tokenIn - sub.price));
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
const change = ckbIn - sub.price;
|
|
323
|
+
const cellSize = ccc.CellOutput.from({ lock: state.lock }, "0x").capacity;
|
|
324
|
+
if (change < 0n || (change > 0n && change < cellSize)) {
|
|
325
|
+
throw new SessionBalanceError(`the subscription holds ${ccc.fixedPointToString(ckbIn)} CKB: not enough for a price of ` +
|
|
326
|
+
`${ccc.fixedPointToString(sub.price)} CKB and a ${ccc.fixedPointToString(cellSize)} CKB cell left behind`);
|
|
327
|
+
}
|
|
328
|
+
if (change > 0n)
|
|
329
|
+
tx.addOutput({ lock: state.lock, capacity: change });
|
|
330
|
+
}
|
|
331
|
+
// The charger's own cell authorises the charge and pays the fee; it comes back last, smaller.
|
|
332
|
+
const floor = keyCellFloor(chargerLock);
|
|
333
|
+
const keyCell = (await collect(client, chargerLock)).find((c) => c.cellOutput.capacity > floor);
|
|
334
|
+
if (!keyCell)
|
|
335
|
+
throw new SessionBalanceError(`the charger needs a plain cell above ${ccc.fixedPointToString(floor)} CKB for fees`);
|
|
336
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: keyCell.outPoint }));
|
|
337
|
+
tx.addOutput({ lock: chargerLock, capacity: 0n });
|
|
338
|
+
await tx.completeFeeChangeToOutput(charger, tx.outputs.length - 1);
|
|
339
|
+
return signAndSend(charger, tx, opts.beforeSend);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The subscriber takes everything left back, in owner mode: no creator involved
|
|
343
|
+
* and no wait. `nothingFound` means no cell is left (already cancelled, or run dry).
|
|
344
|
+
* In a token subscription the tokens come back as one token cell (`refundedTokens`).
|
|
345
|
+
* Subscriptions are also found and swept by `recoverSessions`, since their args
|
|
346
|
+
* begin with the subscriber's lock hash like any session's.
|
|
347
|
+
*/
|
|
348
|
+
export async function cancelSubscription(subscriber, deployment, sub) {
|
|
349
|
+
const lock = (await subscriber.getRecommendedAddressObj()).script;
|
|
350
|
+
if (lock.hash() !== sub.subscriberLockHash)
|
|
351
|
+
throw new SessionPolicyError("this signer is not the subscriber");
|
|
352
|
+
const { cells, balance, tokenBalance, lock: subLock } = await findSubscriptionCells(subscriber.client, deployment, sub);
|
|
353
|
+
const token = tokenOf(sub);
|
|
354
|
+
// A charger may leave a token subscription's last change untyped; its CKB is the subscriber's too (review, 27 Oct).
|
|
355
|
+
const untyped = token ? await subscriptionCells(subscriber.client, subLock) : [];
|
|
356
|
+
const all = [...cells, ...untyped];
|
|
357
|
+
if (!all.length)
|
|
358
|
+
return { refunded: 0n, nothingFound: true };
|
|
359
|
+
const sweep = fitting(all, token);
|
|
360
|
+
const txHash = await ownerSweep(subscriber, deployment, sweep);
|
|
361
|
+
const refunded = sweep.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
362
|
+
return {
|
|
363
|
+
txHash,
|
|
364
|
+
refunded: token ? refunded : balance,
|
|
365
|
+
...(token && { refundedTokens: tokensIn(sweep.map((c) => ({ type: c.cellOutput.type, data: c.outputData })), token) }),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/** How many more prices a subscription's cells can pay. */
|
|
369
|
+
function chargesLeftOf(sub, lock, balance, tokenBalance) {
|
|
370
|
+
if (sub.token)
|
|
371
|
+
return tokenBalance / sub.price;
|
|
372
|
+
const cellSize = ccc.CellOutput.from({ lock }, "0x").capacity;
|
|
373
|
+
return balance >= cellSize + sub.price ? (balance - cellSize) / sub.price : 0n;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The creator checks a subscription from the chain alone. Nothing in the request
|
|
377
|
+
* is trusted but the out point. The cell at `outPoint` must be live, under this
|
|
378
|
+
* deployment of the session lock (never an older one), untyped and without data,
|
|
379
|
+
* with 112-byte args holding exactly these terms, and at least `minBalance`.
|
|
380
|
+
*
|
|
381
|
+
* And the transaction that created the cell must have paid `payTo` at least one
|
|
382
|
+
* price: the opening pays the first period, every charge pays one. A cell the
|
|
383
|
+
* subscriber re-created in owner mode to reset its age paid nothing, and is
|
|
384
|
+
* refused (review H2). So access is granted up to `paidThroughBlock`; check the
|
|
385
|
+
* current cell again (`findSubscriptionCells`) once that block has passed.
|
|
386
|
+
*
|
|
387
|
+
* With `account`, the creating transaction must also carry `subscriptionMemo(account)`
|
|
388
|
+
* in the witness of an input under the subscriber's lock, which only the
|
|
389
|
+
* subscriber's signature covers: someone else's subscription cannot be claimed
|
|
390
|
+
* (review H3). The memo is in the opening only, so check `account` once, when a
|
|
391
|
+
* subscription is registered, then keep the subscription by its lock.
|
|
392
|
+
*
|
|
393
|
+
* With `token` (a token subscription), the cell must instead be typed with exactly
|
|
394
|
+
* that token and hold its amount, with 160-byte args (`max_per_tx` 0, the token's
|
|
395
|
+
* type hash, `price` in token units as `max_per_tx_token`); `minBalance` is in token
|
|
396
|
+
* units, and the payment counted is token units into `payTo`'s cells of the token.
|
|
397
|
+
* Without `token`, a 160-byte cell is refused, and the reverse.
|
|
398
|
+
*/
|
|
399
|
+
export async function verifySubscription(client, deployment, outPoint, expected) {
|
|
400
|
+
const cell = await client.getCellLive(outPoint, true, false);
|
|
401
|
+
if (!cell)
|
|
402
|
+
return { ok: false, reason: "no live cell at that out point" };
|
|
403
|
+
const lock = cell.cellOutput.lock;
|
|
404
|
+
if (lock.codeHash !== ccc.hexFrom(deployment.codeHash) || lock.hashType !== ccc.hashTypeFrom(deployment.hashType)) {
|
|
405
|
+
return { ok: false, reason: "not under this deployment of the session lock" };
|
|
406
|
+
}
|
|
407
|
+
const token = expected.token && ccc.Script.from(expected.token);
|
|
408
|
+
const args = ccc.bytesFrom(lock.args);
|
|
409
|
+
if (token) {
|
|
410
|
+
if (!cell.cellOutput.type?.eq(token) || ccc.bytesFrom(cell.outputData).length < 16) {
|
|
411
|
+
return { ok: false, reason: "a token subscription cell is typed with the token and holds its amount" };
|
|
412
|
+
}
|
|
413
|
+
if (args.length !== 160)
|
|
414
|
+
return { ok: false, reason: "not a token subscription: args must be 160 bytes (token, with a payout address)" };
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
if (cell.cellOutput.type || cell.outputData !== "0x")
|
|
418
|
+
return { ok: false, reason: "a subscription cell carries no type and no data" };
|
|
419
|
+
if (args.length !== 112)
|
|
420
|
+
return { ok: false, reason: "not a subscription: args must be 112 bytes (CKB, with a payout address)" };
|
|
421
|
+
}
|
|
422
|
+
const [chargerLock, payToLock] = await Promise.all([expected.charger, expected.payTo].map(async (a) => (await ccc.Address.fromString(a, client)).script));
|
|
423
|
+
const sub = token
|
|
424
|
+
? {
|
|
425
|
+
subscriberLockHash: ccc.hexFrom(args.slice(0, 32)),
|
|
426
|
+
chargerLockHash: ccc.hexFrom(args.slice(32, 64)),
|
|
427
|
+
price: ccc.numLeFromBytes(args.slice(112, 128)),
|
|
428
|
+
periodBlocks: ccc.numLeFromBytes(args.slice(72, 80)),
|
|
429
|
+
payToLockHash: ccc.hexFrom(args.slice(128, 160)),
|
|
430
|
+
token: { type: { codeHash: token.codeHash, hashType: token.hashType, args: token.args } },
|
|
431
|
+
}
|
|
432
|
+
: {
|
|
433
|
+
subscriberLockHash: ccc.hexFrom(args.slice(0, 32)),
|
|
434
|
+
chargerLockHash: ccc.hexFrom(args.slice(32, 64)),
|
|
435
|
+
price: ccc.numLeFromBytes(args.slice(64, 72)),
|
|
436
|
+
periodBlocks: ccc.numLeFromBytes(args.slice(72, 80)),
|
|
437
|
+
payToLockHash: ccc.hexFrom(args.slice(80, 112)),
|
|
438
|
+
};
|
|
439
|
+
if (token) {
|
|
440
|
+
if (ccc.hexFrom(args.slice(80, 112)) !== token.hash())
|
|
441
|
+
return { ok: false, reason: "a different token" };
|
|
442
|
+
if (ccc.numLeFromBytes(args.slice(64, 72)) !== 0n)
|
|
443
|
+
return { ok: false, reason: "a token subscription must let no CKB out (max_per_tx 0)" };
|
|
444
|
+
}
|
|
445
|
+
if (sub.chargerLockHash !== chargerLock.hash())
|
|
446
|
+
return { ok: false, reason: "a different charger" };
|
|
447
|
+
if (sub.payToLockHash !== payToLock.hash())
|
|
448
|
+
return { ok: false, reason: "a different payout address" };
|
|
449
|
+
if (sub.price !== expected.price)
|
|
450
|
+
return { ok: false, reason: "a different price" };
|
|
451
|
+
if (sub.periodBlocks !== expected.periodBlocks)
|
|
452
|
+
return { ok: false, reason: "a different period" };
|
|
453
|
+
try {
|
|
454
|
+
assertTerms(sub);
|
|
455
|
+
if (token)
|
|
456
|
+
await assertPlainXudt(token, client);
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
460
|
+
}
|
|
461
|
+
const balance = cell.cellOutput.capacity;
|
|
462
|
+
const tokenBalance = token ? tokenAmount(cell.outputData) : 0n;
|
|
463
|
+
if (token) {
|
|
464
|
+
if (tokenBalance < (expected.minBalance ?? 0n))
|
|
465
|
+
return { ok: false, reason: `holds ${tokenBalance} token units, under the minimum` };
|
|
466
|
+
}
|
|
467
|
+
else if (balance < (expected.minBalance ?? ccc.CellOutput.from({ lock }, "0x").capacity)) {
|
|
468
|
+
return { ok: false, reason: `holds ${ccc.fixedPointToString(balance)} CKB, under the minimum` };
|
|
469
|
+
}
|
|
470
|
+
const created = await paymentOf(client, deployment, cell.outPoint.txHash, sub);
|
|
471
|
+
if (typeof created === "string")
|
|
472
|
+
return { ok: false, reason: created };
|
|
473
|
+
// An opening is signed by the subscriber, a charge by the charger. Any other
|
|
474
|
+
// payment to payTo (a paid read, say) must not count as a period too (review, 27 Oct).
|
|
475
|
+
if (!spends(created, sub.subscriberLockHash) && !spends(created, sub.chargerLockHash)) {
|
|
476
|
+
return { ok: false, reason: "the cell was created neither by the subscriber's opening nor by a charge" };
|
|
477
|
+
}
|
|
478
|
+
if (expected.account !== undefined && !commitsToAccount(created, sub, expected.account)) {
|
|
479
|
+
return { ok: false, reason: "the opening does not commit to this account" };
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
ok: true,
|
|
483
|
+
subscription: sub,
|
|
484
|
+
balance,
|
|
485
|
+
...(token && { tokenBalance }),
|
|
486
|
+
chargesLeft: chargesLeftOf(sub, lock, balance, tokenBalance),
|
|
487
|
+
paidThroughBlock: created.block + sub.periodBlocks,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** The most inputs a creating transaction may have before its inputs are looked up (each is an RPC call). */
|
|
491
|
+
const MAX_PAYMENT_INPUTS = 32;
|
|
492
|
+
/**
|
|
493
|
+
* The committed transaction `txHash`, if it paid `sub`'s payout address enough;
|
|
494
|
+
* otherwise why not. Enough is the sum of the prices of every subscription cell
|
|
495
|
+
* it creates for that payout address, not one price: otherwise one payment could
|
|
496
|
+
* open or renew many subscriptions at once (code review, 26 Oct). Only untyped
|
|
497
|
+
* cells count as paid, and an input that cannot be looked up fails the check.
|
|
498
|
+
*
|
|
499
|
+
* In a token subscription, the same in token units: the prices of every token
|
|
500
|
+
* subscription cell (160-byte args) for that payout address and token, against
|
|
501
|
+
* the units the payout address gained in cells typed with exactly that token
|
|
502
|
+
* and holding at least 16 data bytes. CKB and token payments never count for each other.
|
|
503
|
+
*/
|
|
504
|
+
async function paymentOf(client, deployment, txHash, sub) {
|
|
505
|
+
const created = await client.getTransaction(txHash);
|
|
506
|
+
if (!created || created.status !== "committed" || created.blockNumber === undefined || created.blockNumber === null) {
|
|
507
|
+
return "the transaction that created the cell is not committed";
|
|
508
|
+
}
|
|
509
|
+
const tx = created.transaction;
|
|
510
|
+
if (tx.inputs.length > MAX_PAYMENT_INPUTS)
|
|
511
|
+
return `the transaction has over ${MAX_PAYMENT_INPUTS} inputs`;
|
|
512
|
+
const codeHash = ccc.hexFrom(deployment.codeHash);
|
|
513
|
+
const hashType = ccc.hashTypeFrom(deployment.hashType);
|
|
514
|
+
const token = tokenOf(sub);
|
|
515
|
+
const tokenHash = token?.hash();
|
|
516
|
+
let owed = 0n;
|
|
517
|
+
for (const o of tx.outputs) {
|
|
518
|
+
const args = ccc.bytesFrom(o.lock.args);
|
|
519
|
+
if (o.lock.codeHash !== codeHash || o.lock.hashType !== hashType)
|
|
520
|
+
continue;
|
|
521
|
+
if (!token && args.length === 112 && ccc.hexFrom(args.slice(80, 112)) === sub.payToLockHash) {
|
|
522
|
+
owed += ccc.numLeFromBytes(args.slice(64, 72));
|
|
523
|
+
}
|
|
524
|
+
if (token &&
|
|
525
|
+
args.length === 160 &&
|
|
526
|
+
ccc.hexFrom(args.slice(80, 112)) === tokenHash &&
|
|
527
|
+
ccc.hexFrom(args.slice(128, 160)) === sub.payToLockHash) {
|
|
528
|
+
owed += ccc.numLeFromBytes(args.slice(112, 128));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (owed < sub.price)
|
|
532
|
+
owed = sub.price;
|
|
533
|
+
const inputs = await Promise.all(tx.inputs.map((i) => client.getCell(i.previousOutput)));
|
|
534
|
+
if (inputs.some((c) => !c))
|
|
535
|
+
return "an input of the transaction could not be looked up";
|
|
536
|
+
const toPayTo = (cells) => cells.filter((c) => c.cellOutput.lock.hash() === sub.payToLockHash);
|
|
537
|
+
const outs = toPayTo(tx.outputs.map((cellOutput, i) => ({ cellOutput, data: tx.outputsData[i] ?? "0x" })));
|
|
538
|
+
const ins = toPayTo(inputs.map((c) => ({ cellOutput: c.cellOutput, data: c.outputData })));
|
|
539
|
+
const paid = token
|
|
540
|
+
? tokensIn(outs.map((c) => ({ type: c.cellOutput.type, data: c.data })), token) -
|
|
541
|
+
tokensIn(ins.map((c) => ({ type: c.cellOutput.type, data: c.data })), token)
|
|
542
|
+
: [outs, ins]
|
|
543
|
+
.map((cells) => cells.reduce((s, c) => (!c.cellOutput.type ? s + c.cellOutput.capacity : s), 0n))
|
|
544
|
+
.reduce((o, i) => o - i);
|
|
545
|
+
if (paid < owed)
|
|
546
|
+
return "the transaction that created the cell did not pay the price to payTo";
|
|
547
|
+
return { tx, block: ccc.numFrom(created.blockNumber), inputs };
|
|
548
|
+
}
|
|
549
|
+
/** Whether `p` carries `subscriptionMemo(account)` in the witness of an input under the subscriber's lock. */
|
|
550
|
+
function commitsToAccount(p, sub, account) {
|
|
551
|
+
const memo = subscriptionMemo(account);
|
|
552
|
+
return p.inputs.some((c, i) => c?.cellOutput.lock.hash() === sub.subscriberLockHash && p.tx.getWitnessArgsAt(i)?.inputType === memo);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Where a subscription stands, for a creator's server that keeps no database:
|
|
556
|
+
* the subscriber sends the terms' subscriber lock hash, the opening's hash and
|
|
557
|
+
* its account id on every request. The opening must have paid the first price
|
|
558
|
+
* into a cell under exactly this subscription's lock and committed to `account`
|
|
559
|
+
* under the subscriber's signature; then the live cells give the period paid for.
|
|
560
|
+
* Each must have been created by a payment, so a cell re-created by the
|
|
561
|
+
* subscriber to reset its age counts for nothing. Cancelling ends access at once.
|
|
562
|
+
* A token subscription (`sub.token`) counts payments in its token, as `verifySubscription` does.
|
|
563
|
+
*/
|
|
564
|
+
export async function subscriptionStatus(client, deployment, sub, proof) {
|
|
565
|
+
try {
|
|
566
|
+
assertTerms(sub);
|
|
567
|
+
const token = tokenOf(sub);
|
|
568
|
+
if (token)
|
|
569
|
+
await assertPlainXudt(token, client);
|
|
570
|
+
}
|
|
571
|
+
catch (e) {
|
|
572
|
+
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
|
573
|
+
}
|
|
574
|
+
const lock = subscriptionLock(deployment, sub);
|
|
575
|
+
const opening = await paymentOf(client, deployment, proof.openTx, sub);
|
|
576
|
+
if (typeof opening === "string")
|
|
577
|
+
return { ok: false, reason: `opening: ${opening}` };
|
|
578
|
+
if (!opening.tx.outputs.some((o) => o.lock.eq(lock)))
|
|
579
|
+
return { ok: false, reason: "the opening created no cell under this subscription's lock" };
|
|
580
|
+
if (!commitsToAccount(opening, sub, proof.account))
|
|
581
|
+
return { ok: false, reason: "the opening does not commit to this account" };
|
|
582
|
+
const { cells, balance, tokenBalance, nextChargeIn } = await findSubscriptionCells(client, deployment, sub);
|
|
583
|
+
const tip = await client.getTip();
|
|
584
|
+
let paidThroughBlock;
|
|
585
|
+
const openHash = ccc.hexFrom(proof.openTx);
|
|
586
|
+
for (const c of cells) {
|
|
587
|
+
const p = await paymentOf(client, deployment, c.outPoint.txHash, sub);
|
|
588
|
+
if (typeof p === "string")
|
|
589
|
+
continue;
|
|
590
|
+
// Only the proven opening or a charge renews; another payment to payTo that
|
|
591
|
+
// also creates a cell here (a paid read, say) is not a period (review, 27 Oct).
|
|
592
|
+
if (c.outPoint.txHash !== openHash && !spends(p, sub.chargerLockHash))
|
|
593
|
+
continue;
|
|
594
|
+
const through = p.block + sub.periodBlocks;
|
|
595
|
+
if (paidThroughBlock === undefined || through > paidThroughBlock)
|
|
596
|
+
paidThroughBlock = through;
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
ok: true,
|
|
600
|
+
status: {
|
|
601
|
+
paidThroughBlock,
|
|
602
|
+
tip,
|
|
603
|
+
active: paidThroughBlock !== undefined && tip <= paidThroughBlock,
|
|
604
|
+
balance,
|
|
605
|
+
...(sub.token && { tokenBalance }),
|
|
606
|
+
chargesLeft: chargesLeftOf(sub, lock, balance, tokenBalance),
|
|
607
|
+
nextChargeIn,
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* About how many blocks `seconds` is, from the average block time over the last
|
|
613
|
+
* `sample` blocks. Block time varies; round up when a period means "no more often than".
|
|
614
|
+
*/
|
|
615
|
+
export async function blocksForSeconds(client, seconds, sample = 1000n) {
|
|
616
|
+
const tip = await client.getTipHeader();
|
|
617
|
+
const old = await client.getHeaderByNumber(tip.number > sample ? tip.number - sample : 0n);
|
|
618
|
+
if (!old || tip.number === old.number)
|
|
619
|
+
throw new Error("not enough blocks to estimate block time");
|
|
620
|
+
const msPerBlock = Number(tip.timestamp - old.timestamp) / Number(tip.number - old.number);
|
|
621
|
+
return BigInt(Math.ceil((seconds * 1000) / msPerBlock));
|
|
622
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import { type Session } from "./session.js";
|
|
3
|
+
export declare class ScopeError extends Error {
|
|
4
|
+
constructor(reason: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function sessionSigner(session: Session, client: ccc.Client): ccc.SignerCkbPrivateKey;
|
|
7
|
+
/** The address the session key controls. Fund it to give the session a budget. */
|
|
8
|
+
export declare function sessionAddress(session: Session, client: ccc.Client): Promise<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Sends `amount` shannons to `to`, signed by the session key with no wallet
|
|
11
|
+
* dialog. The scope is checked before anything is built or signed.
|
|
12
|
+
*
|
|
13
|
+
* Until the session lock lands, the scope is enforced here in the browser only;
|
|
14
|
+
* the on-chain lock will make the same refusal binding.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sendInScope(session: Session, client: ccc.Client, request: {
|
|
17
|
+
to: string;
|
|
18
|
+
amount: bigint;
|
|
19
|
+
}, now?: Date): Promise<ccc.Hex>;
|
package/dist/transfer.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ccc } from "@ckb-ccc/core";
|
|
2
|
+
import { checkRequest } from "./session.js";
|
|
3
|
+
export class ScopeError extends Error {
|
|
4
|
+
constructor(reason) {
|
|
5
|
+
super(`refused by session scope: ${reason}`);
|
|
6
|
+
this.name = "ScopeError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function sessionSigner(session, client) {
|
|
10
|
+
return new ccc.SignerCkbPrivateKey(client, session.privateKey);
|
|
11
|
+
}
|
|
12
|
+
/** The address the session key controls. Fund it to give the session a budget. */
|
|
13
|
+
export async function sessionAddress(session, client) {
|
|
14
|
+
return sessionSigner(session, client).getRecommendedAddress();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Sends `amount` shannons to `to`, signed by the session key with no wallet
|
|
18
|
+
* dialog. The scope is checked before anything is built or signed.
|
|
19
|
+
*
|
|
20
|
+
* Until the session lock lands, the scope is enforced here in the browser only;
|
|
21
|
+
* the on-chain lock will make the same refusal binding.
|
|
22
|
+
*/
|
|
23
|
+
export async function sendInScope(session, client, request, now = new Date()) {
|
|
24
|
+
const check = checkRequest(session, request, now);
|
|
25
|
+
if (!check.ok)
|
|
26
|
+
throw new ScopeError(check.reason);
|
|
27
|
+
const signer = sessionSigner(session, client);
|
|
28
|
+
const { script: lock } = await ccc.Address.fromString(request.to, client);
|
|
29
|
+
const tx = ccc.Transaction.from({ outputs: [{ lock, capacity: request.amount }] });
|
|
30
|
+
await tx.completeInputsByCapacity(signer);
|
|
31
|
+
await tx.completeFeeBy(signer);
|
|
32
|
+
return signer.sendTransaction(tx);
|
|
33
|
+
}
|