ckb-session-kit 2.0.0 → 2.1.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 +32 -0
- package/README.md +797 -718
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/onchain.d.ts +88 -1
- package/dist/onchain.js +182 -2
- package/dist/session.d.ts +7 -2
- package/package.json +3 -1
package/dist/index.d.ts
CHANGED
|
@@ -6,8 +6,8 @@ export { ScopeError, sendInScope, sessionAddress, sessionSigner } from "./transf
|
|
|
6
6
|
export { relativeBlocksSince, SESSION_LOCK_ERRORS, sessionLockArgs, sessionLockCellDep, sessionLockErrorFrom, sessionLockScript, TESTNET_DEPLOYMENT, TESTNET_LEGACY_DEPLOYMENTS, } from "./lock.js";
|
|
7
7
|
export type { SessionLockDeployment, SessionLockParams } from "./lock.js";
|
|
8
8
|
export { assertCurrentDeployment } from "./deployment.js";
|
|
9
|
-
export { assertPlainXudt, assertSigningLock, closeSession, createPayoutCells, DEFAULT_RETRY, findOwnedSessionCells, findSessionCells, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, SendOutcomeUnknownError, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
|
|
10
|
-
export type { OnChainBinding, RetryOptions, SessionBalanceReason, SessionCells, SessionWarning, SigningLockOptions, } from "./onchain.js";
|
|
9
|
+
export { assertPlainXudt, assertSigningLock, closeSession, createPayoutCells, DEFAULT_FUND_FEE_RATE, DEFAULT_RETRY, deadInputs, findOwnedSessionCells, findSessionCells, fundInSession, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, SendOutcomeUnknownError, sendWithSession, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
|
|
10
|
+
export type { FundedTransaction, FundOptions, OnChainBinding, RetryOptions, SendWithSessionOptions, SessionBalanceReason, SessionCells, SessionWarning, SigningLockOptions, } from "./onchain.js";
|
|
11
11
|
export { blocksForSeconds, cancelSubscription, chargeSubscription, clearPlantedCells, findSubscriptionCells, MAX_SUBSCRIPTION_CELLS, openSubscription, subscriptionLock, subscriptionMemo, subscriptionStatus, verifySubscription, } from "./subscription.js";
|
|
12
12
|
export type { Subscription, SubscriptionCheck, SubscriptionStatus } from "./subscription.js";
|
|
13
13
|
export { paymentMemo, signAccess, verifyAccess } from "./paywall.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export { deserializeSession, indexedDbStore, memoryStore, serializeSession, Sess
|
|
|
3
3
|
export { ScopeError, sendInScope, sessionAddress, sessionSigner } from "./transfer.js";
|
|
4
4
|
export { relativeBlocksSince, SESSION_LOCK_ERRORS, sessionLockArgs, sessionLockCellDep, sessionLockErrorFrom, sessionLockScript, TESTNET_DEPLOYMENT, TESTNET_LEGACY_DEPLOYMENTS, } from "./lock.js";
|
|
5
5
|
export { assertCurrentDeployment } from "./deployment.js";
|
|
6
|
-
export { assertPlainXudt, assertSigningLock, closeSession, createPayoutCells, DEFAULT_RETRY, findOwnedSessionCells, findSessionCells, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, SendOutcomeUnknownError, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
|
|
6
|
+
export { assertPlainXudt, assertSigningLock, closeSession, createPayoutCells, DEFAULT_FUND_FEE_RATE, DEFAULT_RETRY, deadInputs, findOwnedSessionCells, findSessionCells, fundInSession, keyLockOf, matureCells, MAX_PAYOUT_CELLS, MAX_POOL_CHECKS, onchainRecipient, openSession, pickPayoutCell, poolConflict, recoverSessions, SendOutcomeUnknownError, sendWithSession, SessionBalanceError, sessionLockOf, spendInSession, tokenAmount, } from "./onchain.js";
|
|
7
7
|
export { blocksForSeconds, cancelSubscription, chargeSubscription, clearPlantedCells, findSubscriptionCells, MAX_SUBSCRIPTION_CELLS, openSubscription, subscriptionLock, subscriptionMemo, subscriptionStatus, verifySubscription, } from "./subscription.js";
|
|
8
8
|
export { paymentMemo, signAccess, verifyAccess } from "./paywall.js";
|
|
9
9
|
export { applyPlacements, checkPlacement, decodePlacement, encodePlacement, PLACE_BOARD, placementMemo, placementPrice, PlaceMemoError, } from "./place.js";
|
package/dist/onchain.d.ts
CHANGED
|
@@ -351,12 +351,99 @@ type SpendRequest = SpendCommon & ({
|
|
|
351
351
|
token: bigint;
|
|
352
352
|
amount?: undefined;
|
|
353
353
|
});
|
|
354
|
+
/**
|
|
355
|
+
* The fee rate `fundInSession` and `sendWithSession` use when none is given, in
|
|
356
|
+
* shannons per 1000 bytes (twice the usual testnet minimum). `sendWithSession` keeps the
|
|
357
|
+
* same rate on every attempt (see there).
|
|
358
|
+
*/
|
|
359
|
+
export declare const DEFAULT_FUND_FEE_RATE = 2000n;
|
|
360
|
+
/** Options for {@link fundInSession}. */
|
|
361
|
+
export interface FundOptions {
|
|
362
|
+
/** Shannons per 1000 bytes (default `DEFAULT_FUND_FEE_RATE`). The key cell pays it. */
|
|
363
|
+
feeRate?: ccc.NumLike;
|
|
364
|
+
/**
|
|
365
|
+
* The app's own policy on the finished transaction (which outputs it may hold),
|
|
366
|
+
* run last, before anything is signed: throw to refuse it. It guards against
|
|
367
|
+
* app bugs; a leaked key is bounded by the lock, not by this.
|
|
368
|
+
*/
|
|
369
|
+
check?: (tx: ccc.Transaction) => void | Promise<void>;
|
|
370
|
+
now?: Date;
|
|
371
|
+
/** Out points (as `ccc.hexFrom(outPoint.toBytes())`) of session or key cells not to take. */
|
|
372
|
+
avoid?: ReadonlySet<string>;
|
|
373
|
+
}
|
|
374
|
+
/** What {@link fundInSession} returns. */
|
|
375
|
+
export interface FundedTransaction {
|
|
376
|
+
/** The app's transaction with the session's inputs and outputs added, unsigned. */
|
|
377
|
+
tx: ccc.Transaction;
|
|
378
|
+
/** Shannons leaving the session cells (session inputs less change): what the lock holds to `maxPerTx`. */
|
|
379
|
+
outflow: bigint;
|
|
380
|
+
/** The inputs the kit added: session cells, then the key cell (and any key cell CCC added for the fee). */
|
|
381
|
+
sessionInputs: ccc.OutPoint[];
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Funds a transaction the app built (its own inputs, typed outputs, cell deps
|
|
385
|
+
* and witnesses) from the session: adds session cells as inputs, at most one
|
|
386
|
+
* plain change cell under the session lock, and the key cell, which comes back
|
|
387
|
+
* as the last output and pays the fee. Signs nothing; `sendWithSession` signs
|
|
388
|
+
* and sends. The checks follow the order the session lock makes them, so a
|
|
389
|
+
* transaction this returns is one the lock accepts:
|
|
390
|
+
*
|
|
391
|
+
* 1. The session is active, and has no on-chain recipient: a recipient-scoped
|
|
392
|
+
* session can only pay that recipient and the owner, never a typed output
|
|
393
|
+
* (lock errors 13 and 18), so it is refused (`SessionPolicyError`,
|
|
394
|
+
* "session-has-recipient").
|
|
395
|
+
* 2. No input of the app's is under the owner's lock: the lock would run in owner
|
|
396
|
+
* mode, and the wallet would have to sign ("owner-lock-input"). None is under
|
|
397
|
+
* the session lock or the key's lock, and no output is either: the kit adds
|
|
398
|
+
* those ("app-tx-shape").
|
|
399
|
+
* 3. Need = the outputs' capacity less the app inputs'. Over `maxPerTx`, a
|
|
400
|
+
* `ScopeError` before anything is looked up. Session cells are taken (plain
|
|
401
|
+
* ones only, mature ones under a rate limit, with `since` set), and change
|
|
402
|
+
* is either none or at least a session cell's size (121 CKB for 80-byte args).
|
|
403
|
+
* 4. The key cell: the largest, in last, out last; the fee (`feeRate`) comes out
|
|
404
|
+
* of it, so the fee never counts as outflow.
|
|
405
|
+
* 5. outflow = session inputs less change, checked against `maxPerTx` again;
|
|
406
|
+
* then `opts.check`.
|
|
407
|
+
*
|
|
408
|
+
* The app's inputs, witnesses and cell deps stay where they are; the kit's
|
|
409
|
+
* inputs go after them. `tx` itself is not changed.
|
|
410
|
+
*/
|
|
411
|
+
export declare function fundInSession(session: Session, client: ccc.Client, deployment: SessionLockDeployment, binding: OnChainBinding, txLike: ccc.TransactionLike, opts?: FundOptions): Promise<FundedTransaction>;
|
|
412
|
+
/** Options for {@link sendWithSession}. */
|
|
413
|
+
export interface SendWithSessionOptions extends Omit<FundOptions, "avoid"> {
|
|
414
|
+
/** Runs with the signed transaction's hash before it is sent, on every attempt (see `signAndSend`). */
|
|
415
|
+
beforeSend?: (txHash: ccc.Hex) => Promise<void>;
|
|
416
|
+
/** Attempts and backoff for a refusal naming one of the session's own cells (default `DEFAULT_RETRY`). */
|
|
417
|
+
retry?: RetryOptions;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Builds the app's transaction (`build`), funds it from the session
|
|
421
|
+
* (`fundInSession`), signs it with the session key alone and sends it. No
|
|
422
|
+
* wallet is involved: the key signs only its own cell, and the session lock
|
|
423
|
+
* checks no signature. Returns the transaction hash.
|
|
424
|
+
*
|
|
425
|
+
* Retries only when the node refuses the transaction for one of the session's
|
|
426
|
+
* own cells (`Dead` or `Unknown` naming a session or key cell this attempt
|
|
427
|
+
* added: another tab spent it). Then the client's cache is cleared, that cell
|
|
428
|
+
* is left out, and `build` runs again, with the backoff of `rebuildOnDeadInputs`.
|
|
429
|
+
*
|
|
430
|
+
* Any other refusal is thrown unchanged, for the app's own loop: a dead input of
|
|
431
|
+
* the app's (another user took the cell the app built on, such as a
|
|
432
|
+
* crowdfunding campaign cell; `deadInputs(e)` is true) or an RBF refusal
|
|
433
|
+
* (`poolConflict(e)`), which names no cell. The app waits for its cell to move
|
|
434
|
+
* and calls again. A send whose outcome is unknown is never retried.
|
|
435
|
+
*
|
|
436
|
+
* The fee rate is the same on every attempt (`feeRate`, default
|
|
437
|
+
* `DEFAULT_FUND_FEE_RATE`): a retry at a higher rate could replace (RBF)
|
|
438
|
+
* another user's pending transaction on the same app cell.
|
|
439
|
+
*/
|
|
440
|
+
export declare function sendWithSession(session: Session, client: ccc.Client, deployment: SessionLockDeployment, binding: OnChainBinding, build: () => Promise<ccc.TransactionLike>, opts?: SendWithSessionOptions): Promise<ccc.Hex>;
|
|
354
441
|
/**
|
|
355
442
|
* `completeFeeChangeToOutput` into the last output, with CCC's "Insufficient CKB"
|
|
356
443
|
* (the paying cell holds its floor but not the fee too) as a
|
|
357
444
|
* `SessionBalanceError` with reason `no-fee-cell` (audit round 2, 31 Oct).
|
|
358
445
|
*/
|
|
359
|
-
export declare function completeFeeOrNoFeeCell(tx: ccc.Transaction, signer: ccc.Signer, message: string): Promise<void>;
|
|
446
|
+
export declare function completeFeeOrNoFeeCell(tx: ccc.Transaction, signer: ccc.Signer, message: string, feeRate?: ccc.NumLike): Promise<void>;
|
|
360
447
|
/** The cells created at least `blocks` blocks before the tip: those a relative `since` of `blocks` can spend. */
|
|
361
448
|
export declare function matureCells(client: ccc.Client, cells: ccc.Cell[], blocks: bigint): Promise<ccc.Cell[]>;
|
|
362
449
|
/**
|
package/dist/onchain.js
CHANGED
|
@@ -827,14 +827,194 @@ async function spendOnce(session, client, deployment, binding, request, now, avo
|
|
|
827
827
|
}
|
|
828
828
|
return signAndSend(sessionSigner(session, client), tx, request.beforeSend);
|
|
829
829
|
}
|
|
830
|
+
/**
|
|
831
|
+
* The fee rate `fundInSession` and `sendWithSession` use when none is given, in
|
|
832
|
+
* shannons per 1000 bytes (twice the usual testnet minimum). `sendWithSession` keeps the
|
|
833
|
+
* same rate on every attempt (see there).
|
|
834
|
+
*/
|
|
835
|
+
export const DEFAULT_FUND_FEE_RATE = 2000n;
|
|
836
|
+
/**
|
|
837
|
+
* Funds a transaction the app built (its own inputs, typed outputs, cell deps
|
|
838
|
+
* and witnesses) from the session: adds session cells as inputs, at most one
|
|
839
|
+
* plain change cell under the session lock, and the key cell, which comes back
|
|
840
|
+
* as the last output and pays the fee. Signs nothing; `sendWithSession` signs
|
|
841
|
+
* and sends. The checks follow the order the session lock makes them, so a
|
|
842
|
+
* transaction this returns is one the lock accepts:
|
|
843
|
+
*
|
|
844
|
+
* 1. The session is active, and has no on-chain recipient: a recipient-scoped
|
|
845
|
+
* session can only pay that recipient and the owner, never a typed output
|
|
846
|
+
* (lock errors 13 and 18), so it is refused (`SessionPolicyError`,
|
|
847
|
+
* "session-has-recipient").
|
|
848
|
+
* 2. No input of the app's is under the owner's lock: the lock would run in owner
|
|
849
|
+
* mode, and the wallet would have to sign ("owner-lock-input"). None is under
|
|
850
|
+
* the session lock or the key's lock, and no output is either: the kit adds
|
|
851
|
+
* those ("app-tx-shape").
|
|
852
|
+
* 3. Need = the outputs' capacity less the app inputs'. Over `maxPerTx`, a
|
|
853
|
+
* `ScopeError` before anything is looked up. Session cells are taken (plain
|
|
854
|
+
* ones only, mature ones under a rate limit, with `since` set), and change
|
|
855
|
+
* is either none or at least a session cell's size (121 CKB for 80-byte args).
|
|
856
|
+
* 4. The key cell: the largest, in last, out last; the fee (`feeRate`) comes out
|
|
857
|
+
* of it, so the fee never counts as outflow.
|
|
858
|
+
* 5. outflow = session inputs less change, checked against `maxPerTx` again;
|
|
859
|
+
* then `opts.check`.
|
|
860
|
+
*
|
|
861
|
+
* The app's inputs, witnesses and cell deps stay where they are; the kit's
|
|
862
|
+
* inputs go after them. `tx` itself is not changed.
|
|
863
|
+
*/
|
|
864
|
+
export async function fundInSession(session, client, deployment, binding, txLike, opts = {}) {
|
|
865
|
+
const now = opts.now ?? new Date();
|
|
866
|
+
if (!isActive(session, now))
|
|
867
|
+
throw new ScopeError("session expired");
|
|
868
|
+
if (binding.recipientLockHash) {
|
|
869
|
+
throw new SessionPolicyError("an app transaction with typed outputs to other locks needs a session without a recipient: " +
|
|
870
|
+
"this session's lock only lets it pay its recipient and the owner, in plain cells", "session-has-recipient");
|
|
871
|
+
}
|
|
872
|
+
const { maxPerTx } = session.policy.scope;
|
|
873
|
+
const tx = ccc.Transaction.from(txLike).clone();
|
|
874
|
+
const appInputs = tx.inputs.length;
|
|
875
|
+
const state = await findSessionCells(session, client, deployment, binding);
|
|
876
|
+
const ownerHash = ccc.Script.from(binding.ownerLock).hash();
|
|
877
|
+
const ownHashes = [state.lock.hash(), state.keyLock.hash()];
|
|
878
|
+
for (const [i, input] of tx.inputs.entries()) {
|
|
879
|
+
await input.completeExtraInfos(client);
|
|
880
|
+
const lockHash = input.cellOutput?.lock.hash();
|
|
881
|
+
if (lockHash === ownerHash) {
|
|
882
|
+
throw new SessionPolicyError(`input ${i} is under the session owner's lock: the session lock would run in owner mode and ` +
|
|
883
|
+
"the wallet would have to sign; fund this transaction from the wallet instead", "owner-lock-input");
|
|
884
|
+
}
|
|
885
|
+
if (lockHash && ownHashes.includes(lockHash)) {
|
|
886
|
+
throw new SessionPolicyError(`input ${i} is a session or key cell: fundInSession adds those itself`, "app-tx-shape");
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
for (const [i, output] of tx.outputs.entries()) {
|
|
890
|
+
if (ownHashes.includes(output.lock.hash())) {
|
|
891
|
+
throw new SessionPolicyError(`output ${i} is under the session lock or the key's lock: fundInSession adds the change and key cells itself`, "app-tx-shape");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const need = tx.getOutputsCapacity() - (await tx.getInputsCapacity(client));
|
|
895
|
+
if (need < 0n) {
|
|
896
|
+
throw new SessionPolicyError(`the app transaction's inputs exceed its outputs by ${ccc.fixedPointToString(-need)} CKB: ` +
|
|
897
|
+
"give the surplus an output, or it would go to the key cell", "app-tx-shape");
|
|
898
|
+
}
|
|
899
|
+
if (need > maxPerTx) {
|
|
900
|
+
throw new ScopeError(`the transaction needs ${ccc.fixedPointToString(need)} CKB from the session, over maxPerTx ` +
|
|
901
|
+
`(${ccc.fixedPointToString(maxPerTx)} CKB)`);
|
|
902
|
+
}
|
|
903
|
+
const avoid = opts.avoid ?? new Set();
|
|
904
|
+
const free = (c) => !avoid.has(outPointKey(c.outPoint));
|
|
905
|
+
const keyCells = state.keyCells.filter(free);
|
|
906
|
+
if (!keyCells.length)
|
|
907
|
+
throw new SessionBalanceError("no key cell left to pay fees", "no-fee-cell");
|
|
908
|
+
// Plain cells only: in a token session the tokens stay where they are.
|
|
909
|
+
const plain = state.sessionCells.filter((c) => !c.cellOutput.type && free(c));
|
|
910
|
+
const usable = need > 0n && binding.minInterval > 0n ? await matureCells(client, plain, binding.minInterval) : plain;
|
|
911
|
+
if (need > 0n && plain.length && !usable.length) {
|
|
912
|
+
throw new SessionBalanceError(`rate limited: no session cell is ${binding.minInterval} blocks old yet`);
|
|
913
|
+
}
|
|
914
|
+
// Change is none, or a whole session cell: take one more cell rather than leave a sliver.
|
|
915
|
+
const minChange = ccc.CellOutput.from({ lock: state.lock }, "0x").capacity;
|
|
916
|
+
const taken = [];
|
|
917
|
+
let ckbIn = 0n;
|
|
918
|
+
for (const cell of usable) {
|
|
919
|
+
if (ckbIn >= need && (ckbIn === need || ckbIn - need >= minChange))
|
|
920
|
+
break;
|
|
921
|
+
taken.push(cell);
|
|
922
|
+
ckbIn += cell.cellOutput.capacity;
|
|
923
|
+
}
|
|
924
|
+
const balance = plain.reduce((s, c) => s + c.cellOutput.capacity, 0n);
|
|
925
|
+
if (ckbIn < need) {
|
|
926
|
+
throw new SessionBalanceError(`session balance ${ccc.fixedPointToString(balance)} CKB is too low: the transaction needs ${ccc.fixedPointToString(need)} CKB`);
|
|
927
|
+
}
|
|
928
|
+
const change = ckbIn - need;
|
|
929
|
+
if (change > 0n && change < minChange) {
|
|
930
|
+
throw new SessionBalanceError(`the session would keep ${ccc.fixedPointToString(change)} CKB, below the ` +
|
|
931
|
+
`${ccc.fixedPointToString(minChange)} CKB a session cell needs; spend all of it or less`);
|
|
932
|
+
}
|
|
933
|
+
const since = binding.minInterval > 0n ? relativeBlocksSince(binding.minInterval) : 0n;
|
|
934
|
+
tx.inputs.push(...taken.map((c) => ccc.CellInput.from({ previousOutput: c.outPoint, since })));
|
|
935
|
+
if (taken.length)
|
|
936
|
+
tx.addCellDeps(sessionLockCellDep(deployment));
|
|
937
|
+
if (change > 0n)
|
|
938
|
+
tx.addOutput({ lock: state.lock, capacity: change }, "0x");
|
|
939
|
+
// The key cell authorises the spend, comes back last and pays the fee (as in spendOnce).
|
|
940
|
+
tx.inputs.push(ccc.CellInput.from({ previousOutput: largestCell(keyCells).outPoint }));
|
|
941
|
+
tx.addOutput({ lock: state.keyLock, capacity: 0n }, "0x");
|
|
942
|
+
await completeFeeOrNoFeeCell(tx, sessionSigner(session, client), "the key cell cannot cover the fee; top it up", opts.feeRate ?? DEFAULT_FUND_FEE_RATE);
|
|
943
|
+
if (tx.outputs[tx.outputs.length - 1].capacity < keyCellFloor(binding.ownerLock)) {
|
|
944
|
+
throw new SessionBalanceError("the key cell cannot cover another fee; top it up", "no-fee-cell");
|
|
945
|
+
}
|
|
946
|
+
// The lock's own rules on the result (1: outflow; 3 and 5: one plain change cell).
|
|
947
|
+
const outflow = taken.reduce((s, c) => s + c.cellOutput.capacity, 0n) - change;
|
|
948
|
+
if (outflow > maxPerTx) {
|
|
949
|
+
throw new ScopeError(`outflow ${ccc.fixedPointToString(outflow)} CKB exceeds maxPerTx (${ccc.fixedPointToString(maxPerTx)} CKB)`);
|
|
950
|
+
}
|
|
951
|
+
const changeCells = tx.outputs.filter((o) => o.lock.eq(state.lock));
|
|
952
|
+
if (changeCells.length > 1 || changeCells.some((o) => o.type)) {
|
|
953
|
+
throw new SessionPolicyError("the session lock allows one plain change cell", "app-tx-shape");
|
|
954
|
+
}
|
|
955
|
+
await opts.check?.(tx);
|
|
956
|
+
return { tx, outflow, sessionInputs: tx.inputs.slice(appInputs).map((i) => i.previousOutput) };
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Builds the app's transaction (`build`), funds it from the session
|
|
960
|
+
* (`fundInSession`), signs it with the session key alone and sends it. No
|
|
961
|
+
* wallet is involved: the key signs only its own cell, and the session lock
|
|
962
|
+
* checks no signature. Returns the transaction hash.
|
|
963
|
+
*
|
|
964
|
+
* Retries only when the node refuses the transaction for one of the session's
|
|
965
|
+
* own cells (`Dead` or `Unknown` naming a session or key cell this attempt
|
|
966
|
+
* added: another tab spent it). Then the client's cache is cleared, that cell
|
|
967
|
+
* is left out, and `build` runs again, with the backoff of `rebuildOnDeadInputs`.
|
|
968
|
+
*
|
|
969
|
+
* Any other refusal is thrown unchanged, for the app's own loop: a dead input of
|
|
970
|
+
* the app's (another user took the cell the app built on, such as a
|
|
971
|
+
* crowdfunding campaign cell; `deadInputs(e)` is true) or an RBF refusal
|
|
972
|
+
* (`poolConflict(e)`), which names no cell. The app waits for its cell to move
|
|
973
|
+
* and calls again. A send whose outcome is unknown is never retried.
|
|
974
|
+
*
|
|
975
|
+
* The fee rate is the same on every attempt (`feeRate`, default
|
|
976
|
+
* `DEFAULT_FUND_FEE_RATE`): a retry at a higher rate could replace (RBF)
|
|
977
|
+
* another user's pending transaction on the same app cell.
|
|
978
|
+
*/
|
|
979
|
+
export async function sendWithSession(session, client, deployment, binding, build, opts = {}) {
|
|
980
|
+
const attempts = Math.max(1, Math.floor(opts.retry?.attempts ?? DEFAULT_RETRY.attempts));
|
|
981
|
+
const delay = Math.max(0, opts.retry?.delayMs ?? DEFAULT_RETRY.delayMs);
|
|
982
|
+
const feeRate = opts.feeRate ?? DEFAULT_FUND_FEE_RATE; // fixed for every attempt
|
|
983
|
+
const avoid = new Set();
|
|
984
|
+
for (let attempt = 1;; attempt++) {
|
|
985
|
+
let ours = new Set();
|
|
986
|
+
try {
|
|
987
|
+
const funded = await fundInSession(session, client, deployment, binding, await build(), {
|
|
988
|
+
feeRate,
|
|
989
|
+
check: opts.check,
|
|
990
|
+
now: opts.now,
|
|
991
|
+
avoid,
|
|
992
|
+
});
|
|
993
|
+
ours = new Set(funded.sessionInputs.map(outPointKey));
|
|
994
|
+
return await signAndSend(sessionSigner(session, client), funded.tx, opts.beforeSend);
|
|
995
|
+
}
|
|
996
|
+
catch (e) {
|
|
997
|
+
const named = e instanceof SendOutcomeUnknownError ? undefined : rejectedOutPoint(e);
|
|
998
|
+
if (!named || !ours.has(named) || attempt >= attempts)
|
|
999
|
+
throw e;
|
|
1000
|
+
avoid.add(named);
|
|
1001
|
+
await client.cache?.clear();
|
|
1002
|
+
const wait = delay * 2 ** (attempt - 1) * (0.5 + Math.random());
|
|
1003
|
+
if (wait > 0)
|
|
1004
|
+
await sleep(wait);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
830
1008
|
/**
|
|
831
1009
|
* `completeFeeChangeToOutput` into the last output, with CCC's "Insufficient CKB"
|
|
832
1010
|
* (the paying cell holds its floor but not the fee too) as a
|
|
833
1011
|
* `SessionBalanceError` with reason `no-fee-cell` (audit round 2, 31 Oct).
|
|
834
1012
|
*/
|
|
835
|
-
export async function completeFeeOrNoFeeCell(tx, signer, message) {
|
|
1013
|
+
export async function completeFeeOrNoFeeCell(tx, signer, message, feeRate) {
|
|
836
1014
|
try {
|
|
837
|
-
await
|
|
1015
|
+
await (feeRate === undefined
|
|
1016
|
+
? tx.completeFeeChangeToOutput(signer, tx.outputs.length - 1)
|
|
1017
|
+
: tx.completeFeeChangeToOutput(signer, tx.outputs.length - 1, feeRate));
|
|
838
1018
|
}
|
|
839
1019
|
catch (e) {
|
|
840
1020
|
// By class, or by message where a second copy of CCC threw it (a bundler's CJS and ESM builds).
|
package/dist/session.d.ts
CHANGED
|
@@ -50,9 +50,14 @@ export interface CreateOptions {
|
|
|
50
50
|
* Why a policy error is not about the request itself: "payto-not-anyone-can-pay",
|
|
51
51
|
* the payer's own setup (a charge can never top such a `payTo` up); or
|
|
52
52
|
* "too-many-cells", more cells under a subscription's lock than a status or a
|
|
53
|
-
* charge reads (`MAX_SUBSCRIPTION_CELLS`).
|
|
53
|
+
* charge reads (`MAX_SUBSCRIPTION_CELLS`). From `fundInSession`:
|
|
54
|
+
* "session-has-recipient", the session has an on-chain recipient, so it cannot
|
|
55
|
+
* fund outputs to other locks; "owner-lock-input", the app transaction spends a
|
|
56
|
+
* cell under the owner's lock, which would switch the session lock to owner
|
|
57
|
+
* mode; "app-tx-shape", the app transaction already holds cells only the kit
|
|
58
|
+
* may add (session or key cells), or its inputs exceed its outputs.
|
|
54
59
|
*/
|
|
55
|
-
export type SessionPolicyReason = "payto-not-anyone-can-pay" | "too-many-cells" | "owner-lock-unsafe" | "owner-lock-unreviewed";
|
|
60
|
+
export type SessionPolicyReason = "payto-not-anyone-can-pay" | "too-many-cells" | "owner-lock-unsafe" | "owner-lock-unreviewed" | "session-has-recipient" | "owner-lock-input" | "app-tx-shape";
|
|
56
61
|
export declare class SessionPolicyError extends Error {
|
|
57
62
|
readonly reason?: SessionPolicyReason;
|
|
58
63
|
constructor(message: string, reason?: SessionPolicyReason);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ckb-session-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Browser-held self-custody sessions for CKB applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ckb",
|
|
@@ -57,6 +57,8 @@
|
|
|
57
57
|
"smoke:contention": "tsx scripts/testnet-contention.ts",
|
|
58
58
|
"smoke:clear-planted": "tsx scripts/testnet-clear-planted.ts",
|
|
59
59
|
"smoke:owner-locks": "tsx scripts/testnet-owner-locks.ts",
|
|
60
|
+
"smoke:fund-pledge": "tsx scripts/testnet-fund-pledge.ts",
|
|
61
|
+
"smoke:pool-lag": "tsx scripts/testnet-pool-lag.ts",
|
|
60
62
|
"matrix:wallets": "tsx scripts/wallet-matrix.ts",
|
|
61
63
|
"prepack": "npm run build",
|
|
62
64
|
"pack:check": "node scripts/pack-check.mjs"
|