use std::collections::BTreeMap;
use miden_protocol::batch::ProposedBatch;
use miden_standards::account::auth::NetworkAccount;
use miden_standards::account::fees::{BasicConstantFeePolicy, FeePolicyManager};
use super::*;
#[tokio::test]
async fn disabled_allowlist_registers_accounts_without_changing_invitations() {
let (source_rpc, _, store, _server) = start_rpc_with_allowlist(true).await;
let allowlist = AccountAllowlist::load(
DataDirectory::load(store.data_directory.clone())
.unwrap()
.allowlist_database_path(),
)
.unwrap();
let account = |byte| {
AccountId::dummy(
[byte; 15],
AccountIdVersion::Version1,
AccountType::Private,
AssetCallbackFlag::Disabled,
)
};
for (code, account_id) in [("unused", None), ("used", Some(account(255)))] {
allowlist
.import_invitation(InvitationEntry {
invitation_code: InvitationCode::new(code).unwrap(),
account_id,
})
.await
.unwrap();
}
let rpc = RpcService::new(
Arc::clone(&store.state),
RpcBackend::full_node(source_rpc, None),
None,
NonZeroUsize::new(1).unwrap(),
None,
);
let request = |code: &str, account_id| {
let mut request = Request::new(proto::rpc::RegisterAccountRequest {
invitation_code: code.to_owned(),
account_id,
});
request.metadata_mut().insert(
ACCEPT.as_str(),
format!("application/vnd.miden; genesis={}", store.genesis_commitment())
.parse()
.unwrap(),
);
request
};
for account_id in [None, Some(proto::account::AccountId::default())] {
assert_eq!(
rpc.register_account(request("", account_id)).await.unwrap_err().code(),
tonic::Code::InvalidArgument
);
}
for (index, code) in ["", " unknown-\u{e9}\n", "unused", "used"].into_iter().enumerate() {
let invitation = InvitationCode::new(code).ok();
let before = if let Some(invitation) = &invitation {
allowlist.invitation_info(invitation.clone()).await.unwrap()
} else {
None
};
for offset in [0, 4] {
let account_id = account(u8::try_from(index + offset).unwrap());
assert!(!allowlist.contains_account(account_id).await.unwrap());
rpc.register_account(request(code, Some(account_id.into()))).await.unwrap();
assert!(allowlist.contains_account(account_id).await.unwrap());
let registered_at = allowlist.allowlisted_at(account_id).await.unwrap();
assert!(registered_at.is_some());
rpc.register_account(request("", Some(account_id.into()))).await.unwrap();
assert_eq!(allowlist.allowlisted_at(account_id).await.unwrap(), registered_at);
}
if let Some(invitation) = invitation {
assert_eq!(allowlist.invitation_info(invitation).await.unwrap(), before);
}
}
rpc.register_account(request("unused", Some(account(255).into())))
.await
.unwrap();
assert_eq!(
allowlist
.invitation_info(InvitationCode::new("unused").unwrap())
.await
.unwrap()
.unwrap()
.account_id,
None
);
assert_eq!(
allowlist
.invitation_info(InvitationCode::new("used").unwrap())
.await
.unwrap()
.unwrap()
.account_id,
Some(account(255))
);
}
#[rstest::rstest]
#[case::enabled(false)]
#[case::disabled(true)]
#[tokio::test]
async fn registration_requests_funding_once_after_commit(#[case] disabled: bool) {
use axum::{Json, Router};
use serde_json::{Value, json};
let store = TestStore::start().await;
let allowlist = store.bootstrap_allowlist();
let account_admission = if disabled {
AccountAdmission::disabled(Arc::clone(&allowlist))
} else {
AccountAdmission::enabled(Arc::clone(&allowlist))
};
let accounts = [0, 1].map(|byte| {
AccountId::dummy(
[byte; 15],
AccountIdVersion::Version1,
AccountType::Private,
AssetCallbackFlag::Disabled,
)
});
let (requests, mut received) = tokio::sync::mpsc::unbounded_channel();
let funding_allowlist = allowlist.reader();
let funding_api = Router::new().route(
"/request-funds",
axum::routing::post(move |Json(body): Json<Value>| {
let status = if body["account_id"] == accounts[1].to_hex() {
http::StatusCode::SERVICE_UNAVAILABLE
} else {
http::StatusCode::OK
};
let allowlist = funding_allowlist.clone();
let requests = requests.clone();
async move {
let account_id = AccountId::from_hex(body["account_id"].as_str().unwrap()).unwrap();
assert!(allowlist.contains_account(account_id).await.unwrap());
requests.send(body).unwrap();
status
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = Url::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap();
let guard = TestServerGuard(CancellationToken::new());
let shutdown = guard.0.clone();
let server = tokio::spawn(async move {
axum::serve(listener, funding_api)
.with_graceful_shutdown(shutdown.cancelled_owned())
.await
.unwrap();
});
let funding = crate::FundingClient::new(url, std::num::NonZeroU64::new(42).unwrap()).unwrap();
let rpc = RpcService::new(
Arc::clone(&store.state),
RpcBackend::sequencer(
BlockProducerApi::new(
Arc::clone(&store.state),
0.into(),
BlockProducerApiConfig::default(),
guard.0.clone(),
),
ValidatorClients::new(vec![dummy_client::<ValidatorClient>()]).unwrap(),
account_admission.with_funding_client(Some(funding)),
),
None,
NonZeroUsize::new(1).unwrap(),
None,
);
for (index, account) in accounts.into_iter().enumerate() {
let code = if disabled && index == 1 {
String::new()
} else {
format!("funding-{index}")
};
let request = proto::rpc::RegisterAccountRequest {
invitation_code: code.clone(),
account_id: Some(account.into()),
};
if !disabled {
assert_eq!(
rpc.register_account(Request::new(request.clone())).await.unwrap_err().code(),
tonic::Code::NotFound
);
allowlist
.import_invitation(InvitationEntry {
invitation_code: InvitationCode::new(&code).unwrap(),
account_id: None,
})
.await
.unwrap();
}
let response = rpc.register_account(Request::new(request.clone())).await;
if index == 0 {
response.unwrap();
} else {
let error = response.unwrap_err();
assert_eq!(error.code(), tonic::Code::Unavailable);
assert!(error.message().contains("503 Service Unavailable"));
}
assert!(allowlist.contains_account(account).await.unwrap());
rpc.register_account(Request::new(request.clone())).await.unwrap();
if disabled {
rpc.register_account(Request::new(proto::rpc::RegisterAccountRequest {
invitation_code: "another code".to_owned(),
..request
}))
.await
.unwrap();
}
assert_eq!(
received.try_recv().unwrap(),
json!({"account_id": account.to_hex(), "amount": 42})
);
assert!(matches!(
received.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
}
guard.0.cancel();
server.await.unwrap();
}
impl TestStore {
async fn with_account_creation_batch() -> (Self, ProposedBatch) {
let mut builder = MockChainBuilder::new()
.fee_faucet_id(FungibleAsset::mock_issuer())
.verification_base_fee(1);
let accounts = [
builder
.create_new_wallet(Auth::BasicAuth {
auth_scheme: AuthScheme::Falcon512Poseidon2,
})
.unwrap(),
builder
.create_new_wallet(Auth::BasicAuth {
auth_scheme: AuthScheme::Falcon512Poseidon2,
})
.unwrap(),
];
let notes = accounts.each_ref().map(|account| {
builder
.add_p2id_note(
account.id(),
account.id(),
&[FungibleAsset::mock(1_000_000)],
NoteType::Private,
)
.unwrap()
});
let chain = builder.build().unwrap();
let store =
Self::start_from_mock_genesis(&chain.latest_block(), chain.protocol_config()).await;
let mut transactions = Vec::new();
for (account, note) in accounts.into_iter().zip(notes) {
let (auth_args, advice) = commit_fee_conversion_info(
FeeConversionInfo::one_to_one(FungibleAsset::mock_issuer()),
Word::from([9u32, 10, 11, 12]),
);
let context = chain
.build_transaction(account)
.authenticated_input_note(note.id())
.auth_args(auth_args)
.add_advice_map_entry(auth_args, advice)
.build()
.unwrap();
let executed = Box::pin(context.execute()).await.unwrap();
let inputs = executed.tx_inputs().clone();
let proven = spawn_blocking_in_current_span(move || {
LocalTransactionProver::default().prove(inputs)
})
.await
.unwrap()
.unwrap();
transactions.push(Arc::new(proven));
}
let batch = ProposedBatch::new(
transactions,
chain.latest_block_header(),
chain.latest_partial_blockchain(),
BTreeMap::new(),
miden_protocol::MIN_PROOF_SECURITY_LEVEL,
)
.unwrap();
(store, batch)
}
fn account_transaction(&self, account: &Account, is_new: bool) -> ProvenTransaction {
let patch = AccountPatch::try_from(account.clone()).unwrap();
let details = if account.is_public() {
AccountUpdateDetails::Public(patch.clone())
} else {
AccountUpdateDetails::Private
};
let update = TxAccountUpdate::new(
account.id(),
if is_new {
Word::empty()
} else {
Word::from([1u32, 2, 3, 4])
},
account.to_commitment(),
patch.to_commitment(),
details,
)
.unwrap();
ProvenTransaction::new(
update,
Vec::<miden_protocol::transaction::InputNoteCommitment>::new(),
Vec::<OutputNote>::new(),
0.into(),
self.genesis_commitment(),
u32::MAX.into(),
miden_protocol::testing::dummy_execution_proof(),
)
.unwrap()
}
}
#[tokio::test]
async fn is_account_allowed_respects_enforcement() {
let store = TestStore::start().await;
let allowlist = store.bootstrap_allowlist();
let guard = TestServerGuard(CancellationToken::new());
let block_producer = BlockProducerApi::new(
Arc::clone(&store.state),
0.into(),
BlockProducerApiConfig::default(),
guard.0.clone(),
);
let [listed, unlisted] = [[0; 15], [1; 15]].map(|bytes| {
AccountId::dummy(
bytes,
AccountIdVersion::Version1,
AccountType::Private,
AssetCallbackFlag::Disabled,
)
});
allowlist.add_account(listed).await.unwrap();
let path = DataDirectory::load(store.data_directory.clone())
.unwrap()
.allowlist_database_path();
let disabled_allowlist = Arc::new(AccountAllowlist::load(&path).unwrap());
for (admission, unlisted_allowed) in [
(AccountAdmission::enabled(allowlist), false),
(AccountAdmission::disabled(disabled_allowlist), true),
] {
if unlisted_allowed {
fs_err::remove_file(&path).unwrap();
}
let rpc = RpcService::new(
Arc::clone(&store.state),
RpcBackend::sequencer(
block_producer.clone(),
ValidatorClients::new(vec![dummy_client::<ValidatorClient>()]).unwrap(),
admission,
),
None,
NonZeroUsize::new(1).unwrap(),
None,
);
for account_id in [
None,
Some(proto::account::AccountId::default()),
Some(proto::account::AccountId {
version: Some(proto::account::account_id::Version::V1(
proto::account::AccountIdV1 {
suffix: Some(proto::primitives::Felt { value: 0 }),
prefix: Some(proto::primitives::Felt { value: 0 }),
},
)),
}),
] {
let invalid = proto::rpc::IsAccountAllowedRequest { account_id };
assert_eq!(
rpc.is_account_allowed(Request::new(invalid)).await.unwrap_err().code(),
tonic::Code::InvalidArgument
);
}
for (account, expected) in [(listed, true), (unlisted, unlisted_allowed)] {
let query = proto::rpc::IsAccountAllowedRequest { account_id: Some(account.into()) };
let response = rpc.is_account_allowed(Request::new(query)).await.unwrap();
assert_eq!(response.into_inner().allowed, expected);
}
}
}
#[tokio::test]
async fn account_admission_only_restricts_new_non_network_accounts() {
let store = TestStore::start().await;
let allowlist = store.bootstrap_allowlist();
let admission = AccountAdmission::enabled(Arc::clone(&allowlist));
let disabled = AccountAdmission::disabled(Arc::clone(&allowlist));
for account_type in [AccountType::Public, AccountType::Private] {
let account = AccountBuilder::new([1; 32])
.account_type(account_type)
.with_component(BasicWallet)
.with_component(NoopAuthComponent)
.build_existing()
.unwrap();
let creation = store.account_transaction(&account, true);
let existing = store.account_transaction(&account, false);
let error = admission.check(creation.account_update()).await.unwrap_err();
assert_eq!(error.code(), tonic::Code::PermissionDenied);
admission.check(existing.account_update()).await.unwrap();
disabled.check(creation.account_update()).await.unwrap();
assert!(!allowlist.contains_account(creation.account_id()).await.unwrap());
allowlist.add_account(creation.account_id()).await.unwrap();
admission.check(creation.account_update()).await.unwrap();
}
let network_account = NetworkAccount::builder(
[2; 32],
[miden_protocol::note::NoteScriptRoot::from_array([1, 2, 3, 4])].into(),
FeePolicyManager::builder()
.fee_faucet_id(FungibleAsset::mock_issuer())
.active_fee_policy(BasicConstantFeePolicy::new().into())
.build(),
)
.unwrap()
.with_component(BasicWallet)
.build_existing()
.unwrap();
let creation = store.account_transaction(&network_account, true);
admission.check(creation.account_update()).await.unwrap();
assert!(!allowlist.contains_account(creation.account_id()).await.unwrap());
}
#[tokio::test(flavor = "multi_thread")]
async fn submission_endpoints_reject_unregistered_creation_without_partial_batch_admission() {
let (store, batch) = TestStore::with_account_creation_batch().await;
let transactions = batch.transactions();
let allowlist = store.bootstrap_allowlist();
let admission = AccountAdmission::enabled(Arc::clone(&allowlist));
let guard = TestServerGuard(CancellationToken::new());
let block_producer = BlockProducerApi::new(
Arc::clone(&store.state),
0.into(),
BlockProducerApiConfig::default(),
guard.0.clone(),
);
let public = RpcService::new(
Arc::clone(&store.state),
RpcBackend::sequencer(
block_producer.clone(),
ValidatorClients::new(vec![dummy_client::<ValidatorClient>()]).unwrap(),
admission.clone(),
),
None,
NonZeroUsize::new(10).unwrap(),
None,
);
let internal = SequencerInternalService {
state: Arc::clone(&store.state),
block_producer,
account_admission: admission,
};
allowlist.add_account(transactions[0].account_id()).await.unwrap();
let proven_batch = spawn_blocking_in_current_span({
let batch = batch.clone();
move || {
let executed = BatchExecutor::new().execute(batch)?;
LocalBatchProver::default().prove(executed)
}
})
.await
.unwrap()
.unwrap();
let tx = proto::sequencer::AuthenticatedTransaction {
transaction: Some(transactions[1].as_ref().into()),
..Default::default()
};
let mut auth_inputs = Vec::new();
for tx in transactions {
auth_inputs.push(get_tx_inputs(&store.state, tx).await.unwrap().into());
}
let authenticated_batch = proto::sequencer::AuthenticatedTransactionBatch {
proposed_batch: Some((&batch).into()),
batch_proof: Some((&proven_batch).into()),
auth_inputs,
};
for result in [
public
.submit_proven_tx(Request::new(proto::submission::ProvenTransactionSubmission {
transaction: Some(transactions[1].as_ref().into()),
sealed_transaction_inputs: Some(test_sealed_transaction_inputs()),
}))
.await,
public
.submit_proven_tx_batch(Request::new(proto::submission::TransactionBatch {
batch: Some((&proven_batch).into()),
proposed_batch: Some((&batch).into()),
sealed_transaction_inputs: vec![test_sealed_transaction_inputs(); 2],
}))
.await,
internal.submit_authenticated_tx(Request::new(tx)).await,
internal
.submit_authenticated_tx_batch(Request::new(authenticated_batch.clone()))
.await,
] {
let status = result.unwrap_err();
assert_eq!(status.code(), tonic::Code::PermissionDenied, "{status}");
assert!(status.message().contains(&transactions[1].account_id().to_string()));
}
allowlist.add_account(transactions[1].account_id()).await.unwrap();
internal
.submit_authenticated_tx_batch(Request::new(authenticated_batch))
.await
.unwrap();
}