Skip to content

Commit 34aeecd

Browse files
authored
Merge pull request #134 from BitGo/BTC-3006-intent-builder
feat(wasm-solana): add intent-based transaction building
2 parents 3032745 + 32e4e8c commit 34aeecd

23 files changed

+1957
-12
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules/
22
.idea/
33
*.iml
44
*.tsbuildinfo
5+
.cursor/

packages/wasm-solana/Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/wasm-solana/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ solana-keypair = "2.0"
3030
solana-signer = "2.0"
3131
solana-signature = "3.0"
3232
solana-address = "1.0"
33+
# WASM random number generation support (need both 0.1 and 0.2 for all deps)
34+
getrandom_01 = { package = "getrandom", version = "0.1", features = ["wasm-bindgen"] }
35+
getrandom = { version = "0.2", features = ["js"] }
3336
# Serialization
3437
bincode = "1.3"
3538
borsh = "1.5"
382 KB
Binary file not shown.

packages/wasm-solana/eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default tseslint.config(
77
{
88
languageOptions: {
99
parserOptions: {
10-
projectService: true,
10+
project: ["./tsconfig.json", "./tsconfig.test.json"],
1111
tsconfigRootDir: import.meta.dirname,
1212
},
1313
},

packages/wasm-solana/js/index.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@ export type { AddressLookupTableData } from "./versioned.js";
2222
// Top-level function exports
2323
export { parseTransaction } from "./parser.js";
2424
export { buildTransaction, buildFromVersionedData } from "./builder.js";
25+
export { buildFromIntent } from "./intentBuilder.js";
26+
27+
// Intent builder type exports
28+
export type {
29+
BaseIntent,
30+
PaymentIntent,
31+
StakeIntent,
32+
UnstakeIntent,
33+
ClaimIntent,
34+
DeactivateIntent,
35+
DelegateIntent,
36+
EnableTokenIntent,
37+
CloseAtaIntent,
38+
ConsolidateIntent,
39+
SolanaIntent,
40+
StakePoolConfig,
41+
BuildFromIntentParams,
42+
BuildFromIntentResult,
43+
GeneratedKeypair,
44+
NonceSource,
45+
BlockhashNonce,
46+
DurableNonce,
47+
} from "./intentBuilder.js";
2548

2649
// Program ID constants (from WASM)
2750
export {
@@ -47,7 +70,7 @@ export type { AccountMeta, Instruction } from "./transaction.js";
4770
export type {
4871
TransactionInput,
4972
ParsedTransaction,
50-
DurableNonce,
73+
DurableNonce as ParsedDurableNonce,
5174
InstructionParams,
5275
TransferParams,
5376
CreateAccountParams,
@@ -74,7 +97,7 @@ export type {
7497
// Builder type exports (prefixed to avoid conflict with parser/transaction types)
7598
export type {
7699
TransactionIntent,
77-
NonceSource,
100+
NonceSource as BuilderNonceSource,
78101
BlockhashNonceSource,
79102
DurableNonceSource,
80103
AddressLookupTable as BuilderAddressLookupTable,
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* High-level intent-based transaction building.
3+
*
4+
* This module provides `buildFromIntent()` which accepts BitGo intent objects
5+
* directly and builds Solana transactions without requiring the caller to
6+
* construct low-level instructions.
7+
*
8+
* The intent → transaction mapping happens entirely in Rust/WASM for simplicity.
9+
*
10+
* Usage:
11+
* ```typescript
12+
* import { buildFromIntent } from '@bitgo/wasm-solana';
13+
*
14+
* const result = buildFromIntent(intent, {
15+
* feePayer: walletRootAddress,
16+
* nonce: { type: 'blockhash', value: recentBlockhash },
17+
* });
18+
*
19+
* // result.transaction - Transaction object
20+
* // result.generatedKeypairs - any keypairs generated (e.g., stake accounts)
21+
* ```
22+
*/
23+
24+
import { IntentNamespace, WasmTransaction } from "./wasm/wasm_solana.js";
25+
import { Transaction } from "./transaction.js";
26+
27+
/** Internal type for WASM result - matches what Rust returns */
28+
interface WasmBuildResult {
29+
transaction: WasmTransaction;
30+
generatedKeypairs: GeneratedKeypair[];
31+
}
32+
33+
// =============================================================================
34+
// Types
35+
// =============================================================================
36+
37+
/** Nonce source - blockhash or durable nonce */
38+
export type NonceSource = BlockhashNonce | DurableNonce;
39+
40+
export interface BlockhashNonce {
41+
type: "blockhash";
42+
value: string;
43+
}
44+
45+
export interface DurableNonce {
46+
type: "durable";
47+
address: string;
48+
authority: string;
49+
value: string;
50+
}
51+
52+
/** Parameters for building a transaction from intent */
53+
export interface BuildFromIntentParams {
54+
/** Fee payer address (wallet root) */
55+
feePayer: string;
56+
/** Nonce source - blockhash or durable nonce */
57+
nonce: NonceSource;
58+
}
59+
60+
/** A keypair generated during transaction building */
61+
export interface GeneratedKeypair {
62+
/** Purpose of this keypair */
63+
purpose: "stakeAccount" | "unstakeAccount" | "transferAuthority";
64+
/** Public address (base58) */
65+
address: string;
66+
/** Secret key (base58) */
67+
secretKey: string;
68+
}
69+
70+
/** Result from building a transaction from intent */
71+
export interface BuildFromIntentResult {
72+
/** The built transaction */
73+
transaction: Transaction;
74+
/** Generated keypairs (for stake accounts, etc.) */
75+
generatedKeypairs: GeneratedKeypair[];
76+
}
77+
78+
// =============================================================================
79+
// Intent Types (for TypeScript users)
80+
// =============================================================================
81+
82+
/** Base intent - all intents have intentType */
83+
export interface BaseIntent {
84+
intentType: string;
85+
memo?: string;
86+
}
87+
88+
/** Payment intent */
89+
export interface PaymentIntent extends BaseIntent {
90+
intentType: "payment";
91+
recipients?: Array<{
92+
address?: { address: string };
93+
amount?: { value: bigint; symbol?: string };
94+
}>;
95+
}
96+
97+
/** Stake intent */
98+
export interface StakeIntent extends BaseIntent {
99+
intentType: "stake";
100+
validatorAddress: string;
101+
amount?: { value: bigint };
102+
stakingType?: "NATIVE" | "JITO" | "MARINADE";
103+
stakePoolConfig?: StakePoolConfig;
104+
}
105+
106+
/** Stake pool configuration (for Jito) */
107+
export interface StakePoolConfig {
108+
stakePoolAddress: string;
109+
withdrawAuthority: string;
110+
reserveStake: string;
111+
destinationPoolAccount: string;
112+
managerFeeAccount: string;
113+
referralPoolAccount?: string;
114+
poolMint: string;
115+
validatorList?: string;
116+
sourcePoolAccount?: string;
117+
}
118+
119+
/** Unstake intent */
120+
export interface UnstakeIntent extends BaseIntent {
121+
intentType: "unstake";
122+
stakingAddress: string;
123+
validatorAddress?: string;
124+
amount?: { value: bigint };
125+
remainingStakingAmount?: { value: bigint };
126+
stakingType?: "NATIVE" | "JITO" | "MARINADE";
127+
stakePoolConfig?: StakePoolConfig;
128+
}
129+
130+
/** Claim intent (withdraw from deactivated stake) */
131+
export interface ClaimIntent extends BaseIntent {
132+
intentType: "claim";
133+
stakingAddress: string;
134+
amount?: { value: bigint };
135+
}
136+
137+
/** Deactivate intent */
138+
export interface DeactivateIntent extends BaseIntent {
139+
intentType: "deactivate";
140+
stakingAddress?: string;
141+
stakingAddresses?: string[];
142+
}
143+
144+
/** Delegate intent */
145+
export interface DelegateIntent extends BaseIntent {
146+
intentType: "delegate";
147+
validatorAddress: string;
148+
stakingAddress?: string;
149+
stakingAddresses?: string[];
150+
}
151+
152+
/** Enable token intent (create ATA) */
153+
export interface EnableTokenIntent extends BaseIntent {
154+
intentType: "enableToken";
155+
recipientAddress?: string;
156+
tokenAddress?: string;
157+
tokenProgramId?: string;
158+
}
159+
160+
/** Close ATA intent */
161+
export interface CloseAtaIntent extends BaseIntent {
162+
intentType: "closeAssociatedTokenAccount";
163+
tokenAccountAddress?: string;
164+
tokenProgramId?: string;
165+
}
166+
167+
/** Consolidate intent - transfer from child address to root */
168+
export interface ConsolidateIntent extends BaseIntent {
169+
intentType: "consolidate";
170+
/** The child address to consolidate from (sender) */
171+
receiveAddress: string;
172+
/** Recipients (root address for SOL, ATAs for tokens) */
173+
recipients?: Array<{
174+
address?: { address: string };
175+
amount?: { value: bigint };
176+
}>;
177+
}
178+
179+
/** Union of all supported intent types */
180+
export type SolanaIntent =
181+
| PaymentIntent
182+
| StakeIntent
183+
| UnstakeIntent
184+
| ClaimIntent
185+
| DeactivateIntent
186+
| DelegateIntent
187+
| EnableTokenIntent
188+
| CloseAtaIntent
189+
| ConsolidateIntent;
190+
191+
// =============================================================================
192+
// Main Function
193+
// =============================================================================
194+
195+
/**
196+
* Build a Solana transaction from a BitGo intent.
197+
*
198+
* This function passes the intent directly to Rust/WASM which handles
199+
* all the intent-to-transaction mapping internally.
200+
*
201+
* @param intent - The BitGo intent (with intentType, etc.)
202+
* @param params - Build parameters (feePayer, nonce)
203+
* @returns Transaction object and any generated keypairs
204+
*
205+
* @example
206+
* ```typescript
207+
* // Payment intent
208+
* const result = buildFromIntent(
209+
* {
210+
* intentType: 'payment',
211+
* recipients: [{ address: { address: recipient }, amount: { value: 1000000n } }]
212+
* },
213+
* { feePayer: walletRoot, nonce: { type: 'blockhash', value: blockhash } }
214+
* );
215+
*
216+
* // Native staking - generates a new stake account keypair
217+
* const result = buildFromIntent(
218+
* {
219+
* intentType: 'stake',
220+
* validatorAddress: validator,
221+
* amount: { value: 1000000000n }
222+
* },
223+
* { feePayer: walletRoot, nonce: { type: 'blockhash', value: blockhash } }
224+
* );
225+
* // result.generatedKeypairs[0] contains the stake account keypair
226+
* ```
227+
*/
228+
export function buildFromIntent(
229+
intent: BaseIntent,
230+
params: BuildFromIntentParams,
231+
): BuildFromIntentResult {
232+
const result = IntentNamespace.build_from_intent(intent, params) as WasmBuildResult;
233+
234+
return {
235+
transaction: Transaction.fromWasm(result.transaction),
236+
generatedKeypairs: result.generatedKeypairs,
237+
};
238+
}

packages/wasm-solana/js/keypair.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import { WasmKeypair } from "./wasm/wasm_solana.js";
99
export class Keypair {
1010
private constructor(private _wasm: WasmKeypair) {}
1111

12+
/**
13+
* Generate a new random keypair
14+
* @returns A new Keypair instance with randomly generated keys
15+
*/
16+
static generate(): Keypair {
17+
const wasm = WasmKeypair.generate();
18+
return new Keypair(wasm);
19+
}
20+
1221
/**
1322
* Create a keypair from a 32-byte secret key
1423
* @param secretKey - The 32-byte Ed25519 secret key

packages/wasm-solana/src/builder/build.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -798,7 +798,7 @@ fn build_instruction(ix: IntentInstruction) -> Result<Instruction, WasmSolanaErr
798798
"hex" => hex::decode(&data).map_err(|e| {
799799
WasmSolanaError::new(&format!("Invalid hex data in custom instruction: {}", e))
800800
})?,
801-
"base64" | _ => {
801+
_ => {
802802
use base64::Engine;
803803
base64::engine::general_purpose::STANDARD
804804
.decode(&data)

packages/wasm-solana/src/instructions/decode.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ fn decode_system_instruction(ctx: InstructionContext) -> ParsedInstruction {
7979
// This is part of CreateNonceAccount flow - parsed as intermediate NonceInitialize
8080
// Will be combined with CreateAccount in post-processing
8181
// Accounts: [0] nonce, [1] recent_blockhashes_sysvar, [2] rent_sysvar
82-
if ctx.accounts.len() >= 1 {
82+
if !ctx.accounts.is_empty() {
8383
ParsedInstruction::NonceInitialize(NonceInitializeParams {
8484
nonce_address: ctx.accounts[0].clone(),
8585
auth_address: authority.to_string(),
@@ -141,7 +141,7 @@ fn decode_stake_instruction(ctx: InstructionContext) -> ParsedInstruction {
141141
// This is part of StakingActivate flow - parsed as intermediate StakeInitialize
142142
// Will be combined with CreateAccount + DelegateStake in post-processing
143143
// Accounts: [0] stake, [1] rent_sysvar
144-
if ctx.accounts.len() >= 1 {
144+
if !ctx.accounts.is_empty() {
145145
ParsedInstruction::StakeInitialize(StakeInitializeParams {
146146
staking_address: ctx.accounts[0].clone(),
147147
staker: authorized.staker.to_string(),

0 commit comments

Comments
 (0)