From 6366a39c11c0f1dd127f6fbe1047af6119cad0ce Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 3 Jul 2026 19:38:46 +0800 Subject: [PATCH 1/6] feat(transaction): use local tx building in wallet, add package barrel - Make Transaction.encode() public, export FLAG_NOT_SIGNED - Replace RPC-based raw tx building with local Transaction.createTransferTx/createBondTx - Add transaction/index.ts package barrel - Update wallet and main index to import from transaction package --- src/crypto/address.ts | 12 +-- src/index.ts | 2 +- src/transaction/index.ts | 2 + src/transaction/payload/_payload.ts | 2 +- src/transaction/payload/bond.ts | 26 +++--- src/transaction/payload/sortition.ts | 12 +-- src/transaction/payload/transfer.ts | 18 ++--- src/transaction/payload/unbond.ts | 10 +-- src/transaction/payload/withdraw.ts | 16 ++-- src/transaction/transaction.ts | 49 +++++------ src/types/amount.ts | 8 +- src/types/height.ts | 8 +- src/wallet/wallet.ts | 117 ++++++++++++--------------- 13 files changed, 137 insertions(+), 145 deletions(-) create mode 100644 src/transaction/index.ts diff --git a/src/crypto/address.ts b/src/crypto/address.ts index 0da7c1f..8e2aed3 100644 --- a/src/crypto/address.ts +++ b/src/crypto/address.ts @@ -95,23 +95,23 @@ export class Address { } /** Encode the address to the writer. */ - encode(writer: Writer): void { + encode(w: Writer): void { if (this.isTreasuryAddress()) { - writer.writeUint8(AddressType.TREASURY); + w.writeUint8(AddressType.TREASURY); } else { - writer.writeFixedBytes(this.rawBytes()); + w.writeFixedBytes(this.rawBytes()); } } /** Decode an Address from the reader. */ - static decode(reader: Reader): Address { - const addrType = reader.readUint8(); + static decode(r: Reader): Address { + const addrType = r.readUint8(); if (addrType === AddressType.TREASURY) { return new Address(AddressType.TREASURY, new Uint8Array(20)); } - const data = reader.readFixedBytes(20); + const data = r.readFixedBytes(20); return new Address(addrType as AddressType, data); } diff --git a/src/index.ts b/src/index.ts index 20bb598..7079112 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ */ export * from './wallet'; -export * from './transaction/transaction'; +export * from './transaction'; export * from './transaction/payload'; export { Address } from './crypto/address'; export { Amount } from './types/amount'; diff --git a/src/transaction/index.ts b/src/transaction/index.ts new file mode 100644 index 0000000..d36321e --- /dev/null +++ b/src/transaction/index.ts @@ -0,0 +1,2 @@ +export * from './transaction'; +export * from './payload'; diff --git a/src/transaction/payload/_payload.ts b/src/transaction/payload/_payload.ts index f20d887..4519c63 100644 --- a/src/transaction/payload/_payload.ts +++ b/src/transaction/payload/_payload.ts @@ -11,7 +11,7 @@ export enum PayloadType { } export interface Payload { - encode(writer: Writer): void; + encode(w: Writer): void; getType(): PayloadType; signer(): Address; } diff --git a/src/transaction/payload/bond.ts b/src/transaction/payload/bond.ts index 4e56318..da1f17c 100644 --- a/src/transaction/payload/bond.ts +++ b/src/transaction/payload/bond.ts @@ -12,18 +12,18 @@ export class BondPayload { public readonly stake: Amount ) {} - encode(writer: Writer): void { - this.sender.encode(writer); - this.receiver.encode(writer); + encode(w: Writer): void { + this.sender.encode(w); + this.receiver.encode(w); if (this.publicKey === null) { - writer.writeVarInt(0); + w.writeVarInt(0); } else { - writer.writeVarInt(96); - writer.writeFixedBytes(this.publicKey); + w.writeVarInt(96); + w.writeFixedBytes(this.publicKey); } - this.stake.encode(writer); + this.stake.encode(w); } getType(): PayloadType { @@ -34,19 +34,19 @@ export class BondPayload { return this.sender; } - static decode(reader: Reader): BondPayload { - const sender = Address.decode(reader); - const receiver = Address.decode(reader); - const pubKeySize = reader.readVarInt(); + static decode(r: Reader): BondPayload { + const sender = Address.decode(r); + const receiver = Address.decode(r); + const pubKeySize = r.readVarInt(); let publicKey: Uint8Array | null = null; if (pubKeySize === 96n) { - publicKey = reader.readFixedBytes(96); + publicKey = r.readFixedBytes(96); } else if (pubKeySize !== 0n) { throw new Error(`invalid public key size: ${pubKeySize}`); } - const stake = Amount.decode(reader); + const stake = Amount.decode(r); return new BondPayload(sender, receiver, publicKey, stake); } diff --git a/src/transaction/payload/sortition.ts b/src/transaction/payload/sortition.ts index 4842479..c65be8c 100644 --- a/src/transaction/payload/sortition.ts +++ b/src/transaction/payload/sortition.ts @@ -9,9 +9,9 @@ export class SortitionPayload { public readonly proof: Uint8Array ) {} - encode(writer: Writer): void { - this.address.encode(writer); - writer.writeFixedBytes(this.proof); + encode(w: Writer): void { + this.address.encode(w); + w.writeFixedBytes(this.proof); } getType(): PayloadType { @@ -22,9 +22,9 @@ export class SortitionPayload { return this.address; } - static decode(reader: Reader): SortitionPayload { - const address = Address.decode(reader); - const proof = reader.readFixedBytes(48); + static decode(r: Reader): SortitionPayload { + const address = Address.decode(r); + const proof = r.readFixedBytes(48); return new SortitionPayload(address, proof); } diff --git a/src/transaction/payload/transfer.ts b/src/transaction/payload/transfer.ts index 294cc57..c1b3531 100644 --- a/src/transaction/payload/transfer.ts +++ b/src/transaction/payload/transfer.ts @@ -1,6 +1,6 @@ import { Address } from '../../crypto/address'; import { Amount } from '../../types/amount'; -import type { Writer, Reader } from '../../encoding'; +import type { Writer as w, Reader } from '../../encoding'; import { PayloadType } from './_payload'; @@ -11,10 +11,10 @@ export class TransferPayload { public readonly amount: Amount ) {} - encode(writer: Writer): void { - this.sender.encode(writer); - this.receiver.encode(writer); - this.amount.encode(writer); + encode(w: w): void { + this.sender.encode(w); + this.receiver.encode(w); + this.amount.encode(w); } getType(): PayloadType { @@ -25,10 +25,10 @@ export class TransferPayload { return this.sender; } - static decode(reader: Reader): TransferPayload { - const sender = Address.decode(reader); - const receiver = Address.decode(reader); - const amount = Amount.decode(reader); + static decode(r: Reader): TransferPayload { + const sender = Address.decode(r); + const receiver = Address.decode(r); + const amount = Amount.decode(r); return new TransferPayload(sender, receiver, amount); } diff --git a/src/transaction/payload/unbond.ts b/src/transaction/payload/unbond.ts index 168e023..ac78279 100644 --- a/src/transaction/payload/unbond.ts +++ b/src/transaction/payload/unbond.ts @@ -1,13 +1,13 @@ import { Address } from '../../crypto/address'; -import type { Writer, Reader } from '../../encoding'; +import type { Writer as w, Reader } from '../../encoding'; import { PayloadType } from './_payload'; export class UnbondPayload { constructor(public readonly validator: Address) {} - encode(writer: Writer): void { - this.validator.encode(writer); + encode(w: w): void { + this.validator.encode(w); } getType(): PayloadType { @@ -18,8 +18,8 @@ export class UnbondPayload { return this.validator; } - static decode(reader: Reader): UnbondPayload { - const validator = Address.decode(reader); + static decode(r: Reader): UnbondPayload { + const validator = Address.decode(r); return new UnbondPayload(validator); } diff --git a/src/transaction/payload/withdraw.ts b/src/transaction/payload/withdraw.ts index 3de0e73..0935df5 100644 --- a/src/transaction/payload/withdraw.ts +++ b/src/transaction/payload/withdraw.ts @@ -11,10 +11,10 @@ export class WithdrawPayload { public readonly amount: Amount ) {} - encode(writer: Writer): void { - this.fromAddr.encode(writer); - this.toAddr.encode(writer); - this.amount.encode(writer); + encode(w: Writer): void { + this.fromAddr.encode(w); + this.toAddr.encode(w); + this.amount.encode(w); } getType(): PayloadType { @@ -25,10 +25,10 @@ export class WithdrawPayload { return this.fromAddr; } - static decode(reader: Reader): WithdrawPayload { - const fromAddr = Address.decode(reader); - const toAddr = Address.decode(reader); - const amount = Amount.decode(reader); + static decode(r: Reader): WithdrawPayload { + const fromAddr = Address.decode(r); + const toAddr = Address.decode(r); + const amount = Amount.decode(r); return new WithdrawPayload(fromAddr, toAddr, amount); } diff --git a/src/transaction/transaction.ts b/src/transaction/transaction.ts index 879d4c9..55891db 100644 --- a/src/transaction/transaction.ts +++ b/src/transaction/transaction.ts @@ -68,7 +68,8 @@ export interface RawTransferTransaction { // ---- Transaction implementation ---- const FLAG_STRIPPED_PUBLIC_KEY = 0x01; -const FLAG_NOT_SIGNED = 0x02; + +export const FLAG_NOT_SIGNED = 0x02; export class Transaction { public flags: number; @@ -92,31 +93,31 @@ export class Transaction { } /** Decode a Transaction from the reader. */ - static decode(reader: Reader): Transaction { - const flags = reader.readUint8(); - const version = reader.readUint8(); - const lockTime = Height.decode(reader); - const fee = Amount.decode(reader); - const memo = reader.readStr(); - const payloadType = reader.readUint8(); + static decode(r: Reader): Transaction { + const flags = r.readUint8(); + const version = r.readUint8(); + const lockTime = Height.decode(r); + const fee = Amount.decode(r); + const memo = r.readStr(); + const payloadType = r.readUint8(); let payload: Payload; switch (payloadType) { case PayloadType.TRANSFER: - payload = TransferPayload.decode(reader); + payload = TransferPayload.decode(r); break; case PayloadType.BOND: - payload = BondPayload.decode(reader); + payload = BondPayload.decode(r); break; case PayloadType.SORTITION: - payload = SortitionPayload.decode(reader); + payload = SortitionPayload.decode(r); break; case PayloadType.UNBOND: - payload = UnbondPayload.decode(reader); + payload = UnbondPayload.decode(r); break; case PayloadType.WITHDRAW: - payload = WithdrawPayload.decode(reader); + payload = WithdrawPayload.decode(r); break; default: throw new Error(`unknown payload type: ${payloadType}`); @@ -136,12 +137,12 @@ export class Transaction { const signerType = payload.signer().addressType(); const sigSize = getSignatureSize(signerType); - tx.signature = reader.readFixedBytes(sigSize); + tx.signature = r.readFixedBytes(sigSize); if ((flags & FLAG_STRIPPED_PUBLIC_KEY) === 0) { const pubSize = getPublicKeySize(signerType); - tx.publicKey = reader.readFixedBytes(pubSize); + tx.publicKey = r.readFixedBytes(pubSize); } return tx; @@ -198,21 +199,21 @@ export class Transaction { } /** Write the unsigned bytes of the transaction to the writer. */ - private writeUnsignedBytes(writer: Writer): void { - writer.writeUint8(this.flags); - writer.writeUint8(this.version); - this.lockTime.encode(writer); - this.fee.encode(writer); - writer.writeStr(this.memo); - writer.writeUint8(this.payload.getType()); - this.payload.encode(writer); + encode(w: Writer): void { + w.writeUint8(this.flags); + w.writeUint8(this.version); + this.lockTime.encode(w); + this.fee.encode(w); + w.writeStr(this.memo); + w.writeUint8(this.payload.getType()); + this.payload.encode(w); } /** Return the bytes to be signed (everything except flags). */ signBytes(): Uint8Array { const w = new Writer(); - this.writeUnsignedBytes(w); + this.encode(w); return w.toBytes().slice(1); } diff --git a/src/types/amount.ts b/src/types/amount.ts index 8d1efc1..047f123 100644 --- a/src/types/amount.ts +++ b/src/types/amount.ts @@ -191,13 +191,13 @@ export class Amount { } /** Encode the amount as a varint to the writer. */ - encode(writer: Writer): void { - writer.writeVarInt(BigInt(this.value)); + encode(w: Writer): void { + w.writeVarInt(BigInt(this.value)); } /** Decode an Amount from the reader. */ - static decode(reader: Reader): Amount { - const val = reader.readVarInt(); + static decode(r: Reader): Amount { + const val = r.readVarInt(); return new Amount(val.toString()); } diff --git a/src/types/height.ts b/src/types/height.ts index 20eb928..9f74e60 100644 --- a/src/types/height.ts +++ b/src/types/height.ts @@ -14,12 +14,12 @@ export class Height { } /** Encode the height as uint32 (little-endian) to the writer. */ - encode(writer: Writer): void { - writer.writeUint32(this.value); + encode(w: Writer): void { + w.writeUint32(this.value); } /** Decode a Height from the reader. */ - static decode(reader: Reader): Height { - return new Height(reader.readUint32()); + static decode(r: Reader): Height { + return new Height(r.readUint32()); } } diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index 8437001..f02bbe9 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -1,16 +1,22 @@ import * as crypto from 'crypto'; import * as bip39 from 'bip39'; +import { bech32m } from 'bech32'; import type { WalletCore } from '@trustwallet/wallet-core'; import type { HDWallet } from '@trustwallet/wallet-core/dist/src/wallet-core'; import { Amount } from '../types/amount'; +import { Height } from '../types/height'; +import { Address } from '../crypto/address'; import type { RawTransferTransaction, TransferTransaction, BondTransaction, -} from '../transaction/transaction'; -import { TransactionDetailsType } from '../transaction/transaction'; +} from '../transaction'; +import { Transaction, TransactionDetailsType, FLAG_NOT_SIGNED } from '../transaction'; +import { Writer } from '../encoding'; + +import { bytesToHex } from '../crypto/utils'; import { Encrypter } from './encrypter/encrypter'; import { MnemonicError, StorageError, NetworkError } from './error'; @@ -589,7 +595,7 @@ export class Wallet { fee: calculatedFee, memo: memo ?? '', // Ensure memo is always a string }; - const rawTxHex = await this.getRawTransferTransaction(tx); + const rawTxHex = this.getRawTransferTransaction(tx); // Sign transaction const { signedRawTxHex } = await this.signTransaction( @@ -644,7 +650,7 @@ export class Wallet { // eslint-disable-next-line @typescript-eslint/naming-convention public_key: publicKey ?? '', }; - const rawTxHex = await this.getRawBondTransaction(tx); + const rawTxHex = this.getRawBondTransaction(tx); // Sign transaction const { signedRawTxHex } = await this.signTransaction( @@ -658,39 +664,31 @@ export class Wallet { } /** - * Get raw transfer transaction hex + * Get raw transfer transaction hex — built locally, no RPC. */ - private async getRawTransferTransaction( + private getRawTransferTransaction( tx: TransferTransaction - ): Promise { - const txParams = { - sender: tx.sender, - receiver: tx.receiver, - amount: Number(tx.amount.toString()), // Convert to number - fee: Number(tx.fee.toString()), // Convert to number - memo: tx.memo || '', - }; + ): RawTransferTransaction { + const sender = Address.fromString(tx.sender); + const receiver = Address.fromString(tx.receiver); + const lockTime = new Height(0); + + const txn = Transaction.createTransferTx( + lockTime, + sender, + receiver, + tx.amount, + tx.fee, + tx.memo ?? '' + ); - try { - const result = await this.withFailover(client => - client.pactusTransactionGetRawTransferTransaction( - undefined, - txParams.sender, - txParams.receiver, - txParams.amount, - txParams.fee, - txParams.memo - ) - ); + txn.flags = FLAG_NOT_SIGNED; - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - raw_transaction: result.raw_transaction ?? '', - id: result.id ?? '', - }; - } catch (error) { - throw new NetworkError(`Failed to get raw transfer transaction: ${error}`); - } + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_transaction: bytesToHex(this.encodeTx(txn)), + id: bytesToHex(txn.id()), + }; } /** @@ -713,40 +711,31 @@ export class Wallet { } /** - * Get raw bond transaction hex + * Get raw bond transaction hex — built locally, no RPC. */ - private async getRawBondTransaction(tx: BondTransaction): Promise { - const txParams = { - sender: tx.sender, - receiver: tx.receiver, - stake: Number(tx.stake.toString()), // Convert to number - fee: Number(tx.fee.toString()), // Convert to number - memo: tx.memo || '', - // eslint-disable-next-line @typescript-eslint/naming-convention - public_key: tx.public_key, - }; + private getRawBondTransaction(tx: BondTransaction): RawTransferTransaction { + const sender = Address.fromString(tx.sender); + const receiver = Address.fromString(tx.receiver); + const lockTime = new Height(0); + const publicKeyBytes = decodeBech32PublicKey(tx.public_key); + + const txn = Transaction.createBondTx( + lockTime, + sender, + receiver, + publicKeyBytes, + tx.fee, + tx.stake, + tx.memo ?? '' + ); - try { - const result = await this.withFailover(client => - client.pactusTransactionGetRawBondTransaction( - undefined, - txParams.sender, - txParams.receiver, - txParams.stake, - txParams.public_key, - txParams.fee, - txParams.memo - ) - ); + txn.flags = FLAG_NOT_SIGNED; - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - raw_transaction: result.raw_transaction ?? '', - id: result.id ?? '', - }; - } catch (error) { - throw new NetworkError(`Failed to get raw bond transaction: ${error}`); - } + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_transaction: bytesToHex(this.encodeTx(txn)), + id: bytesToHex(txn.id()), + }; } /** From 62061b49fa35b54794f04f1425e000889d98b48b Mon Sep 17 00:00:00 2001 From: Mostafa Date: Fri, 3 Jul 2026 23:57:19 +0800 Subject: [PATCH 2/6] feat(crypto): define IPublicKey/ISignature/IPrivateKey interfaces, implement Transaction.sign/verify - Add crypto/interfaces.ts with IPublicKey, ISignature, IPrivateKey - Transaction now stores typed publicKey/signature instead of raw bytes - Implement Transaction.sign(privateKey) and Transaction.verify() - Update Transaction.encode/decode for signed tx round-trip - Add encodeTx() and decodeBech32PublicKey() helpers - Rework wallet.signTransaction() to use Transaction.sign() + verify - Fix signBytes() to always return unsigned portion independently --- src/crypto/interfaces.ts | 55 +++++++++++++++++ src/crypto/utils.ts | 18 ++++++ src/transaction/transaction.test.ts | 57 ++++++++++++----- src/transaction/transaction.ts | 94 +++++++++++++++++++++++++---- src/wallet/wallet.test.ts | 54 +++++++++++++++-- src/wallet/wallet.ts | 64 ++++++++++++-------- 6 files changed, 281 insertions(+), 61 deletions(-) create mode 100644 src/crypto/interfaces.ts diff --git a/src/crypto/interfaces.ts b/src/crypto/interfaces.ts new file mode 100644 index 0000000..bf7a1df --- /dev/null +++ b/src/crypto/interfaces.ts @@ -0,0 +1,55 @@ +import type { Writer } from '../encoding'; + +/** + * Interface for a public key that can verify signatures. + * + * Concrete implementations: ed25519.PublicKey, bls.PublicKey + */ +export interface IPublicKey { + /** Raw key material. */ + rawBytes(): Uint8Array; + + /** Bech32m-encoded string representation (e.g. "public1..."). */ + string(): string; + + /** Encode the key to the writer. */ + encode(w: Writer): void; + + /** Verify a signature against a message. */ + verify(msg: Uint8Array, sig: ISignature): boolean; +} + +/** + * Interface for a cryptographic signature. + * + * Concrete implementations: ed25519.Signature, bls.Signature + */ +export interface ISignature { + /** Raw signature bytes. */ + rawBytes(): Uint8Array; + + /** Hex-encoded string representation. */ + string(): string; + + /** Encode the signature to the writer. */ + encode(w: Writer): void; +} + +/** + * Interface for a private key that can sign messages. + * + * Concrete implementations: ed25519.PrivateKey, bls.PrivateKey + */ +export interface IPrivateKey { + /** Raw scalar bytes. */ + rawBytes(): Uint8Array; + + /** Bech32m-encoded string representation (e.g. "secret1..."). */ + string(): string; + + /** Derive the corresponding public key. */ + publicKey(): IPublicKey; + + /** Sign a message and return the signature. */ + sign(msg: Uint8Array): ISignature; +} diff --git a/src/crypto/utils.ts b/src/crypto/utils.ts index 37d58d1..e0c97c9 100644 --- a/src/crypto/utils.ts +++ b/src/crypto/utils.ts @@ -38,3 +38,21 @@ export function hexToBytes(hex: string): Uint8Array { return bytes; } + +/** + * Decode a bech32m-encoded public key string into raw bytes. + * + * Validates the HRP against the given value (default "public" for mainnet). + */ +export function decodeBech32PublicKey( + text: string, + expectedHrp: string = 'public' +): Uint8Array { + const { hrp, data } = decodeWithType(text); + + if (hrp !== expectedHrp) { + throw new Error(`Invalid public key hrp: ${hrp}, expected: ${expectedHrp}`); + } + + return data; +} diff --git a/src/transaction/transaction.test.ts b/src/transaction/transaction.test.ts index 26b7721..ef5799d 100644 --- a/src/transaction/transaction.test.ts +++ b/src/transaction/transaction.test.ts @@ -1,10 +1,13 @@ -import { Transaction } from './transaction'; -import { Address } from '../crypto/address'; +import { Transaction, FLAG_NOT_SIGNED } from './transaction'; +import { Address, AddressType } from '../crypto/address'; import { Amount } from '../types/amount'; import { Height } from '../types/height'; -import { Reader } from '../encoding'; +import { Reader, Writer } from '../encoding'; import type { TransferPayload, BondPayload } from './payload'; import { PayloadType } from './payload'; +import { PrivateKey as Ed25519PrivateKey } from '../crypto/ed25519/private_key'; +import { PublicKey as Ed25519PublicKey } from '../crypto/ed25519/public_key'; +import { Signature as Ed25519Signature } from '../crypto/ed25519/signature'; describe('Transaction', () => { describe('decode transfer transaction', () => { @@ -44,11 +47,9 @@ describe('Transaction', () => { expect(tx.publicKey).not.toBeNull(); // Signature is 64 bytes (ED25519_ACCOUNT) - const sig = tx.signature as Uint8Array; - expect(sig.length).toBe(64); + expect(tx.signature!.rawBytes().length).toBe(64); // Public key is 32 bytes (ED25519_ACCOUNT) - const pub = tx.publicKey as Uint8Array; - expect(pub.length).toBe(32); + expect(tx.publicKey!.rawBytes().length).toBe(32); // Transaction ID expect(bytesToHex(tx.id())).toBe(expectedTxId); @@ -98,11 +99,9 @@ describe('Transaction', () => { expect(tx.publicKey).not.toBeNull(); // Signature is 64 bytes (ED25519_ACCOUNT) - const sig = tx.signature as Uint8Array; - expect(sig.length).toBe(64); + expect(tx.signature!.rawBytes().length).toBe(64); // Public key is 32 bytes (ED25519_ACCOUNT) - const pub = tx.publicKey as Uint8Array; - expect(pub.length).toBe(32); + expect(tx.publicKey!.rawBytes().length).toBe(32); // Transaction ID expect(bytesToHex(tx.id())).toBe(expectedTxId); @@ -138,17 +137,43 @@ describe('Transaction', () => { }); }); - describe('sign', () => { - it('should throw error when sign is called', () => { + describe('sign and verify', () => { + it('should sign and verify a transaction with round-trip', () => { + const privKey = Ed25519PrivateKey.random(); + const pubKey = privKey.publicKey(); + const sender = pubKey.accountAddress(); + const receiver = Address.fromString('pc1r0g22ufzn8qtw0742dmfglnw73e260hep0k3yra'); const lockTime = new Height(0); - const sender = Address.fromString('pc1z5x2a0lkt5nrrdqe0rkcv6r4pfkmdhrr3mawvua'); - const receiver = Address.fromString('pc1zt6qcdymkk48c5ds0fzfsaf6puwu8w8djn3ffpn'); const amount = Amount.zero(); const fee = Amount.zero(); const tx = Transaction.createTransferTx(lockTime, sender, receiver, amount, fee); - expect(() => tx.sign()).toThrow('signing is not supported in the TypeScript SDK'); + // Unsigned tx should not have sig/pubKey + expect(tx.signature).toBeNull(); + expect(tx.publicKey).toBeNull(); + + // Mark as unsigned and sign + tx.flags = FLAG_NOT_SIGNED; + tx.sign(privKey); + + expect(tx.signature).not.toBeNull(); + expect(tx.publicKey).not.toBeNull(); + expect(tx.verify()).toBe(true); + + // Encode signed tx + const w = new Writer(); + + tx.encode(w); + const signedBytes = w.toBytes(); + + // Decode round-trip + const decoded = Transaction.decode(new Reader(signedBytes)); + + expect(decoded.flags).toBe(0); + expect(decoded.signature).not.toBeNull(); + expect(decoded.publicKey).not.toBeNull(); + expect(decoded.verify()).toBe(true); }); }); }); diff --git a/src/transaction/transaction.ts b/src/transaction/transaction.ts index 55891db..a8fe9d7 100644 --- a/src/transaction/transaction.ts +++ b/src/transaction/transaction.ts @@ -1,6 +1,12 @@ import { blake2b } from 'blakejs'; import type { Address } from '../crypto/address'; +import { AddressType } from '../crypto/address'; +import type { IPublicKey, ISignature, IPrivateKey } from '../crypto/interfaces'; +import { PublicKey as Ed25519PublicKey } from '../crypto/ed25519/public_key'; +import { Signature as Ed25519Signature } from '../crypto/ed25519/signature'; +import { PublicKey as BlsPublicKey } from '../crypto/bls/public_key'; +import { Signature as BlsSignature } from '../crypto/bls/signature'; import { Amount } from '../types/amount'; import { Height } from '../types/height'; import type { Reader } from '../encoding'; @@ -78,8 +84,8 @@ export class Transaction { public fee: Amount; public memo: string; public payload: Payload; - public publicKey: Uint8Array | null; - public signature: Uint8Array | null; + public publicKey: IPublicKey | null; + public signature: ISignature | null; constructor(lockTime: Height, fee: Amount, memo: string, payload: Payload) { this.lockTime = lockTime; @@ -136,13 +142,14 @@ export class Transaction { const signerType = payload.signer().addressType(); const sigSize = getSignatureSize(signerType); + const sigBytes = r.readFixedBytes(sigSize); - tx.signature = r.readFixedBytes(sigSize); + tx.signature = createSignature(sigBytes, signerType); if ((flags & FLAG_STRIPPED_PUBLIC_KEY) === 0) { const pubSize = getPublicKeySize(signerType); - tx.publicKey = r.readFixedBytes(pubSize); + tx.publicKey = createPublicKey(r.readFixedBytes(pubSize), signerType); } return tx; @@ -198,7 +205,7 @@ export class Transaction { return new Transaction(lockTime, fee, memo, payload); } - /** Write the unsigned bytes of the transaction to the writer. */ + /** Encode the transaction to the writer (unsigned or signed). */ encode(w: Writer): void { w.writeUint8(this.flags); w.writeUint8(this.version); @@ -207,15 +214,25 @@ export class Transaction { w.writeStr(this.memo); w.writeUint8(this.payload.getType()); this.payload.encode(w); + + if (!(this.flags & FLAG_NOT_SIGNED) && this.signature && this.publicKey) { + this.signature.encode(w); + this.publicKey.encode(w); + } } - /** Return the bytes to be signed (everything except flags). */ + /** Return the bytes to be signed (version through payload, without flags). */ signBytes(): Uint8Array { const w = new Writer(); - this.encode(w); + w.writeUint8(this.version); + this.lockTime.encode(w); + this.fee.encode(w); + w.writeStr(this.memo); + w.writeUint8(this.payload.getType()); + this.payload.encode(w); - return w.toBytes().slice(1); + return w.toBytes(); } /** Return the transaction ID (blake2b-256 of sign bytes). */ @@ -224,12 +241,63 @@ export class Transaction { } /** - * Sign the transaction and return signed bytes. + * Sign the transaction with a private key. + * + * Clears the FLAG_NOT_SIGNED flag, computes the signature over signBytes(), + * and stores both signature and the derived public key. + */ + sign(privateKey: IPrivateKey): void { + const msg = this.signBytes(); + + this.signature = privateKey.sign(msg); + this.publicKey = privateKey.publicKey(); + this.flags &= ~FLAG_NOT_SIGNED; + } + + /** + * Verify the transaction signature. * - * NOTE: Signing is not yet supported in the TypeScript SDK. - * BLS, Ed25519, and secp256k1 signing are not implemented. + * Returns true if the signature is valid against the stored public key and + * the transaction's sign bytes. Returns false if publicKey or signature is + * missing. */ - sign(): Uint8Array { - throw new Error('signing is not supported in the TypeScript SDK'); + verify(): boolean { + if (!this.publicKey || !this.signature) { + return false; + } + + return this.publicKey.verify(this.signBytes(), this.signature); + } +} + +// ---- Factory helpers for typed crypto objects ---- + +function createPublicKey(bytes: Uint8Array, addressType: AddressType): IPublicKey { + // Ensure a plain Uint8Array (not a Buffer subclass) for noble compatibility + const raw = new Uint8Array(bytes); + + switch (addressType) { + case AddressType.ED25519_ACCOUNT: + return new Ed25519PublicKey(raw); + case AddressType.BLS_ACCOUNT: + case AddressType.VALIDATOR: + return new BlsPublicKey(raw); + default: + throw new Error(`unknown address type for public key: ${addressType}`); + } +} + +function createSignature(bytes: Uint8Array, addressType: AddressType): ISignature { + // Ensure a plain Uint8Array (not a Buffer subclass) for noble compatibility + const raw = new Uint8Array(bytes); + + switch (addressType) { + case AddressType.ED25519_ACCOUNT: + return new Ed25519Signature(raw); + case AddressType.BLS_ACCOUNT: + case AddressType.VALIDATOR: + return new BlsSignature(raw); + default: + throw new Error(`unknown address type for signature: ${addressType}`); } } diff --git a/src/wallet/wallet.test.ts b/src/wallet/wallet.test.ts index e9c63f1..7c9a4d5 100644 --- a/src/wallet/wallet.test.ts +++ b/src/wallet/wallet.test.ts @@ -9,6 +9,13 @@ import { getWordCount } from './utils'; import { Wallet } from './wallet'; import type { IStorage } from './storage/storage'; import type { WalletCore } from '@trustwallet/wallet-core'; +import { Address } from '../crypto/address'; +import { HRP } from '../crypto/hrp'; +import { Amount } from '../types/amount'; +import { Height } from '../types/height'; +import { Transaction, FLAG_NOT_SIGNED } from '../transaction'; +import { Writer, Reader } from '../encoding'; +import { bytesToHex } from '../crypto/utils'; // Jest typings setup declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -534,19 +541,54 @@ describe('Pactus Wallet Tests', () => { // Create an address to get its path const addrInfo = await wallet.createAddress('Test Address', password); - // Test transaction raw hex with correct flag byte (0x02 for unsigned) - const rawTxHex = '020000000000000001000000000000000100000000000000'; + // The wallet uses testnet addresses, so set HRP to testnet for Address.fromString + HRP.useTestnet(); + + // Build a proper unsigned transfer transaction using the wallet's address + const senderAddr = Address.fromString(addrInfo.address); + const receiverAddr = Address.fromString(addrInfo.address); // self-transfer for test + const lockTime = new Height(0); + const amount = Amount.zero(); + const fee = Amount.zero(); + + const txn = Transaction.createTransferTx( + lockTime, + senderAddr, + receiverAddr, + amount, + fee + ); + + txn.flags = FLAG_NOT_SIGNED; + const rawTxHex = bytesToHex( + (() => { + const w = new Writer(); + + txn.encode(w); + + return w.toBytes(); + })() + ); // Sign the transaction const { signedRawTxHex } = await wallet.signTransaction(rawTxHex, addrInfo.path, password); - // Verify the result is not empty and has expected format + // Verify the signed hex is longer (includes signature + public key) expect(signedRawTxHex).toBeTruthy(); expect(signedRawTxHex.length).toBeGreaterThan(rawTxHex.length); - // The signed transaction should start with 0x00 (signed flag) followed by the transaction data without the flag byte - expect(signedRawTxHex.startsWith('00')).toBe(true); - expect(signedRawTxHex.substring(2, rawTxHex.length)).toBe(rawTxHex.substring(2)); + // Decode the signed bytes and verify + const decoded = Transaction.decode( + new Reader(Buffer.from(signedRawTxHex, 'hex')) + ); + + expect(decoded.flags & FLAG_NOT_SIGNED).toBe(0); + expect(decoded.signature).not.toBeNull(); + expect(decoded.publicKey).not.toBeNull(); + expect(decoded.verify()).toBe(true); + + // Restore mainnet HRP + HRP.useMainnet(); }); it('should throw an error for empty raw transaction', async () => { diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index f02bbe9..290870b 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -1,7 +1,6 @@ import * as crypto from 'crypto'; import * as bip39 from 'bip39'; -import { bech32m } from 'bech32'; import type { WalletCore } from '@trustwallet/wallet-core'; import type { HDWallet } from '@trustwallet/wallet-core/dist/src/wallet-core'; @@ -14,9 +13,10 @@ import type { BondTransaction, } from '../transaction'; import { Transaction, TransactionDetailsType, FLAG_NOT_SIGNED } from '../transaction'; -import { Writer } from '../encoding'; +import { Writer, Reader } from '../encoding'; -import { bytesToHex } from '../crypto/utils'; +import { bytesToHex, decodeBech32PublicKey } from '../crypto/utils'; +import { PrivateKey as Ed25519PrivateKey } from '../crypto/ed25519/private_key'; import { Encrypter } from './encrypter/encrypter'; import { MnemonicError, StorageError, NetworkError } from './error'; @@ -663,6 +663,17 @@ export class Wallet { return { signedRawTxHex }; } + /** + * Encode a Transaction object to raw bytes. + */ + private encodeTx(txn: Transaction): Uint8Array { + const w = new Writer(); + + txn.encode(w); + + return w.toBytes(); + } + /** * Get raw transfer transaction hex — built locally, no RPC. */ @@ -739,7 +750,10 @@ export class Wallet { } /** - * Sign the raw transaction + * Sign a raw (unsigned) transaction using the wallet's private key. + * + * Decodes the hex bytes into a Transaction, signs it via + * Transaction.sign(), and returns the fully-encoded signed hex. */ async signTransaction( rawTxHex: string, @@ -752,37 +766,35 @@ export class Wallet { throw new Error('Empty transaction buffer'); } - const hdWallet = await this.hdWallet(password); - const derivationPath = addressPath; - const privateKey = hdWallet.getKey(this.core.CoinType.pactus, derivationPath); + // Decode the raw unsigned transaction + const reader = new Reader(rawTxBytes); + const txn = Transaction.decode(reader); - // First byte of rawTxBytes should be the flags byte (0x00 for unsigned, 0x02 for signed) - // Make sure we're working with an unsigned transaction - if (rawTxBytes[0] !== 0x02) { - console.warn('Warning: Raw transaction does not have the expected flag byte'); + if (!(txn.flags & FLAG_NOT_SIGNED)) { + console.warn('Warning: Transaction is already signed'); } - // Get the bytes to sign (remove the first byte which is the flags) - const bytesToSign = rawTxBytes.subarray(1); + // Derive the ed25519 private key from the HD wallet + const hdWallet = await this.hdWallet(password); + const twPrivateKey = hdWallet.getKey(this.core.CoinType.pactus, addressPath); + const privKey = new Ed25519PrivateKey( + new Uint8Array(twPrivateKey.data()) + ); // Sign the transaction - const signatureBytes = privateKey.sign(Uint8Array.from(bytesToSign), this.core.Curve.ed25519); + txn.sign(privKey); - // Get the public key bytes - const publicKeyBytes = privateKey.getPublicKeyEd25519().data(); + // Verify the signature + if (!txn.verify()) { + throw new Error('Signature verification failed'); + } - // Create a new buffer with flags byte set to 0x00 (signed) - const signedTxHeader = Buffer.from([0x00]); + // Encode the signed transaction + const w = new Writer(); - // Concatenate: [flags=0x00] + original tx without flags + signature + public key - const signedTxBytes = Buffer.concat([ - signedTxHeader, - bytesToSign, - Buffer.from(signatureBytes), - Buffer.from(publicKeyBytes), - ]); + txn.encode(w); - return { signedRawTxHex: signedTxBytes.toString('hex') }; + return { signedRawTxHex: bytesToHex(w.toBytes()) }; } /** From 0b22a5234e9a2972f48899440ef158be46e547f5 Mon Sep 17 00:00:00 2001 From: b00f Date: Sat, 4 Jul 2026 15:40:12 +0800 Subject: [PATCH 3/6] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/transaction/transaction.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/transaction/transaction.test.ts b/src/transaction/transaction.test.ts index ef5799d..2813438 100644 --- a/src/transaction/transaction.test.ts +++ b/src/transaction/transaction.test.ts @@ -7,7 +7,6 @@ import type { TransferPayload, BondPayload } from './payload'; import { PayloadType } from './payload'; import { PrivateKey as Ed25519PrivateKey } from '../crypto/ed25519/private_key'; import { PublicKey as Ed25519PublicKey } from '../crypto/ed25519/public_key'; -import { Signature as Ed25519Signature } from '../crypto/ed25519/signature'; describe('Transaction', () => { describe('decode transfer transaction', () => { From 28a4ff7487a0c8dec42fbc5550e7a4f372780443 Mon Sep 17 00:00:00 2001 From: b00f Date: Sat, 4 Jul 2026 15:40:23 +0800 Subject: [PATCH 4/6] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/transaction/transaction.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transaction/transaction.test.ts b/src/transaction/transaction.test.ts index 2813438..cc713e8 100644 --- a/src/transaction/transaction.test.ts +++ b/src/transaction/transaction.test.ts @@ -1,5 +1,5 @@ import { Transaction, FLAG_NOT_SIGNED } from './transaction'; -import { Address, AddressType } from '../crypto/address'; +import { Address } from '../crypto/address'; import { Amount } from '../types/amount'; import { Height } from '../types/height'; import { Reader, Writer } from '../encoding'; From 4fa7a604f63f578304aae4f2928442c40d8208d8 Mon Sep 17 00:00:00 2001 From: b00f Date: Sat, 4 Jul 2026 15:41:29 +0800 Subject: [PATCH 5/6] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/transaction/transaction.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/transaction/transaction.test.ts b/src/transaction/transaction.test.ts index cc713e8..d13c40b 100644 --- a/src/transaction/transaction.test.ts +++ b/src/transaction/transaction.test.ts @@ -6,7 +6,6 @@ import { Reader, Writer } from '../encoding'; import type { TransferPayload, BondPayload } from './payload'; import { PayloadType } from './payload'; import { PrivateKey as Ed25519PrivateKey } from '../crypto/ed25519/private_key'; -import { PublicKey as Ed25519PublicKey } from '../crypto/ed25519/public_key'; describe('Transaction', () => { describe('decode transfer transaction', () => { From 7745ba14d2e8ec9ed7cda508ac923ebb8fd52f62 Mon Sep 17 00:00:00 2001 From: Mostafa Date: Sat, 4 Jul 2026 15:45:41 +0800 Subject: [PATCH 6/6] chore: fix linting issues --- src/crypto/utils.ts | 5 +---- src/transaction/transaction.test.ts | 8 ++++---- src/wallet/wallet.test.ts | 12 ++---------- src/wallet/wallet.ts | 15 +++------------ 4 files changed, 10 insertions(+), 30 deletions(-) diff --git a/src/crypto/utils.ts b/src/crypto/utils.ts index e0c97c9..c8aaba3 100644 --- a/src/crypto/utils.ts +++ b/src/crypto/utils.ts @@ -44,10 +44,7 @@ export function hexToBytes(hex: string): Uint8Array { * * Validates the HRP against the given value (default "public" for mainnet). */ -export function decodeBech32PublicKey( - text: string, - expectedHrp: string = 'public' -): Uint8Array { +export function decodeBech32PublicKey(text: string, expectedHrp: string = 'public'): Uint8Array { const { hrp, data } = decodeWithType(text); if (hrp !== expectedHrp) { diff --git a/src/transaction/transaction.test.ts b/src/transaction/transaction.test.ts index d13c40b..88f8ee9 100644 --- a/src/transaction/transaction.test.ts +++ b/src/transaction/transaction.test.ts @@ -45,9 +45,9 @@ describe('Transaction', () => { expect(tx.publicKey).not.toBeNull(); // Signature is 64 bytes (ED25519_ACCOUNT) - expect(tx.signature!.rawBytes().length).toBe(64); + expect(tx.signature?.rawBytes().length).toBe(64); // Public key is 32 bytes (ED25519_ACCOUNT) - expect(tx.publicKey!.rawBytes().length).toBe(32); + expect(tx.publicKey?.rawBytes().length).toBe(32); // Transaction ID expect(bytesToHex(tx.id())).toBe(expectedTxId); @@ -97,9 +97,9 @@ describe('Transaction', () => { expect(tx.publicKey).not.toBeNull(); // Signature is 64 bytes (ED25519_ACCOUNT) - expect(tx.signature!.rawBytes().length).toBe(64); + expect(tx.signature?.rawBytes().length).toBe(64); // Public key is 32 bytes (ED25519_ACCOUNT) - expect(tx.publicKey!.rawBytes().length).toBe(32); + expect(tx.publicKey?.rawBytes().length).toBe(32); // Transaction ID expect(bytesToHex(tx.id())).toBe(expectedTxId); diff --git a/src/wallet/wallet.test.ts b/src/wallet/wallet.test.ts index 7c9a4d5..c948b78 100644 --- a/src/wallet/wallet.test.ts +++ b/src/wallet/wallet.test.ts @@ -551,13 +551,7 @@ describe('Pactus Wallet Tests', () => { const amount = Amount.zero(); const fee = Amount.zero(); - const txn = Transaction.createTransferTx( - lockTime, - senderAddr, - receiverAddr, - amount, - fee - ); + const txn = Transaction.createTransferTx(lockTime, senderAddr, receiverAddr, amount, fee); txn.flags = FLAG_NOT_SIGNED; const rawTxHex = bytesToHex( @@ -578,9 +572,7 @@ describe('Pactus Wallet Tests', () => { expect(signedRawTxHex.length).toBeGreaterThan(rawTxHex.length); // Decode the signed bytes and verify - const decoded = Transaction.decode( - new Reader(Buffer.from(signedRawTxHex, 'hex')) - ); + const decoded = Transaction.decode(new Reader(Buffer.from(signedRawTxHex, 'hex'))); expect(decoded.flags & FLAG_NOT_SIGNED).toBe(0); expect(decoded.signature).not.toBeNull(); diff --git a/src/wallet/wallet.ts b/src/wallet/wallet.ts index 290870b..935f385 100644 --- a/src/wallet/wallet.ts +++ b/src/wallet/wallet.ts @@ -7,14 +7,9 @@ import type { HDWallet } from '@trustwallet/wallet-core/dist/src/wallet-core'; import { Amount } from '../types/amount'; import { Height } from '../types/height'; import { Address } from '../crypto/address'; -import type { - RawTransferTransaction, - TransferTransaction, - BondTransaction, -} from '../transaction'; +import type { RawTransferTransaction, TransferTransaction, BondTransaction } from '../transaction'; import { Transaction, TransactionDetailsType, FLAG_NOT_SIGNED } from '../transaction'; import { Writer, Reader } from '../encoding'; - import { bytesToHex, decodeBech32PublicKey } from '../crypto/utils'; import { PrivateKey as Ed25519PrivateKey } from '../crypto/ed25519/private_key'; @@ -677,9 +672,7 @@ export class Wallet { /** * Get raw transfer transaction hex — built locally, no RPC. */ - private getRawTransferTransaction( - tx: TransferTransaction - ): RawTransferTransaction { + private getRawTransferTransaction(tx: TransferTransaction): RawTransferTransaction { const sender = Address.fromString(tx.sender); const receiver = Address.fromString(tx.receiver); const lockTime = new Height(0); @@ -777,9 +770,7 @@ export class Wallet { // Derive the ed25519 private key from the HD wallet const hdWallet = await this.hdWallet(password); const twPrivateKey = hdWallet.getKey(this.core.CoinType.pactus, addressPath); - const privKey = new Ed25519PrivateKey( - new Uint8Array(twPrivateKey.data()) - ); + const privKey = new Ed25519PrivateKey(new Uint8Array(twPrivateKey.data())); // Sign the transaction txn.sign(privKey);