diff --git a/proto/tm2/multisig.proto b/proto/tm2/multisig.proto new file mode 100644 index 0000000..662778e --- /dev/null +++ b/proto/tm2/multisig.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; +package tm; + +option go_package = "github.com/gnolang/gno/tm2/pkg/crypto/multisig/pb"; + +// imports +import "google/protobuf/any.proto"; + +// messages +message PubKeyMultisig { + uint64 k = 1 [json_name = "threshold"]; + repeated google.protobuf.Any pub_keys = 2 [json_name = "pubkeys"]; +} + +message Multisignature { + CompactBitArray bit_array = 1; + repeated bytes sigs = 2; +} + +message CompactBitArray { + uint32 extra_bits_stored = 1 [json_name = "extra_bits"]; // The number of extra bits in elems. + bytes elems = 2 [json_name = "bits"]; +} \ No newline at end of file diff --git a/src/proto/google/protobuf/any.ts b/src/proto/google/protobuf/any.ts index 589ae9e..b193490 100644 --- a/src/proto/google/protobuf/any.ts +++ b/src/proto/google/protobuf/any.ts @@ -1,6 +1,6 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: -// protoc-gen-ts_proto v2.7.5 +// protoc-gen-ts_proto v2.7.7 // protoc v5.29.3 // source: google/protobuf/any.proto diff --git a/src/proto/tm2/abci.ts b/src/proto/tm2/abci.ts index da935fa..a334725 100644 --- a/src/proto/tm2/abci.ts +++ b/src/proto/tm2/abci.ts @@ -1,6 +1,6 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: -// protoc-gen-ts_proto v2.7.5 +// protoc-gen-ts_proto v2.7.7 // protoc v5.29.3 // source: tm2/abci.proto diff --git a/src/proto/tm2/multisig.test.ts b/src/proto/tm2/multisig.test.ts new file mode 100644 index 0000000..4b416ba --- /dev/null +++ b/src/proto/tm2/multisig.test.ts @@ -0,0 +1,27 @@ +import { bytesToHex } from '@noble/hashes/utils'; +import { CompactBitArray } from './multisig'; + +describe('TestMarshalCompactBitArrayAmino', () => { + const testCases = [ + { marshalledBA: `null`, hexOutput: '' }, + { marshalledBA: `null`, hexOutput: '' }, + { marshalledBA: `"_"`, hexOutput: '0801120100' }, + { marshalledBA: `"x"`, hexOutput: '0801120180' }, + { marshalledBA: `"xx___"`, hexOutput: '08051201c0' }, + { marshalledBA: `"xx______x"`, hexOutput: '08011202c080' }, + { marshalledBA: `"xx_____________x"`, hexOutput: '1202c001' }, + ]; + + test.each(testCases)('$marshalledBA', async ({ marshalledBA, hexOutput }) => { + // Parse JSON into CompactBitArray + const jsonData = JSON.parse(marshalledBA); + const bA = CompactBitArray.fromJSON(jsonData); + + // Marshal using Amino + const bz = CompactBitArray.encode(bA).finish(); + + // Convert bytes to hex and compare + const actualHex = bytesToHex(bz); + expect(actualHex).toBe(hexOutput); + }); +}); diff --git a/src/proto/tm2/multisig.ts b/src/proto/tm2/multisig.ts new file mode 100644 index 0000000..b80693f --- /dev/null +++ b/src/proto/tm2/multisig.ts @@ -0,0 +1,428 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.7.7 +// protoc v5.29.3 +// source: tm2/multisig.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from '@bufbuild/protobuf/wire'; +import Long from 'long'; +import { Any } from '../google/protobuf/any'; + +export const protobufPackage = 'tm'; + +/** messages */ +export interface PubKeyMultisig { + k: Long; + pub_keys: Any[]; +} + +export interface Multisignature { + bit_array?: CompactBitArray | undefined; + sigs: Uint8Array[]; +} + +export interface CompactBitArray { + /** The number of extra bits in elems. */ + extra_bits_stored: number; + elems: Uint8Array; +} + +function createBasePubKeyMultisig(): PubKeyMultisig { + return { k: Long.UZERO, pub_keys: [] }; +} + +export const PubKeyMultisig: MessageFns = { + encode( + message: PubKeyMultisig, + writer: BinaryWriter = new BinaryWriter() + ): BinaryWriter { + if (!message.k.equals(Long.UZERO)) { + writer.uint32(8).uint64(message.k.toString()); + } + for (const v of message.pub_keys) { + Any.encode(v!, writer.uint32(18).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): PubKeyMultisig { + const reader = + input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBasePubKeyMultisig(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.k = Long.fromString(reader.uint64().toString(), true); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.pub_keys.push(Any.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): PubKeyMultisig { + return { + k: isSet(object.threshold) + ? Long.fromValue(object.threshold) + : Long.UZERO, + pub_keys: globalThis.Array.isArray(object?.pubkeys) + ? object.pubkeys.map((e: any) => Any.fromJSON(e)) + : [], + }; + }, + + toJSON(message: PubKeyMultisig): unknown { + const obj: any = {}; + if (message.k !== undefined) { + obj.threshold = (message.k || Long.UZERO).toString(); + } + if (message.pub_keys?.length) { + obj.pubkeys = message.pub_keys.map((e) => Any.toJSON(e)); + } + return obj; + }, + + create, I>>( + base?: I + ): PubKeyMultisig { + return PubKeyMultisig.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I + ): PubKeyMultisig { + const message = createBasePubKeyMultisig(); + message.k = + object.k !== undefined && object.k !== null + ? Long.fromValue(object.k) + : Long.UZERO; + message.pub_keys = object.pub_keys?.map((e) => Any.fromPartial(e)) || []; + return message; + }, +}; + +function createBaseMultisignature(): Multisignature { + return { bit_array: undefined, sigs: [] }; +} + +export const Multisignature: MessageFns = { + encode( + message: Multisignature, + writer: BinaryWriter = new BinaryWriter() + ): BinaryWriter { + if (message.bit_array !== undefined) { + CompactBitArray.encode( + message.bit_array, + writer.uint32(10).fork() + ).join(); + } + for (const v of message.sigs) { + writer.uint32(18).bytes(v!); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Multisignature { + const reader = + input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseMultisignature(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.bit_array = CompactBitArray.decode(reader, reader.uint32()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.sigs.push(reader.bytes()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Multisignature { + return { + bit_array: isSet(object.bit_array) + ? CompactBitArray.fromJSON(object.bit_array) + : undefined, + sigs: globalThis.Array.isArray(object?.sigs) + ? object.sigs.map((e: any) => bytesFromBase64(e)) + : [], + }; + }, + + toJSON(message: Multisignature): unknown { + const obj: any = {}; + if (message.bit_array !== undefined) { + obj.bit_array = CompactBitArray.toJSON(message.bit_array); + } + if (message.sigs?.length) { + obj.sigs = message.sigs.map((e) => base64FromBytes(e)); + } + return obj; + }, + + create, I>>( + base?: I + ): Multisignature { + return Multisignature.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I + ): Multisignature { + const message = createBaseMultisignature(); + message.bit_array = + object.bit_array !== undefined && object.bit_array !== null + ? CompactBitArray.fromPartial(object.bit_array) + : undefined; + message.sigs = object.sigs?.map((e) => e) || []; + return message; + }, +}; + +function createBaseCompactBitArray(): CompactBitArray { + return { extra_bits_stored: 0, elems: new Uint8Array(0) }; +} + +export const CompactBitArray: MessageFns = { + encode( + message: CompactBitArray, + writer: BinaryWriter = new BinaryWriter() + ): BinaryWriter { + if (message.extra_bits_stored !== 0) { + writer.uint32(8).uint32(message.extra_bits_stored); + } + if (message.elems.length !== 0) { + writer.uint32(18).bytes(message.elems); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): CompactBitArray { + const reader = + input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseCompactBitArray(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.extra_bits_stored = reader.uint32(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.elems = reader.bytes(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(json: any): CompactBitArray { + if (json === null) { + // Handle null case + return createBaseCompactBitArray(); + } + + if (typeof json !== 'string') { + throw new Error( + `CompactBitArray in JSON should be a string or null but got ${typeof json}` + ); + } + + const bits = json; + const numBits = bits.length; + + // Create a new CompactBitArray + const numBytes = Math.ceil(numBits / 8); + const elems = new Uint8Array(numBytes); + const extraBitsStored = numBits % 8; + const bitArray = { extra_bits_stored: extraBitsStored, elems }; + + // Set bits based on the string representation + for (let i = 0; i < numBits; i++) { + if (bits[i] === 'x') { + compactBitArraySetIndex(bitArray, i, true); + } + // For '_', we don't need to do anything as bits are initialized to 0 + } + + return bitArray; + }, + + toJSON(message: CompactBitArray): unknown { + throw new Error('not implemented'); + }, + + create, I>>( + base?: I + ): CompactBitArray { + return CompactBitArray.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I + ): CompactBitArray { + const message = createBaseCompactBitArray(); + message.extra_bits_stored = object.extra_bits_stored ?? 0; + message.elems = object.elems ?? new Uint8Array(0); + return message; + }, +}; + +export function createCompactBitArray(bits: number): CompactBitArray { + if (bits <= 0) { + throw new Error('empty'); + } + + const extraBitsStored = bits % 8; + const elems = new Uint8Array(Math.ceil(bits / 8)); + + return { extra_bits_stored: extraBitsStored, elems }; +} + +export function compactBitArraySize(bA: CompactBitArray): number { + if (bA.elems === null) { + return 0; + } else if (bA.extra_bits_stored === 0) { + return bA.elems.length * 8; + } + return (bA.elems.length - 1) * 8 + bA.extra_bits_stored; +} + +// SetIndex sets the bit at index i within the bit array +// Returns true if successful, false if out of bounds or array is null +export function compactBitArraySetIndex( + bA: CompactBitArray, + i: number, + v: boolean +): boolean { + if (bA.elems === null) { + return false; + } + + if (i >= compactBitArraySize(bA)) { + return false; + } + + if (v) { + // Set the bit (most significant bit first) + bA.elems[i >> 3] |= 1 << (7 - (i % 8)); + } else { + // Clear the bit + bA.elems[i >> 3] &= ~(1 << (7 - (i % 8))); + } + + return true; +} + +function bytesFromBase64(b64: string): Uint8Array { + if ((globalThis as any).Buffer) { + return Uint8Array.from(globalThis.Buffer.from(b64, 'base64')); + } else { + const bin = globalThis.atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; ++i) { + arr[i] = bin.charCodeAt(i); + } + return arr; + } +} + +function base64FromBytes(arr: Uint8Array): string { + if ((globalThis as any).Buffer) { + return globalThis.Buffer.from(arr).toString('base64'); + } else { + const bin: string[] = []; + arr.forEach((byte) => { + bin.push(globalThis.String.fromCharCode(byte)); + }); + return globalThis.btoa(bin.join('')); + } +} + +type Builtin = + | Date + | Function + | Uint8Array + | string + | number + | boolean + | undefined; + +export type DeepPartial = T extends Builtin + ? T + : T extends Long + ? string | number | Long + : T extends globalThis.Array + ? globalThis.Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends {} + ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin + ? P + : P & { [K in keyof P]: Exact } & { + [K in Exclude>]: never; + }; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/src/proto/tm2/tx.ts b/src/proto/tm2/tx.ts index 3420523..f46c377 100644 --- a/src/proto/tm2/tx.ts +++ b/src/proto/tm2/tx.ts @@ -1,6 +1,6 @@ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: -// protoc-gen-ts_proto v2.7.5 +// protoc-gen-ts_proto v2.7.7 // protoc v5.29.3 // source: tm2/tx.proto diff --git a/src/wallet/wallet.test.ts b/src/wallet/wallet.test.ts index 37a7f0d..d15c6f0 100644 --- a/src/wallet/wallet.test.ts +++ b/src/wallet/wallet.test.ts @@ -1,11 +1,12 @@ import { + ABCIAccount, BroadcastTxSyncResult, JSONRPCProvider, Status, TransactionEndpoint, } from '../provider'; import { mock } from 'jest-mock-extended'; -import { Wallet } from './wallet'; +import { SignTransactionOptions, Wallet } from './wallet'; import { EnglishMnemonic, Secp256k1 } from '@cosmjs/crypto'; import { defaultAddressPrefix, @@ -183,8 +184,16 @@ describe('Wallet', () => { const mockProvider = mock(); mockProvider.getStatus.mockResolvedValue(mockStatus); - mockProvider.getAccountNumber.mockResolvedValue(10); - mockProvider.getAccountSequence.mockResolvedValue(10); + const mockAccount: ABCIAccount = { + BaseAccount: { + address: '', + coins: '', + public_key: null, + account_number: '', + sequence: '', + }, + }; + mockProvider.getAccount.mockResolvedValue(mockAccount); const wallet: Wallet = await Wallet.createRandom(); wallet.connect(mockProvider); @@ -198,8 +207,72 @@ describe('Wallet', () => { ); expect(mockProvider.getStatus).toHaveBeenCalled(); - expect(mockProvider.getAccountNumber).toHaveBeenCalled(); - expect(mockProvider.getAccountSequence).toHaveBeenCalled(); + expect(mockProvider.getAccount).toHaveBeenCalled(); + + expect(signedTx.signatures).toHaveLength(1); + + const sig: TxSignature = signedTx.signatures[0]; + expect(sig.pub_key?.type_url).toBe(Secp256k1PubKeyType); + expect(sig.pub_key?.value).not.toBeNull(); + expect(sig.signature).not.toBeNull(); + }); + + test('signTransactionWithAllOpts', async () => { + const mockTx = mock(); + mockTx.signatures = []; + mockTx.fee = { + gas_fee: '10', + gas_wanted: new Long(10), + }; + mockTx.messages = []; + + const opts: SignTransactionOptions = { + accountNumber: '42', + sequence: '42', + }; + + const mockStatus = mock(); + mockStatus.node_info = { + version_set: [], + version: '', + net_address: '', + software: '', + channels: '', + monkier: '', + other: { + tx_index: '', + rpc_address: '', + }, + network: 'testchain', + }; + + const mockProvider = mock(); + mockProvider.getStatus.mockResolvedValue(mockStatus); + const mockAccount: ABCIAccount = { + BaseAccount: { + address: '', + coins: '', + public_key: null, + account_number: '', + sequence: '', + }, + }; + mockProvider.getAccount.mockResolvedValue(mockAccount); + + const wallet: Wallet = await Wallet.createRandom(); + wallet.connect(mockProvider); + + const emptyDecodeCallback = (_: Any[]): any[] => { + return []; + }; + const signedTx: Tx = await wallet.signTransaction( + mockTx, + emptyDecodeCallback, + opts + ); + + expect(mockProvider.getStatus).toHaveBeenCalled(); + expect(mockProvider.getAccount).not.toHaveBeenCalled(); expect(signedTx.signatures).toHaveLength(1); diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index f315826..41a7c95 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -25,6 +25,11 @@ import { } from './types'; import { sortedJsonStringify } from '@cosmjs/amino/build/signdoc'; +export interface SignTransactionOptions { + accountNumber?: string; + sequence?: string; +} + /** * Wallet is a single account abstraction * that can interact with the blockchain @@ -245,7 +250,8 @@ export class Wallet { */ signTransaction = async ( tx: Tx, - decodeTxMessages: (messages: Any[]) => any[] + decodeTxMessages: (messages: Any[]) => any[], + opts?: SignTransactionOptions ): Promise => { if (!this.provider) { throw new Error('provider not connected'); @@ -261,17 +267,25 @@ export class Wallet { const chainID: string = status.node_info.network; // Extract the relevant account data - const address: string = await this.getAddress(); - const accountNumber: number = await this.provider.getAccountNumber(address); - const accountSequence: number = - await this.provider.getAccountSequence(address); + let accountNumber = opts?.accountNumber; + let accountSequence = opts?.sequence; + if (accountNumber === undefined || accountSequence === undefined) { + const address: string = await this.getAddress(); + const account = await this.provider.getAccount(address); + if (accountNumber === undefined) { + accountNumber = account.BaseAccount.account_number; + } + if (accountSequence === undefined) { + accountSequence = account.BaseAccount.sequence; + } + } const publicKey: Uint8Array = await this.signer.getPublicKey(); // Create the signature payload const signPayload: TxSignPayload = { chain_id: chainID, - account_number: accountNumber.toString(10), - sequence: accountSequence.toString(10), + account_number: accountNumber, + sequence: accountSequence, fee: { gas_fee: tx.fee.gas_fee, gas_wanted: tx.fee.gas_wanted.toString(10),