Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ Callers own the final completion pipeline:
2. Before send, call `sdk.completeTransaction(...)` or `completeIckbTransaction(...)` from `@ickb/sdk`.
3. Only then send the transaction.

Withdrawal requests built from public pool ready deposits may include `requiredLiveDeposits`. `@ickb/sdk` adds those cells as live `cell_dep` checks so a transaction fails if a protected pool anchor disappears before inclusion.

## Scan Completeness Boundary

Stack cell scans that feed account state, pool state, order books, or maturity estimates request one sentinel entry beyond the configured logical limit and fail closed if the sentinel appears. Callers should treat these errors as incomplete state, not as zero balance or unavailable liquidity.

## User Lock Assumption

Current stack flows assume user-owned cells are protected by locks whose signatures bind the whole transaction, such as standard `sighash` wallet flows. Passing a raw `ccc.Script` is only safe when that lock gives the same output and recipient binding. Delegated-signature or OTX-style locks are integration-specific and must account for the weak-lock boundary documented in the iCKB whitepaper and contracts audit.
Expand Down
6 changes: 3 additions & 3 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ graph TD;

If a caller will send the returned transaction, it still must:

1. Finish iCKB UDT completion.
2. Finish CCC-native CKB capacity and fee completion.
3. Check `ccc.isDaoOutputLimitExceeded(...)` before send.
1. Complete the transaction before send.
2. Prefer the shared stack path in `@ickb/sdk`: `sdk.completeTransaction(...)` or `completeIckbTransaction(...)`.
3. Only use lower-level manual completion when the caller intentionally owns UDT completion, CCC-native fee/capacity completion, and the DAO output-limit check itself.

## Epoch Semantic Versioning

Expand Down
3 changes: 3 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,8 @@
"@ickb/dao": "workspace:*",
"@ickb/utils": "workspace:*",
"tslib": "^2.8.1"
},
"devDependencies": {
"@ickb/testkit": "workspace:*"
}
}
56 changes: 5 additions & 51 deletions packages/core/src/cells.test.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,17 @@
import { ccc } from "@ckb-ccc/core";
import { byte32FromByte, headerLike as testHeaderLike, script } from "@ickb/testkit";
import { describe, expect, it, vi } from "vitest";
import { DaoManager } from "@ickb/dao";
import { receiptCellFrom } from "./cells.js";
import { ReceiptData } from "./entities.js";
import { IckbUdt, ickbValue } from "./udt.js";

function byte32FromByte(hexByte: string): `0x${string}` {
if (!/^[0-9a-f]{2}$/iu.test(hexByte)) {
throw new Error("Expected exactly one byte as two hex chars");
}
return `0x${hexByte.repeat(32)}`;
}

function script(codeHashByte: string): ccc.Script {
return ccc.Script.from({
codeHash: byte32FromByte(codeHashByte),
hashType: "type",
args: "0x",
});
}

function headerLike(ar: bigint): {
compactTarget: bigint;
dao: {
c: bigint;
ar: bigint;
s: bigint;
u: bigint;
};
epoch: [bigint, bigint, bigint];
extraHash: `0x${string}`;
hash: `0x${string}`;
nonce: bigint;
number: bigint;
parentHash: `0x${string}`;
proposalsHash: `0x${string}`;
timestamp: bigint;
transactionsRoot: `0x${string}`;
version: bigint;
} {
return {
compactTarget: 0n,
dao: {
c: 0n,
ar,
s: 0n,
u: 0n,
},
function headerLike(ar: bigint): ccc.ClientBlockHeader {
return testHeaderLike({
dao: { c: 0n, ar, s: 0n, u: 0n },
epoch: [1n, 0n, 1n],
extraHash: byte32FromByte("aa"),
hash: byte32FromByte("bb"),
nonce: 0n,
number: 1n,
parentHash: byte32FromByte("cc"),
proposalsHash: byte32FromByte("dd"),
timestamp: 0n,
transactionsRoot: byte32FromByte("ee"),
version: 0n,
};
});
}

function clientWithHeader(header: ccc.ClientBlockHeader): ccc.Client {
Expand Down
88 changes: 73 additions & 15 deletions packages/core/src/logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
import { ccc } from "@ckb-ccc/core";
import { byte32FromByte, script } from "@ickb/testkit";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DaoManager } from "@ickb/dao";
import { collect } from "@ickb/utils";
import { ReceiptData } from "./entities.js";
import { LogicManager } from "./logic.js";

function byte32FromByte(hexByte: string): `0x${string}` {
if (!/^[0-9a-f]{2}$/iu.test(hexByte)) {
throw new Error("Expected exactly one byte as two hex chars");
}
return `0x${hexByte.repeat(32)}`;
}

function script(codeHashByte: string): ccc.Script {
return ccc.Script.from({
codeHash: byte32FromByte(codeHashByte),
hashType: "type",
args: "0x",
});
}

describe("LogicManager.deposit", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -91,6 +77,36 @@ describe("LogicManager.deposit", () => {
);
});

it("rejects non-safe-integer deposit quantities before allocation", async () => {
const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), []));

for (const quantity of [1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
await expect(
manager.deposit(
ccc.Transaction.default(),
quantity,
ccc.fixedPointFrom(1082),
script("33"),
{} as ccc.Client,
),
).rejects.toThrow("iCKB deposit quantity must be a safe integer");
}
});

it("rejects deposit quantities that cannot fit in one DAO transaction", async () => {
const manager = new LogicManager(script("11"), [], new DaoManager(script("22"), []));

await expect(
manager.deposit(
ccc.Transaction.default(),
64,
ccc.fixedPointFrom(1082),
script("33"),
{} as ccc.Client,
),
).rejects.toThrow("iCKB deposit quantity maximum is 63");
});

it("filters receipts by exact lock and type while deduplicating locks", async () => {
const logic = script("11");
const wantedLock = script("22");
Expand Down Expand Up @@ -245,4 +261,46 @@ describe("LogicManager.deposit", () => {
secondReceipt.outPoint.txHash,
]);
});

it("fails closed when receipt scanning exceeds the limit", async () => {
const logic = script("11");
const wantedLock = script("22");
const receiptData = ReceiptData.from({
depositQuantity: 1,
depositAmount: ccc.fixedPointFrom(100000),
}).toBytes();
const firstReceipt = ccc.Cell.from({
outPoint: { txHash: byte32FromByte("44"), index: 0n },
cellOutput: {
capacity: ccc.fixedPointFrom(100082),
lock: wantedLock,
type: logic,
},
outputData: receiptData,
});
const secondReceipt = ccc.Cell.from({
outPoint: { txHash: byte32FromByte("55"), index: 0n },
cellOutput: {
capacity: ccc.fixedPointFrom(100082),
lock: wantedLock,
type: logic,
},
outputData: receiptData,
});
let requestedLimit = 0;
const client = {
findCells: async function* (_query: unknown, _order: unknown, limit: number) {
requestedLimit = limit;
await Promise.resolve();
yield firstReceipt;
yield secondReceipt;
},
} as unknown as ccc.Client;
const manager = new LogicManager(logic, [], new DaoManager(script("88"), []));

await expect(
collect(manager.findReceipts(client, [wantedLock], { limit: 1 })),
).rejects.toThrow("receipt cell scan reached limit 1; state may be incomplete");
expect(requestedLimit).toBe(2);
});
});
28 changes: 17 additions & 11 deletions packages/core/src/logic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ccc } from "@ckb-ccc/core";
import { assertDaoOutputLimit, DaoManager } from "@ickb/dao";
import {
collectCompleteScan,
defaultFindCellsLimit,
type ScriptDeps,
unique,
Expand All @@ -13,6 +14,8 @@ import {
} from "./cells.js";
import { ReceiptData } from "./entities.js";

const maxDepositQuantity = 63;

/**
* Manages logic related to deposits and receipts in the blockchain.
* Implements the ScriptDeps interface.
Expand Down Expand Up @@ -75,6 +78,14 @@ export class LogicManager implements ScriptDeps {
if (depositQuantity <= 0) {
return tx;
}
if (!Number.isSafeInteger(depositQuantity)) {
throw new Error("iCKB deposit quantity must be a safe integer");
}
if (depositQuantity > maxDepositQuantity) {
throw new Error(
`iCKB deposit quantity maximum is ${String(maxDepositQuantity)}`,
);
}

const depositCell = ccc.Cell.from({
previousOutput: {
Expand Down Expand Up @@ -218,19 +229,14 @@ export class LogicManager implements ScriptDeps {
withData: true,
},
"asc",
limit,
] as const;

const receiptCandidates: ccc.Cell[] = [];
for await (const cell of options?.onChain
? client.findCellsOnChain(...findCellsArgs)
: client.findCells(...findCellsArgs)) {
if (!this.isReceipt(cell) || !cell.cellOutput.lock.eq(lock)) {
continue;
}

receiptCandidates.push(cell);
}
const receiptCandidates = (await collectCompleteScan(
(scanLimit) => options?.onChain
? client.findCellsOnChain(...findCellsArgs, scanLimit)
: client.findCells(...findCellsArgs, scanLimit),
{ limit, label: "receipt cell" },
)).filter((cell) => this.isReceipt(cell) && cell.cellOutput.lock.eq(lock));
Comment thread
phroi marked this conversation as resolved.
Comment thread
phroi marked this conversation as resolved.
Comment thread
phroi marked this conversation as resolved.

const receipts = await Promise.all(
receiptCandidates.map((cell) => receiptCellFrom({ client, cell })),
Expand Down
Loading
Loading