From 3c23ac219499d55fd9e315d3f7590a227042765b Mon Sep 17 00:00:00 2001 From: hmrkx Date: Thu, 8 Jan 2026 16:48:56 +0000 Subject: [PATCH 1/7] feat: implementing device events --- .../__test__/unit/encryptedResponses.test.ts | 105 ++++++++++++++++++ .../sdk/src/__test__/unit/sendEvent.test.ts | 60 ++++++++++ packages/sdk/src/client.ts | 15 +++ packages/sdk/src/functions/index.ts | 1 + packages/sdk/src/functions/sendEvent.ts | 97 ++++++++++++++++ packages/sdk/src/protocol/latticeConstants.ts | 3 + packages/sdk/src/protocol/secureMessages.ts | 6 +- packages/sdk/src/types/event.ts | 23 ++++ packages/sdk/src/types/index.ts | 10 ++ 9 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/src/__test__/unit/encryptedResponses.test.ts create mode 100644 packages/sdk/src/__test__/unit/sendEvent.test.ts create mode 100644 packages/sdk/src/functions/sendEvent.ts create mode 100644 packages/sdk/src/types/event.ts diff --git a/packages/sdk/src/__test__/unit/encryptedResponses.test.ts b/packages/sdk/src/__test__/unit/encryptedResponses.test.ts new file mode 100644 index 00000000..47ccb1d4 --- /dev/null +++ b/packages/sdk/src/__test__/unit/encryptedResponses.test.ts @@ -0,0 +1,105 @@ +import { + LatticeSecureEncryptedRequestType, + ProtocolConstants, + encryptedSecureRequest, +} from '../../protocol'; +import { aes256_encrypt, checksum, getP256KeyPair } from '../../util'; +import { request } from '../../shared/functions'; + +vi.mock('../../shared/functions', async () => { + const actual = await vi.importActual< + typeof import('../../shared/functions') + >('../../shared/functions'); + return { + ...actual, + request: vi.fn(), + }; +}); + +const requestMock = vi.mocked(request); + +const buildEncryptedResponse = ({ + sharedSecret, + requestType, + status, + responsePub, +}: { + sharedSecret: Buffer; + requestType: LatticeSecureEncryptedRequestType; + status: number; + responsePub: Buffer; +}) => { + const responseDataSize = + ProtocolConstants.msgSizes.secure.data.response.encrypted[requestType]; + const decrypted = Buffer.alloc( + ProtocolConstants.msgSizes.secure.data.response.encrypted.encryptedData, + ); + responsePub.copy(decrypted, 0); + decrypted[responsePub.length] = status; + const checksumOffset = responsePub.length + responseDataSize; + const cs = checksum(decrypted.slice(0, checksumOffset)); + decrypted.writeUInt32BE(cs, checksumOffset); + return aes256_encrypt(decrypted, sharedSecret); +}; + +describe('encryptedSecureRequest response sizes', () => { + const requestType = LatticeSecureEncryptedRequestType.event; + const sharedSecret = Buffer.alloc(32, 7); + const ephemeralPub = getP256KeyPair(Buffer.alloc(32, 3)); + const requestData = Buffer.alloc( + ProtocolConstants.msgSizes.secure.data.request.encrypted[requestType], + ); + const responseKey = getP256KeyPair(Buffer.alloc(32, 9)); + const responsePub = Buffer.from( + responseKey.getPublic().encode('hex', false), + 'hex', + ); + + beforeEach(() => { + requestMock.mockReset(); + }); + + it('accepts compact encrypted response size', async () => { + const encryptedResponse = buildEncryptedResponse({ + sharedSecret, + requestType, + status: 0, + responsePub, + }); + requestMock.mockResolvedValueOnce(encryptedResponse); + + const result = await encryptedSecureRequest({ + data: requestData, + requestType, + sharedSecret, + ephemeralPub, + url: 'http://example.test', + }); + + expect(result.decryptedData[0]).toBe(0); + }); + + it('accepts legacy encrypted response size', async () => { + const encryptedResponse = buildEncryptedResponse({ + sharedSecret, + requestType, + status: 0, + responsePub, + }); + const legacyResponse = Buffer.concat([ + encryptedResponse, + Buffer.alloc(encryptedResponse.length), + ]); + requestMock.mockResolvedValueOnce(legacyResponse); + + const result = await encryptedSecureRequest({ + data: requestData, + requestType, + sharedSecret, + ephemeralPub, + url: 'http://example.test', + }); + + expect(result.decryptedData[0]).toBe(0); + }); +}); diff --git a/packages/sdk/src/__test__/unit/sendEvent.test.ts b/packages/sdk/src/__test__/unit/sendEvent.test.ts new file mode 100644 index 00000000..a67d60e4 --- /dev/null +++ b/packages/sdk/src/__test__/unit/sendEvent.test.ts @@ -0,0 +1,60 @@ +import { __private__ as sendEventPrivate } from '../../functions/sendEvent'; + +const { encodeEventPayload } = sendEventPrivate; + +describe('sendEvent encoding', () => { + it('encodes and pads message payload', () => { + const payload = encodeEventPayload({ + eventType: 1, + eventId: '00000000-0000-0000-0000-000000000000', + message: 'hi', + }); + expect(payload.length).toBe(1722); + expect(payload[0]).toBe(1); + expect(payload.slice(1, 17).every((b) => b === 0)).toBe(true); + expect(payload.readUInt16LE(17)).toBe(2); + expect(payload.slice(19, 21).toString('utf8')).toBe('hi'); + expect(payload.slice(21).every((b) => b === 0)).toBe(true); + }); + + it('throws on empty message', () => { + expect(() => + encodeEventPayload({ + eventType: 1, + eventId: '00000000-0000-0000-0000-000000000000', + message: '', + }), + ).toThrow(/must not be empty/i); + }); + + it('throws when message is too long', () => { + const longMsg = 'a'.repeat(1704); + expect(() => + encodeEventPayload({ + eventType: 1, + eventId: '00000000-0000-0000-0000-000000000000', + message: longMsg, + }), + ).toThrow(/too long/i); + }); + + it('throws on invalid eventId', () => { + expect(() => + encodeEventPayload({ + eventType: 1, + eventId: 'not-a-uuid', + message: 'hi', + }), + ).toThrow(/eventId/i); + }); + + it('throws on invalid eventType', () => { + expect(() => + encodeEventPayload({ + eventType: 256, + eventId: '00000000-0000-0000-0000-000000000000', + message: 'hi', + }), + ).toThrow(/eventType/i); + }); +}); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 797a6159..7d9a0bd6 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -15,6 +15,7 @@ import { pair, removeKvRecords, sign, + sendEvent, } from './functions/index'; import { buildRetryWrapper } from './shared/functions'; import { getPubKeyBytes } from './shared/utilities'; @@ -30,6 +31,8 @@ import type { RemoveKvRecordsRequestParams, SignData, SignRequestParams, + SendEventParams, + SendEventResponse, } from './types'; import { getP256KeyPair, getP256KeyPairFromPub, randomBytes } from './util'; @@ -261,6 +264,18 @@ export class Client { return this.retryWrapper(removeKvRecords, { type, ids }); } + /** + * Send a simple message to the device firmware. + * @category Lattice + */ + public async sendEvent({ + eventType, + eventId, + message, + }: SendEventParams): Promise { + return this.retryWrapper(sendEvent, { eventType, eventId, message }); + } + /** * Fetch a record of encrypted data from the Lattice. * Must specify a data type. Returns a Buffer containing diff --git a/packages/sdk/src/functions/index.ts b/packages/sdk/src/functions/index.ts index dea08a11..5f9fcd97 100644 --- a/packages/sdk/src/functions/index.ts +++ b/packages/sdk/src/functions/index.ts @@ -6,4 +6,5 @@ export * from './getAddresses'; export * from './getKvRecords'; export * from './pair'; export * from './removeKvRecords'; +export * from './sendEvent'; export * from './sign'; diff --git a/packages/sdk/src/functions/sendEvent.ts b/packages/sdk/src/functions/sendEvent.ts new file mode 100644 index 00000000..96532ba4 --- /dev/null +++ b/packages/sdk/src/functions/sendEvent.ts @@ -0,0 +1,97 @@ +import { + LatticeSecureEncryptedRequestType, + encryptedSecureRequest, +} from '../protocol'; +import { parse as parseUuid, validate as validateUuid } from 'uuid'; +import { validateConnectedClient } from '../shared/validators'; +import type { SendEventRequestFunctionParams, SendEventResponse } from '../types'; + +const EVENT_TYPE_BYTES = 1; +const EVENT_ID_BYTES = 16; +const MESSAGE_LENGTH_BYTES = 2; +const MAX_MESSAGE_BYTES = 1703; +const EVENT_PAYLOAD_BYTES = + EVENT_TYPE_BYTES + EVENT_ID_BYTES + MESSAGE_LENGTH_BYTES + MAX_MESSAGE_BYTES; +const EVENT_TYPE_OFFSET = 0; +const EVENT_ID_OFFSET = EVENT_TYPE_OFFSET + EVENT_TYPE_BYTES; +const MESSAGE_LENGTH_OFFSET = EVENT_ID_OFFSET + EVENT_ID_BYTES; +const MESSAGE_OFFSET = MESSAGE_LENGTH_OFFSET + MESSAGE_LENGTH_BYTES; + +const parseEventId = (eventId: string): Buffer => { + if (!validateUuid(eventId)) { + throw new Error('eventId must be a valid UUID.'); + } + const bytes = Buffer.from(parseUuid(eventId)); + if (bytes.length !== EVENT_ID_BYTES) { + throw new Error('eventId must be 16 bytes.'); + } + return bytes; +}; + +const validateEventType = (eventType: number) => { + if (!Number.isInteger(eventType) || eventType < 0 || eventType > 0xff) { + throw new Error('eventType must be a uint8.'); + } +}; + +const encodeEventPayload = ({ + eventType, + eventId, + message, +}: { + eventType: number; + eventId: string; + message: string; +}): Buffer => { + validateEventType(eventType); + const msgBytes = Buffer.from(message, 'utf8'); + if (msgBytes.length === 0) { + throw new Error('Message must not be empty.'); + } + if (msgBytes.length > MAX_MESSAGE_BYTES) { + throw new Error( + `Message is too long. Max length is ${MAX_MESSAGE_BYTES} bytes.`, + ); + } + + const payload = Buffer.alloc(EVENT_PAYLOAD_BYTES); + const eventIdBytes = parseEventId(eventId); + payload[EVENT_TYPE_OFFSET] = eventType; + eventIdBytes.copy(payload, EVENT_ID_OFFSET); + payload.writeUInt16LE(msgBytes.length, MESSAGE_LENGTH_OFFSET); + msgBytes.copy(payload, MESSAGE_OFFSET); + return payload; +}; + +/** + * Send a simple event payload to the device firmware. + * @category Lattice + */ +export const sendEvent = async ({ + client, + eventType, + eventId, + message, +}: SendEventRequestFunctionParams): Promise => { + const { url, sharedSecret, ephemeralPub } = validateConnectedClient(client); + const data = encodeEventPayload({ eventType, eventId, message }); + + const { decryptedData, newEphemeralPub } = await encryptedSecureRequest({ + data, + requestType: LatticeSecureEncryptedRequestType.event, + sharedSecret, + ephemeralPub, + url, + }); + + client.mutate({ + ephemeralPub: newEphemeralPub, + }); + + const status = decryptedData[0] ?? 0; + return { status }; +}; + +export const __private__ = { + encodeEventPayload, +}; diff --git a/packages/sdk/src/protocol/latticeConstants.ts b/packages/sdk/src/protocol/latticeConstants.ts index 93993b75..5d535c78 100644 --- a/packages/sdk/src/protocol/latticeConstants.ts +++ b/packages/sdk/src/protocol/latticeConstants.ts @@ -41,6 +41,7 @@ export enum LatticeSecureEncryptedRequestType { removeKvRecords = 9, fetchEncryptedData = 12, test = 13, + event = 14, } export enum LatticeGetAddressesFlag { @@ -177,6 +178,7 @@ export const ProtocolConstants = { [LatticeSecureEncryptedRequestType.removeKvRecords]: 405, [LatticeSecureEncryptedRequestType.fetchEncryptedData]: 1025, [LatticeSecureEncryptedRequestType.test]: 506, + [LatticeSecureEncryptedRequestType.event]: 1722, }, }, // All responses also have a `responseCode`, which is omitted @@ -197,6 +199,7 @@ export const ProtocolConstants = { [LatticeSecureEncryptedRequestType.removeKvRecords]: 0, [LatticeSecureEncryptedRequestType.fetchEncryptedData]: 1608, [LatticeSecureEncryptedRequestType.test]: 1646, + [LatticeSecureEncryptedRequestType.event]: 1, }, }, }, diff --git a/packages/sdk/src/protocol/secureMessages.ts b/packages/sdk/src/protocol/secureMessages.ts index 5a7acf59..d043f47e 100644 --- a/packages/sdk/src/protocol/secureMessages.ts +++ b/packages/sdk/src/protocol/secureMessages.ts @@ -132,8 +132,10 @@ export async function encryptedSecureRequest({ payload: msg, }); - // Deserialize the response payload data - if (resp.length !== szs.payload.response.encrypted - 1) { + // Deserialize the response payload data. Accept both legacy and compact sizes. + const legacyResponseSize = szs.payload.response.encrypted - 1; + const compactResponseSize = szs.data.response.encrypted.encryptedData; + if (resp.length !== legacyResponseSize && resp.length !== compactResponseSize) { throw new Error('Wrong Lattice response message size.'); } diff --git a/packages/sdk/src/types/event.ts b/packages/sdk/src/types/event.ts new file mode 100644 index 00000000..70f0668a --- /dev/null +++ b/packages/sdk/src/types/event.ts @@ -0,0 +1,23 @@ +import type { Client } from '../client'; + +/** Parameters required to send a device event. */ +export interface SendEventParams { + /** Firmware event type code (uint8). */ + eventType: number; + /** UUID v4 string used by firmware to dedupe events. */ + eventId: string; + /** UTF-8 serialized event payload to display on the device. */ + message: string; +} + +/** Arguments for the sendEvent function including the bound client. */ +export interface SendEventRequestFunctionParams extends SendEventParams { + /** Connected SDK client instance. */ + client: Client; +} + +/** Response returned by the sendEvent request. */ +export interface SendEventResponse { + /** Firmware response status byte. */ + status: number; +} diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index b111da12..c87fc7cd 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -28,6 +28,9 @@ export * from './messages'; // Re-export everything from pair.ts export * from './pair'; +// Re-export everything from event.ts +export * from './event'; + // Re-export everything from removeKvRecords.ts export * from './removeKvRecords'; @@ -72,6 +75,13 @@ export type { EIP2335KeyExportData, } from './fetchEncData'; +// Exports from message.ts +export type { + SendEventParams, + SendEventRequestFunctionParams, + SendEventResponse, +} from './event'; + // Exports from getKvRecords.ts export type { GetKvRecordsRequestParams, From ca8e25b191a2b148cfa1b555b50d235ab60c61e9 Mon Sep 17 00:00:00 2001 From: hmrkx Date: Mon, 26 Jan 2026 15:34:54 +0000 Subject: [PATCH 2/7] feat: add monorepo parity features - Add sendEvent functionality (cherry-picked from feat/device-events) - Add CALLDATA.SOLANA exports for Solana transaction parsing - Add Constants.DERIVATION_PATHS.SOLANA and ETH - Fix retryCount: 0 semantics to properly disable retries - Export SignData and LatticeSignature types --- packages/sdk/src/calldata/index.ts | 35 ++++ packages/sdk/src/calldata/solana.ts | 274 ++++++++++++++++++++++++++++ packages/sdk/src/client.ts | 7 +- packages/sdk/src/constants.ts | 10 + packages/sdk/src/index.ts | 1 + 5 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/calldata/solana.ts diff --git a/packages/sdk/src/calldata/index.ts b/packages/sdk/src/calldata/index.ts index c5d46b8e..1c57ec3d 100644 --- a/packages/sdk/src/calldata/index.ts +++ b/packages/sdk/src/calldata/index.ts @@ -9,6 +9,16 @@ import { parseSolidityJSONABI, replaceNestedDefs, } from './evm'; +import { + bytesToHex, + decodeTransaction, + extractMessageFromTransaction, + fromBase58Bytes, + hexToBytes, + injectSignature, + readCompactU16, + toEd25519Bytes, +} from './solana'; export const CALLDATA = { EVM: { @@ -22,4 +32,29 @@ export const CALLDATA = { replaceNestedDefs, }, }, + SOLANA: { + type: 2, + parsers: { + /** Decode a transaction from base64 encoding */ + decodeTransaction, + /** Wrap pre-decoded base58 bytes */ + fromBase58Bytes, + /** Read a compact-u16 value from buffer */ + readCompactU16, + }, + processors: { + /** Extract the message portion from a full transaction */ + extractMessageFromTransaction, + /** Inject a signature into a transaction */ + injectSignature, + }, + utils: { + /** Convert Ed25519 public key to normalized bytes */ + toEd25519Bytes, + /** Convert bytes to hex string */ + bytesToHex, + /** Convert hex string to bytes */ + hexToBytes, + }, + }, }; diff --git a/packages/sdk/src/calldata/solana.ts b/packages/sdk/src/calldata/solana.ts new file mode 100644 index 00000000..633e563c --- /dev/null +++ b/packages/sdk/src/calldata/solana.ts @@ -0,0 +1,274 @@ +/** + * Solana transaction parsing utilities for the GridPlus SDK. + * + * Provides functions for decoding, parsing, and manipulating Solana transactions + * in their wire format. The Lattice device signs only the message portion of a + * transaction, so these utilities help extract and inject signatures. + * + * @module calldata/solana + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Compact-u16 Encoding +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Read a compact-u16 from a buffer at the given offset. + * + * Solana uses compact-u16 encoding for array lengths in the transaction wire format. + * This is a variable-length encoding that uses 1-3 bytes. + * + * @param buffer - The buffer to read from + * @param offset - The byte offset to start reading + * @returns Tuple of [value, bytesRead] + * @throws Error if buffer is too short + */ +export function readCompactU16(buffer: Uint8Array, offset: number): [number, number] { + if (offset >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16') + } + const first = buffer[offset] + if (first < 0x80) { + return [first, 1] + } + if (offset + 1 >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16 (2 bytes)') + } + const second = buffer[offset + 1] + if (first < 0xc0) { + return [((first & 0x7f) << 7) | second, 2] + } + if (offset + 2 >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16 (3 bytes)') + } + const third = buffer[offset + 2] + return [((first & 0x3f) << 14) | (second << 7) | third, 3] +} + +// ───────────────────────────────────────────────────────────────────────────── +// Transaction Decoding +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Result of decoding a Solana transaction. + */ +export interface DecodedTransaction { + /** The original encoding format detected */ + encoding: 'base64' | 'base58' + /** The decoded transaction bytes */ + bytes: Uint8Array +} + +/** + * Decode a transaction from base64 or base58 encoding. + * + * Attempts base64 first (more common for WalletConnect), then falls back to base58. + * + * @param transaction - Base64 or base58 encoded transaction string + * @returns Decoded transaction with encoding metadata + * @throws Error if decoding fails + */ +export function decodeTransaction(transaction: string): DecodedTransaction { + // Try base64 first (more common for WalletConnect) + try { + // Use Buffer in Node.js environment, atob in browser + const decoded = typeof Buffer !== 'undefined' ? Buffer.from(transaction, 'base64') : Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)) + + // Validate it's valid base64 by checking round-trip + const reEncoded = typeof Buffer !== 'undefined' ? (decoded as Buffer).toString('base64') : btoa(String.fromCharCode(...decoded)) + + if (reEncoded === transaction) { + return { + encoding: 'base64', + bytes: new Uint8Array(decoded), + } + } + } catch { + // Fall through to base58 + } + + // Try base58 - requires external bs58 library, caller should handle + throw new Error('Transaction is not valid base64. Use decodeTransactionBase58 for base58 encoded transactions.') +} + +/** + * Decode a transaction from base58 encoding. + * + * This function accepts a pre-decoded Uint8Array since base58 decoding + * requires the bs58 library which is not bundled with the SDK. + * + * @param decodedBytes - Pre-decoded transaction bytes from bs58.decode() + * @returns Decoded transaction with encoding metadata + */ +export function fromBase58Bytes(decodedBytes: Uint8Array): DecodedTransaction { + return { + encoding: 'base58', + bytes: decodedBytes, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Message Extraction +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Parsed Solana transaction structure. + */ +export interface ParsedTransaction { + /** Number of signature slots in the transaction */ + numSignatures: number + /** Byte offset where signatures start (after compact-u16 count) */ + signaturesOffset: number + /** Byte offset where the message starts */ + messageOffset: number + /** The message bytes (what gets signed) */ + messageBytes: Uint8Array +} + +/** + * Extract the message bytes from a Solana transaction wire format. + * + * Solana transaction wire format: + * - compact-u16: number of signatures + * - 64 bytes per signature (may be zero-filled placeholders) + * - message bytes (rest of buffer) + * + * The Lattice SDK expects just the message bytes, not the full transaction. + * + * @param txBytes - Full transaction bytes + * @returns Parsed transaction with message bytes and metadata + * @throws Error if transaction format is invalid + */ +export function extractMessageFromTransaction(txBytes: Uint8Array): ParsedTransaction { + const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0) + + // Signatures section starts after the compact-u16 count + const signaturesOffset = sigCountBytes + + // Message starts after all signatures (64 bytes each) + const messageOffset = sigCountBytes + numSignatures * 64 + + if (messageOffset > txBytes.length) { + throw new Error(`Invalid transaction: message offset ${messageOffset} exceeds buffer length ${txBytes.length}`) + } + + const messageBytes = txBytes.subarray(messageOffset) + + return { + numSignatures, + signaturesOffset, + messageOffset, + messageBytes, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Signature Injection +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Inject a signature into a Solana transaction at the specified index. + * + * Solana transactions have reserved signature slots. This function writes + * a 64-byte Ed25519 signature into the specified slot. + * + * @param txBytes - Full transaction bytes + * @param signature - 64-byte Ed25519 signature + * @param signatureIndex - Which signature slot to fill (0-indexed, default 0) + * @returns New transaction bytes with signature injected + * @throws Error if signature index is out of bounds or signature is wrong size + */ +export function injectSignature(txBytes: Uint8Array, signature: Uint8Array, signatureIndex = 0): Uint8Array { + if (signature.length !== 64) { + throw new Error(`Invalid signature length: expected 64 bytes, got ${signature.length}`) + } + + const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0) + + if (signatureIndex >= numSignatures) { + throw new Error(`Signature index ${signatureIndex} out of bounds (${numSignatures} signature slots)`) + } + + // Create a copy to avoid mutating the original + const signedTx = new Uint8Array(txBytes) + + // Calculate where this signature should be written + const signatureOffset = sigCountBytes + signatureIndex * 64 + + // Copy signature bytes into the transaction + signedTx.set(signature, signatureOffset) + + return signedTx +} + +// ───────────────────────────────────────────────────────────────────────────── +// Ed25519 Public Key Utilities +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Convert raw Ed25519 public key bytes to a normalized Uint8Array. + * + * Handles various input formats that may be returned from the Lattice device. + * + * @param entry - Raw public key data (Uint8Array, Buffer, or hex string) + * @returns Normalized 32-byte public key, or null if conversion fails + */ +export function toEd25519Bytes(entry: unknown): Uint8Array | null { + if (entry instanceof Uint8Array) { + return entry.slice(0, 32) + } + + // Handle Buffer (Node.js) + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(entry)) { + return new Uint8Array(entry.slice(0, 32)) + } + + if (typeof entry === 'string') { + const hex = entry.startsWith('0x') ? entry.slice(2) : /^[0-9a-fA-F]+$/.test(entry) ? entry : '' + if (hex.length >= 64) { + const out = new Uint8Array(32) + for (let i = 0; i < 32; i++) { + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return out + } + } + + return null +} + +/** + * Convert bytes to a hex string. + * + * @param bytes - Bytes to convert + * @param prefix - Whether to include '0x' prefix (default true) + * @returns Hex string representation + */ +export function bytesToHex(bytes: Uint8Array, prefix = true): string { + const hex = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + return prefix ? `0x${hex}` : hex +} + +/** + * Convert a hex string to bytes. + * + * @param hex - Hex string (with or without 0x prefix) + * @returns Uint8Array of bytes + * @throws Error if hex string is invalid + */ +export function hexToBytes(hex: string): Uint8Array { + const normalized = hex.startsWith('0x') ? hex.slice(2) : hex + if (normalized.length % 2 !== 0) { + throw new Error('Hex string must have even length') + } + if (!/^[0-9a-fA-F]*$/.test(normalized)) { + throw new Error('Invalid hex characters') + } + const bytes = new Uint8Array(normalized.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 7d9a0bd6..c86b62f9 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -108,13 +108,14 @@ export class Client { /** Function to set the stored client data */ setStoredClient?: (clientData: string | null) => Promise; }) { + const retryOverride = typeof retryCount === 'number' ? retryCount : undefined; this.name = name || 'Unknown'; this.baseUrl = baseUrl || BASE_URL; this.deviceId = deviceId; this.isPaired = false; this.activeWallets = DEFAULT_ACTIVE_WALLETS; this.timeout = timeout || 60000; - this.retryCount = retryCount || 3; + this.retryCount = retryOverride ?? 3; this.skipRetryOnWrongWallet = skipRetryOnWrongWallet || false; this.privKey = privKey || randomBytes(32); this.key = getP256KeyPair(this.privKey); @@ -127,6 +128,10 @@ export class Client { if (stateData) { this.unpackAndApplyStateData(stateData); } + if (retryOverride !== undefined) { + this.retryCount = retryOverride; + this.retryWrapper = buildRetryWrapper(this, this.retryCount); + } } /** diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 8d52ed67..b03aaf6c 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -13,6 +13,9 @@ import type { WalletPath, } from './types/index.js'; +/** @internal Hardened offset for BIP32 derivation paths */ +const HARDENED = 0x80000000; + /** * Externally exported constants used for building requests * @public @@ -71,6 +74,13 @@ export const EXTERNAL = { VOLUNTARY_EXIT: Buffer.from('04000000', 'hex'), }, }, + // Standard derivation paths for various chains + DERIVATION_PATHS: { + /** Solana: m/44'/501'/0'/0' - Ed25519 curve */ + SOLANA: [HARDENED + 44, HARDENED + 501, HARDENED, HARDENED] as readonly number[], + /** Ethereum: m/44'/60'/0'/0/0 - secp256k1 curve */ + ETH: [HARDENED + 44, HARDENED + 60, HARDENED, 0, 0] as readonly number[], + }, } as const; //=============================== diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 53feb6e8..158138ca 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -4,3 +4,4 @@ export { EXTERNAL as Constants } from './constants'; export { EXTERNAL as Utils } from './util'; export * from './api'; export * as btc from './btc'; +export type { SignData, LatticeSignature } from './types/client'; From abee773aa13458cc434bf5ad3e8d7a0e0446911d Mon Sep 17 00:00:00 2001 From: hmrkx Date: Mon, 26 Jan 2026 15:40:18 +0000 Subject: [PATCH 3/7] style: fix formatting for biome compliance --- .../__test__/unit/encryptedResponses.test.ts | 6 +- packages/sdk/src/calldata/solana.ts | 297 ++++++++++-------- packages/sdk/src/client.ts | 3 +- packages/sdk/src/constants.ts | 7 +- packages/sdk/src/functions/sendEvent.ts | 5 +- packages/sdk/src/protocol/secureMessages.ts | 5 +- 6 files changed, 181 insertions(+), 142 deletions(-) diff --git a/packages/sdk/src/__test__/unit/encryptedResponses.test.ts b/packages/sdk/src/__test__/unit/encryptedResponses.test.ts index 47ccb1d4..b02990e6 100644 --- a/packages/sdk/src/__test__/unit/encryptedResponses.test.ts +++ b/packages/sdk/src/__test__/unit/encryptedResponses.test.ts @@ -7,9 +7,9 @@ import { aes256_encrypt, checksum, getP256KeyPair } from '../../util'; import { request } from '../../shared/functions'; vi.mock('../../shared/functions', async () => { - const actual = await vi.importActual< - typeof import('../../shared/functions') - >('../../shared/functions'); + const actual = await vi.importActual( + '../../shared/functions', + ); return { ...actual, request: vi.fn(), diff --git a/packages/sdk/src/calldata/solana.ts b/packages/sdk/src/calldata/solana.ts index 633e563c..b57a321e 100644 --- a/packages/sdk/src/calldata/solana.ts +++ b/packages/sdk/src/calldata/solana.ts @@ -23,26 +23,29 @@ * @returns Tuple of [value, bytesRead] * @throws Error if buffer is too short */ -export function readCompactU16(buffer: Uint8Array, offset: number): [number, number] { - if (offset >= buffer.length) { - throw new Error('Buffer underflow reading compact-u16') - } - const first = buffer[offset] - if (first < 0x80) { - return [first, 1] - } - if (offset + 1 >= buffer.length) { - throw new Error('Buffer underflow reading compact-u16 (2 bytes)') - } - const second = buffer[offset + 1] - if (first < 0xc0) { - return [((first & 0x7f) << 7) | second, 2] - } - if (offset + 2 >= buffer.length) { - throw new Error('Buffer underflow reading compact-u16 (3 bytes)') - } - const third = buffer[offset + 2] - return [((first & 0x3f) << 14) | (second << 7) | third, 3] +export function readCompactU16( + buffer: Uint8Array, + offset: number, +): [number, number] { + if (offset >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16'); + } + const first = buffer[offset]; + if (first < 0x80) { + return [first, 1]; + } + if (offset + 1 >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16 (2 bytes)'); + } + const second = buffer[offset + 1]; + if (first < 0xc0) { + return [((first & 0x7f) << 7) | second, 2]; + } + if (offset + 2 >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16 (3 bytes)'); + } + const third = buffer[offset + 2]; + return [((first & 0x3f) << 14) | (second << 7) | third, 3]; } // ───────────────────────────────────────────────────────────────────────────── @@ -53,10 +56,10 @@ export function readCompactU16(buffer: Uint8Array, offset: number): [number, num * Result of decoding a Solana transaction. */ export interface DecodedTransaction { - /** The original encoding format detected */ - encoding: 'base64' | 'base58' - /** The decoded transaction bytes */ - bytes: Uint8Array + /** The original encoding format detected */ + encoding: 'base64' | 'base58'; + /** The decoded transaction bytes */ + bytes: Uint8Array; } /** @@ -69,26 +72,34 @@ export interface DecodedTransaction { * @throws Error if decoding fails */ export function decodeTransaction(transaction: string): DecodedTransaction { - // Try base64 first (more common for WalletConnect) - try { - // Use Buffer in Node.js environment, atob in browser - const decoded = typeof Buffer !== 'undefined' ? Buffer.from(transaction, 'base64') : Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)) - - // Validate it's valid base64 by checking round-trip - const reEncoded = typeof Buffer !== 'undefined' ? (decoded as Buffer).toString('base64') : btoa(String.fromCharCode(...decoded)) - - if (reEncoded === transaction) { - return { - encoding: 'base64', - bytes: new Uint8Array(decoded), - } - } - } catch { - // Fall through to base58 - } - - // Try base58 - requires external bs58 library, caller should handle - throw new Error('Transaction is not valid base64. Use decodeTransactionBase58 for base58 encoded transactions.') + // Try base64 first (more common for WalletConnect) + try { + // Use Buffer in Node.js environment, atob in browser + const decoded = + typeof Buffer !== 'undefined' + ? Buffer.from(transaction, 'base64') + : Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)); + + // Validate it's valid base64 by checking round-trip + const reEncoded = + typeof Buffer !== 'undefined' + ? (decoded as Buffer).toString('base64') + : btoa(String.fromCharCode(...decoded)); + + if (reEncoded === transaction) { + return { + encoding: 'base64', + bytes: new Uint8Array(decoded), + }; + } + } catch { + // Fall through to base58 + } + + // Try base58 - requires external bs58 library, caller should handle + throw new Error( + 'Transaction is not valid base64. Use decodeTransactionBase58 for base58 encoded transactions.', + ); } /** @@ -101,10 +112,10 @@ export function decodeTransaction(transaction: string): DecodedTransaction { * @returns Decoded transaction with encoding metadata */ export function fromBase58Bytes(decodedBytes: Uint8Array): DecodedTransaction { - return { - encoding: 'base58', - bytes: decodedBytes, - } + return { + encoding: 'base58', + bytes: decodedBytes, + }; } // ───────────────────────────────────────────────────────────────────────────── @@ -115,14 +126,14 @@ export function fromBase58Bytes(decodedBytes: Uint8Array): DecodedTransaction { * Parsed Solana transaction structure. */ export interface ParsedTransaction { - /** Number of signature slots in the transaction */ - numSignatures: number - /** Byte offset where signatures start (after compact-u16 count) */ - signaturesOffset: number - /** Byte offset where the message starts */ - messageOffset: number - /** The message bytes (what gets signed) */ - messageBytes: Uint8Array + /** Number of signature slots in the transaction */ + numSignatures: number; + /** Byte offset where signatures start (after compact-u16 count) */ + signaturesOffset: number; + /** Byte offset where the message starts */ + messageOffset: number; + /** The message bytes (what gets signed) */ + messageBytes: Uint8Array; } /** @@ -139,27 +150,31 @@ export interface ParsedTransaction { * @returns Parsed transaction with message bytes and metadata * @throws Error if transaction format is invalid */ -export function extractMessageFromTransaction(txBytes: Uint8Array): ParsedTransaction { - const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0) - - // Signatures section starts after the compact-u16 count - const signaturesOffset = sigCountBytes - - // Message starts after all signatures (64 bytes each) - const messageOffset = sigCountBytes + numSignatures * 64 - - if (messageOffset > txBytes.length) { - throw new Error(`Invalid transaction: message offset ${messageOffset} exceeds buffer length ${txBytes.length}`) - } - - const messageBytes = txBytes.subarray(messageOffset) - - return { - numSignatures, - signaturesOffset, - messageOffset, - messageBytes, - } +export function extractMessageFromTransaction( + txBytes: Uint8Array, +): ParsedTransaction { + const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0); + + // Signatures section starts after the compact-u16 count + const signaturesOffset = sigCountBytes; + + // Message starts after all signatures (64 bytes each) + const messageOffset = sigCountBytes + numSignatures * 64; + + if (messageOffset > txBytes.length) { + throw new Error( + `Invalid transaction: message offset ${messageOffset} exceeds buffer length ${txBytes.length}`, + ); + } + + const messageBytes = txBytes.subarray(messageOffset); + + return { + numSignatures, + signaturesOffset, + messageOffset, + messageBytes, + }; } // ───────────────────────────────────────────────────────────────────────────── @@ -178,27 +193,35 @@ export function extractMessageFromTransaction(txBytes: Uint8Array): ParsedTransa * @returns New transaction bytes with signature injected * @throws Error if signature index is out of bounds or signature is wrong size */ -export function injectSignature(txBytes: Uint8Array, signature: Uint8Array, signatureIndex = 0): Uint8Array { - if (signature.length !== 64) { - throw new Error(`Invalid signature length: expected 64 bytes, got ${signature.length}`) - } - - const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0) - - if (signatureIndex >= numSignatures) { - throw new Error(`Signature index ${signatureIndex} out of bounds (${numSignatures} signature slots)`) - } - - // Create a copy to avoid mutating the original - const signedTx = new Uint8Array(txBytes) - - // Calculate where this signature should be written - const signatureOffset = sigCountBytes + signatureIndex * 64 - - // Copy signature bytes into the transaction - signedTx.set(signature, signatureOffset) - - return signedTx +export function injectSignature( + txBytes: Uint8Array, + signature: Uint8Array, + signatureIndex = 0, +): Uint8Array { + if (signature.length !== 64) { + throw new Error( + `Invalid signature length: expected 64 bytes, got ${signature.length}`, + ); + } + + const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0); + + if (signatureIndex >= numSignatures) { + throw new Error( + `Signature index ${signatureIndex} out of bounds (${numSignatures} signature slots)`, + ); + } + + // Create a copy to avoid mutating the original + const signedTx = new Uint8Array(txBytes); + + // Calculate where this signature should be written + const signatureOffset = sigCountBytes + signatureIndex * 64; + + // Copy signature bytes into the transaction + signedTx.set(signature, signatureOffset); + + return signedTx; } // ───────────────────────────────────────────────────────────────────────────── @@ -214,27 +237,31 @@ export function injectSignature(txBytes: Uint8Array, signature: Uint8Array, sign * @returns Normalized 32-byte public key, or null if conversion fails */ export function toEd25519Bytes(entry: unknown): Uint8Array | null { - if (entry instanceof Uint8Array) { - return entry.slice(0, 32) - } - - // Handle Buffer (Node.js) - if (typeof Buffer !== 'undefined' && Buffer.isBuffer(entry)) { - return new Uint8Array(entry.slice(0, 32)) - } - - if (typeof entry === 'string') { - const hex = entry.startsWith('0x') ? entry.slice(2) : /^[0-9a-fA-F]+$/.test(entry) ? entry : '' - if (hex.length >= 64) { - const out = new Uint8Array(32) - for (let i = 0; i < 32; i++) { - out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) - } - return out - } - } - - return null + if (entry instanceof Uint8Array) { + return entry.slice(0, 32); + } + + // Handle Buffer (Node.js) + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(entry)) { + return new Uint8Array(entry.slice(0, 32)); + } + + if (typeof entry === 'string') { + const hex = entry.startsWith('0x') + ? entry.slice(2) + : /^[0-9a-fA-F]+$/.test(entry) + ? entry + : ''; + if (hex.length >= 64) { + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; + } + } + + return null; } /** @@ -245,10 +272,10 @@ export function toEd25519Bytes(entry: unknown): Uint8Array | null { * @returns Hex string representation */ export function bytesToHex(bytes: Uint8Array, prefix = true): string { - const hex = Array.from(bytes) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - return prefix ? `0x${hex}` : hex + const hex = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return prefix ? `0x${hex}` : hex; } /** @@ -259,16 +286,16 @@ export function bytesToHex(bytes: Uint8Array, prefix = true): string { * @throws Error if hex string is invalid */ export function hexToBytes(hex: string): Uint8Array { - const normalized = hex.startsWith('0x') ? hex.slice(2) : hex - if (normalized.length % 2 !== 0) { - throw new Error('Hex string must have even length') - } - if (!/^[0-9a-fA-F]*$/.test(normalized)) { - throw new Error('Invalid hex characters') - } - const bytes = new Uint8Array(normalized.length / 2) - for (let i = 0; i < bytes.length; i++) { - bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16) - } - return bytes + const normalized = hex.startsWith('0x') ? hex.slice(2) : hex; + if (normalized.length % 2 !== 0) { + throw new Error('Hex string must have even length'); + } + if (!/^[0-9a-fA-F]*$/.test(normalized)) { + throw new Error('Invalid hex characters'); + } + const bytes = new Uint8Array(normalized.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16); + } + return bytes; } diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c86b62f9..015efdd1 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -108,7 +108,8 @@ export class Client { /** Function to set the stored client data */ setStoredClient?: (clientData: string | null) => Promise; }) { - const retryOverride = typeof retryCount === 'number' ? retryCount : undefined; + const retryOverride = + typeof retryCount === 'number' ? retryCount : undefined; this.name = name || 'Unknown'; this.baseUrl = baseUrl || BASE_URL; this.deviceId = deviceId; diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index b03aaf6c..84bca0e4 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -77,7 +77,12 @@ export const EXTERNAL = { // Standard derivation paths for various chains DERIVATION_PATHS: { /** Solana: m/44'/501'/0'/0' - Ed25519 curve */ - SOLANA: [HARDENED + 44, HARDENED + 501, HARDENED, HARDENED] as readonly number[], + SOLANA: [ + HARDENED + 44, + HARDENED + 501, + HARDENED, + HARDENED, + ] as readonly number[], /** Ethereum: m/44'/60'/0'/0/0 - secp256k1 curve */ ETH: [HARDENED + 44, HARDENED + 60, HARDENED, 0, 0] as readonly number[], }, diff --git a/packages/sdk/src/functions/sendEvent.ts b/packages/sdk/src/functions/sendEvent.ts index 96532ba4..58214a9b 100644 --- a/packages/sdk/src/functions/sendEvent.ts +++ b/packages/sdk/src/functions/sendEvent.ts @@ -4,7 +4,10 @@ import { } from '../protocol'; import { parse as parseUuid, validate as validateUuid } from 'uuid'; import { validateConnectedClient } from '../shared/validators'; -import type { SendEventRequestFunctionParams, SendEventResponse } from '../types'; +import type { + SendEventRequestFunctionParams, + SendEventResponse, +} from '../types'; const EVENT_TYPE_BYTES = 1; const EVENT_ID_BYTES = 16; diff --git a/packages/sdk/src/protocol/secureMessages.ts b/packages/sdk/src/protocol/secureMessages.ts index d043f47e..f50cdc12 100644 --- a/packages/sdk/src/protocol/secureMessages.ts +++ b/packages/sdk/src/protocol/secureMessages.ts @@ -135,7 +135,10 @@ export async function encryptedSecureRequest({ // Deserialize the response payload data. Accept both legacy and compact sizes. const legacyResponseSize = szs.payload.response.encrypted - 1; const compactResponseSize = szs.data.response.encrypted.encryptedData; - if (resp.length !== legacyResponseSize && resp.length !== compactResponseSize) { + if ( + resp.length !== legacyResponseSize && + resp.length !== compactResponseSize + ) { throw new Error('Wrong Lattice response message size.'); } From 244154901c08fd4894c048e6c5612fda0922745f Mon Sep 17 00:00:00 2001 From: hmrkx Date: Mon, 26 Jan 2026 15:46:34 +0000 Subject: [PATCH 4/7] fix: correct compact-u16 decoding and relax base64 validation - Fix compact-u16 (shortvec) decoding to use little-endian byte order (first byte holds low 7 bits, subsequent bytes shift left) - Relax base64 validation to accept non-canonical padding variants by checking decoded length instead of round-trip comparison --- packages/sdk/src/calldata/solana.ts | 40 +++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/calldata/solana.ts b/packages/sdk/src/calldata/solana.ts index b57a321e..ead84c6f 100644 --- a/packages/sdk/src/calldata/solana.ts +++ b/packages/sdk/src/calldata/solana.ts @@ -15,8 +15,14 @@ /** * Read a compact-u16 from a buffer at the given offset. * - * Solana uses compact-u16 encoding for array lengths in the transaction wire format. - * This is a variable-length encoding that uses 1-3 bytes. + * Solana uses compact-u16 encoding (also called "shortvec") for array lengths + * in the transaction wire format. This is a variable-length little-endian + * encoding that uses 1-3 bytes where each byte's high bit indicates continuation. + * + * Encoding: + * - Byte 0: low 7 bits of value (bits 0-6), bit 7 is continuation flag + * - Byte 1: next 7 bits of value (bits 7-13), bit 7 is continuation flag + * - Byte 2: final 2 bits of value (bits 14-15) * * @param buffer - The buffer to read from * @param offset - The byte offset to start reading @@ -30,22 +36,34 @@ export function readCompactU16( if (offset >= buffer.length) { throw new Error('Buffer underflow reading compact-u16'); } + const first = buffer[offset]; - if (first < 0x80) { + // If high bit is clear, this is a single-byte value (0-127) + if ((first & 0x80) === 0) { return [first, 1]; } + + // Need second byte if (offset + 1 >= buffer.length) { throw new Error('Buffer underflow reading compact-u16 (2 bytes)'); } const second = buffer[offset + 1]; - if (first < 0xc0) { - return [((first & 0x7f) << 7) | second, 2]; + + // If second byte's high bit is clear, this is a two-byte value + // Little-endian: first byte has low 7 bits, second byte has next 7 bits + if ((second & 0x80) === 0) { + return [(first & 0x7f) | ((second & 0x7f) << 7), 2]; } + + // Need third byte for values > 16383 if (offset + 2 >= buffer.length) { throw new Error('Buffer underflow reading compact-u16 (3 bytes)'); } const third = buffer[offset + 2]; - return [((first & 0x3f) << 14) | (second << 7) | third, 3]; + + // Little-endian: combine all three bytes + // Note: third byte only uses low 2 bits for u16 (max 65535) + return [(first & 0x7f) | ((second & 0x7f) << 7) | ((third & 0x03) << 14), 3]; } // ───────────────────────────────────────────────────────────────────────────── @@ -80,13 +98,9 @@ export function decodeTransaction(transaction: string): DecodedTransaction { ? Buffer.from(transaction, 'base64') : Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)); - // Validate it's valid base64 by checking round-trip - const reEncoded = - typeof Buffer !== 'undefined' - ? (decoded as Buffer).toString('base64') - : btoa(String.fromCharCode(...decoded)); - - if (reEncoded === transaction) { + // Validate we got reasonable bytes (Solana transactions are typically 200-1232 bytes) + // A minimal transaction is at least ~100 bytes, max is 1232 bytes + if (decoded.length >= 100 && decoded.length <= 1232) { return { encoding: 'base64', bytes: new Uint8Array(decoded), From 7f82be56b83a82d1a8c8e6a160a05c96a40aa945 Mon Sep 17 00:00:00 2001 From: hmrkx Date: Mon, 26 Jan 2026 15:52:28 +0000 Subject: [PATCH 5/7] refactor: remove verbose comments from solana and sendEvent --- packages/sdk/src/calldata/solana.ts | 181 +++--------------------- packages/sdk/src/functions/sendEvent.ts | 31 ++-- packages/sdk/src/types/event.ts | 8 -- 3 files changed, 28 insertions(+), 192 deletions(-) diff --git a/packages/sdk/src/calldata/solana.ts b/packages/sdk/src/calldata/solana.ts index ead84c6f..2b3b7adb 100644 --- a/packages/sdk/src/calldata/solana.ts +++ b/packages/sdk/src/calldata/solana.ts @@ -1,34 +1,9 @@ /** - * Solana transaction parsing utilities for the GridPlus SDK. - * - * Provides functions for decoding, parsing, and manipulating Solana transactions - * in their wire format. The Lattice device signs only the message portion of a - * transaction, so these utilities help extract and inject signatures. - * + * Solana transaction parsing utilities. * @module calldata/solana */ -// ───────────────────────────────────────────────────────────────────────────── -// Compact-u16 Encoding -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Read a compact-u16 from a buffer at the given offset. - * - * Solana uses compact-u16 encoding (also called "shortvec") for array lengths - * in the transaction wire format. This is a variable-length little-endian - * encoding that uses 1-3 bytes where each byte's high bit indicates continuation. - * - * Encoding: - * - Byte 0: low 7 bits of value (bits 0-6), bit 7 is continuation flag - * - Byte 1: next 7 bits of value (bits 7-13), bit 7 is continuation flag - * - Byte 2: final 2 bits of value (bits 14-15) - * - * @param buffer - The buffer to read from - * @param offset - The byte offset to start reading - * @returns Tuple of [value, bytesRead] - * @throws Error if buffer is too short - */ +/** Read a compact-u16 (shortvec) from buffer. Little-endian 7-bit groups. */ export function readCompactU16( buffer: Uint8Array, offset: number, @@ -38,224 +13,116 @@ export function readCompactU16( } const first = buffer[offset]; - // If high bit is clear, this is a single-byte value (0-127) if ((first & 0x80) === 0) { return [first, 1]; } - // Need second byte if (offset + 1 >= buffer.length) { throw new Error('Buffer underflow reading compact-u16 (2 bytes)'); } const second = buffer[offset + 1]; - // If second byte's high bit is clear, this is a two-byte value - // Little-endian: first byte has low 7 bits, second byte has next 7 bits if ((second & 0x80) === 0) { return [(first & 0x7f) | ((second & 0x7f) << 7), 2]; } - // Need third byte for values > 16383 if (offset + 2 >= buffer.length) { throw new Error('Buffer underflow reading compact-u16 (3 bytes)'); } const third = buffer[offset + 2]; - // Little-endian: combine all three bytes - // Note: third byte only uses low 2 bits for u16 (max 65535) return [(first & 0x7f) | ((second & 0x7f) << 7) | ((third & 0x03) << 14), 3]; } -// ───────────────────────────────────────────────────────────────────────────── -// Transaction Decoding -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Result of decoding a Solana transaction. - */ export interface DecodedTransaction { - /** The original encoding format detected */ encoding: 'base64' | 'base58'; - /** The decoded transaction bytes */ bytes: Uint8Array; } -/** - * Decode a transaction from base64 or base58 encoding. - * - * Attempts base64 first (more common for WalletConnect), then falls back to base58. - * - * @param transaction - Base64 or base58 encoded transaction string - * @returns Decoded transaction with encoding metadata - * @throws Error if decoding fails - */ +/** Decode base64 transaction. Throws if invalid. */ export function decodeTransaction(transaction: string): DecodedTransaction { - // Try base64 first (more common for WalletConnect) try { - // Use Buffer in Node.js environment, atob in browser const decoded = typeof Buffer !== 'undefined' ? Buffer.from(transaction, 'base64') : Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)); - // Validate we got reasonable bytes (Solana transactions are typically 200-1232 bytes) - // A minimal transaction is at least ~100 bytes, max is 1232 bytes + // Solana txs are 100-1232 bytes if (decoded.length >= 100 && decoded.length <= 1232) { - return { - encoding: 'base64', - bytes: new Uint8Array(decoded), - }; + return { encoding: 'base64', bytes: new Uint8Array(decoded) }; } } catch { - // Fall through to base58 + // Fall through } - // Try base58 - requires external bs58 library, caller should handle throw new Error( - 'Transaction is not valid base64. Use decodeTransactionBase58 for base58 encoded transactions.', + 'Transaction is not valid base64. Use fromBase58Bytes for base58.', ); } -/** - * Decode a transaction from base58 encoding. - * - * This function accepts a pre-decoded Uint8Array since base58 decoding - * requires the bs58 library which is not bundled with the SDK. - * - * @param decodedBytes - Pre-decoded transaction bytes from bs58.decode() - * @returns Decoded transaction with encoding metadata - */ +/** Wrap pre-decoded base58 bytes. */ export function fromBase58Bytes(decodedBytes: Uint8Array): DecodedTransaction { - return { - encoding: 'base58', - bytes: decodedBytes, - }; + return { encoding: 'base58', bytes: decodedBytes }; } -// ───────────────────────────────────────────────────────────────────────────── -// Message Extraction -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Parsed Solana transaction structure. - */ export interface ParsedTransaction { - /** Number of signature slots in the transaction */ numSignatures: number; - /** Byte offset where signatures start (after compact-u16 count) */ signaturesOffset: number; - /** Byte offset where the message starts */ messageOffset: number; - /** The message bytes (what gets signed) */ messageBytes: Uint8Array; } -/** - * Extract the message bytes from a Solana transaction wire format. - * - * Solana transaction wire format: - * - compact-u16: number of signatures - * - 64 bytes per signature (may be zero-filled placeholders) - * - message bytes (rest of buffer) - * - * The Lattice SDK expects just the message bytes, not the full transaction. - * - * @param txBytes - Full transaction bytes - * @returns Parsed transaction with message bytes and metadata - * @throws Error if transaction format is invalid - */ +/** Extract message bytes from Solana transaction wire format. */ export function extractMessageFromTransaction( txBytes: Uint8Array, ): ParsedTransaction { const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0); - - // Signatures section starts after the compact-u16 count const signaturesOffset = sigCountBytes; - - // Message starts after all signatures (64 bytes each) const messageOffset = sigCountBytes + numSignatures * 64; if (messageOffset > txBytes.length) { throw new Error( - `Invalid transaction: message offset ${messageOffset} exceeds buffer length ${txBytes.length}`, + `Invalid transaction: offset ${messageOffset} exceeds length ${txBytes.length}`, ); } - const messageBytes = txBytes.subarray(messageOffset); - return { numSignatures, signaturesOffset, messageOffset, - messageBytes, + messageBytes: txBytes.subarray(messageOffset), }; } -// ───────────────────────────────────────────────────────────────────────────── -// Signature Injection -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Inject a signature into a Solana transaction at the specified index. - * - * Solana transactions have reserved signature slots. This function writes - * a 64-byte Ed25519 signature into the specified slot. - * - * @param txBytes - Full transaction bytes - * @param signature - 64-byte Ed25519 signature - * @param signatureIndex - Which signature slot to fill (0-indexed, default 0) - * @returns New transaction bytes with signature injected - * @throws Error if signature index is out of bounds or signature is wrong size - */ +/** Inject 64-byte signature into transaction at given slot index. */ export function injectSignature( txBytes: Uint8Array, signature: Uint8Array, signatureIndex = 0, ): Uint8Array { if (signature.length !== 64) { - throw new Error( - `Invalid signature length: expected 64 bytes, got ${signature.length}`, - ); + throw new Error(`Expected 64-byte signature, got ${signature.length}`); } const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0); if (signatureIndex >= numSignatures) { throw new Error( - `Signature index ${signatureIndex} out of bounds (${numSignatures} signature slots)`, + `Signature index ${signatureIndex} out of bounds (${numSignatures} slots)`, ); } - // Create a copy to avoid mutating the original const signedTx = new Uint8Array(txBytes); - - // Calculate where this signature should be written - const signatureOffset = sigCountBytes + signatureIndex * 64; - - // Copy signature bytes into the transaction - signedTx.set(signature, signatureOffset); - + signedTx.set(signature, sigCountBytes + signatureIndex * 64); return signedTx; } -// ───────────────────────────────────────────────────────────────────────────── -// Ed25519 Public Key Utilities -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Convert raw Ed25519 public key bytes to a normalized Uint8Array. - * - * Handles various input formats that may be returned from the Lattice device. - * - * @param entry - Raw public key data (Uint8Array, Buffer, or hex string) - * @returns Normalized 32-byte public key, or null if conversion fails - */ +/** Convert raw Ed25519 pubkey to 32-byte Uint8Array. */ export function toEd25519Bytes(entry: unknown): Uint8Array | null { if (entry instanceof Uint8Array) { return entry.slice(0, 32); } - // Handle Buffer (Node.js) if (typeof Buffer !== 'undefined' && Buffer.isBuffer(entry)) { return new Uint8Array(entry.slice(0, 32)); } @@ -278,13 +145,6 @@ export function toEd25519Bytes(entry: unknown): Uint8Array | null { return null; } -/** - * Convert bytes to a hex string. - * - * @param bytes - Bytes to convert - * @param prefix - Whether to include '0x' prefix (default true) - * @returns Hex string representation - */ export function bytesToHex(bytes: Uint8Array, prefix = true): string { const hex = Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) @@ -292,13 +152,6 @@ export function bytesToHex(bytes: Uint8Array, prefix = true): string { return prefix ? `0x${hex}` : hex; } -/** - * Convert a hex string to bytes. - * - * @param hex - Hex string (with or without 0x prefix) - * @returns Uint8Array of bytes - * @throws Error if hex string is invalid - */ export function hexToBytes(hex: string): Uint8Array { const normalized = hex.startsWith('0x') ? hex.slice(2) : hex; if (normalized.length % 2 !== 0) { diff --git a/packages/sdk/src/functions/sendEvent.ts b/packages/sdk/src/functions/sendEvent.ts index 58214a9b..6a02d975 100644 --- a/packages/sdk/src/functions/sendEvent.ts +++ b/packages/sdk/src/functions/sendEvent.ts @@ -15,10 +15,6 @@ const MESSAGE_LENGTH_BYTES = 2; const MAX_MESSAGE_BYTES = 1703; const EVENT_PAYLOAD_BYTES = EVENT_TYPE_BYTES + EVENT_ID_BYTES + MESSAGE_LENGTH_BYTES + MAX_MESSAGE_BYTES; -const EVENT_TYPE_OFFSET = 0; -const EVENT_ID_OFFSET = EVENT_TYPE_OFFSET + EVENT_TYPE_BYTES; -const MESSAGE_LENGTH_OFFSET = EVENT_ID_OFFSET + EVENT_ID_BYTES; -const MESSAGE_OFFSET = MESSAGE_LENGTH_OFFSET + MESSAGE_LENGTH_BYTES; const parseEventId = (eventId: string): Buffer => { if (!validateUuid(eventId)) { @@ -59,17 +55,17 @@ const encodeEventPayload = ({ const payload = Buffer.alloc(EVENT_PAYLOAD_BYTES); const eventIdBytes = parseEventId(eventId); - payload[EVENT_TYPE_OFFSET] = eventType; - eventIdBytes.copy(payload, EVENT_ID_OFFSET); - payload.writeUInt16LE(msgBytes.length, MESSAGE_LENGTH_OFFSET); - msgBytes.copy(payload, MESSAGE_OFFSET); + payload[0] = eventType; + eventIdBytes.copy(payload, EVENT_TYPE_BYTES); + payload.writeUInt16LE(msgBytes.length, EVENT_TYPE_BYTES + EVENT_ID_BYTES); + msgBytes.copy( + payload, + EVENT_TYPE_BYTES + EVENT_ID_BYTES + MESSAGE_LENGTH_BYTES, + ); return payload; }; -/** - * Send a simple event payload to the device firmware. - * @category Lattice - */ +/** Send an event payload to device firmware. */ export const sendEvent = async ({ client, eventType, @@ -87,14 +83,9 @@ export const sendEvent = async ({ url, }); - client.mutate({ - ephemeralPub: newEphemeralPub, - }); + client.mutate({ ephemeralPub: newEphemeralPub }); - const status = decryptedData[0] ?? 0; - return { status }; + return { status: decryptedData[0] ?? 0 }; }; -export const __private__ = { - encodeEventPayload, -}; +export const __private__ = { encodeEventPayload }; diff --git a/packages/sdk/src/types/event.ts b/packages/sdk/src/types/event.ts index 70f0668a..6c219a35 100644 --- a/packages/sdk/src/types/event.ts +++ b/packages/sdk/src/types/event.ts @@ -1,23 +1,15 @@ import type { Client } from '../client'; -/** Parameters required to send a device event. */ export interface SendEventParams { - /** Firmware event type code (uint8). */ eventType: number; - /** UUID v4 string used by firmware to dedupe events. */ eventId: string; - /** UTF-8 serialized event payload to display on the device. */ message: string; } -/** Arguments for the sendEvent function including the bound client. */ export interface SendEventRequestFunctionParams extends SendEventParams { - /** Connected SDK client instance. */ client: Client; } -/** Response returned by the sendEvent request. */ export interface SendEventResponse { - /** Firmware response status byte. */ status: number; } From 844832b43c77ef46da26d30aa662d89136c30688 Mon Sep 17 00:00:00 2001 From: hmrkx Date: Mon, 26 Jan 2026 20:02:53 +0000 Subject: [PATCH 6/7] feat: solana tests base64 --- packages/sdk/src/__test__/unit/solana.test.ts | 215 ++++++++++++++++++ packages/sdk/src/calldata/solana.ts | 78 +++++-- 2 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 packages/sdk/src/__test__/unit/solana.test.ts diff --git a/packages/sdk/src/__test__/unit/solana.test.ts b/packages/sdk/src/__test__/unit/solana.test.ts new file mode 100644 index 00000000..426310d5 --- /dev/null +++ b/packages/sdk/src/__test__/unit/solana.test.ts @@ -0,0 +1,215 @@ +import { decodeTransaction, toEd25519Bytes } from '../../calldata/solana'; + +describe('Solana utilities', () => { + describe('decodeTransaction', () => { + // Generate a valid base64 transaction of minimum valid size (100 bytes) + const createValidBase64Tx = (length: number): string => { + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = i % 256; + } + return Buffer.from(bytes).toString('base64'); + }; + + test('should decode valid base64 transaction within size range', () => { + const validTx = createValidBase64Tx(200); + const result = decodeTransaction(validTx); + + expect(result.encoding).toBe('base64'); + expect(result.bytes).toBeInstanceOf(Uint8Array); + expect(result.bytes.length).toBe(200); + }); + + test('should decode transaction at minimum valid size (100 bytes)', () => { + const validTx = createValidBase64Tx(100); + const result = decodeTransaction(validTx); + + expect(result.encoding).toBe('base64'); + expect(result.bytes.length).toBe(100); + }); + + test('should decode transaction at maximum valid size (1232 bytes)', () => { + const validTx = createValidBase64Tx(1232); + const result = decodeTransaction(validTx); + + expect(result.encoding).toBe('base64'); + expect(result.bytes.length).toBe(1232); + }); + + test('should reject transaction below minimum size (99 bytes)', () => { + const tooSmall = createValidBase64Tx(99); + expect(() => decodeTransaction(tooSmall)).toThrow( + /outside valid Solana range/, + ); + }); + + test('should reject transaction above maximum size (1233 bytes)', () => { + const tooLarge = createValidBase64Tx(1233); + expect(() => decodeTransaction(tooLarge)).toThrow( + /outside valid Solana range/, + ); + }); + + test('should reject base58-encoded string (common Solana RPC format)', () => { + // This is a base58 string (uses only base58 alphabet: 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz) + // Note: base58 excludes 0, O, I, l which are in base64 + // This should NOT silently decode as garbage base64 + const base58String = '4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi'; + + expect(() => decodeTransaction(base58String)).toThrow(/not valid base64/); + }); + + test('should reject base58 transaction that would decode to valid length if treated as base64', () => { + // Create a string with length not divisible by 4 (invalid base64 padding) + // This simulates a base58 string that would fail the round-trip check + const fakeBase58 = + 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijk123456789'.repeat(5) + 'ABC'; + + expect(() => decodeTransaction(fakeBase58)).toThrow(/not valid base64/); + }); + + test('should reject strings with invalid base64 characters', () => { + // Contains characters not in base64 alphabet ($ and @) + const invalidChars = 'SGVsbG8$V29ybGQ@'; + expect(() => decodeTransaction(invalidChars)).toThrow(/not valid base64/); + }); + + test('should reject strings with wrong padding', () => { + // Valid base64 should have proper = padding + const wrongPadding = 'SGVsbG8gV29ybGQ'; + expect(() => decodeTransaction(wrongPadding)).toThrow(/not valid base64/); + }); + + test('should reject empty string', () => { + expect(() => decodeTransaction('')).toThrow(); + }); + }); + + describe('toEd25519Bytes', () => { + test('should return 32-byte array for valid 32-byte Uint8Array', () => { + const validKey = new Uint8Array(32).fill(42); + const result = toEd25519Bytes(validKey); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[0]).toBe(42); + }); + + test('should truncate 64-byte Uint8Array to 32 bytes', () => { + const longKey = new Uint8Array(64); + for (let i = 0; i < 64; i++) { + longKey[i] = i; + } + const result = toEd25519Bytes(longKey); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[31]).toBe(31); + }); + + test('should return null for Uint8Array shorter than 32 bytes', () => { + const shortKey = new Uint8Array(16).fill(1); + const result = toEd25519Bytes(shortKey); + + expect(result).toBeNull(); + }); + + test('should return null for 31-byte Uint8Array (off by one)', () => { + const almostValid = new Uint8Array(31).fill(1); + const result = toEd25519Bytes(almostValid); + + expect(result).toBeNull(); + }); + + test('should return null for empty Uint8Array', () => { + const empty = new Uint8Array(0); + const result = toEd25519Bytes(empty); + + expect(result).toBeNull(); + }); + + test('should return 32-byte array for valid 32-byte Buffer', () => { + const validBuffer = Buffer.alloc(32, 0xab); + const result = toEd25519Bytes(validBuffer); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[0]).toBe(0xab); + }); + + test('should truncate longer Buffer to 32 bytes', () => { + const longBuffer = Buffer.alloc(48, 0xcd); + const result = toEd25519Bytes(longBuffer); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + }); + + test('should return null for Buffer shorter than 32 bytes', () => { + const shortBuffer = Buffer.alloc(20, 0xef); + const result = toEd25519Bytes(shortBuffer); + + expect(result).toBeNull(); + }); + + test('should parse valid 64-character hex string (32 bytes)', () => { + const hex64 = 'a'.repeat(64); + const result = toEd25519Bytes(hex64); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[0]).toBe(0xaa); + }); + + test('should parse valid 0x-prefixed hex string', () => { + const hex = '0x' + 'b'.repeat(64); + const result = toEd25519Bytes(hex); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[0]).toBe(0xbb); + }); + + test('should truncate longer hex string to 32 bytes', () => { + const longHex = '0x' + 'c'.repeat(128); + const result = toEd25519Bytes(longHex); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + }); + + test('should return null for hex string shorter than 64 chars (32 bytes)', () => { + const shortHex = 'd'.repeat(62); + const result = toEd25519Bytes(shortHex); + + expect(result).toBeNull(); + }); + + test('should return null for non-hex string', () => { + const notHex = 'not-a-valid-hex-string-at-all-xyz'; + const result = toEd25519Bytes(notHex); + + expect(result).toBeNull(); + }); + + test('should return null for null input', () => { + const result = toEd25519Bytes(null); + expect(result).toBeNull(); + }); + + test('should return null for undefined input', () => { + const result = toEd25519Bytes(undefined); + expect(result).toBeNull(); + }); + + test('should return null for number input', () => { + const result = toEd25519Bytes(12345); + expect(result).toBeNull(); + }); + + test('should return null for object input', () => { + const result = toEd25519Bytes({ length: 32 }); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/sdk/src/calldata/solana.ts b/packages/sdk/src/calldata/solana.ts index 2b3b7adb..89a0c65a 100644 --- a/packages/sdk/src/calldata/solana.ts +++ b/packages/sdk/src/calldata/solana.ts @@ -39,24 +39,64 @@ export interface DecodedTransaction { bytes: Uint8Array; } -/** Decode base64 transaction. Throws if invalid. */ -export function decodeTransaction(transaction: string): DecodedTransaction { +/** + * Validates that a string is strictly valid base64 encoding. + * Base58 strings (common Solana RPC format) will be rejected since base58 + * uses a different alphabet that would decode to garbage if treated as base64. + * @param input - The string to validate + * @returns true if the string is valid base64, false otherwise + */ +function isValidBase64(input: string): boolean { + // Base64 must have length divisible by 4 (with padding) + if (input.length % 4 !== 0) { + return false; + } + + // Check for valid base64 characters: A-Z, a-z, 0-9, +, /, and = for padding + // This explicitly rejects base58-only chars that aren't in base64 + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(input)) { + return false; + } + + // Round-trip validation: decode and re-encode to verify integrity try { - const decoded = - typeof Buffer !== 'undefined' - ? Buffer.from(transaction, 'base64') - : Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)); - - // Solana txs are 100-1232 bytes - if (decoded.length >= 100 && decoded.length <= 1232) { - return { encoding: 'base64', bytes: new Uint8Array(decoded) }; + if (typeof Buffer !== 'undefined') { + const decoded = Buffer.from(input, 'base64'); + const reencoded = decoded.toString('base64'); + return reencoded === input; } + const decoded = atob(input); + const reencoded = btoa(decoded); + return reencoded === input; } catch { - // Fall through + return false; + } +} + +/** + * Decode base64 transaction. Throws if invalid. + * @throws Error if the input is not valid base64 or decoded length is outside 100-1232 bytes + */ +export function decodeTransaction(transaction: string): DecodedTransaction { + // Validate base64 strictly before decoding to reject base58 inputs + if (!isValidBase64(transaction)) { + throw new Error( + 'Transaction is not valid base64. Use fromBase58Bytes for base58.', + ); + } + + const decoded = + typeof Buffer !== 'undefined' + ? Buffer.from(transaction, 'base64') + : Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)); + + // Solana txs are 100-1232 bytes + if (decoded.length >= 100 && decoded.length <= 1232) { + return { encoding: 'base64', bytes: new Uint8Array(decoded) }; } throw new Error( - 'Transaction is not valid base64. Use fromBase58Bytes for base58.', + `Decoded transaction length ${decoded.length} is outside valid Solana range (100-1232 bytes).`, ); } @@ -117,13 +157,25 @@ export function injectSignature( return signedTx; } -/** Convert raw Ed25519 pubkey to 32-byte Uint8Array. */ +/** + * Convert raw Ed25519 pubkey to 32-byte Uint8Array. + * @param entry - The input to convert (Uint8Array, Buffer, or hex string) + * @returns A 32-byte Uint8Array, or null if the input is invalid or too short + */ export function toEd25519Bytes(entry: unknown): Uint8Array | null { if (entry instanceof Uint8Array) { + // Reject inputs shorter than 32 bytes to prevent malformed keys + if (entry.length < 32) { + return null; + } return entry.slice(0, 32); } if (typeof Buffer !== 'undefined' && Buffer.isBuffer(entry)) { + // Reject inputs shorter than 32 bytes to prevent malformed keys + if (entry.length < 32) { + return null; + } return new Uint8Array(entry.slice(0, 32)); } From 9a9f05d9b7ab88002b5f6a7e58aedebf0ad4a1b7 Mon Sep 17 00:00:00 2001 From: hmrkx Date: Mon, 26 Jan 2026 20:09:51 +0000 Subject: [PATCH 7/7] feat: solana lint --- packages/sdk/src/__test__/unit/solana.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/__test__/unit/solana.test.ts b/packages/sdk/src/__test__/unit/solana.test.ts index 426310d5..b1feb0e1 100644 --- a/packages/sdk/src/__test__/unit/solana.test.ts +++ b/packages/sdk/src/__test__/unit/solana.test.ts @@ -62,8 +62,7 @@ describe('Solana utilities', () => { test('should reject base58 transaction that would decode to valid length if treated as base64', () => { // Create a string with length not divisible by 4 (invalid base64 padding) // This simulates a base58 string that would fail the round-trip check - const fakeBase58 = - 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijk123456789'.repeat(5) + 'ABC'; + const fakeBase58 = `${'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijk123456789'.repeat(5)}ABC`; expect(() => decodeTransaction(fakeBase58)).toThrow(/not valid base64/); }); @@ -162,7 +161,7 @@ describe('Solana utilities', () => { }); test('should parse valid 0x-prefixed hex string', () => { - const hex = '0x' + 'b'.repeat(64); + const hex = `0x${'b'.repeat(64)}`; const result = toEd25519Bytes(hex); expect(result).not.toBeNull(); @@ -171,7 +170,7 @@ describe('Solana utilities', () => { }); test('should truncate longer hex string to 32 bytes', () => { - const longHex = '0x' + 'c'.repeat(128); + const longHex = `0x${'c'.repeat(128)}`; const result = toEd25519Bytes(longHex); expect(result).not.toBeNull();