feat(amm)!: add transaction deadlines to swap and liquidity instructions

All mutable AMM instructions now require a `deadline: u64` field (Unix
timestamp in milliseconds). Enforcement uses the LEZ-native timestamp
validity window set on ProgramOutput; the runtime rejects the
transaction if the sequencer submission timestamp is at or past the
deadline.

BREAKING CHANGE: AddLiquidity, RemoveLiquidity, SwapExactInput,
SwapExactOutput, and NewDefinition instruction variants now require a
`deadline` field.

Closes #8
This commit is contained in:
r4bbit 2026-04-23 17:19:15 +02:00
parent 5a61cf39f2
commit 6b21c3695a
10 changed files with 280 additions and 21 deletions

View File

@ -35,6 +35,8 @@ pub enum Instruction {
token_b_amount: u128,
fees: u128,
amm_program_id: ProgramId,
/// Unix timestamp (milliseconds) after which this transaction is invalid.
deadline: u64,
},
/// Adds liquidity to the Pool
@ -51,6 +53,8 @@ pub enum Instruction {
min_amount_liquidity: u128,
max_amount_to_add_token_a: u128,
max_amount_to_add_token_b: u128,
/// Unix timestamp (milliseconds) after which this transaction is invalid.
deadline: u64,
},
/// Removes liquidity from the Pool
@ -67,6 +71,8 @@ pub enum Instruction {
remove_liquidity_amount: u128,
min_amount_to_remove_token_a: u128,
min_amount_to_remove_token_b: u128,
/// Unix timestamp (milliseconds) after which this transaction is invalid.
deadline: u64,
},
/// Swap some quantity of Tokens (either Token A or Token B)
@ -77,12 +83,13 @@ pub enum Instruction {
/// - Vault Holding Account for Token A (initialized)
/// - Vault Holding Account for Token B (initialized)
/// - User Holding Account for Token A
/// - User Holding Account for Token B Either User Holding Account for Token A or Token B is
/// authorized.
/// - User Holding Account for Token B; either is authorized.
SwapExactInput {
swap_amount_in: u128,
min_amount_out: u128,
token_definition_id_in: AccountId,
/// Unix timestamp (milliseconds) after which this transaction is invalid.
deadline: u64,
},
/// Swap tokens specifying the exact desired output amount,
@ -93,12 +100,13 @@ pub enum Instruction {
/// - Vault Holding Account for Token A (initialized)
/// - Vault Holding Account for Token B (initialized)
/// - User Holding Account for Token A
/// - User Holding Account for Token B Either User Holding Account for Token A or Token B is
/// authorized.
/// - User Holding Account for Token B; either is authorized.
SwapExactOutput {
exact_amount_out: u128,
max_amount_in: u128,
token_definition_id_in: AccountId,
/// Unix timestamp (milliseconds) after which this transaction is invalid.
deadline: u64,
},
/// Sync pool reserves with current vault balances.

View File

@ -2898,10 +2898,11 @@ dependencies = [
[[package]]
name = "spel-framework"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"borsh",
"nssa_core",
"serde_json",
"spel-framework-core",
"spel-framework-macros",
]
@ -2909,24 +2910,27 @@ dependencies = [
[[package]]
name = "spel-framework-core"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"borsh",
"nssa_core",
"serde",
"serde_json",
"sha2",
"syn 2.0.117",
"thiserror 1.0.69",
]
[[package]]
name = "spel-framework-macros"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"proc-macro2",
"quote",
"serde_json",
"sha2",
"spel-framework-core",
"syn 2.0.117",
]

View File

@ -10,7 +10,7 @@ name = "amm"
path = "src/bin/amm.rs"
[dependencies]
spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v0.2.0-rc.2", package = "spel-framework" }
spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3", package = "spel-framework" }
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc1" }
risc0-zkvm = { version = "=3.0.5", default-features = false }
amm_core = { path = "../../core" }

View File

@ -31,6 +31,7 @@ mod amm {
token_b_amount: u128,
fees: u128,
amm_program_id: ProgramId,
deadline: u64,
) -> SpelResult {
let (post_states, chained_calls) = amm_program::new_definition::new_definition(
pool,
@ -46,7 +47,8 @@ mod amm {
fees,
amm_program_id,
);
Ok(SpelOutput::with_chained_calls(post_states, chained_calls))
Ok(SpelOutput::with_chained_calls(post_states, chained_calls)
.with_timestamp_validity_window(..deadline))
}
/// Adds liquidity to the Pool.
@ -62,6 +64,7 @@ mod amm {
min_amount_liquidity: u128,
max_amount_to_add_token_a: u128,
max_amount_to_add_token_b: u128,
deadline: u64,
) -> SpelResult {
let (post_states, chained_calls) = amm_program::add::add_liquidity(
pool,
@ -75,7 +78,8 @@ mod amm {
max_amount_to_add_token_a,
max_amount_to_add_token_b,
);
Ok(SpelOutput::with_chained_calls(post_states, chained_calls))
Ok(SpelOutput::with_chained_calls(post_states, chained_calls)
.with_timestamp_validity_window(..deadline))
}
/// Removes liquidity from the Pool.
@ -91,6 +95,7 @@ mod amm {
remove_liquidity_amount: u128,
min_amount_to_remove_token_a: u128,
min_amount_to_remove_token_b: u128,
deadline: u64,
) -> SpelResult {
let (post_states, chained_calls) = amm_program::remove::remove_liquidity(
pool,
@ -105,7 +110,8 @@ mod amm {
min_amount_to_remove_token_a,
min_amount_to_remove_token_b,
);
Ok(SpelOutput::with_chained_calls(post_states, chained_calls))
Ok(SpelOutput::with_chained_calls(post_states, chained_calls)
.with_timestamp_validity_window(..deadline))
}
/// Swap some quantity of tokens while maintaining the pool constant product.
@ -119,6 +125,7 @@ mod amm {
swap_amount_in: u128,
min_amount_out: u128,
token_definition_id_in: AccountId,
deadline: u64,
) -> SpelResult {
let (post_states, chained_calls) = amm_program::swap::swap_exact_input(
pool,
@ -130,7 +137,8 @@ mod amm {
min_amount_out,
token_definition_id_in,
);
Ok(SpelOutput::with_chained_calls(post_states, chained_calls))
Ok(SpelOutput::with_chained_calls(post_states, chained_calls)
.with_timestamp_validity_window(..deadline))
}
/// Swap tokens specifying the exact desired output amount.
@ -144,6 +152,7 @@ mod amm {
exact_amount_out: u128,
max_amount_in: u128,
token_definition_id_in: AccountId,
deadline: u64,
) -> SpelResult {
let (post_states, chained_calls) = amm_program::swap::swap_exact_output(
pool,
@ -155,7 +164,8 @@ mod amm {
max_amount_in,
token_definition_id_in,
);
Ok(SpelOutput::with_chained_calls(post_states, chained_calls))
Ok(SpelOutput::with_chained_calls(post_states, chained_calls)
.with_timestamp_validity_window(..deadline))
}
/// Sync pool reserves with current vault balances.

View File

@ -70,6 +70,10 @@
{
"name": "amm_program_id",
"type": "program_id"
},
{
"name": "deadline",
"type": "u64"
}
]
},
@ -131,6 +135,10 @@
{
"name": "max_amount_to_add_token_b",
"type": "u128"
},
{
"name": "deadline",
"type": "u64"
}
]
},
@ -192,6 +200,10 @@
{
"name": "min_amount_to_remove_token_b",
"type": "u128"
},
{
"name": "deadline",
"type": "u64"
}
]
},
@ -241,6 +253,10 @@
{
"name": "token_definition_id_in",
"type": "account_id"
},
{
"name": "deadline",
"type": "u64"
}
]
},
@ -290,6 +306,10 @@
{
"name": "token_definition_id_in",
"type": "account_id"
},
{
"name": "deadline",
"type": "u64"
}
]
},

View File

@ -2897,10 +2897,11 @@ dependencies = [
[[package]]
name = "spel-framework"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"borsh",
"nssa_core",
"serde_json",
"spel-framework-core",
"spel-framework-macros",
]
@ -2908,24 +2909,27 @@ dependencies = [
[[package]]
name = "spel-framework-core"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"borsh",
"nssa_core",
"serde",
"serde_json",
"sha2",
"syn 2.0.117",
"thiserror 1.0.69",
]
[[package]]
name = "spel-framework-macros"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"proc-macro2",
"quote",
"serde_json",
"sha2",
"spel-framework-core",
"syn 2.0.117",
]

View File

@ -10,7 +10,7 @@ name = "ata"
path = "src/bin/ata.rs"
[dependencies]
spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v0.2.0-rc.2", package = "spel-framework" }
spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3", package = "spel-framework" }
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc1" }
risc0-zkvm = { version = "=3.0.5", default-features = false }
ata_core = { path = "../../core" }

View File

@ -966,6 +966,7 @@ fn try_execute_new_definition(
token_b_amount: Balances::vault_b_init(),
fees,
amm_program_id: Ids::amm_program(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1018,6 +1019,7 @@ fn execute_swap_a_to_b(state: &mut V03State, swap_amount_in: u128, min_amount_ou
swap_amount_in,
min_amount_out,
token_definition_id_in: Ids::token_a_definition(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1045,6 +1047,7 @@ fn execute_swap_b_to_a(state: &mut V03State, swap_amount_in: u128, min_amount_ou
swap_amount_in,
min_amount_out,
token_definition_id_in: Ids::token_b_definition(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1077,6 +1080,7 @@ fn execute_add_liquidity(
min_amount_liquidity,
max_amount_to_add_token_a,
max_amount_to_add_token_b,
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1115,6 +1119,7 @@ fn execute_remove_liquidity(
remove_liquidity_amount,
min_amount_to_remove_token_a,
min_amount_to_remove_token_b,
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1178,6 +1183,7 @@ fn amm_remove_liquidity() {
remove_liquidity_amount: Balances::remove_lp(),
min_amount_to_remove_token_a: Balances::remove_min_a(),
min_amount_to_remove_token_b: Balances::remove_min_b(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1240,6 +1246,7 @@ fn amm_remove_liquidity_insufficient_user_lp_fails() {
remove_liquidity_amount: Balances::remove_lp(),
min_amount_to_remove_token_a: Balances::remove_min_a(),
min_amount_to_remove_token_b: Balances::remove_min_b(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1464,6 +1471,7 @@ fn amm_add_liquidity() {
min_amount_liquidity: Balances::add_min_lp(),
max_amount_to_add_token_a: Balances::add_max_a(),
max_amount_to_add_token_b: Balances::add_max_b(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1526,6 +1534,7 @@ fn amm_swap_b_to_a() {
swap_amount_in: Balances::swap_amount_in(),
min_amount_out: Balances::swap_min_out(),
token_definition_id_in: Ids::token_b_definition(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1577,6 +1586,7 @@ fn amm_swap_a_to_b() {
swap_amount_in: Balances::swap_amount_in(),
min_amount_out: Balances::swap_min_out(),
token_definition_id_in: Ids::token_a_definition(),
deadline: u64::MAX,
};
let message = public_transaction::Message::try_new(
@ -1671,6 +1681,205 @@ fn amm_fee_accumulates_across_multiple_swaps_and_pays_out_on_remove() {
);
}
#[test]
fn amm_swap_rejects_expired_deadline() {
let mut state = state_for_amm_tests();
let deadline_ms = 1_000u64;
let block_timestamp_ms = 2_000u64;
let instruction = amm_core::Instruction::SwapExactInput {
swap_amount_in: Balances::swap_amount_in(),
min_amount_out: Balances::swap_min_out(),
token_definition_id_in: Ids::token_a_definition(),
deadline: deadline_ms,
};
let message = public_transaction::Message::try_new(
Ids::amm_program(),
vec![
Ids::pool_definition(),
Ids::vault_a(),
Ids::vault_b(),
Ids::user_a(),
Ids::user_b(),
],
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::user_a()]);
let tx = PublicTransaction::new(message, witness_set);
assert!(matches!(
state.transition_from_public_transaction(&tx, 0, block_timestamp_ms),
Err(NssaError::OutOfValidityWindow)
));
}
#[test]
fn amm_swap_exact_output_rejects_expired_deadline() {
let mut state = state_for_amm_tests();
let deadline_ms = 1_000u64;
let block_timestamp_ms = 2_000u64;
let instruction = amm_core::Instruction::SwapExactOutput {
exact_amount_out: Balances::swap_min_out(),
max_amount_in: Balances::swap_amount_in(),
token_definition_id_in: Ids::token_a_definition(),
deadline: deadline_ms,
};
let message = public_transaction::Message::try_new(
Ids::amm_program(),
vec![
Ids::pool_definition(),
Ids::vault_a(),
Ids::vault_b(),
Ids::user_a(),
Ids::user_b(),
],
vec![current_nonce(&state, Ids::user_a())],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::user_a()]);
let tx = PublicTransaction::new(message, witness_set);
assert!(matches!(
state.transition_from_public_transaction(&tx, 0, block_timestamp_ms),
Err(NssaError::OutOfValidityWindow)
));
}
#[test]
fn amm_add_liquidity_rejects_expired_deadline() {
let mut state = state_for_amm_tests();
let deadline_ms = 1_000u64;
let block_timestamp_ms = 2_000u64;
let instruction = amm_core::Instruction::AddLiquidity {
min_amount_liquidity: Balances::add_min_lp(),
max_amount_to_add_token_a: Balances::add_max_a(),
max_amount_to_add_token_b: Balances::add_max_b(),
deadline: deadline_ms,
};
let message = public_transaction::Message::try_new(
Ids::amm_program(),
vec![
Ids::pool_definition(),
Ids::vault_a(),
Ids::vault_b(),
Ids::token_lp_definition(),
Ids::user_a(),
Ids::user_b(),
Ids::user_lp(),
],
vec![
current_nonce(&state, Ids::user_a()),
current_nonce(&state, Ids::user_b()),
],
instruction,
)
.unwrap();
let witness_set =
public_transaction::WitnessSet::for_message(&message, &[&Keys::user_a(), &Keys::user_b()]);
let tx = PublicTransaction::new(message, witness_set);
assert!(matches!(
state.transition_from_public_transaction(&tx, 0, block_timestamp_ms),
Err(NssaError::OutOfValidityWindow)
));
}
#[test]
fn amm_remove_liquidity_rejects_expired_deadline() {
let mut state = state_for_amm_tests();
let deadline_ms = 1_000u64;
let block_timestamp_ms = 2_000u64;
let instruction = amm_core::Instruction::RemoveLiquidity {
remove_liquidity_amount: Balances::remove_lp(),
min_amount_to_remove_token_a: Balances::remove_min_a(),
min_amount_to_remove_token_b: Balances::remove_min_b(),
deadline: deadline_ms,
};
let message = public_transaction::Message::try_new(
Ids::amm_program(),
vec![
Ids::pool_definition(),
Ids::vault_a(),
Ids::vault_b(),
Ids::token_lp_definition(),
Ids::user_a(),
Ids::user_b(),
Ids::user_lp(),
],
vec![current_nonce(&state, Ids::user_lp())],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::user_lp()]);
let tx = PublicTransaction::new(message, witness_set);
assert!(matches!(
state.transition_from_public_transaction(&tx, 0, block_timestamp_ms),
Err(NssaError::OutOfValidityWindow)
));
}
#[test]
fn amm_new_definition_rejects_expired_deadline() {
let mut state = state_for_amm_tests_with_precreated_user_lp_for_new_def();
let deadline_ms = 1_000u64;
let block_timestamp_ms = 2_000u64;
let instruction = amm_core::Instruction::NewDefinition {
token_a_amount: Balances::vault_a_init(),
token_b_amount: Balances::vault_b_init(),
fees: amm_core::FEE_TIER_BPS_30,
amm_program_id: Ids::amm_program(),
deadline: deadline_ms,
};
let message = public_transaction::Message::try_new(
Ids::amm_program(),
vec![
Ids::pool_definition(),
Ids::vault_a(),
Ids::vault_b(),
Ids::token_lp_definition(),
Ids::lp_lock_holding(),
Ids::user_a(),
Ids::user_b(),
Ids::user_lp(),
],
vec![
current_nonce(&state, Ids::user_a()),
current_nonce(&state, Ids::user_b()),
current_nonce(&state, Ids::user_lp()),
],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::user_a(), &Keys::user_b(), &Keys::user_lp()],
);
let tx = PublicTransaction::new(message, witness_set);
assert!(matches!(
state.transition_from_public_transaction(&tx, 0, block_timestamp_ms),
Err(NssaError::OutOfValidityWindow)
));
}
#[test]
fn amm_add_liquidity_after_fee_accrual() {
let mut state = state_for_amm_tests();

View File

@ -2864,10 +2864,11 @@ dependencies = [
[[package]]
name = "spel-framework"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"borsh",
"nssa_core",
"serde_json",
"spel-framework-core",
"spel-framework-macros",
]
@ -2875,24 +2876,27 @@ dependencies = [
[[package]]
name = "spel-framework-core"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"borsh",
"nssa_core",
"serde",
"serde_json",
"sha2",
"syn 2.0.117",
"thiserror 1.0.69",
]
[[package]]
name = "spel-framework-macros"
version = "0.2.0"
source = "git+https://github.com/logos-co/spel.git?tag=v0.2.0-rc.2#9005e9fbbd78b0530412f9987273f753ed32eb2d"
source = "git+https://github.com/logos-co/spel.git?rev=9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3#9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3"
dependencies = [
"proc-macro2",
"quote",
"serde_json",
"sha2",
"spel-framework-core",
"syn 2.0.117",
]

View File

@ -10,7 +10,7 @@ name = "token"
path = "src/bin/token.rs"
[dependencies]
spel-framework = { git = "https://github.com/logos-co/spel.git", tag = "v0.2.0-rc.2", package = "spel-framework" }
spel-framework = { git = "https://github.com/logos-co/spel.git", rev = "9e7f2754e1d4cdb3ea36e63b1ff86c3af55488d3", package = "spel-framework" }
nssa_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0-rc1" }
risc0-zkvm = { version = "=3.0.5", default-features = false }
token_core = { path = "../../core" }