Hello folks at CKB DevRel,
Udt.completeChangeToLock calls completeInputsByUdt, which can add and burn a foreign UDT cell whose type args extend the target args. This can destroy foreign tokens.
Minimal Reproduction
import { ccc } from "@ckb-ccc/core";
const client = new ccc.ClientPublicTestnet({ url: "https://example.invalid" });
const signer = new ccc.SignerCkbPrivateKey(client, `0x${"11".repeat(32)}`);
const lock = ccc.Script.from({
codeHash: `0x${"1".repeat(64)}`,
hashType: "type",
args: "0x",
});
const type = ccc.Script.from({
codeHash: `0x${"2".repeat(64)}`,
hashType: "type",
args: `0x${"aa".repeat(32)}`,
});
const udtCell = (id, args, balance) =>
ccc.Cell.from({
outPoint: { txHash: `0x${id.repeat(64)}`, index: 0 },
cellOutput: {
capacity: ccc.fixedPointFrom(142),
lock,
type: { codeHash: type.codeHash, hashType: type.hashType, args },
},
outputData: ccc.numLeToBytes(balance, 16),
});
const realCell = udtCell("a", type.args, 100);
const foreignCell = udtCell("f", `${type.args}00`, 900);
signer.findCells = async function* ({ script }) {
for (const cell of [realCell, foreignCell]) {
const candidate = cell.cellOutput.type;
if (
candidate.codeHash === script.codeHash &&
candidate.hashType === script.hashType &&
candidate.args.startsWith(script.args)
) {
yield cell;
}
}
};
const tx = ccc.Transaction.from({
outputs: [{ lock, type }],
outputsData: [ccc.numLeToBytes(50, 16)],
});
await tx.completeInputsByUdt(signer, type);
console.log(
"foreign UDT input added without a matching output:",
tx.inputs.some((input) => input.previousOutput.eq(foreignCell.outPoint)),
);
Behavior
foreign UDT input added without a matching output: true
completeInputsByUdt supplies { script: type, outputDataLenRange: [16, 0xffffffff] }; completeInputs calls Signer.findCells(filter, true), leaving order and limit unset. Signer.findCells makes the lock exact and nests the UDT filter. CKB's RPC filter has no script search mode, and its documented filter.script check is prefix-only; this is a footgun for any caller expecting exact nested script identity. completeInputs instead reads every returned cell as target balance, although existing inputs are checked with .eq(type).
Proposed Solution
Either constrain the query with scriptLenRange: [type.occupiedSize, type.occupiedSize + 1] after normalizing type with Script.from, or skip returned cells whose cellOutput.type is not .eq(type) before reading their balance or adding them as inputs.
Environment
Keep up the Great Work,
Phroi %54
Hello folks at CKB DevRel,
Udt.completeChangeToLockcallscompleteInputsByUdt, which can add and burn a foreign UDT cell whose type args extend the target args. This can destroy foreign tokens.Minimal Reproduction
Behavior
completeInputsByUdtsupplies{ script: type, outputDataLenRange: [16, 0xffffffff] };completeInputscallsSigner.findCells(filter, true), leavingorderandlimitunset.Signer.findCellsmakes the lock exact and nests the UDT filter. CKB's RPC filter has no script search mode, and its documentedfilter.scriptcheck is prefix-only; this is a footgun for any caller expecting exact nested script identity.completeInputsinstead reads every returned cell as target balance, although existing inputs are checked with.eq(type).Proposed Solution
Either constrain the query with
scriptLenRange: [type.occupiedSize, type.occupiedSize + 1]after normalizingtypewithScript.from, or skip returned cells whosecellOutput.typeis not.eq(type)before reading their balance or adding them as inputs.Environment
@ckb-ccc/core@1.19.1(latest release)@ckb-ccc/udt@0.2.9devat9d1aa1f5ca4abbe953b7af94ff10f954bc1e088fKeep up the Great Work,
Phroi %54