Skip to content

Commit 139be84

Browse files
authored
Merge pull request #156 from BitGo/BTC-3025-sol-wasm-marinade
feat(wasm-solana): add Marinade staking/unstaking support
2 parents ddd58eb + 0342ddc commit 139be84

2 files changed

Lines changed: 194 additions & 12 deletions

File tree

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

Lines changed: 158 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,13 @@ fn build_stake(
201201
let amount: u64 = intent.amount.as_ref().map(|a| a.value).unwrap_or(0);
202202

203203
// Check if Jito staking
204-
if intent.staking_type.as_deref() == Some("JITO") {
204+
if intent.staking_type == Some(StakingType::Jito) {
205205
if let Some(config) = &intent.stake_pool_config {
206206
return build_jito_stake(config, &fee_payer, amount);
207207
}
208208
}
209209

210-
// Native staking: generate stake account keypair
210+
// Generate stake account keypair (used by both native and Marinade)
211211
let stake_keypair = Keypair::new();
212212
let stake_address = stake_keypair.address();
213213
let stake_pubkey: Pubkey = stake_address
@@ -219,16 +219,45 @@ fn build_stake(
219219
.parse()
220220
.map_err(|_| WasmSolanaError::new("Invalid validatorAddress"))?;
221221

222+
// Marinade staking: CreateAccount + Initialize (no Delegate)
223+
// Staker authority is the validator, withdrawer is the user
224+
if intent.staking_type == Some(StakingType::Marinade) {
225+
let instructions = vec![
226+
system_ix::create_account(
227+
&fee_payer,
228+
&stake_pubkey,
229+
amount + STAKE_ACCOUNT_RENT,
230+
STAKE_ACCOUNT_SPACE,
231+
&solana_stake_interface::program::ID,
232+
),
233+
stake_ix::initialize(
234+
&stake_pubkey,
235+
&Authorized {
236+
staker: validator_pubkey,
237+
withdrawer: fee_payer,
238+
},
239+
&Lockup::default(),
240+
),
241+
];
242+
243+
let generated = vec![GeneratedKeypair {
244+
purpose: "stakeAccount".to_string(),
245+
address: stake_address,
246+
secret_key: solana_sdk::bs58::encode(stake_keypair.secret_key_bytes()).into_string(),
247+
}];
248+
249+
return Ok((instructions, generated));
250+
}
251+
252+
// Native staking: CreateAccount + Initialize + Delegate
222253
let instructions = vec![
223-
// Create account
224254
system_ix::create_account(
225255
&fee_payer,
226256
&stake_pubkey,
227257
amount + STAKE_ACCOUNT_RENT,
228258
STAKE_ACCOUNT_SPACE,
229259
&solana_stake_interface::program::ID,
230260
),
231-
// Initialize stake
232261
stake_ix::initialize(
233262
&stake_pubkey,
234263
&Authorized {
@@ -237,7 +266,6 @@ fn build_stake(
237266
},
238267
&Lockup::default(),
239268
),
240-
// Delegate
241269
stake_ix::delegate_stake(&stake_pubkey, &fee_payer, &validator_pubkey),
242270
];
243271

@@ -344,13 +372,22 @@ fn build_unstake(
344372
.parse()
345373
.map_err(|_| WasmSolanaError::new("Invalid feePayer"))?;
346374

347-
let stake_pubkey: Pubkey = intent
375+
// Marinade unstake: SystemProgram.transfer to recipient (no stake account involved)
376+
if intent.staking_type == Some(StakingType::Marinade) {
377+
return build_marinade_unstake(&intent, &fee_payer);
378+
}
379+
380+
// For native/Jito, staking_address is required
381+
let staking_address = intent
348382
.staking_address
383+
.as_ref()
384+
.ok_or_else(|| WasmSolanaError::new("Missing stakingAddress for native/Jito unstake"))?;
385+
let stake_pubkey: Pubkey = staking_address
349386
.parse()
350387
.map_err(|_| WasmSolanaError::new("Invalid stakingAddress"))?;
351388

352389
// Check if Jito unstaking
353-
if intent.staking_type.as_deref() == Some("JITO") {
390+
if intent.staking_type == Some(StakingType::Jito) {
354391
if let Some(config) = &intent.stake_pool_config {
355392
let amount: u64 = intent.amount.as_ref().map(|a| a.value).unwrap_or(0);
356393
return build_jito_unstake(config, &fee_payer, &intent.validator_address, amount);
@@ -417,6 +454,42 @@ fn build_partial_unstake(
417454
Ok((instructions, generated))
418455
}
419456

457+
fn build_marinade_unstake(
458+
intent: &UnstakeIntent,
459+
fee_payer: &Pubkey,
460+
) -> Result<(Vec<Instruction>, Vec<GeneratedKeypair>), WasmSolanaError> {
461+
let recipients = intent
462+
.recipients
463+
.as_ref()
464+
.ok_or_else(|| WasmSolanaError::new("Missing recipients for Marinade unstake"))?;
465+
466+
if recipients.is_empty() {
467+
return Err(WasmSolanaError::new(
468+
"Recipients array is empty for Marinade unstake",
469+
));
470+
}
471+
472+
let recipient = &recipients[0];
473+
let to_address = recipient
474+
.address
475+
.as_ref()
476+
.map(|a| &a.address)
477+
.ok_or_else(|| WasmSolanaError::new("Recipient missing address for Marinade unstake"))?;
478+
let amount = recipient
479+
.amount
480+
.as_ref()
481+
.map(|a| a.value)
482+
.ok_or_else(|| WasmSolanaError::new("Recipient missing amount for Marinade unstake"))?;
483+
484+
let to_pubkey: Pubkey = to_address
485+
.parse()
486+
.map_err(|_| WasmSolanaError::new(&format!("Invalid recipient address: {}", to_address)))?;
487+
488+
let instructions = vec![system_ix::transfer(fee_payer, &to_pubkey, amount)];
489+
490+
Ok((instructions, vec![]))
491+
}
492+
420493
fn build_jito_unstake(
421494
config: &StakePoolConfig,
422495
fee_payer: &Pubkey,
@@ -1020,4 +1093,82 @@ mod tests {
10201093
let result = result.unwrap();
10211094
assert!(result.generated_keypairs.is_empty());
10221095
}
1096+
1097+
#[test]
1098+
fn test_build_marinade_stake_intent() {
1099+
// Marinade stake: CreateAccount + Initialize (no Delegate)
1100+
// Staker = validator, Withdrawer = fee_payer
1101+
let intent = serde_json::json!({
1102+
"intentType": "stake",
1103+
"validatorAddress": "CyjoLt3kjqB57K7ewCBHmnHq3UgEj3ak6A7m6EsBsuhA",
1104+
"amount": { "value": "300000" },
1105+
"stakingType": "MARINADE"
1106+
});
1107+
1108+
let result = build_from_intent(&intent, &test_params());
1109+
assert!(result.is_ok(), "Failed: {:?}", result);
1110+
let result = result.unwrap();
1111+
1112+
// Should generate a stake account keypair
1113+
assert_eq!(result.generated_keypairs.len(), 1);
1114+
assert_eq!(result.generated_keypairs[0].purpose, "stakeAccount");
1115+
1116+
// Transaction should have 2 instructions (CreateAccount + Initialize)
1117+
// No Delegate instruction for Marinade
1118+
let msg = result.transaction.message();
1119+
assert_eq!(
1120+
msg.instructions.len(),
1121+
2,
1122+
"Marinade stake should have exactly 2 instructions (CreateAccount + Initialize)"
1123+
);
1124+
}
1125+
1126+
#[test]
1127+
fn test_build_marinade_unstake_intent() {
1128+
// Marinade unstake: SystemProgram.transfer to recipient
1129+
let intent = serde_json::json!({
1130+
"intentType": "unstake",
1131+
"stakingType": "MARINADE",
1132+
"amount": { "value": "500000000000" },
1133+
"recipients": [{
1134+
"address": { "address": "opNS8ENpEMWdXcJUgJCsJTDp7arTXayoBEeBUg6UezP" },
1135+
"amount": { "value": "500000000000" }
1136+
}],
1137+
"memo": "{\"PrepareForRevoke\":{\"user\":\"DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB\",\"amount\":\"500000000000\"}}"
1138+
});
1139+
1140+
let result = build_from_intent(&intent, &test_params());
1141+
assert!(result.is_ok(), "Failed: {:?}", result);
1142+
let result = result.unwrap();
1143+
1144+
// No generated keypairs for Marinade unstake
1145+
assert!(result.generated_keypairs.is_empty());
1146+
1147+
// Transaction should have 1 transfer + 1 memo = 2 instructions
1148+
let msg = result.transaction.message();
1149+
assert_eq!(
1150+
msg.instructions.len(),
1151+
2,
1152+
"Marinade unstake should have transfer + memo instructions"
1153+
);
1154+
}
1155+
1156+
#[test]
1157+
fn test_build_marinade_unstake_requires_recipients() {
1158+
let intent = serde_json::json!({
1159+
"intentType": "unstake",
1160+
"stakingType": "MARINADE",
1161+
"amount": { "value": "500000000000" }
1162+
});
1163+
1164+
let result = build_from_intent(&intent, &test_params());
1165+
assert!(result.is_err(), "Should fail without recipients");
1166+
assert!(
1167+
result
1168+
.unwrap_err()
1169+
.to_string()
1170+
.contains("Missing recipients"),
1171+
"Error should mention missing recipients"
1172+
);
1173+
}
10231174
}

packages/wasm-solana/src/intent/types.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@
44
55
use serde::{Deserialize, Serialize};
66

7+
/// Intent type discriminant.
8+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9+
#[serde(rename_all = "camelCase")]
10+
pub enum IntentType {
11+
Payment,
12+
GoUnstake,
13+
Stake,
14+
Unstake,
15+
Claim,
16+
Deactivate,
17+
Delegate,
18+
EnableToken,
19+
CloseAssociatedTokenAccount,
20+
Consolidate,
21+
Authorize,
22+
CustomTx,
23+
}
24+
25+
/// Staking type for stake/unstake intents.
26+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
27+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
28+
pub enum StakingType {
29+
Jito,
30+
Marinade,
31+
}
32+
733
/// Build parameters provided by wallet-platform.
834
/// These are NOT part of the intent but needed to build the transaction.
935
#[derive(Debug, Clone, Deserialize)]
@@ -143,12 +169,12 @@ pub struct PaymentIntent {
143169
#[derive(Debug, Clone, Deserialize)]
144170
#[serde(rename_all = "camelCase")]
145171
pub struct StakeIntent {
146-
pub intent_type: String,
172+
pub intent_type: IntentType,
147173
pub validator_address: String,
148174
#[serde(default)]
149175
pub amount: Option<AmountWrapper>,
150176
#[serde(default)]
151-
pub staking_type: Option<String>,
177+
pub staking_type: Option<StakingType>,
152178
#[serde(default)]
153179
pub stake_pool_config: Option<StakePoolConfig>,
154180
#[serde(default)]
@@ -180,18 +206,23 @@ pub struct StakePoolConfig {
180206
#[derive(Debug, Clone, Deserialize)]
181207
#[serde(rename_all = "camelCase")]
182208
pub struct UnstakeIntent {
183-
pub intent_type: String,
184-
pub staking_address: String,
209+
pub intent_type: IntentType,
210+
/// Staking address - required for native/Jito, must NOT be set for Marinade
211+
#[serde(default)]
212+
pub staking_address: Option<String>,
185213
#[serde(default)]
186214
pub validator_address: Option<String>,
187215
#[serde(default)]
188216
pub amount: Option<AmountWrapper>,
189217
#[serde(default)]
190218
pub remaining_staking_amount: Option<AmountWrapper>,
191219
#[serde(default)]
192-
pub staking_type: Option<String>,
220+
pub staking_type: Option<StakingType>,
193221
#[serde(default)]
194222
pub stake_pool_config: Option<StakePoolConfig>,
223+
/// Recipients - used by Marinade unstake (transfer to contract address)
224+
#[serde(default)]
225+
pub recipients: Option<Vec<Recipient>>,
195226
#[serde(default)]
196227
pub memo: Option<String>,
197228
}

0 commit comments

Comments
 (0)