Skip to content

Commit 8af8a46

Browse files
committed
feat(sdk-coin-sol): add Solana v1 CT transaction assembly
Bump @solana/web3.js to 1.99.0 and add @solana/kit 8.3.0. Extend the shared SolVersionedTransactionData with optional version and transactionConfig fields and mirror them through the express route schemas. Add a pure-additive v1 message compiler and serializer module. Refs: CHALO-1602
1 parent 9eb4301 commit 8af8a46

15 files changed

Lines changed: 1026 additions & 25 deletions

File tree

‎modules/express/src/typedRoutes/api/v2/consolidateAccount.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ export const ConsolidateAccountRequestBody = {
195195
numReadonlyUnsignedAccounts: t.number,
196196
}),
197197
recentBlockhash: t.string,
198+
version: t.union([t.literal(0), t.literal(1)]),
199+
transactionConfig: t.partial({
200+
computeUnitLimit: t.union([t.number, t.null]),
201+
heapSize: t.union([t.number, t.null]),
202+
loadedAccountsDataSizeLimit: t.union([t.number, t.null]),
203+
priorityFee: t.union([t.number, t.null]),
204+
}),
198205
})
199206
),
200207
/** Aptos custom transaction parameters for entry function calls */

‎modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,19 @@ export const SolVersionedTransactionData = t.partial({
164164
messageHeader: SolMessageHeader,
165165
/** Recent blockhash */
166166
recentBlockhash: t.string,
167+
/** Transaction version. Absent or 0 = v0 (existing), 1 = v1 */
168+
version: t.union([t.literal(0), t.literal(1)]),
169+
/** V1 transaction config. Required when version === 1 */
170+
transactionConfig: t.partial({
171+
/** Compute unit limit, or null if unset */
172+
computeUnitLimit: t.union([t.number, t.null]),
173+
/** Requested heap size, or null if unset */
174+
heapSize: t.union([t.number, t.null]),
175+
/** Loaded accounts data size limit, or null if unset */
176+
loadedAccountsDataSizeLimit: t.union([t.number, t.null]),
177+
/** Priority fee in total lamports, or null if unset */
178+
priorityFee: t.union([t.number, t.null]),
179+
}),
167180
});
168181

169182
/**

‎modules/express/src/typedRoutes/api/v2/sendCoins.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,13 @@ export const SendCoinsRequestBody = {
318318
numReadonlyUnsignedAccounts: t.number,
319319
}),
320320
recentBlockhash: t.string,
321+
version: t.union([t.literal(0), t.literal(1)]),
322+
transactionConfig: t.partial({
323+
computeUnitLimit: t.union([t.number, t.null]),
324+
heapSize: t.union([t.number, t.null]),
325+
loadedAccountsDataSizeLimit: t.union([t.number, t.null]),
326+
priorityFee: t.union([t.number, t.null]),
327+
}),
321328
})
322329
),
323330

‎modules/express/src/typedRoutes/api/v2/sendmany.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,13 @@ export const SendManyRequestBody = {
418418
numReadonlyUnsignedAccounts: t.number,
419419
}),
420420
recentBlockhash: t.string,
421+
version: t.union([t.literal(0), t.literal(1)]),
422+
transactionConfig: t.partial({
423+
computeUnitLimit: t.union([t.number, t.null]),
424+
heapSize: t.union([t.number, t.null]),
425+
loadedAccountsDataSizeLimit: t.union([t.number, t.null]),
426+
priorityFee: t.union([t.number, t.null]),
427+
}),
421428
})
422429
),
423430

‎modules/express/src/typedRoutes/api/v2/walletAccelerateTx.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,13 @@ export const AccelerateTxRequestBody = {
340340
numReadonlyUnsignedAccounts: t.number,
341341
}),
342342
recentBlockhash: t.string,
343+
version: t.union([t.literal(0), t.literal(1)]),
344+
transactionConfig: t.partial({
345+
computeUnitLimit: t.union([t.number, t.null]),
346+
heapSize: t.union([t.number, t.null]),
347+
loadedAccountsDataSizeLimit: t.union([t.number, t.null]),
348+
priorityFee: t.union([t.number, t.null]),
349+
}),
343350
})
344351
),
345352

‎modules/express/src/typedRoutes/api/v2/walletEnableTokens.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,13 @@ export const EnableTokensRequestBody = {
284284
numReadonlyUnsignedAccounts: t.number,
285285
}),
286286
recentBlockhash: t.string,
287+
version: t.union([t.literal(0), t.literal(1)]),
288+
transactionConfig: t.partial({
289+
computeUnitLimit: t.union([t.number, t.null]),
290+
heapSize: t.union([t.number, t.null]),
291+
loadedAccountsDataSizeLimit: t.union([t.number, t.null]),
292+
priorityFee: t.union([t.number, t.null]),
293+
}),
287294
})
288295
),
289296

‎modules/express/test/unit/typedRoutes/prebuildAndSignTransaction.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as assert from 'assert';
22
import * as t from 'io-ts';
33
import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types';
4-
import { PrebuildAndSignTransactionParams } from '../../../src/typedRoutes/api/v2/prebuildAndSignTransaction';
4+
import {
5+
PrebuildAndSignTransactionParams,
6+
SolVersionedTransactionData,
7+
} from '../../../src/typedRoutes/api/v2/prebuildAndSignTransaction';
58
import {
69
FullySignedTransactionResponse,
710
HalfSignedAccountTransactionResponse,
@@ -734,6 +737,59 @@ describe('PrebuildAndSignTransaction codec tests', function () {
734737
});
735738
});
736739

740+
describe('SolVersionedTransactionData codec validation', function () {
741+
it('should validate v1 data with version and transactionConfig', function () {
742+
const v1Data = {
743+
versionedInstructions: [],
744+
addressLookupTables: [],
745+
staticAccountKeys: ['test'],
746+
messageHeader: { numRequiredSignatures: 1, numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 0 },
747+
recentBlockhash: 'test',
748+
version: 1,
749+
transactionConfig: {
750+
computeUnitLimit: 200000,
751+
heapSize: 32768,
752+
loadedAccountsDataSizeLimit: 65536,
753+
priorityFee: 5000,
754+
},
755+
};
756+
const decoded = assertDecode(SolVersionedTransactionData, v1Data);
757+
assert.deepStrictEqual(decoded.version, 1);
758+
assert.deepStrictEqual(decoded.transactionConfig, v1Data.transactionConfig);
759+
});
760+
761+
it('should validate v1 data with null config fields', function () {
762+
const v1Data = {
763+
versionedInstructions: [],
764+
addressLookupTables: [],
765+
staticAccountKeys: ['test'],
766+
messageHeader: { numRequiredSignatures: 1, numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 0 },
767+
recentBlockhash: 'test',
768+
version: 1,
769+
transactionConfig: {
770+
computeUnitLimit: null,
771+
heapSize: null,
772+
loadedAccountsDataSizeLimit: null,
773+
priorityFee: null,
774+
},
775+
};
776+
const decoded = assertDecode(SolVersionedTransactionData, v1Data);
777+
assert.deepStrictEqual(decoded.version, 1);
778+
});
779+
780+
it('should validate existing v0 data without version fields', function () {
781+
const v0Data = {
782+
versionedInstructions: [],
783+
addressLookupTables: [],
784+
staticAccountKeys: ['test'],
785+
messageHeader: { numRequiredSignatures: 1, numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 0 },
786+
recentBlockhash: 'test',
787+
};
788+
const decoded = assertDecode(SolVersionedTransactionData, v0Data);
789+
assert.strictEqual(decoded.version, undefined);
790+
});
791+
});
792+
737793
describe('Response codec validation', function () {
738794
it('should validate FullySignedTransactionResponse', function () {
739795
const validResponse = {

‎modules/sdk-coin-sol/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@
6363
"@bitgo/statics": "^59.18.0",
6464
"@bitgo/wasm-solana": "^2.6.0",
6565
"@solana/buffer-layout": "^4.0.1",
66+
"@solana/kit": "8.3.0",
6667
"@solana/spl-stake-pool": "1.1.8",
6768
"@solana/spl-token": "0.4.9",
68-
"@solana/web3.js": "1.92.1",
69+
"@solana/web3.js": "1.99.0",
6970
"bignumber.js": "^9.0.0",
7071
"bs58": "^4.0.1",
7172
"lodash": "^4.18.0",
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { BuildTransactionError, SolV1TransactionConfig } from '@bitgo/sdk-core';
2+
import { PublicKey, TransactionInstruction } from '@solana/web3.js';
3+
import {
4+
AccountRole,
5+
address,
6+
appendTransactionMessageInstructions,
7+
compileTransactionMessage,
8+
createTransactionMessage,
9+
getCompiledTransactionMessageEncoder,
10+
pipe,
11+
setTransactionMessageConfig,
12+
setTransactionMessageFeePayer,
13+
setTransactionMessageLifetimeUsingBlockhash,
14+
type Blockhash,
15+
type V1TransactionConfig,
16+
} from '@solana/kit';
17+
18+
const COMPUTE_BUDGET_PROGRAM_ID = 'ComputeBudget111111111111111111111111111111';
19+
const SET_COMPUTE_UNIT_LIMIT_DISCRIMINATOR = 2;
20+
const SET_COMPUTE_UNIT_PRICE_DISCRIMINATOR = 3;
21+
const MICRO_LAMPORTS_PER_LAMPORT = 1_000_000;
22+
23+
function toKitInstruction(instruction: TransactionInstruction) {
24+
return {
25+
programAddress: address(instruction.programId.toBase58()),
26+
accounts: instruction.keys.map((k) => ({
27+
address: address(k.pubkey.toBase58()),
28+
role: k.isSigner
29+
? k.isWritable
30+
? AccountRole.WRITABLE_SIGNER
31+
: AccountRole.READONLY_SIGNER
32+
: k.isWritable
33+
? AccountRole.WRITABLE
34+
: AccountRole.READONLY,
35+
})),
36+
data: instruction.data,
37+
};
38+
}
39+
40+
function readU32LE(data: Uint8Array, offset: number): number {
41+
return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
42+
}
43+
44+
function readU64LE(data: Uint8Array, offset: number): number {
45+
let value = 0;
46+
for (let i = 0; i < 8; i++) {
47+
value += Number(data[offset + i]) * Math.pow(2, 8 * i);
48+
}
49+
return value;
50+
}
51+
52+
function stripComputeBudgetInstructions(
53+
instructions: TransactionInstruction[],
54+
transactionConfig: SolV1TransactionConfig
55+
): { instructions: TransactionInstruction[]; config: SolV1TransactionConfig } {
56+
const filtered: TransactionInstruction[] = [];
57+
const config: SolV1TransactionConfig = { ...transactionConfig };
58+
59+
for (const instruction of instructions) {
60+
const isComputeBudget = instruction.programId.toBase58() === COMPUTE_BUDGET_PROGRAM_ID;
61+
const discriminator = instruction.data?.[0];
62+
63+
if (isComputeBudget && discriminator === SET_COMPUTE_UNIT_LIMIT_DISCRIMINATOR) {
64+
if (config.computeUnitLimit === null) {
65+
config.computeUnitLimit = readU32LE(instruction.data, 1);
66+
}
67+
continue;
68+
}
69+
70+
if (isComputeBudget && discriminator === SET_COMPUTE_UNIT_PRICE_DISCRIMINATOR) {
71+
if (config.priorityFee === null && config.computeUnitLimit !== null) {
72+
const microLamports = readU64LE(instruction.data, 1);
73+
config.priorityFee = Math.ceil((microLamports * config.computeUnitLimit) / MICRO_LAMPORTS_PER_LAMPORT);
74+
}
75+
continue;
76+
}
77+
78+
filtered.push(instruction);
79+
}
80+
81+
return { instructions: filtered, config };
82+
}
83+
84+
function toKitV1Config(config: SolV1TransactionConfig): V1TransactionConfig {
85+
const kitConfig: V1TransactionConfig = {};
86+
if (config.computeUnitLimit !== null) {
87+
kitConfig.computeUnitLimit = config.computeUnitLimit;
88+
}
89+
if (config.heapSize !== null) {
90+
kitConfig.heapSize = config.heapSize;
91+
}
92+
if (config.loadedAccountsDataSizeLimit !== null) {
93+
kitConfig.loadedAccountsDataSizeLimit = config.loadedAccountsDataSizeLimit;
94+
}
95+
if (config.priorityFee !== null) {
96+
kitConfig.priorityFeeLamports = BigInt(config.priorityFee);
97+
}
98+
return kitConfig;
99+
}
100+
101+
export function compileV1Message(args: {
102+
instructions: TransactionInstruction[];
103+
feePayer: PublicKey;
104+
recentBlockhash: string;
105+
transactionConfig: SolV1TransactionConfig;
106+
}): Uint8Array {
107+
const { instructions, feePayer, recentBlockhash, transactionConfig } = args;
108+
109+
if (!instructions || instructions.length === 0) {
110+
throw new BuildTransactionError('At least one instruction is required to compile a v1 transaction');
111+
}
112+
if (!transactionConfig) {
113+
throw new BuildTransactionError('transactionConfig is required to compile a v1 transaction');
114+
}
115+
116+
const { instructions: filteredInstructions, config } = stripComputeBudgetInstructions(
117+
instructions,
118+
transactionConfig
119+
);
120+
const kitInstructions = filteredInstructions.map(toKitInstruction);
121+
122+
const message = pipe(
123+
createTransactionMessage({ version: 1 }),
124+
(m) => setTransactionMessageFeePayer(address(feePayer.toBase58()), m),
125+
// lastValidBlockHeight is metadata only: kit does not serialize it into the v1 wire bytes,
126+
// and submission is handled by the caller directly, so a non-zero placeholder is not needed.
127+
(m) =>
128+
setTransactionMessageLifetimeUsingBlockhash(
129+
{ blockhash: recentBlockhash as Blockhash, lastValidBlockHeight: 0n },
130+
m
131+
),
132+
(m) => appendTransactionMessageInstructions(kitInstructions, m),
133+
(m) => setTransactionMessageConfig(toKitV1Config(config), m)
134+
);
135+
136+
let compiled;
137+
try {
138+
compiled = compileTransactionMessage(message);
139+
} catch (e) {
140+
throw new BuildTransactionError(
141+
`Failed to compile v1 transaction message: ${e instanceof Error ? e.message : String(e)}`
142+
);
143+
}
144+
145+
return new Uint8Array(getCompiledTransactionMessageEncoder().encode(compiled));
146+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { BuildTransactionError } from '@bitgo/sdk-core';
2+
3+
const SIGNATURE_BYTE_LENGTH = 64;
4+
const V1_VERSION_PREFIX = 0x81;
5+
const V1_TRANSACTION_SIZE_LIMIT = 4096;
6+
7+
/**
8+
* Assemble the v1 wire format of a signed transaction.
9+
*
10+
* Unlike legacy and v0 transactions, where signatures are prefixed before the message, a v1
11+
* transaction places the message first and appends the signatures at the tail with no length
12+
* prefix; the signature count is derived from the message header's numRequiredSignatures.
13+
*
14+
* @param messageBytes - The serialized v1 message bytes (starting with the 0x81 version prefix)
15+
* @param signatures - The ed25519 signatures over the message bytes, one per required signer
16+
* @returns The full v1 wire transaction bytes
17+
*/
18+
export function serializeV1Transaction(messageBytes: Uint8Array, signatures: Uint8Array[]): Uint8Array {
19+
if (!messageBytes || messageBytes.length === 0) {
20+
throw new BuildTransactionError('messageBytes is required to serialize a v1 transaction');
21+
}
22+
if (!signatures || signatures.length === 0) {
23+
throw new BuildTransactionError('At least one signature is required to serialize a v1 transaction');
24+
}
25+
26+
for (const signature of signatures) {
27+
if (signature.length !== SIGNATURE_BYTE_LENGTH) {
28+
throw new BuildTransactionError(
29+
`Invalid signature length: expected ${SIGNATURE_BYTE_LENGTH} bytes, got ${signature.length}`
30+
);
31+
}
32+
}
33+
34+
if (messageBytes[0] !== V1_VERSION_PREFIX) {
35+
throw new BuildTransactionError(
36+
`Invalid v1 message: expected version prefix 0x${V1_VERSION_PREFIX.toString(
37+
16
38+
)}, got 0x${messageBytes[0].toString(16)}`
39+
);
40+
}
41+
42+
if (messageBytes[1] !== signatures.length) {
43+
throw new BuildTransactionError(
44+
`Signature count mismatch: message declares ${messageBytes[1]} required signers but ${signatures.length} were provided`
45+
);
46+
}
47+
48+
const totalLength = messageBytes.length + signatures.length * SIGNATURE_BYTE_LENGTH;
49+
if (totalLength > V1_TRANSACTION_SIZE_LIMIT) {
50+
throw new BuildTransactionError(
51+
`v1 transaction exceeds the ${V1_TRANSACTION_SIZE_LIMIT}-byte size limit: ${totalLength} bytes`
52+
);
53+
}
54+
55+
const buffer = new Uint8Array(totalLength);
56+
buffer.set(messageBytes, 0);
57+
58+
let offset = messageBytes.length;
59+
for (const signature of signatures) {
60+
buffer.set(signature, offset);
61+
offset += SIGNATURE_BYTE_LENGTH;
62+
}
63+
64+
return buffer;
65+
}

0 commit comments

Comments
 (0)