diff --git a/.changeset/fix-spore-lockproxy-and-public-filter.md b/.changeset/fix-spore-lockproxy-and-public-filter.md new file mode 100644 index 000000000..f5020d8c9 --- /dev/null +++ b/.changeset/fix-spore-lockproxy-and-public-filter.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/spore": patch +"@ckb-ccc/core": patch +--- + +fix(spore): restore the selected lock-proxy input via an output tagged with that input's outPoint (survives clone/JSON, stays per-transaction), keep one owner restore per cluster, and treat `clusterId: ""` as public spores only diff --git a/packages/core/src/ckb/transaction.test.ts b/packages/core/src/ckb/transaction.test.ts index 69b03cf47..d728776f1 100644 --- a/packages/core/src/ckb/transaction.test.ts +++ b/packages/core/src/ckb/transaction.test.ts @@ -1663,6 +1663,50 @@ describe("Transaction", () => { expect(result).toBe(originalOutput); // Should return the same instance }); + it("copies lockProxyRestoredFrom through from, clone, and JSON", () => { + const outPoint = { + txHash: `0x${"ab".repeat(32)}`, + index: 1, + }; + const output = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(200), + lock, + lockProxyRestoredFrom: outPoint, + }); + const untagged = ccc.CellOutput.from({ + capacity: output.capacity, + lock, + }); + + expect(output.lockProxyRestoredFrom?.eq(outPoint)).toBe(true); + expect(output.clone().lockProxyRestoredFrom?.eq(outPoint)).toBe(true); + expect( + ccc.CellOutput.from( + JSON.parse(ccc.stringify(output)) as ccc.CellOutputLike, + ).lockProxyRestoredFrom?.eq(outPoint), + ).toBe(true); + expect(output.toBytes()).toEqual(untagged.toBytes()); + }); + + it("keeps lockProxyRestoredFrom when from bumps capacity for output data", () => { + const outPoint = { + txHash: `0x${"ab".repeat(32)}`, + index: 1, + }; + const tagged = ccc.CellOutput.from({ + capacity: 1000n, + lock, + lockProxyRestoredFrom: outPoint, + }); + const data = `0x${"12".repeat(100)}`; + const bumped = ccc.CellOutput.from(tagged, data); + + expect(bumped).not.toBe(tagged); + expect(bumped.capacity).toBeGreaterThan(tagged.capacity); + expect(bumped.lockProxyRestoredFrom?.eq(outPoint)).toBe(true); + expect(tagged.lockProxyRestoredFrom?.eq(outPoint)).toBe(true); + }); + it("should handle Cell instance passed to Cell.from", () => { const originalCell = ccc.Cell.from({ outPoint: { diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index a103ebe1b..8d02254b4 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -231,6 +231,12 @@ export type CellOutputLike = { capacity?: NumLike | null; lock: ScriptLike; type?: ScriptLike | null; + /** + * Off-chain: the lock-proxy input this output restores. + * Copied by {@link CellOutput.from} / {@link CellOutput.clone} and JSON, + * but not encoded on-chain or sent over RPC. + */ + lockProxyRestoredFrom?: OutPointLike | null; }; /** * @public @@ -259,6 +265,12 @@ export class CellOutput extends Entity.Base() { super(); } + /** + * Off-chain: the lock-proxy input this output restores. + * Not encoded in the on-chain cell. + */ + lockProxyRestoredFrom?: OutPoint; + get occupiedSize(): number { return 8 + this.lock.occupiedSize + (this.type?.occupiedSize ?? 0); } @@ -305,7 +317,13 @@ export class CellOutput extends Entity.Base() { return cellOutput; } - return new CellOutput(minCapacity, cellOutput.lock, cellOutput.type); + const bumped = new CellOutput( + minCapacity, + cellOutput.lock, + cellOutput.type, + ); + bumped.lockProxyRestoredFrom = cellOutput.lockProxyRestoredFrom?.clone(); + return bumped; } const output = new CellOutput( @@ -313,6 +331,10 @@ export class CellOutput extends Entity.Base() { Script.from(cellOutput.lock), apply(Script.from, cellOutput.type), ); + output.lockProxyRestoredFrom = apply( + OutPoint.from, + cellOutput.lockProxyRestoredFrom, + ); if (outputData != null) { output.capacity = numMax( @@ -339,6 +361,7 @@ export class CellOutput extends Entity.Base() { capacity: this.capacity, lock: this.lock.clone(), type: this.type?.clone(), + lockProxyRestoredFrom: this.lockProxyRestoredFrom?.clone(), }); } } diff --git a/packages/spore/src/spore/advanced.ts b/packages/spore/src/spore/advanced.ts index e83c0dc84..40b718ea8 100644 --- a/packages/spore/src/spore/advanced.ts +++ b/packages/spore/src/spore/advanced.ts @@ -3,6 +3,25 @@ import { assembleTransferClusterAction } from "../advanced.js"; import { assertCluster } from "../cluster/index.js"; import { Action, SporeDataView } from "../codec/index.js"; +function isLockProxyRestored( + tx: ccc.Transaction, + outPoint: ccc.OutPoint, +): boolean { + return tx.outputs.some( + (output) => output.lockProxyRestoredFrom?.eq(outPoint) === true, + ); +} + +function addLockProxyRestore( + tx: ccc.Transaction, + proxyInput: ccc.CellInput, + proxyCell: ccc.Cell, +): void { + const restore = proxyCell.cellOutput.clone(); + restore.lockProxyRestoredFrom = proxyInput.previousOutput.clone(); + tx.addOutput(restore, proxyCell.outputData); +} + export async function prepareCluster( signer: ccc.Signer, tx: ccc.Transaction, @@ -47,11 +66,14 @@ export async function prepareCluster( } } - // Add the output only if there's not already one with proxy lock - if (!tx.outputs.some((output) => output.lock === lock)) { - tx.addOutput( - await tx.getInput(lockProxyInputIndex)!.getCell(signer.client), - ); + // Restore this selected proxy input, not an arbitrary owner-locked + // output. Tag the restore output with the input outPoint so the mark + // lives on the transaction (survives clone/JSON, stays tx-local, and + // clears if that output is removed). + const proxyInput = tx.getInput(lockProxyInputIndex)!; + const proxyCell = await proxyInput.getCell(signer.client); + if (!isLockProxyRestored(tx, proxyInput.previousOutput)) { + addLockProxyRestore(tx, proxyInput, proxyCell); } tx.addCellDeps({ diff --git a/packages/spore/src/spore/index.ts b/packages/spore/src/spore/index.ts index 75f3ca271..da0ce23a5 100644 --- a/packages/spore/src/spore/index.ts +++ b/packages/spore/src/spore/index.ts @@ -246,6 +246,23 @@ export async function meltSpore(params: { }; } +/** `undefined` matches every spore. `""` matches spores with no cluster id. */ +function matchClusterFilter( + sporeClusterId: ccc.HexLike | undefined, + clusterId: ccc.HexLike | undefined, +): boolean { + // No filter: every spore matches. + if (clusterId === undefined) { + return true; + } + // `""` keeps public spores only. Do not pass `""` to `hexFrom`. + if (clusterId === "" || !sporeClusterId) { + return clusterId === "" && !sporeClusterId; + } + // Any other cluster id is compared as hex. + return ccc.hexFrom(sporeClusterId) === ccc.hexFrom(clusterId); +} + /** * Search on-chain spores under the signer's control, if cluster provided, filter spores belonging to this cluster * @@ -285,11 +302,7 @@ export async function* findSporesBySigner(params: { limit, )) { const sporeData = unpackToRawSporeData(spore.outputData); - if ( - !clusterId || - (clusterId === "" && !sporeData.clusterId) || - sporeData.clusterId === ccc.hexFrom(clusterId) - ) { + if (matchClusterFilter(sporeData.clusterId, clusterId)) { yield { cell: spore, spore, @@ -350,11 +363,7 @@ export async function* findSpores(params: { limit, )) { const sporeData = unpackToRawSporeData(spore.outputData); - if ( - !clusterId || - (clusterId === "" && !sporeData.clusterId) || - sporeData.clusterId === ccc.hexFrom(clusterId) - ) { + if (matchClusterFilter(sporeData.clusterId, clusterId)) { yield { cell: spore, spore,