Skip to content

Commit 0756fb3

Browse files
authored
Merge pull request #52 from KevinMB0220/feat/transfer-lock-escrow-lifecycle-27
feat: connect invoice-token transfer lock to escrow settlement/refund
2 parents 41284f5 + 8068bab commit 0756fb3

16 files changed

Lines changed: 2142 additions & 25 deletions

contracts/invoice-escrow/src/integration_test.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ fn test_integration_escrow_lifecycle_happy_path() {
7272
assert_eq!(payment_token_client.balance(&buyer), 0);
7373
assert_eq!(payment_token_client.balance(&escrow_id), amount);
7474

75+
// Verify invoice token is locked while escrow is active
76+
assert!(inv_token_client.transfer_locked());
77+
7578
// 9. Record Payment (Payer settles the invoice)
7679
escrow_client.record_payment(&invoice_id, &payer, &amount);
7780

@@ -88,6 +91,15 @@ fn test_integration_escrow_lifecycle_happy_path() {
8891
escrow_client.get_escrow_status(&invoice_id),
8992
EscrowStatus::Settled
9093
);
94+
95+
// Invoice token transfers must be unlocked after settlement
96+
assert!(!inv_token_client.transfer_locked());
97+
98+
// Buyer can now transfer their invoice tokens freely
99+
let recipient = Address::generate(&env);
100+
inv_token_client.transfer(&buyer, &recipient, &amount);
101+
assert_eq!(inv_token_client.balance(&buyer), 0);
102+
assert_eq!(inv_token_client.balance(&recipient), amount);
91103
}
92104

93105
#[test]
@@ -155,4 +167,74 @@ fn test_integration_refund_lifecycle() {
155167
escrow_client.get_escrow_status(&invoice_id),
156168
EscrowStatus::Refunded
157169
);
170+
171+
// Invoice token transfers must be unlocked after refund
172+
assert!(!inv_token_client.transfer_locked());
173+
}
174+
175+
#[test]
176+
fn test_integration_token_locked_during_active_escrow() {
177+
let env = Env::default();
178+
env.mock_all_auths();
179+
180+
let admin = Address::generate(&env);
181+
let seller = Address::generate(&env);
182+
let buyer = Address::generate(&env);
183+
184+
let escrow_id = env.register_contract(None, InvoiceEscrow);
185+
let escrow_client = InvoiceEscrowClient::new(&env, &escrow_id);
186+
187+
let inv_token_id = env.register_contract(None, InvoiceToken);
188+
let inv_token_client = InvoiceTokenClient::new(&env, &inv_token_id);
189+
190+
let payment_token_admin = Address::generate(&env);
191+
let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone());
192+
let payment_token_asset = AssetClient::new(&env, &payment_token_id.address());
193+
194+
let invoice_id = Symbol::new(&env, "INVLOCK");
195+
inv_token_client.initialize(
196+
&admin,
197+
&SorobanString::from_str(&env, "Lock Test Invoice"),
198+
&SorobanString::from_str(&env, "INVLCK"),
199+
&18,
200+
&invoice_id,
201+
&escrow_id,
202+
);
203+
204+
escrow_client.initialize(&admin, &300);
205+
206+
let amount = 500i128;
207+
payment_token_asset.mint(&buyer, &amount);
208+
209+
let due_date = 20000u64;
210+
escrow_client.create_escrow(
211+
&invoice_id,
212+
&seller,
213+
&amount,
214+
&due_date,
215+
&payment_token_id.address(),
216+
&inv_token_id,
217+
);
218+
219+
// Token is locked even before funding (initialized locked)
220+
assert!(inv_token_client.transfer_locked());
221+
222+
escrow_client.fund_escrow(&invoice_id, &buyer);
223+
224+
// Token is still locked after funding — transfers are blocked while invoice is active
225+
assert!(inv_token_client.transfer_locked());
226+
let other = Address::generate(&env);
227+
let result = inv_token_client.try_transfer(&buyer, &other, &100);
228+
assert!(result.is_err());
229+
230+
// After settlement, token unlocks
231+
let payer = Address::generate(&env);
232+
payment_token_asset.mint(&payer, &amount);
233+
escrow_client.record_payment(&invoice_id, &payer, &amount);
234+
235+
assert!(!inv_token_client.transfer_locked());
236+
// Buyer can now freely transfer invoice tokens
237+
inv_token_client.transfer(&buyer, &other, &100);
238+
assert_eq!(inv_token_client.balance(&buyer), amount - 100);
239+
assert_eq!(inv_token_client.balance(&other), 100);
158240
}

contracts/invoice-escrow/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ impl InvoiceEscrow {
147147
token.transfer(&contract, &config.admin, &platform_fee);
148148
data.status = EscrowStatus::Settled;
149149
storage::set_escrow(&env, invoice_id.clone(), &data);
150+
151+
// Unlock invoice token transfers now that the invoice is settled
152+
env.invoke_contract::<()>(
153+
&data.inv_token,
154+
&Symbol::new(&env, "set_transfer_locked"),
155+
soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
156+
);
157+
150158
events::payment_settled(&env, invoice_id, amount, platform_fee, investor_amount);
151159
Ok(())
152160
}
@@ -169,6 +177,14 @@ impl InvoiceEscrow {
169177
token.transfer(&contract, funder, &amount);
170178
data.status = EscrowStatus::Refunded;
171179
storage::set_escrow(&env, invoice_id.clone(), &data);
180+
181+
// Unlock invoice token transfers now that the invoice is refunded
182+
env.invoke_contract::<()>(
183+
&data.inv_token,
184+
&Symbol::new(&env, "set_transfer_locked"),
185+
soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
186+
);
187+
172188
events::escrow_refunded(&env, invoice_id, funder, amount);
173189
Ok(())
174190
}

contracts/invoice-escrow/src/test.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ impl MockInvoiceToken {
1616
// Just mock the mint call
1717
env.storage().instance().set(&to, &amount);
1818
}
19+
20+
pub fn set_transfer_locked(_env: Env, _caller: Address, _locked: bool) {
21+
// Mock the set_transfer_locked call — no-op for unit tests
22+
}
1923
}
2024

2125
#[test]

contracts/invoice-escrow/test_snapshots/integration_test/test_integration_escrow_lifecycle_happy_path.1.json

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"generators": {
3-
"address": 8,
3+
"address": 9,
44
"nonce": 0
55
},
66
"auth": [
@@ -163,6 +163,7 @@
163163
[],
164164
[],
165165
[],
166+
[],
166167
[
167168
[
168169
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4",
@@ -219,6 +220,37 @@
219220
[],
220221
[],
221222
[],
223+
[],
224+
[],
225+
[
226+
[
227+
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M",
228+
{
229+
"function": {
230+
"contract_fn": {
231+
"contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4",
232+
"function_name": "transfer",
233+
"args": [
234+
{
235+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M"
236+
},
237+
{
238+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON"
239+
},
240+
{
241+
"i128": {
242+
"hi": 0,
243+
"lo": 1000
244+
}
245+
}
246+
]
247+
}
248+
},
249+
"sub_invocations": []
250+
}
251+
]
252+
],
253+
[],
222254
[]
223255
],
224256
"ledger": {
@@ -358,6 +390,39 @@
358390
6311999
359391
]
360392
],
393+
[
394+
{
395+
"contract_data": {
396+
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M",
397+
"key": {
398+
"ledger_key_nonce": {
399+
"nonce": 8370022561469687789
400+
}
401+
},
402+
"durability": "temporary"
403+
}
404+
},
405+
[
406+
{
407+
"last_modified_ledger_seq": 0,
408+
"data": {
409+
"contract_data": {
410+
"ext": "v0",
411+
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M",
412+
"key": {
413+
"ledger_key_nonce": {
414+
"nonce": 8370022561469687789
415+
}
416+
},
417+
"durability": "temporary",
418+
"val": "void"
419+
}
420+
},
421+
"ext": "v0"
422+
},
423+
6311999
424+
]
425+
],
361426
[
362427
{
363428
"contract_data": {
@@ -576,7 +641,7 @@
576641
"symbol": "Balance"
577642
},
578643
{
579-
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M"
644+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON"
580645
}
581646
]
582647
},
@@ -596,7 +661,7 @@
596661
"symbol": "Balance"
597662
},
598663
{
599-
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M"
664+
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATYON"
600665
}
601666
]
602667
},
@@ -700,7 +765,7 @@
700765
"symbol": "transfer_locked"
701766
},
702767
"val": {
703-
"bool": true
768+
"bool": false
704769
}
705770
}
706771
]

contracts/invoice-escrow/test_snapshots/integration_test/test_integration_refund_lifecycle.1.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
[],
139139
[],
140140
[],
141+
[],
141142
[]
142143
],
143144
"ledger": {
@@ -586,7 +587,7 @@
586587
"symbol": "transfer_locked"
587588
},
588589
"val": {
589-
"bool": true
590+
"bool": false
590591
}
591592
}
592593
]

0 commit comments

Comments
 (0)