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
18 changes: 18 additions & 0 deletions contracts/inheritance-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ impl InheritanceContract {
/// using the stored basis points, and transfers tokens safely.
/// Remaining dust from integer division is allocated to the last beneficiary.
/// Aborts the entire transaction if any single transfer fails.
/// Emits a `payout` event per beneficiary and, when `fiat_anchor_info` is
/// non-empty, an additional `anchor_payout` event for the backend to pick up
/// and execute a Stellar Anchor SEP off-ramp flow.
pub fn trigger_payout(env: Env, owner: Address) -> Result<(), Error> {
let key = DataKey::Plan(owner.clone());
let plan: Plan = env
Expand Down Expand Up @@ -237,11 +240,26 @@ impl InheritanceContract {
remaining -= amount;
amount
};

token_client.transfer(
&env.current_contract_address(),
&beneficiary.address,
&share,
);

// Emit general payout event
env.events().publish(
(soroban_sdk::symbol_short!("payout"), owner.clone(), beneficiary.address.clone()),
share,
);

// Emit fiat anchor off-ramp event when beneficiary has routing info
if beneficiary.fiat_anchor_info.len() > 0 {
env.events().publish(
(soroban_sdk::symbol_short!("anchor_pay"), owner.clone(), beneficiary.address.clone()),
(share, beneficiary.fiat_anchor_info.clone()),
);
}
}

Ok(())
Expand Down
180 changes: 180 additions & 0 deletions contracts/inheritance-contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,3 +539,183 @@ fn test_trigger_payout_no_plan() {
let result = client.try_trigger_payout(&owner);
assert_eq!(result, Err(Ok(Error::PlanNotFound)));
}

#[test]
fn test_trigger_payout_emits_anchor_event_for_fiat_beneficiary() {
use soroban_sdk::testutils::Events;

let env = Env::default();
env.mock_all_auths();

let contract_id = env.register_contract(None, InheritanceContract);
let client = InheritanceContractClient::new(&env, &contract_id);

let token_id = env.register_contract(None, mock_token::MockToken);
let token_client = mock_token::MockTokenClient::new(&env, &token_id);

let owner = Address::generate(&env);
let beneficiary = Address::generate(&env);

token_client.mint(&owner, &1000);

let b = Beneficiary {
address: beneficiary.clone(),
allocation_bps: 10000,
fiat_anchor_info: String::from_str(&env, "NGN_BANK:0123456789"),
};

env.ledger().set_timestamp(1_000_000);
client.create_plan(
&owner,
&token_id,
&1000,
&Vec::from_array(&env, [b]),
&3600,
&false,
&0,
);
client.close_plan(&owner);
env.ledger().set_timestamp(1_000_000 + 4000);
client.trigger_payout(&owner);

let payout_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("payout").into();
let anchor_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("anchor_pay").into();

let mut has_payout = false;
let mut has_anchor = false;

for (contract, topics, _data) in env.events().all().iter() {
if contract != contract_id {
continue;
}
if let Some(topic0) = topics.get(0) {
if topic0 == payout_sym {
has_payout = true;
}
if topic0 == anchor_sym {
has_anchor = true;
}
}
}

assert!(has_payout, "expected payout event");
assert!(has_anchor, "expected anchor_pay event for fiat beneficiary");
}

#[test]
fn test_trigger_payout_no_anchor_event_for_non_fiat_beneficiary() {
use soroban_sdk::testutils::Events;

let env = Env::default();
env.mock_all_auths();

let contract_id = env.register_contract(None, InheritanceContract);
let client = InheritanceContractClient::new(&env, &contract_id);

let token_id = env.register_contract(None, mock_token::MockToken);
let token_client = mock_token::MockTokenClient::new(&env, &token_id);

let owner = Address::generate(&env);
let beneficiary = Address::generate(&env);

token_client.mint(&owner, &1000);

// Empty fiat_anchor_info → no anchor event should be emitted
let b = Beneficiary {
address: beneficiary.clone(),
allocation_bps: 10000,
fiat_anchor_info: String::from_str(&env, ""),
};

env.ledger().set_timestamp(1_000_000);
client.create_plan(
&owner,
&token_id,
&1000,
&Vec::from_array(&env, [b]),
&3600,
&false,
&0,
);
client.close_plan(&owner);
env.ledger().set_timestamp(1_000_000 + 4000);
client.trigger_payout(&owner);

let anchor_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("anchor_pay").into();
let mut has_anchor = false;

for (contract, topics, _data) in env.events().all().iter() {
if contract != contract_id {
continue;
}
if topics.get(0).map(|v| v == anchor_sym).unwrap_or(false) {
has_anchor = true;
}
}

assert!(!has_anchor, "should not emit anchor_pay for empty fiat_anchor_info");
}

#[test]
fn test_trigger_payout_mixed_fiat_and_non_fiat_beneficiaries() {
use soroban_sdk::testutils::Events;

let env = Env::default();
env.mock_all_auths();

let contract_id = env.register_contract(None, InheritanceContract);
let client = InheritanceContractClient::new(&env, &contract_id);

let token_id = env.register_contract(None, mock_token::MockToken);
let token_client = mock_token::MockTokenClient::new(&env, &token_id);

let owner = Address::generate(&env);
let alice = Address::generate(&env); // fiat
let bob = Address::generate(&env); // crypto-only

token_client.mint(&owner, &1000);

let alice_bene = Beneficiary {
address: alice.clone(),
allocation_bps: 6000,
fiat_anchor_info: String::from_str(&env, "KES_MPESA:0700000000"),
};
let bob_bene = Beneficiary {
address: bob.clone(),
allocation_bps: 4000,
fiat_anchor_info: String::from_str(&env, ""),
};

env.ledger().set_timestamp(1_000_000);
client.create_plan(
&owner,
&token_id,
&1000,
&Vec::from_array(&env, [alice_bene, bob_bene]),
&3600,
&false,
&0,
);
client.close_plan(&owner);
env.ledger().set_timestamp(1_000_000 + 4000);
client.trigger_payout(&owner);

let anchor_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("anchor_pay").into();
let mut anchor_count = 0u32;

for (contract, topics, _data) in env.events().all().iter() {
if contract != contract_id {
continue;
}
if topics.get(0).map(|v| v == anchor_sym).unwrap_or(false) {
anchor_count += 1;
}
}

// Only one anchor event (for Alice), not two
assert_eq!(anchor_count, 1, "expected exactly one anchor_pay event");

// Balances still correct
assert_eq!(token_client.balance(&alice), 600);
assert_eq!(token_client.balance(&bob), 400);
}
68 changes: 68 additions & 0 deletions frontend/app/api/commitments/validate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";

export interface CommitmentDraft {
title?: string;
net_amount?: number | string;
currency_preference?: string;
beneficiary_name?: string;
bank_account_number?: string;
bank_name?: string;
inactivity_days?: number | string;
}

export interface FieldError {
field: string;
message: string;
}

export interface ValidateResponse {
valid: boolean;
errors: FieldError[];
}

const SUPPORTED_CURRENCIES = ["XLM", "USDC", "EURC", "NGN", "KES", "BRL", "PHP", "EUR", "USD"];

export async function POST(req: NextRequest): Promise<NextResponse<ValidateResponse>> {
let body: CommitmentDraft;
try {
body = await req.json();
} catch {
return NextResponse.json({ valid: false, errors: [{ field: "_", message: "Invalid JSON body" }] }, { status: 400 });
}

const errors: FieldError[] = [];

if (!body.title || String(body.title).trim().length < 3) {
errors.push({ field: "title", message: "Title must be at least 3 characters." });
}

const amount = Number(body.net_amount);
if (!body.net_amount || isNaN(amount) || amount <= 0) {
errors.push({ field: "net_amount", message: "Amount must be a positive number." });
}

if (!body.currency_preference || !SUPPORTED_CURRENCIES.includes(String(body.currency_preference).toUpperCase())) {
errors.push({ field: "currency_preference", message: `Currency must be one of: ${SUPPORTED_CURRENCIES.join(", ")}.` });
}

if (!body.beneficiary_name || String(body.beneficiary_name).trim().length < 2) {
errors.push({ field: "beneficiary_name", message: "Beneficiary name must be at least 2 characters." });
}

if (!body.bank_account_number || !/^\d{6,20}$/.test(String(body.bank_account_number).trim())) {
errors.push({ field: "bank_account_number", message: "Bank account number must be 6–20 digits." });
}

if (!body.bank_name || String(body.bank_name).trim().length < 2) {
errors.push({ field: "bank_name", message: "Bank name must be at least 2 characters." });
}

const days = Number(body.inactivity_days);
if (body.inactivity_days !== undefined && body.inactivity_days !== "") {
if (isNaN(days) || days < 30 || days > 3650) {
errors.push({ field: "inactivity_days", message: "Inactivity period must be between 30 and 3650 days." });
}
}

return NextResponse.json({ valid: errors.length === 0, errors });
}
Loading
Loading