Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 158 additions & 7 deletions packages/wasm-solana/src/intent/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,13 @@ fn build_stake(
let amount: u64 = intent.amount.as_ref().map(|a| a.value).unwrap_or(0);

// Check if Jito staking
if intent.staking_type.as_deref() == Some("JITO") {
if intent.staking_type == Some(StakingType::Jito) {
if let Some(config) = &intent.stake_pool_config {
return build_jito_stake(config, &fee_payer, amount);
}
}

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

// Marinade staking: CreateAccount + Initialize (no Delegate)
// Staker authority is the validator, withdrawer is the user
if intent.staking_type == Some(StakingType::Marinade) {
let instructions = vec![
system_ix::create_account(
&fee_payer,
&stake_pubkey,
amount + STAKE_ACCOUNT_RENT,
STAKE_ACCOUNT_SPACE,
&solana_stake_interface::program::ID,
),
stake_ix::initialize(
&stake_pubkey,
&Authorized {
staker: validator_pubkey,
withdrawer: fee_payer,
},
&Lockup::default(),
),
];

let generated = vec![GeneratedKeypair {
purpose: "stakeAccount".to_string(),
address: stake_address,
secret_key: solana_sdk::bs58::encode(stake_keypair.secret_key_bytes()).into_string(),
}];

return Ok((instructions, generated));
}

// Native staking: CreateAccount + Initialize + Delegate
let instructions = vec![
// Create account
system_ix::create_account(
&fee_payer,
&stake_pubkey,
amount + STAKE_ACCOUNT_RENT,
STAKE_ACCOUNT_SPACE,
&solana_stake_interface::program::ID,
),
// Initialize stake
stake_ix::initialize(
&stake_pubkey,
&Authorized {
Expand All @@ -237,7 +266,6 @@ fn build_stake(
},
&Lockup::default(),
),
// Delegate
stake_ix::delegate_stake(&stake_pubkey, &fee_payer, &validator_pubkey),
];

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

let stake_pubkey: Pubkey = intent
// Marinade unstake: SystemProgram.transfer to recipient (no stake account involved)
if intent.staking_type == Some(StakingType::Marinade) {
return build_marinade_unstake(&intent, &fee_payer);
}

// For native/Jito, staking_address is required
let staking_address = intent
.staking_address
.as_ref()
.ok_or_else(|| WasmSolanaError::new("Missing stakingAddress for native/Jito unstake"))?;
let stake_pubkey: Pubkey = staking_address
.parse()
.map_err(|_| WasmSolanaError::new("Invalid stakingAddress"))?;

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

fn build_marinade_unstake(
intent: &UnstakeIntent,
fee_payer: &Pubkey,
) -> Result<(Vec<Instruction>, Vec<GeneratedKeypair>), WasmSolanaError> {
let recipients = intent
.recipients
.as_ref()
.ok_or_else(|| WasmSolanaError::new("Missing recipients for Marinade unstake"))?;

if recipients.is_empty() {
return Err(WasmSolanaError::new(
"Recipients array is empty for Marinade unstake",
));
}

let recipient = &recipients[0];
let to_address = recipient
.address
.as_ref()
.map(|a| &a.address)
.ok_or_else(|| WasmSolanaError::new("Recipient missing address for Marinade unstake"))?;
let amount = recipient
.amount
.as_ref()
.map(|a| a.value)
.ok_or_else(|| WasmSolanaError::new("Recipient missing amount for Marinade unstake"))?;

let to_pubkey: Pubkey = to_address
.parse()
.map_err(|_| WasmSolanaError::new(&format!("Invalid recipient address: {}", to_address)))?;

let instructions = vec![system_ix::transfer(fee_payer, &to_pubkey, amount)];

Ok((instructions, vec![]))
}

fn build_jito_unstake(
config: &StakePoolConfig,
fee_payer: &Pubkey,
Expand Down Expand Up @@ -1020,4 +1093,82 @@ mod tests {
let result = result.unwrap();
assert!(result.generated_keypairs.is_empty());
}

#[test]
fn test_build_marinade_stake_intent() {
// Marinade stake: CreateAccount + Initialize (no Delegate)
// Staker = validator, Withdrawer = fee_payer
let intent = serde_json::json!({
"intentType": "stake",
"validatorAddress": "CyjoLt3kjqB57K7ewCBHmnHq3UgEj3ak6A7m6EsBsuhA",
"amount": { "value": "300000" },
"stakingType": "MARINADE"
});

let result = build_from_intent(&intent, &test_params());
assert!(result.is_ok(), "Failed: {:?}", result);
let result = result.unwrap();

// Should generate a stake account keypair
assert_eq!(result.generated_keypairs.len(), 1);
assert_eq!(result.generated_keypairs[0].purpose, "stakeAccount");

// Transaction should have 2 instructions (CreateAccount + Initialize)
// No Delegate instruction for Marinade
let msg = result.transaction.message();
assert_eq!(
msg.instructions.len(),
2,
"Marinade stake should have exactly 2 instructions (CreateAccount + Initialize)"
);
}

#[test]
fn test_build_marinade_unstake_intent() {
// Marinade unstake: SystemProgram.transfer to recipient
let intent = serde_json::json!({
"intentType": "unstake",
"stakingType": "MARINADE",
"amount": { "value": "500000000000" },
"recipients": [{
"address": { "address": "opNS8ENpEMWdXcJUgJCsJTDp7arTXayoBEeBUg6UezP" },
"amount": { "value": "500000000000" }
}],
"memo": "{\"PrepareForRevoke\":{\"user\":\"DgT9qyYwYKBRDyDw3EfR12LHQCQjtNrKu2qMsXHuosmB\",\"amount\":\"500000000000\"}}"
});

let result = build_from_intent(&intent, &test_params());
assert!(result.is_ok(), "Failed: {:?}", result);
let result = result.unwrap();

// No generated keypairs for Marinade unstake
assert!(result.generated_keypairs.is_empty());

// Transaction should have 1 transfer + 1 memo = 2 instructions
let msg = result.transaction.message();
assert_eq!(
msg.instructions.len(),
2,
"Marinade unstake should have transfer + memo instructions"
);
}

#[test]
fn test_build_marinade_unstake_requires_recipients() {
let intent = serde_json::json!({
"intentType": "unstake",
"stakingType": "MARINADE",
"amount": { "value": "500000000000" }
});

let result = build_from_intent(&intent, &test_params());
assert!(result.is_err(), "Should fail without recipients");
assert!(
result
.unwrap_err()
.to_string()
.contains("Missing recipients"),
"Error should mention missing recipients"
);
}
}
41 changes: 36 additions & 5 deletions packages/wasm-solana/src/intent/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@

use serde::{Deserialize, Serialize};

/// Intent type discriminant.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum IntentType {
Payment,
GoUnstake,
Stake,
Unstake,
Claim,
Deactivate,
Delegate,
EnableToken,
CloseAssociatedTokenAccount,
Consolidate,
Authorize,
CustomTx,
}

/// Staking type for stake/unstake intents.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum StakingType {
Jito,
Marinade,
}

/// Build parameters provided by wallet-platform.
/// These are NOT part of the intent but needed to build the transaction.
#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -143,12 +169,12 @@ pub struct PaymentIntent {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StakeIntent {
pub intent_type: String,
pub intent_type: IntentType,
pub validator_address: String,
#[serde(default)]
pub amount: Option<AmountWrapper>,
#[serde(default)]
pub staking_type: Option<String>,
pub staking_type: Option<StakingType>,
#[serde(default)]
pub stake_pool_config: Option<StakePoolConfig>,
#[serde(default)]
Expand Down Expand Up @@ -180,18 +206,23 @@ pub struct StakePoolConfig {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnstakeIntent {
pub intent_type: String,
pub staking_address: String,
pub intent_type: IntentType,
/// Staking address - required for native/Jito, must NOT be set for Marinade
#[serde(default)]
pub staking_address: Option<String>,
#[serde(default)]
pub validator_address: Option<String>,
#[serde(default)]
pub amount: Option<AmountWrapper>,
#[serde(default)]
pub remaining_staking_amount: Option<AmountWrapper>,
#[serde(default)]
pub staking_type: Option<String>,
pub staking_type: Option<StakingType>,
#[serde(default)]
pub stake_pool_config: Option<StakePoolConfig>,
/// Recipients - used by Marinade unstake (transfer to contract address)
#[serde(default)]
pub recipients: Option<Vec<Recipient>>,
#[serde(default)]
pub memo: Option<String>,
}
Expand Down