diff --git a/src/App.js b/src/App.js index 73a6f8e..7598602 100644 --- a/src/App.js +++ b/src/App.js @@ -24,7 +24,8 @@ function findAddress(state, args) { function Router() { const [page, setPage] = useHash(); const [isPending, startTransition] = useTransition(); - const [state, { addAddress, deleteAddress }] = usePersistReducer(); + const [state, { addAddress, deleteAddress, addTransaction }] = + usePersistReducer(); const navigate = (url) => startTransition(() => setPage(url)); @@ -35,7 +36,9 @@ function Router() { "#/addresses/import": () => ( ), - "#/transactions/import": () => , + "#/transactions/import": () => ( + + ), }; staticRoutes[""] = staticRoutes["#/"]; const dynamicRoutes = [ diff --git a/src/ImportTransactionPage.js b/src/ImportTransactionPage.js index 77cad28..a53afdb 100644 --- a/src/ImportTransactionPage.js +++ b/src/ImportTransactionPage.js @@ -20,7 +20,9 @@ export default function ImportTransactionPage({ addTransaction, navigate }) { const fileContent = await readAsText(fileInput.files[0]); const transaction = importTransaction(JSON.parse(fileContent)); addTransaction(transaction); - navigate(`#/transaction/${transaction.hash}`); + navigate( + `#/transaction/${transaction.buildingPacket.value.payload.hash}`, + ); } catch (error) { setState({ isProcessing: false, diff --git a/src/lib/transaction.js b/src/lib/transaction.js index 951bf96..48a2089 100644 --- a/src/lib/transaction.js +++ b/src/lib/transaction.js @@ -1,3 +1,128 @@ +import { + Transaction, + getRawTransaction, + RawTransaction, +} from "@ckb-cobuild/ckb-molecule-codecs"; +import { toJson } from "@ckb-cobuild/molecule"; +import { ckbHasher } from "@ckb-cobuild/ckb-hasher"; +import { + buildAction, + SCRIPT_INFO, + SCRIPT_INFO_HASH, + MultisigConfig, +} from "./multisig-lock-action.js"; +import { convertDeprecatedSecp256k1Address } from "./multisig-address.js"; +import { decodeCkbAddress } from "./ckb-address.js"; + +/** + * Import transaction from the ckb-cli tx JSON. + * + * Pay attention to following two issues: + * + * 1. There's no resolved inputs in the ckb-cli JSON. The transaction page need a feature to load resolved inputs from a CKB node via JSONRPC. + * 2. It does not who have provided the signature tell the signature. Fortunately, the pubkey can be recovered from the signature via `ecrecover`. + */ +export function importFromCkbCli(jsonContent) { + const payload = Transaction.parse({ + hash: `0x${"0".repeat(64)}`, + ...jsonContent.transaction, + }); + payload.hash = ckbHasher() + .update(RawTransaction.pack(getRawTransaction(payload))) + .digest(); + + // If the tx file has any input with the secp256k1 lock, it's impossible to restore the witnesses. + + const buildingPacket = { + type: "BuildingPacketV1", + value: { + message: { actions: [] }, + // There's no resolved inputs info in ckb-cli tx.json + resolved_inputs: { + outputs: [], + outputs_data: [], + }, + change_output: null, + script_infos: [SCRIPT_INFO], + // The tx file does not contain the resolved inputs. Without resolved inputs, it's impossible to compute the sighash. And without sighash, we cannot recover the pubkey. + lock_actions: [], + payload, + }, + }; + + return toJson({ + pendingSecp256k1Signatures: jsonContent.signatures, + buildingPacket, + }); +} + export function importTransaction(jsonContent) { - throw new Error("Not a valid JSON"); + if ( + "transaction" in jsonContent && + "multisig_configs" in jsonContent && + "signatures" in jsonContent + ) { + return importFromCkbCli(jsonContent); + } + throw new Error("Unknown JSON format"); +} + +export function resolvePendingSecp256k1Signatures(transaction) { + // TODO: implement + return transaction; +} + +export function mergeTransaction(target, from) { + // 1. Merge lockActions + for (const lockAction of from.buildingPacket.value.lock_actions) { + // Assume that Action.script_hash must be unique + const existing = target.buildingPacket.value.lock_actions.find( + (item) => item.script_hash === lockAction.script_hash, + ); + if (existing === undefined) { + target.buildingPacket.value.lock_actions.push(lockAction); + } else if (lockAction.script_info_hash != toJson(SCRIPT_INFO_HASH)) { + // non-multisig lock action, just overwrite + Object.assign(existing, lockAction); + } else { + const existingData = MultisigConfig.unpack(existing.data); + const newData = MultisigConfig.unpack(lockAction.data); + for (const sig of newData.signed) { + const newPubKeyHash = toJson(sig.pubkey_hash); + const existingSigIndex = existingData.findIndex( + (item) => toJson(item.pubkey_hash) === newPubKeyHash, + ); + if (existingSigIndex !== -1) { + existingData.signed.splice(existingSigIndex, 1); + } + existingData.signed.push(sig); + } + } + } + + // 2. Merge pendingSecp256k1Signatures + for (const [args, signatures] of Object.entries( + from.pendingSecp256k1Signatures, + )) { + if (args in target.pendingSecp256k1Signatures) { + for (const signature of signatures) { + if (!target.pendingSecp256k1Signatures[args].includes(signature)) { + target.pendingSecp256k1Signatures[args].push(signature); + } + } + } else { + target.pendingSecp256k1Signatures[args] = signatures; + } + } + + // 3. Merge witnesses + for (const [i, witness] of from.buildingPacket.value.payload.witnesses) { + if (witness !== null && witness !== undefined && witness !== "0x") { + target.buildingPacket.value.payload.witnesses[i] = witness; + } + } + + if (target.buildingPacket.value.resolved_inputs.outputs.length > 0) { + resolvePendingSecp256k1Signatures(target); + } } diff --git a/src/reducer.js b/src/reducer.js index f9a5571..b4ad86c 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -2,17 +2,34 @@ import { current } from "immer"; import { useCallback } from "react"; import { useLocalStorage } from "react-use"; import { useImmerReducer } from "use-immer"; +import { mergeTransaction } from "./lib/transaction.js"; const LOCAL_STORAGE_KEY = "ckb-multisig"; const INITIAL_STATE = { addresses: [], + transactions: [], }; function deleteAddressByArgs(draft, args) { const index = draft.addresses.findIndex((address) => address.args === args); if (index !== -1) { - draft.addresses.splice(index, 1); + return draft.addresses.splice(index, 1)[0]; + } +} + +function findTransactionByHash(draft, hash) { + return draft.transactions.find( + (tx) => tx.buildingPacket.value.payload.hash === hash, + ); +} + +function deleteTransactionByHash(draft, hash) { + const index = draft.transactions.findIndex( + (tx) => tx.buildingPacket.value.payload.hash === hash, + ); + if (index !== -1) { + return draft.transactions.splice(index, 1)[0]; } } @@ -30,6 +47,20 @@ function reducer(draft, action) { case "deleteAddress": deleteAddressByArgs(draft, action.payload); break; + case "addTransaction": + const existing = findTransactionByHash( + draft, + action.payload.buildingPacket.value.payload.hash, + ); + if (existing === undefined) { + draft.transactions.push(action.payload); + } else { + mergeTransaction(existing, action.payload); + } + break; + case "deleteTransaction": + deleteTransactionByHash(draft, action.payload); + break; default: throw new Error(`Unknown action type ${action.type}`); } @@ -58,7 +89,10 @@ const usePersistReducer = () => { // use wrapped reducer and the saved value from // `localStorage` as params to `useReducer`. // this will return `[state, dispatch]` - const [state, dispatch] = useImmerReducer(reducerLocalStorage, savedState); + const [state, dispatch] = useImmerReducer(reducerLocalStorage, { + ...INITIAL_STATE, + ...savedState, + }); const addAddress = useCallback( (address) => dispatch({ type: "addAddress", payload: address }), [dispatch], @@ -67,7 +101,18 @@ const usePersistReducer = () => { (args) => dispatch({ type: "deleteAddress", payload: args }), [dispatch], ); - return [state, { addAddress, deleteAddress }]; + const addTransaction = useCallback( + (transaction) => dispatch({ type: "addTransaction", payload: transaction }), + [dispatch], + ); + const deleteTransaction = useCallback( + (hash) => dispatch({ type: "deleteTransaction", payload: hash }), + [dispatch], + ); + return [ + state, + { addAddress, deleteAddress, addTransaction, deleteTransaction }, + ]; }; export default usePersistReducer;