diff --git a/project.ts b/project.ts index bc581e0..d182c4a 100644 --- a/project.ts +++ b/project.ts @@ -14,7 +14,7 @@ const dotenvPath = path.resolve(__dirname, `.env.${mode}`); dotenv.config({ path: dotenvPath }); const endpoints: string[] = process.env.ENDPOINT?.split(",") as string[]; -console.log(`Endpoints: ${endpoints}`); +console.log(`Parsed Endpoints: ${endpoints}`); // Can expand the Datasource processor types via the generic param const project: CosmosProject = { @@ -424,6 +424,7 @@ const project: CosmosProject = { startBlock: 1, // migration at 25507 on alpha // msg grants at 23196 on alpha + // mutlsig send tx at 39712 on beta kind: CosmosDatasourceKind.Runtime, mapping: { file: "./dist/index.js", diff --git a/schema.graphql b/schema.graphql index 93d8f32..6169d81 100644 --- a/schema.graphql +++ b/schema.graphql @@ -232,6 +232,19 @@ type ValidatorCommissionParams @jsonField { maxChangeRate: String! } +type Multisig @jsonField { + from: String! + all: [String]! + signed: [String]! + # Using indices, threshold, extraBitsStored, pubkeysBase64, bitarrayElems, extraBitsStored + # allows to rebuild the fields: from, all and signed + indices: [Int]! + threshold: Int! + extraBitsStored: Int! + multisigPubKey: String! + bitarrayElems: String! +} + ### ENTITIES # Represent the balance of an account at the genesis state (usually genesis file) @@ -328,8 +341,13 @@ type Transaction @entity { idx: Int! codespace: String timeoutHeight: BigInt @index - # NB: only the first signer! + # Mode = Single -> First signer + # Mode = Multi -> Address from multisig public key signerAddress: String @index + # indicates if the transaction is signed using /cosmos.crypto.multisig.LegacyAminoPubKey + isMultisig: Boolean! + # could be null is isMulti = false + multisig: Multisig messages: [Message] @derivedFrom(field: "transaction") events: [Event]@derivedFrom(field: "transaction") } diff --git a/src/mappings/pocket/validator.ts b/src/mappings/pocket/validator.ts index a7dee0e..8bd511b 100644 --- a/src/mappings/pocket/validator.ts +++ b/src/mappings/pocket/validator.ts @@ -4,7 +4,10 @@ import { CosmosMessage, } from "@subql/types-cosmos"; import type { MsgCreateValidator } from "cosmjs-types/cosmos/staking/v1beta1/tx"; -import { isNil } from "lodash"; +import { + isEmpty, + isNil, +} from "lodash"; import { parseCoins } from "../../cosmjs/utils"; import { StakeStatus, @@ -13,6 +16,7 @@ import { import { MsgCreateValidator as MsgCreateValidatorEntity } from "../../types/models/MsgCreateValidator"; import { ValidatorCommissionProps } from "../../types/models/ValidatorCommission"; import { ValidatorRewardProps } from "../../types/models/ValidatorReward"; +import { SignerInfo } from "../../types/proto-interfaces/cosmos/tx/v1beta1/tx"; import { enforceAccountsExists } from "../bank"; import { PREFIX, @@ -25,6 +29,11 @@ import { messageId, } from "../utils/ids"; import { stringify } from "../utils/json"; +import { + extractThresholdAndPubkeysFromMultisig, + getMultiSignPubKeyAddress, + isMulti, +} from "../utils/multisig"; import { Ed25519, pubKeyToAddress, @@ -36,19 +45,41 @@ async function _handleValidatorMsgCreate(msg: CosmosMessage) const msgId = messageId(msg); const blockId = getBlockId(msg.block); const createValMsg = msg.msg.decodedMsg; - const signer = msg.tx.decodedTx.authInfo.signerInfos[0]; - if (isNil(signer) || isNil(signer.publicKey)) { - throw new Error("Signer is nil"); + if (isEmpty(msg.tx.decodedTx.authInfo.signerInfos) || isNil(msg.tx.decodedTx.authInfo.signerInfos[0]?.publicKey)) { + throw new Error(`[handleValidatorMsgCreate] (block ${msg.block.block.header.height}): hash=${msg.tx.hash} missing signerInfos public key`); + } + + const signerInfo = (msg.tx.decodedTx.authInfo.signerInfos as SignerInfo[])[0]; + + if (!signerInfo.publicKey) { + throw new Error(`[handleValidatorMsgCreate] (block ${msg.tx.block.block.header.height}): hash=${msg.tx.hash} missing signerInfos public key`); + } + + const signerType = signerInfo.publicKey.typeUrl; + let signerAddress, poktSignerAddress; + + if (isMulti(signerInfo)) { + // TODO: is this doable? + // probably yes, but we should attempt to reproduce this and see if this + // code satisfied this well enough + const { pubkeysBase64, threshold } = extractThresholdAndPubkeysFromMultisig(signerInfo.publicKey.value); + const { from: validatorFrom } = getMultiSignPubKeyAddress(pubkeysBase64, threshold, VALIDATOR_PREFIX); + signerAddress = validatorFrom; + const { from: poktValidatorFrom } = getMultiSignPubKeyAddress(pubkeysBase64, threshold, PREFIX); + poktSignerAddress = poktValidatorFrom; + } else if (signerType === Secp256k1) { + signerAddress = pubKeyToAddress(Secp256k1, signerInfo.publicKey.value, VALIDATOR_PREFIX); + poktSignerAddress = pubKeyToAddress(Secp256k1, signerInfo.publicKey.value, PREFIX); + } else { + signerAddress = `Unsupported Signer: ${signerType}`; + poktSignerAddress = `Unsupported Signer: ${signerType}`; } if (isNil(createValMsg.pubkey)) { throw new Error("Pubkey is nil"); } - const signerAddress = pubKeyToAddress(Secp256k1, signer.publicKey.value, VALIDATOR_PREFIX); - const poktSignerAddress = pubKeyToAddress(Secp256k1, signer.publicKey.value, PREFIX); - const msgCreateValidator = MsgCreateValidatorEntity.create({ id: msgId, pubkey: { diff --git a/src/mappings/primitives/genesis.ts b/src/mappings/primitives/genesis.ts index c198262..78a9066 100644 --- a/src/mappings/primitives/genesis.ts +++ b/src/mappings/primitives/genesis.ts @@ -389,6 +389,7 @@ async function _handleGenesisServices(genesis: Genesis, block: CosmosBlock): Pro memo: "", log: "", status: TxStatus.Success, + isMultisig: false, code: 0, }); @@ -447,6 +448,7 @@ async function _handleGenesisSuppliers(genesis: Genesis, block: CosmosBlock): Pr memo: "", log: "", timeoutHeight: BigInt(0), + isMultisig: false, }); supplierMsgStakes.push({ @@ -574,6 +576,7 @@ async function _handleGenesisApplications(genesis: Genesis, block: CosmosBlock): memo: "", log: "", status: TxStatus.Success, + isMultisig: false, code: 0, }); @@ -687,6 +690,7 @@ async function _handleGenesisGateways(genesis: Genesis, block: CosmosBlock): Pro memo: "", log: "", status: TxStatus.Success, + isMultisig: false, code: 0, }); @@ -870,6 +874,7 @@ async function _handleGenesisGenTxs(genesis: Genesis, block: CosmosBlock): Promi memo: "", log: "", status: TxStatus.Success, + isMultisig: false, code: 0, }); } diff --git a/src/mappings/primitives/transactions.ts b/src/mappings/primitives/transactions.ts index 1e14ed5..c428d22 100644 --- a/src/mappings/primitives/transactions.ts +++ b/src/mappings/primitives/transactions.ts @@ -3,32 +3,79 @@ import { isEmpty, isNil, } from "lodash"; +import { Multisig } from "../../types"; import { TransactionProps } from "../../types/models/Transaction"; +import { SignerInfo } from "../../types/proto-interfaces/cosmos/tx/v1beta1/tx"; import { PREFIX, VALIDATOR_PREFIX, } from "../constants"; import { optimizedBulkCreate } from "../utils/db"; import { getBlockId } from "../utils/ids"; +import { + getMultisigInfo, + isMulti, +} from "../utils/multisig"; import { getTxStatus, isMsgValidatorRelated, } from "../utils/primitives"; -import { pubKeyToAddress } from "../utils/pub_key"; +import { + pubKeyToAddress, + Secp256k1, +} from "../utils/pub_key"; function _handleTransaction(tx: CosmosTransaction): TransactionProps { let signerAddress; - if (isEmpty(tx.decodedTx.authInfo.signerInfos) || isNil(tx.decodedTx.authInfo.signerInfos[0]?.publicKey)) { + + if (isEmpty(tx.decodedTx.authInfo.signerInfos) || isNil(tx.decodedTx.authInfo.signerInfos[0].publicKey)) { throw new Error(`[handleTransaction] (block ${tx.block.block.header.height}): hash=${tx.hash} missing signerInfos public key`); - } else { - const prefix = isMsgValidatorRelated(tx.decodedTx.body.messages[0].typeUrl) ? VALIDATOR_PREFIX : PREFIX; - // if the first message is a MsgCreateValidator, we assume the signer is the account related to it, - // that is hashed with a different prefix. + } + + const prefix = isMsgValidatorRelated(tx.decodedTx.body.messages[0].typeUrl) ? VALIDATOR_PREFIX : PREFIX; + + const signerInfo = (tx.decodedTx.authInfo.signerInfos as SignerInfo[])[0]; + + if (!signerInfo.publicKey) { + throw new Error(`[handleTransaction] (block ${tx.block.block.header.height}): hash=${tx.hash} missing signerInfos public key`); + } + + const signerType = signerInfo.publicKey.typeUrl; + const isMultisig = isMulti(signerInfo); + let multisigObject: Multisig | undefined; + + if (isMultisig) { + const { + allSignerAddresses, + bitarrayElems, + extraBitsStored, + fromAddress, + multisigPubKey, + signedSignerAddresses, + signerIndices, + threshold, + } = getMultisigInfo(signerInfo); + + // TODO: We should probably "create" this account otherwise maybe will not exists? + signerAddress = fromAddress; + multisigObject = { + from: fromAddress, + all: allSignerAddresses, + signed: signedSignerAddresses, + indices: signerIndices, + threshold, + multisigPubKey, + bitarrayElems, + extraBitsStored, + }; + } else if (signerType === Secp256k1) { signerAddress = pubKeyToAddress( - tx.decodedTx.authInfo.signerInfos[0]?.publicKey.typeUrl, + signerType, tx.decodedTx.authInfo.signerInfos[0]?.publicKey.value, prefix, ); + } else { + signerAddress = `Unsupported Signer: ${signerType}`; } const feeAmount = !isNil(tx.decodedTx.authInfo.fee) ? tx.decodedTx.authInfo.fee.amount : []; @@ -45,6 +92,8 @@ function _handleTransaction(tx: CosmosTransaction): TransactionProps { log: tx.tx.log || "", status: getTxStatus(tx), signerAddress, + isMultisig, + multisig: isMultisig ? multisigObject : undefined, code: tx.tx.code, codespace: tx.tx.codespace, }; diff --git a/src/mappings/utils/multisig.ts b/src/mappings/utils/multisig.ts new file mode 100644 index 0000000..a3eca69 --- /dev/null +++ b/src/mappings/utils/multisig.ts @@ -0,0 +1,204 @@ +import { + createMultisigThresholdPubkey, + encodeAminoPubkey, + encodeSecp256k1Pubkey, + pubkeyToAddress, + SinglePubkey, +} from "@cosmjs/amino"; +import { + fromBase64, + toBase64, +} from "@cosmjs/encoding"; +import { decodePubkey } from "@cosmjs/proto-signing"; +import { SignerInfo } from "../../types/proto-interfaces/cosmos/tx/v1beta1/tx"; +import { MultisigLegacyAminoPubKey } from "./pub_key"; + +export interface MultisigInfo { + fromAddress: string; + allSignerAddresses: string[]; + signedSignerAddresses: string[]; + signerIndices: number[]; + threshold: number; + multisigPubKey: string; + extraBitsStored: number; + bitarrayElems: string; +} + +/** + * Decodes a base64-encoded bit array and determines the indices of bits set to 1. + * + * @param {string} elemsBase64 - The base64-encoded string representing the bit array. + * @param {number} extraBitsStored - The total number of bits considered in the bit array. + * @return {number[]} An array of indices where the corresponding bits in the bit array are set to 1. + */ +function decodeBitArray(elemsBase64: string, extraBitsStored: number): number[] { + const bitArray = fromBase64(elemsBase64); + + const signerIndices: number[] = []; + + for (let i = 0; i < extraBitsStored; i++) { + const byteIndex = Math.floor(i / 8); + const bitIndex = i % 8; + const byte = bitArray[byteIndex]; + + // Cosmos uses MSB first: bit 0 is the highest bit in the byte + const bit = (byte >> (7 - bitIndex)) & 1; + + if (bit === 1) { + signerIndices.push(i); + } + } + + return signerIndices; +} + +/** + * Generates a multi-signature public key address based on the provided public keys, threshold, and address prefix. + * + * @param {string[]} pubkeysBase64 - An array of public keys in base64 encoding. + * @param {number} threshold - The minimum number of signatures required to authorize a transaction. + * @param {string} prefix - The prefix to be used for the derived address (e.g., "cosmos", "terra"). + * @return {Object} An object containing the derived address and the multi-signature public key: + * - `from`: The derived address corresponding to the multi-signature public key. + * - `pubkey`: The base64-encoded amino multi-signature public key. + */ +export function getMultiSignPubKeyAddress(pubkeysBase64: string[], threshold: number, prefix: string): { + from: string, + pubkey: string +} { + // Encode each base64 pubkey as Amino SinglePubkey + const pubkeyObjs: SinglePubkey[] = pubkeysBase64.map((b64) => + encodeSecp256k1Pubkey(fromBase64(b64)), + ); + + // Build a multisig pubkey and derive fromAddress + const multisigPubkey = createMultisigThresholdPubkey(pubkeyObjs, threshold); + const aminoMultsigPubKey = encodeAminoPubkey(multisigPubkey); + return { + from: pubkeyToAddress(multisigPubkey, prefix), + pubkey: toBase64(aminoMultsigPubKey), + }; +} + +/** + * Parses multisig signer information from provided parameters, including pubkeys, bitarrays, and other metadata. + * + * @param {Object} params - The input parameters for the function. + * @param {string[]} params.pubkeysBase64 - An array of public keys encoded in base64 format. + * @param {number} params.threshold - The minimum number of signatures required to validate the multisig. + * @param {string} params.bitarrayElems - The bitarray representing which signatures are provided. + * @param {number} params.extraBitsStored - A number representing additional bits stored in the bitarray. + * @param {string} params.prefix - The address prefix used to encode addresses. + * @return {MultisigInfo} An object containing parsed multisig information, + * including the derived `fromAddress`, signer addresses, and metadata. + */ +export function parseMultisigSignerInfo({ + bitarrayElems, + extraBitsStored, + prefix, + pubkeysBase64, + threshold, + }: { + pubkeysBase64: string[]; + threshold: number; + bitarrayElems: string; + extraBitsStored: number; + prefix: string; +}): MultisigInfo { + // Encode each base64 pubkey as Amino SinglePubkey + const pubkeyObjs: SinglePubkey[] = pubkeysBase64.map((b64) => + encodeSecp256k1Pubkey(fromBase64(b64)), + ); + + // Build a multisig pubkey and derive fromAddress + const { from: fromAddress, pubkey: multisigPubKey } = getMultiSignPubKeyAddress(pubkeysBase64, threshold, prefix); + + // All signer addresses + const allSignerAddresses = pubkeyObjs.map((pk) => pubkeyToAddress(pk, prefix)); + + // Decode the bitarray to find actual signers + const signerIndices = decodeBitArray(bitarrayElems, extraBitsStored); + const signedSignerAddresses = signerIndices.map((i) => allSignerAddresses[i]); + + return { + fromAddress, + allSignerAddresses, + signedSignerAddresses, + signerIndices, + threshold, + multisigPubKey, + bitarrayElems, + extraBitsStored, + }; +} + +/** + * Extracts the threshold and public keys in Base64 format from a multisig public key bytes' representation. + * The input must be a valid multisig public key, or an error will be thrown. + * + * @param {Uint8Array} pubkeyBytes - The serialized public key bytes representing a multisig instance. + * @return {Object} An object containing the threshold and an array of public keys in Base64 format. + * @return {number} return.threshold - The threshold value for the multisig key. + * @return {string[]} return.pubkeysBase64 - Array of public keys (in Base64 format) involved in the multisig. + */ +export function extractThresholdAndPubkeysFromMultisig(pubkeyBytes: Uint8Array): { + threshold: number; + pubkeysBase64: string[]; +} { + const decoded = decodePubkey({ + typeUrl: "/cosmos.crypto.multisig.LegacyAminoPubKey", + value: pubkeyBytes, + }); + + if (!decoded || decoded.type !== "tendermint/PubKeyMultisigThreshold") { + throw new Error("Not a valid multisig pubkey"); + } + + const { pubkeys, threshold } = decoded.value; + const pubkeysBase64 = pubkeys.map((pk: { value: never; }) => pk.value); // already base64 + + return { + threshold: Number(threshold), + pubkeysBase64, + }; +} + +/** + * Retrieves the multisignature information from the given signer information. + * + * @param {SignerInfo} signerInfo - The signer information to extract multisig information from. + * Must be valid multisig signer information with a defined public key. + * @return {MultisigInfo} The extracted multisignature information including public keys, threshold, and bitarray data. + * @throws {Error} If the provided signer information is not a multisig or lacks a public key. + */ +export function getMultisigInfo(signerInfo: SignerInfo): MultisigInfo { + if (!signerInfo || !isMulti(signerInfo)) { + throw new Error("[getMultisigInfo] signerInfo is not a multisig"); + } + if (!signerInfo.publicKey) { + throw new Error("[getMultisigInfo] missing signerInfos public key"); + } + const { pubkeysBase64, threshold } = extractThresholdAndPubkeysFromMultisig(signerInfo.publicKey.value); + + return parseMultisigSignerInfo({ + pubkeysBase64, + threshold, + bitarrayElems: toBase64(signerInfo.modeInfo?.multi?.bitarray?.elems as Uint8Array), + extraBitsStored: signerInfo.modeInfo?.multi?.bitarray?.extraBitsStored as number, + prefix: "pokt", + }); +} + +/** + * Determines if the provided signer information corresponds to a multisignature public key. + * + * @param {SignerInfo} signerInfo - The signer information containing a public key to be checked. + * @return {boolean} Returns true if the public key type URL matches a multisignature public key; otherwise, false. + */ +export function isMulti(signerInfo: SignerInfo): boolean { + if (!signerInfo.publicKey) { + throw new Error(`missing signerInfos public key`); + } + + return signerInfo.publicKey.typeUrl === MultisigLegacyAminoPubKey; +} diff --git a/src/mappings/utils/pub_key.ts b/src/mappings/utils/pub_key.ts index 6ee00f6..194c603 100644 --- a/src/mappings/utils/pub_key.ts +++ b/src/mappings/utils/pub_key.ts @@ -14,6 +14,7 @@ import { export const Secp256k1 = "/cosmos.crypto.secp256k1.PubKey"; export const Ed25519 = "/cosmos.crypto.ed25519.PubKey"; +export const MultisigLegacyAminoPubKey = "/cosmos.crypto.multisig.LegacyAminoPubKey"; function rawEd25519PubKeyToRawAddress(pubKey: Uint8Array): Uint8Array { let pk = pubKey;