Skip to content
Open
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
76 changes: 67 additions & 9 deletions contracts/teachlink/src/bft_consensus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,20 @@ impl BFTConsensus {
///
/// A proposal is considered expired if *either* signal is exceeded.
///
/// # TODO
/// - Add a proposer field so off-chain indexers can attribute proposals
/// to specific relayers for analytics and accountability.
pub fn create_proposal(env: &Env, message: CrossChainMessage) -> Result<u64, BridgeError> {
/// # Proposer Attribution
///
/// `proposer` is the relayer/caller submitting the proposal. It is
/// authenticated via `require_auth()`, stored on the proposal, and
/// included in the `ProposalCreatedEvent` so off-chain indexers can
/// attribute proposals to specific relayers without an extra storage
/// read (#495).
pub fn create_proposal(
env: &Env,
proposer: Address,
message: CrossChainMessage,
) -> Result<u64, BridgeError> {
proposer.require_auth();

// Get proposal counter
let mut proposal_counter: u64 = env
.storage()
Expand Down Expand Up @@ -344,6 +354,7 @@ impl BFTConsensus {
let proposal = BridgeProposal {
proposal_id: proposal_counter,
message: message.clone(),
proposer: proposer.clone(),
votes: Map::new(env),
vote_count: 0,
required_votes,
Expand Down Expand Up @@ -384,6 +395,7 @@ impl BFTConsensus {
proposal_id: proposal_counter,
message,
required_votes,
proposer,
}
.publish(env);

Expand Down Expand Up @@ -834,8 +846,8 @@ mod tests {
use crate::types::CrossChainMessage;
use crate::TeachLinkBridge;
use crate::TeachLinkBridgeClient;
use soroban_sdk::testutils::{Address as _, Ledger};
use soroban_sdk::{Bytes, Env, Map};
use soroban_sdk::testutils::{Address as _, Events, Ledger};
use soroban_sdk::{vec, Bytes, Env, Event as _, Map, Val};

fn set_ledger(env: &Env, timestamp: u64, sequence: u32) {
env.ledger().with_mut(|li| {
Expand Down Expand Up @@ -869,7 +881,10 @@ mod tests {
recipient: soroban_sdk::Address::generate(&env),
destination_chain: 2,
};
let proposal_id = client.try_create_bridge_proposal(&msg).unwrap().unwrap();
let proposal_id = client
.try_create_bridge_proposal(&validator, &msg)
.unwrap()
.unwrap();

// Ensure the sequence-based expiry is stored.
let deadline = env.as_contract(&contract_id, || {
Expand Down Expand Up @@ -951,7 +966,7 @@ mod tests {
recipient: soroban_sdk::Address::generate(&env),
destination_chain: 2,
};
let proposal_id = client.create_bridge_proposal(&msg);
let proposal_id = client.create_bridge_proposal(&validator, &msg);
client.vote_on_proposal(&validator, &proposal_id, &true);

let after_rep = env.as_contract(&contract_id, || {
Expand Down Expand Up @@ -1003,7 +1018,7 @@ mod tests {
recipient: soroban_sdk::Address::generate(&env),
destination_chain: 2,
};
let proposal_id = client.create_bridge_proposal(&msg);
let proposal_id = client.create_bridge_proposal(&whale, &msg);

// All three Sybil validators approve. Their combined stake
// (3 * MIN_VALIDATOR_STAKE) is far below the 2/3 stake threshold, so
Expand All @@ -1024,4 +1039,47 @@ mod tests {
assert_eq!(approved.vote_count, total_stake);
assert_eq!(approved.status, crate::types::ProposalStatus::Approved);
}

#[test]
fn create_proposal_records_and_emits_proposer() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(TeachLinkBridge, ());
set_ledger(&env, 1_000, 1);

let relayer = soroban_sdk::Address::generate(&env);
let client = TeachLinkBridgeClient::new(&env, &contract_id);
client.register_validator(&relayer, &MIN_VALIDATOR_STAKE);

let msg = CrossChainMessage {
source_chain: 1,
source_tx_hash: Bytes::from_slice(&env, &[0x33; 32]),
nonce: 1,
token: soroban_sdk::Address::generate(&env),
amount: 1,
recipient: soroban_sdk::Address::generate(&env),
destination_chain: 2,
};
let proposal_id = client.create_bridge_proposal(&relayer, &msg);

// Capture events from the create_bridge_proposal invocation before any
// further contract calls reset the "last invocation" event log.
let emitted_events = env.events().all();

// The stored proposal attributes the relayer as proposer.
let proposal = client.get_proposal(&proposal_id).unwrap();
assert_eq!(proposal.proposer, relayer);

// The emitted ProposalCreatedEvent carries the same proposer (#495).
let expected_event = crate::events::ProposalCreatedEvent {
proposal_id,
message: msg,
required_votes: proposal.required_votes,
proposer: relayer.clone(),
};
let expected_topics: soroban_sdk::Vec<Val> = expected_event.topics(&env);
let expected_data: Val = expected_event.data(&env);
let expected = vec![&env, (contract_id.clone(), expected_topics, expected_data)];
assert_eq!(emitted_events, expected);
}
}
2 changes: 2 additions & 0 deletions contracts/teachlink/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ pub struct ProposalCreatedEvent {
pub message: CrossChainMessage,
/// Stake-weighted approving stake required for consensus (#496).
pub required_votes: i128,
/// The relayer/validator that submitted this proposal (#495).
pub proposer: Address,
}

#[contractevent]
Expand Down
3 changes: 2 additions & 1 deletion contracts/teachlink/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,10 @@ impl TeachLinkBridge {
/// Create a bridge proposal for BFT consensus
pub fn create_bridge_proposal(
env: Env,
proposer: Address,
message: CrossChainMessage,
) -> Result<u64, BridgeError> {
bft_consensus::BFTConsensus::create_proposal(&env, message)
bft_consensus::BFTConsensus::create_proposal(&env, proposer, message)
}

/// Vote on a bridge proposal
Expand Down
5 changes: 5 additions & 0 deletions contracts/teachlink/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ pub struct ValidatorInfo {
pub struct BridgeProposal {
pub proposal_id: u64,
pub message: CrossChainMessage,
/// The relayer/validator that submitted this proposal, authenticated via
/// `require_auth()` at creation time. Enables off-chain indexers to
/// attribute proposals to specific relayers for analytics and
/// accountability (#495).
pub proposer: Address,
pub votes: Map<Address, bool>,
/// Stake-weighted tally of approving votes: the sum of the stake of every
/// validator that has approved, not a raw vote count (#496).
Expand Down
6 changes: 3 additions & 3 deletions contracts/teachlink/tests/test_module_interactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ fn test_proposal_vote_then_bridge_completion() {

// Create a bridge proposal via BFT
let message = make_cross_chain_message(&env, &token, &recipient, 1, 500);
let proposal_id = client.create_bridge_proposal(&message);
let proposal_id = client.create_bridge_proposal(&validator, &message);

// Vote to approve
client.vote_on_proposal(&validator, &proposal_id, &true);
Expand Down Expand Up @@ -145,7 +145,7 @@ fn test_proposal_rejected_blocks_consensus() {
client.register_validator(&v3, &100_000_000);

let message = make_cross_chain_message(&env, &token, &recipient, 1, 1000);
let proposal_id = client.create_bridge_proposal(&message);
let proposal_id = client.create_bridge_proposal(&v1, &message);

// Two validators reject
client.vote_on_proposal(&v1, &proposal_id, &false);
Expand Down Expand Up @@ -212,7 +212,7 @@ fn test_reward_validator_after_consensus_participation() {

// Participate in consensus
let message = make_cross_chain_message(&env, &token, &recipient, 1, 100);
let proposal_id = client.create_bridge_proposal(&message);
let proposal_id = client.create_bridge_proposal(&validator, &message);
client.vote_on_proposal(&validator, &proposal_id, &true);
assert!(client.is_consensus_reached(&proposal_id));

Expand Down
Loading