diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dc9be4c3..2c8073d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to the Aptos TypeScript SDK will be captured in this file. T ## Unreleased +- [`fix`] Allow variable length bitmaps in Multikey accounts, allowing for compatibility between SDKs properly + # 1.38.0 (2025-04-02) - Adds and default implementation of `verifySignatureAsync` to `PublicKey`. diff --git a/src/core/crypto/multiKey.ts b/src/core/crypto/multiKey.ts index 7238093aa..f723af733 100644 --- a/src/core/crypto/multiKey.ts +++ b/src/core/crypto/multiKey.ts @@ -285,6 +285,56 @@ export class MultiKey extends AbstractMultiKey { // endregion + /** + * Create a bitmap that holds the mapping from the original public keys + * to the signatures passed in + * + * @param args.bits array of the index mapping to the matching public keys + * @returns Uint8array bit map + * @group Implementation + * @category Serialization + */ + createBitmap(args: { bits: number[] }): Uint8Array { + const { bits } = args; + // Bits are read from left to right. e.g. 0b10000000 represents the first bit is set in one byte. + // The decimal value of 0b10000000 is 128. + const firstBitInByte = 128; + const bitmap: number[] = []; + + // Check if duplicates exist in bits + const dupCheckSet = new Set(); + + bits.forEach((bit: number, idx: number) => { + if (idx + 1 > this.publicKeys.length) { + throw new Error(`Signature index ${idx + 1} is out of public keys range, ${this.publicKeys.length}.`); + } + + if (dupCheckSet.has(bit)) { + throw new Error(`Duplicate bit ${bit} detected.`); + } + + dupCheckSet.add(bit); + + const byteOffset = Math.floor(bit / 8); + + // Extend by required number of bytes + if (bitmap.length < byteOffset) { + for (let i = bitmap.length; i < byteOffset; i += 1) { + bitmap.push(0); + } + } + + let byte = bitmap[byteOffset]; + + // eslint-disable-next-line no-bitwise + byte |= firstBitInByte >> bit % 8; + + bitmap[byteOffset] = byte; + }); + + return new Uint8Array(bitmap); + } + /** * Get the index of the provided public key. * @@ -374,8 +424,6 @@ export class MultiKeySignature extends Signature { if (!(bitmap instanceof Uint8Array)) { this.bitmap = MultiKeySignature.createBitmap({ bits: bitmap }); - } else if (bitmap.length !== MultiKeySignature.BITMAP_LEN) { - throw new Error(`"bitmap" length should be ${MultiKeySignature.BITMAP_LEN}`); } else { this.bitmap = bitmap; } diff --git a/tests/e2e/transaction/transactionSubmission.test.ts b/tests/e2e/transaction/transactionSubmission.test.ts index 295cb89ef..a67cbca5d 100644 --- a/tests/e2e/transaction/transactionSubmission.test.ts +++ b/tests/e2e/transaction/transactionSubmission.test.ts @@ -12,6 +12,8 @@ import { TransactionPayloadEntryFunction, Bool, MoveString, + Ed25519PublicKey, + AnyPublicKey, CallArgument, MultiEd25519PublicKey, Ed25519PrivateKey, @@ -776,6 +778,115 @@ describe("transaction submission", () => { }); expect(() => new MultiKeyAccount({ multiKey, signers: [singleSignerED25519SenderAccount] })).toThrow(); }); + + test("it submits a multi key transaction with lots of signers", async () => { + const subAccounts = []; + for (let i = 0; i < 32; i += 1) { + switch (i % 3) { + case 0: + subAccounts.push(Account.generate({ scheme: SigningSchemeInput.Ed25519, legacy: false })); + break; + case 1: + subAccounts.push(Account.generate({ scheme: SigningSchemeInput.Ed25519, legacy: true })); + break; + case 2: + subAccounts.push(Account.generate({ scheme: SigningSchemeInput.Secp256k1Ecdsa })); + break; + default: + break; + } + } + const publicKeys = subAccounts.map((account) => { + if (account.publicKey instanceof Ed25519PublicKey) { + return new AnyPublicKey(account.publicKey); + } + return account.publicKey; + }); + + const multiKey = new MultiKey({ + publicKeys, + signaturesRequired: 1, + }); + + const account = new MultiKeyAccount({ + multiKey, + signers: subAccounts, + }); + + await aptos.fundAccount({ accountAddress: account.accountAddress, amount: 100_000_000 }); + + const transaction = await aptos.transaction.build.simple({ + sender: account.accountAddress, + data: { + function: `0x${contractPublisherAccount.accountAddress.toStringWithoutPrefix()}::transfer::transfer`, + functionArguments: [1, receiverAccounts[0].accountAddress], + }, + }); + + const senderAuthenticator = aptos.transaction.sign({ signer: account, transaction }); + + const response = await aptos.transaction.submit.simple({ transaction, senderAuthenticator }); + await aptos.waitForTransaction({ + transactionHash: response.hash, + }); + expect(response.signature?.type).toBe("single_sender"); + + // Sign with only one of them now + const account2 = new MultiKeyAccount({ + multiKey, + signers: [subAccounts[0]], + }); + expect(account2.accountAddress).toEqual(account.accountAddress); + + await aptos.fundAccount({ accountAddress: account2.accountAddress, amount: 100_000_000 }); + + const transaction2 = await aptos.transaction.build.simple({ + sender: account2.accountAddress, + data: { + function: `0x${contractPublisherAccount.accountAddress.toStringWithoutPrefix()}::transfer::transfer`, + functionArguments: [1, receiverAccounts[0].accountAddress], + }, + }); + + const senderAuthenticator2 = aptos.transaction.sign({ signer: account2, transaction: transaction2 }); + + const response2 = await aptos.transaction.submit.simple({ + transaction: transaction2, + senderAuthenticator: senderAuthenticator2, + }); + await aptos.waitForTransaction({ + transactionHash: response2.hash, + }); + expect(response2.signature?.type).toBe("single_sender"); + + // Sign with the last one now + const account3 = new MultiKeyAccount({ + multiKey, + signers: [subAccounts[31]], + }); + expect(account3.accountAddress).toEqual(account.accountAddress); + + await aptos.fundAccount({ accountAddress: account3.accountAddress, amount: 100_000_000 }); + + const transaction3 = await aptos.transaction.build.simple({ + sender: account3.accountAddress, + data: { + function: `0x${contractPublisherAccount.accountAddress.toStringWithoutPrefix()}::transfer::transfer`, + functionArguments: [1, receiverAccounts[0].accountAddress], + }, + }); + + const senderAuthenticator3 = aptos.transaction.sign({ signer: account3, transaction: transaction3 }); + + const response3 = await aptos.transaction.submit.simple({ + transaction: transaction3, + senderAuthenticator: senderAuthenticator3, + }); + await aptos.waitForTransaction({ + transactionHash: response3.hash, + }); + expect(response3.signature?.type).toBe("single_sender"); + }); }); describe("MultiEd25519", () => { diff --git a/tests/unit/helper.ts b/tests/unit/helper.ts index 84d2fe981..b58880ec3 100644 --- a/tests/unit/helper.ts +++ b/tests/unit/helper.ts @@ -179,7 +179,7 @@ export const multiKeyTestObject = { signaturesRequired: 2, address: "0x738a998ac1f69db4a91fc5a0152f792c98ad87354c65a2a842a118d7a17109b1", authKey: "0x738a998ac1f69db4a91fc5a0152f792c98ad87354c65a2a842a118d7a17109b1", - bitmap: [160, 0, 0, 0], + bitmap: [160], stringBytes: "0x030141049a6f7caddff8064a7dd5800e4fb512bf1ff91daee965409385dfa040e3e63008ab7ef566f4377c2de5aeb2948208a01bcee2050c1c8578ce5fa6e0c3c507cca200207a73df1afd028e75e7f9e23b2187a37d092a6ccebcb3edff6e02f93185cbde86002017fe89a825969c1c0e5f5e80b95f563a6cb6240f88c4246c19cb39c9535a148602", }; diff --git a/tests/unit/multiKey.test.ts b/tests/unit/multiKey.test.ts index c5104cd25..3aef3be1a 100644 --- a/tests/unit/multiKey.test.ts +++ b/tests/unit/multiKey.test.ts @@ -1,7 +1,15 @@ // Copyright © Aptos Foundation // SPDX-License-Identifier: Apache-2.0 -import { Deserializer, Ed25519PublicKey, Secp256k1PublicKey, MultiKey } from "../../src"; +import { + Deserializer, + Ed25519PublicKey, + Secp256k1PublicKey, + MultiKey, + Hex, + MultiKeySignature, + Serializer, +} from "../../src"; import { multiKeyTestObject } from "./helper"; describe("MultiKey", () => { @@ -117,4 +125,17 @@ describe("MultiKey", () => { const bitmap = multiKey.createBitmap({ bits: [0, 2] }); expect(bitmap).toEqual(new Uint8Array(multiKeyTestObject.bitmap)); }); + + it("should be able to decode from other SDKs", () => { + const serializedBytes = Hex.fromHexString( + // eslint-disable-next-line max-len + "020140118d6ebe543aaf3a541453f98a5748ab5b9e3f96d781b8c0a43740af2b65c03529fdf62b7de7aad9150770e0994dc4e0714795fdebf312be66cd0550c607755e00401a90421453aa53fa5a7aa3dfe70d913823cbf087bf372a762219ccc824d3a0eeecccaa9d34f22db4366aec61fb6c204d2440f4ed288bc7cc7e407b766723a60901c0", + ).toUint8Array(); + const deserializer = new Deserializer(serializedBytes); + const multiKeySig = MultiKeySignature.deserialize(deserializer); + const serializer = new Serializer(); + multiKeySig.serialize(serializer); + const outBytes = serializer.toUint8Array(); + expect(outBytes).toEqual(serializedBytes); + }); });