diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb56197e..b0a05871e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the Aptos TypeScript SDK will be captured in this file. T ## Fixed +- Fix `serializeAsBytes is not a function` error when wallet extensions (e.g. Petra) bundle an older SDK and serialize v6 transaction objects. Added `serializeEntryFunctionBytesCompat()` helper with runtime fallback to the pre-v6 `bcsToBytes()` + `serializeBytes()` pattern (DVR-143) - Fix simple function arguments for `Vector>` types: BCS-encoded values (e.g. `AccountAddress.ONE`) passed as elements of `vector>` are now automatically wrapped in `MoveOption` instead of throwing a type mismatch error - Resolve moderate security advisories in `confidential-assets` dev tooling by pinning transitive `file-type` and `yauzl` (via `@swc/cli` → `@xhmikosr/downloader`) to patched releases - Remove hardcoded `maxGasAmount: 2000` from e2e tests (Account Derivation APIs, WebAuthn submission) that caused `MAX_GAS_UNITS_BELOW_MIN_TRANSACTION_GAS_UNITS` failures after the on-chain minimum gas increase diff --git a/src/bcs/serializable/movePrimitives.ts b/src/bcs/serializable/movePrimitives.ts index 9aec5c10b..71ba96a33 100644 --- a/src/bcs/serializable/movePrimitives.ts +++ b/src/bcs/serializable/movePrimitives.ts @@ -21,7 +21,13 @@ import { MAX_I256_BIG_INT, } from "../consts"; import { Deserializer } from "../deserializer"; -import { Serializable, Serializer, ensureBoolean, validateNumberInRange } from "../serializer"; +import { + Serializable, + Serializer, + ensureBoolean, + serializeEntryFunctionBytesCompat, + validateNumberInRange, +} from "../serializer"; import { TransactionArgument } from "../../transactions/instances/transactionArgument"; import { AnyNumber, Uint16, Uint32, Uint8, Int8, Int16, Int32, ScriptTransactionArgumentVariants } from "../../types"; @@ -76,14 +82,14 @@ export class Bool extends Serializable implements TransactionArgument { /** * Serializes the current instance for use in an entry function by converting it to a byte sequence. * This allows the instance to be properly formatted for serialization in transactions. - * Uses the optimized serializeAsBytes method to reduce allocations. + * Uses serializeAsBytes when available, with a fallback for older Serializer versions. * * @param serializer - The serializer instance used to serialize the byte sequence. * @group Implementation * @category BCS */ serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } /** @@ -138,7 +144,7 @@ export class U8 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -174,7 +180,7 @@ export class U16 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -209,7 +215,7 @@ export class U32 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -247,7 +253,7 @@ export class U64 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -283,7 +289,7 @@ export class U128 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -319,7 +325,7 @@ export class U256 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -354,7 +360,7 @@ export class I8 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -390,7 +396,7 @@ export class I16 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -425,7 +431,7 @@ export class I32 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -463,7 +469,7 @@ export class I64 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -499,7 +505,7 @@ export class I128 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -535,7 +541,7 @@ export class I256 extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { diff --git a/src/bcs/serializable/moveStructs.ts b/src/bcs/serializable/moveStructs.ts index 8431c8ed0..a5591e802 100644 --- a/src/bcs/serializable/moveStructs.ts +++ b/src/bcs/serializable/moveStructs.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Bool, U128, U16, U256, U32, U64, U8, I8, I16, I32, I64, I128, I256 } from "./movePrimitives"; -import { Serializable, Serializer } from "../serializer"; +import { Serializable, Serializer, serializeEntryFunctionBytesCompat } from "../serializer"; import { Deserializable, Deserializer } from "../deserializer"; import { AnyNumber, HexInput, ScriptTransactionArgumentVariants } from "../../types"; import { Hex } from "../../core/hex"; @@ -69,14 +69,14 @@ export class MoveVector /** * Serializes the current instance into a byte sequence suitable for entry functions. * This allows the data to be properly formatted for transmission or storage. - * Uses the optimized serializeAsBytes method to reduce allocations. + * Uses serializeAsBytes when available, with a fallback for older Serializer versions. * * @param serializer - The serializer instance used to serialize the byte sequence. * @group Implementation * @category BCS */ serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } /** @@ -493,7 +493,7 @@ export class MoveString extends Serializable implements TransactionArgument { } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } serializeForScriptFunction(serializer: Serializer): void { @@ -530,7 +530,7 @@ export class MoveOption } serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } /** diff --git a/src/bcs/serializer.ts b/src/bcs/serializer.ts index c065e9efb..cb9cf6e72 100644 --- a/src/bcs/serializer.ts +++ b/src/bcs/serializer.ts @@ -77,6 +77,25 @@ export abstract class Serializable { } } +/** + * Serialize a Serializable value as length-prefixed bytes into a Serializer, + * with backwards compatibility for older Serializer implementations that lack + * the `serializeAsBytes` method. This is critical for cross-version compatibility + * when SDK objects built with a newer SDK are serialized by an older SDK's Serializer + * (e.g., wallet extensions bundling an older SDK version). + * + * @param serializer - The serializer to write into (may be from any SDK version). + * @param value - The Serializable value to serialize as bytes. + */ +export function serializeEntryFunctionBytesCompat(serializer: Serializer, value: Serializable): void { + if (typeof (serializer as { serializeAsBytes?: unknown }).serializeAsBytes === "function") { + serializer.serializeAsBytes(value); + } else { + const bcsBytes = value.bcsToBytes(); + serializer.serializeBytes(bcsBytes); + } +} + /** * Minimum buffer growth increment to avoid too many small reallocations. */ diff --git a/src/core/accountAddress.ts b/src/core/accountAddress.ts index 34cf0735b..cbdc151ac 100644 --- a/src/core/accountAddress.ts +++ b/src/core/accountAddress.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; -import { Serializable, Serializer } from "../bcs/serializer"; +import { Serializable, Serializer, serializeEntryFunctionBytesCompat } from "../bcs/serializer"; import { Deserializer } from "../bcs/deserializer"; import { ParsingError, ParsingResult } from "./common"; import { TransactionArgument } from "../transactions/instances/transactionArgument"; @@ -240,14 +240,14 @@ export class AccountAddress extends Serializable implements TransactionArgument /** * Serializes the current instance into a byte sequence suitable for entry functions. * This allows for the proper encoding of data when interacting with entry functions in the blockchain. - * Uses the optimized serializeAsBytes method to reduce allocations. + * Uses serializeAsBytes when available, with a fallback for older Serializer versions. * * @param serializer - The serializer instance used to convert the data into bytes. * @group Implementation * @category Serialization */ serializeForEntryFunction(serializer: Serializer): void { - serializer.serializeAsBytes(this); + serializeEntryFunctionBytesCompat(serializer, this); } /** diff --git a/tests/unit/walletSerializerCompat.test.ts b/tests/unit/walletSerializerCompat.test.ts new file mode 100644 index 000000000..c72f8c2b9 --- /dev/null +++ b/tests/unit/walletSerializerCompat.test.ts @@ -0,0 +1,388 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +/** + * Tests that verify cross-version serializer compatibility for the wallet adapter flow. + * + * When a wallet (e.g. Petra) bundles an older SDK version and receives a transaction + * object built with a newer SDK, the wallet's older Serializer may lack newer methods + * like `serializeAsBytes`. This test simulates that scenario by creating a "legacy" + * serializer that mirrors the older SDK's Serializer API, then verifying that v6 + * transaction objects can still be serialized through it. + * + * See: DVR-143 (Petra fee payer error after upgrading to TS SDK 6.2.0) + */ + +import { + AccountAddress, + Bool, + Deserializer, + EntryFunction, + MoveOption, + MoveString, + MoveVector, + RawTransaction, + Serializer, + SimpleTransaction, + TransactionPayloadEntryFunction, + TypeTagAddress, + U128, + U16, + U256, + U32, + U64, + U8, + I8, + I16, + I32, + I64, + I128, + I256, + ChainId, + FeePayerRawTransaction, +} from "../../src"; + +/** + * Creates a Serializer instance that mimics an older SDK version's Serializer + * (one that does NOT have the `serializeAsBytes` method). This simulates what + * happens when a wallet bundles an older SDK and tries to serialize a transaction + * built with a newer SDK. + */ +function createLegacySerializer(): Serializer { + const serializer = new Serializer(); + // Remove the serializeAsBytes method to simulate an older Serializer + (serializer as Record).serializeAsBytes = undefined; + return serializer; +} + +describe("Cross-version serializer compatibility (wallet adapter flow)", () => { + describe("Move primitives serialize correctly with a legacy serializer", () => { + const testCases: Array<{ + name: string; + value: { serializeForEntryFunction(s: Serializer): void; bcsToBytes(): Uint8Array }; + }> = [ + { name: "Bool(true)", value: new Bool(true) }, + { name: "Bool(false)", value: new Bool(false) }, + { name: "U8(42)", value: new U8(42) }, + { name: "U16(1000)", value: new U16(1000) }, + { name: "U32(100000)", value: new U32(100000) }, + { name: "U64(1000000n)", value: new U64(1000000n) }, + { name: "U128(999999999999n)", value: new U128(999999999999n) }, + { name: "U256(12345678901234567890n)", value: new U256(12345678901234567890n) }, + { name: "I8(-42)", value: new I8(-42) }, + { name: "I16(-1000)", value: new I16(-1000) }, + { name: "I32(-100000)", value: new I32(-100000) }, + { name: "I64(-1000000n)", value: new I64(-1000000n) }, + { name: "I128(-999999999999n)", value: new I128(-999999999999n) }, + { name: "I256(-12345678901234567890n)", value: new I256(-12345678901234567890n) }, + ]; + + for (const { name, value } of testCases) { + it(`${name} produces identical bytes with legacy vs modern serializer`, () => { + const modernSerializer = new Serializer(); + value.serializeForEntryFunction(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + value.serializeForEntryFunction(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + } + }); + + describe("Complex types serialize correctly with a legacy serializer", () => { + it("AccountAddress produces identical bytes", () => { + const addr = AccountAddress.from("0x1"); + + const modernSerializer = new Serializer(); + addr.serializeForEntryFunction(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + addr.serializeForEntryFunction(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + + it("MoveVector produces identical bytes", () => { + const vec = MoveVector.U8([1, 2, 3, 4, 5]); + + const modernSerializer = new Serializer(); + vec.serializeForEntryFunction(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + vec.serializeForEntryFunction(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + + it("MoveVector produces identical bytes", () => { + const vec = MoveVector.U64([100n, 200n, 300n]); + + const modernSerializer = new Serializer(); + vec.serializeForEntryFunction(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + vec.serializeForEntryFunction(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + + it("MoveString produces identical bytes", () => { + const str = new MoveString("hello world"); + + const modernSerializer = new Serializer(); + str.serializeForEntryFunction(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + str.serializeForEntryFunction(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + + it("MoveOption produces identical bytes", () => { + const opt = MoveOption.U64(12345n); + + const modernSerializer = new Serializer(); + opt.serializeForEntryFunction(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + opt.serializeForEntryFunction(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + }); + + describe("Full transaction serialization with legacy serializer", () => { + it("RawTransaction with entry function serializes correctly through a legacy serializer", () => { + const sender = AccountAddress.from("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + const recipient = AccountAddress.from("0x1"); + const amount = new U64(100); + + const entryFunction = EntryFunction.build( + "0x1::aptos_account", + "transfer_coins", + [new TypeTagAddress()], + [recipient, amount], + ); + const payload = new TransactionPayloadEntryFunction(entryFunction); + const rawTxn = new RawTransaction( + sender, + 0n, + payload, + 200000n, + 100n, + BigInt(Math.floor(Date.now() / 1000) + 600), + new ChainId(2), + ); + + // Serialize with modern serializer + const modernSerializer = new Serializer(); + rawTxn.serialize(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + // Simulate what a wallet with an older SDK would do: + // create its own serializer (missing serializeAsBytes) and serialize the v6 RawTransaction + const legacySerializer = createLegacySerializer(); + rawTxn.serialize(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + + it("SimpleTransaction (no fee payer) serializes correctly through a legacy serializer", () => { + const sender = AccountAddress.from("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + const recipient = AccountAddress.from("0x1"); + const amount = new U64(100); + + const entryFunction = EntryFunction.build( + "0x1::aptos_account", + "transfer_coins", + [new TypeTagAddress()], + [recipient, amount], + ); + const payload = new TransactionPayloadEntryFunction(entryFunction); + const rawTxn = new RawTransaction( + sender, + 0n, + payload, + 200000n, + 100n, + BigInt(Math.floor(Date.now() / 1000) + 600), + new ChainId(2), + ); + const simpleTxn = new SimpleTransaction(rawTxn); + + const modernSerializer = new Serializer(); + simpleTxn.serialize(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + simpleTxn.serialize(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + + it("SimpleTransaction with fee payer serializes correctly through a legacy serializer", () => { + const sender = AccountAddress.from("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + const recipient = AccountAddress.from("0x1"); + const amount = new U64(100); + const feePayer = AccountAddress.from("0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"); + + const entryFunction = EntryFunction.build( + "0x1::aptos_account", + "transfer_coins", + [new TypeTagAddress()], + [recipient, amount], + ); + const payload = new TransactionPayloadEntryFunction(entryFunction); + const rawTxn = new RawTransaction( + sender, + 0n, + payload, + 200000n, + 100n, + BigInt(Math.floor(Date.now() / 1000) + 600), + new ChainId(2), + ); + + const simpleTxn = new SimpleTransaction(rawTxn, feePayer); + + const modernSerializer = new Serializer(); + simpleTxn.serialize(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + simpleTxn.serialize(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + + it("FeePayerRawTransaction serializes correctly through a legacy serializer", () => { + const sender = AccountAddress.from("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + const recipient = AccountAddress.from("0x1"); + const amount = new U64(100); + const feePayer = AccountAddress.from("0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"); + + const entryFunction = EntryFunction.build( + "0x1::aptos_account", + "transfer_coins", + [new TypeTagAddress()], + [recipient, amount], + ); + const payload = new TransactionPayloadEntryFunction(entryFunction); + const rawTxn = new RawTransaction( + sender, + 0n, + payload, + 200000n, + 100n, + BigInt(Math.floor(Date.now() / 1000) + 600), + new ChainId(2), + ); + + const feePayerTxn = new FeePayerRawTransaction(rawTxn, [], feePayer); + + const modernSerializer = new Serializer(); + feePayerTxn.serialize(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + const legacySerializer = createLegacySerializer(); + feePayerTxn.serialize(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + expect(legacyBytes).toEqual(modernBytes); + }); + + it("bcsToBytes still works correctly on transaction objects (uses internal v6 Serializer)", () => { + const sender = AccountAddress.from("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + const recipient = AccountAddress.from("0x1"); + const amount = new U64(100); + const feePayer = AccountAddress.from("0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"); + + const entryFunction = EntryFunction.build( + "0x1::aptos_account", + "transfer_coins", + [new TypeTagAddress()], + [recipient, amount], + ); + const payload = new TransactionPayloadEntryFunction(entryFunction); + const rawTxn = new RawTransaction( + sender, + 0n, + payload, + 200000n, + 100n, + BigInt(Math.floor(Date.now() / 1000) + 600), + new ChainId(2), + ); + const simpleTxn = new SimpleTransaction(rawTxn, feePayer); + + // bcsToBytes uses the internal Serializer (which always has serializeAsBytes) + const bytes = simpleTxn.bcsToBytes(); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toBeGreaterThan(0); + + // Verify round-trip + const deserializedTxn = SimpleTransaction.deserialize(new Deserializer(bytes)); + expect(deserializedTxn.rawTransaction.sender.toString()).toBe(sender.toString()); + expect(deserializedTxn.feePayerAddress?.toString()).toBe(feePayer.toString()); + }); + }); + + describe("Wallet adapter simulation: wallet with older serializer signs a v6 fee payer transaction", () => { + it("generates identical signing messages for fee payer transaction when serialized with legacy serializer", () => { + const sender = AccountAddress.from("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + const recipient = AccountAddress.from("0x1"); + const amount = new U64(100); + const feePayer = AccountAddress.from("0x0000000000000000000000000000000000000000000000000000000000000000"); + + const entryFunction = EntryFunction.build( + "0x1::aptos_account", + "transfer_coins", + [new TypeTagAddress()], + [recipient, amount], + ); + const payload = new TransactionPayloadEntryFunction(entryFunction); + const rawTxn = new RawTransaction( + sender, + 1n, + payload, + 200000n, + 100n, + BigInt(Math.floor(Date.now() / 1000) + 600), + new ChainId(2), + ); + + // This is what the wallet would do: create a FeePayerRawTransaction from + // the SimpleTransaction's fields and serialize it for signing + const feePayerTxn = new FeePayerRawTransaction(rawTxn, [], feePayer); + + // Modern serializer (has serializeAsBytes) + const modernSerializer = new Serializer(); + feePayerTxn.serialize(modernSerializer); + const modernBytes = modernSerializer.toUint8Array(); + + // Legacy serializer (lacks serializeAsBytes - simulates older wallet SDK) + const legacySerializer = createLegacySerializer(); + feePayerTxn.serialize(legacySerializer); + const legacyBytes = legacySerializer.toUint8Array(); + + // The signing message must be identical regardless of serializer version + expect(legacyBytes).toEqual(modernBytes); + }); + }); +});