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;