Skip to content

Commit 8ab1005

Browse files
committed
Yield donations corrections and improvements
- Always allow canceling when effective donation is <= 0 ADA - Move the donated amount in NFT datum from extra plutus data into metadata map (as "donated") - Correct the recorded donated amount to use donation amount in ADA and not sOADA - Add timestamp to NFT datum to mark the day the donation was committed - Ensure ID NFT is burned on donation - Add and update unit and integration tests for the above
1 parent f58df42 commit 8ab1005

4 files changed

Lines changed: 344 additions & 48 deletions

File tree

oada/plutus.json

Lines changed: 75 additions & 6 deletions
Large diffs are not rendered by default.

oada/validators/donate_soada.ak

Lines changed: 209 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use aiken/bytearray
22
use aiken/cbor
33
use aiken/dict
44
use aiken/hash.{blake2b_256}
5+
use aiken/interval.{Interval, IntervalBound, Finite, PositiveInfinity}
56
use aiken/list
67
use aiken/math/rational
78
use aiken/option
@@ -16,13 +17,14 @@ use aiken/transaction.{
1617
NoDatum,
1718
OutputReference,
1819
TransactionId,
19-
Transaction
20+
Transaction,
21+
ValidityRange
2022
}
2123
use aiken/transaction/credential.{Address, Credential, ScriptCredential, VerificationKeyCredential}
2224
use aiken/transaction/value
2325

2426
use optim/types.{IdMintRedeemer, KeyHash, Id, MintId, AssetClass, ScriptHash}
25-
use optim/utils.{find_id_input, get_output_datum, mint_own_id}
27+
use optim/utils.{burn_own_id, find_id_input, get_output_datum, mint_own_id}
2628
use optim/types/oada.{StakingAmoDatum}
2729

2830
pub type WrappedRedeemer<data> {
@@ -51,6 +53,27 @@ type Cip68Datum<extra> {
5153
extra: extra
5254
}
5355

56+
validator(_tag: Int) {
57+
fn always_mint(_r: Data, _ctx: Data) {
58+
True
59+
}
60+
61+
fn always_spend(_d: Data, _r: Data, _ctx: Data) {
62+
True
63+
}
64+
}
65+
66+
const one_day = 86_400_000 // 60 * 60 * 24
67+
// actually just gives an arbitrary time within the current day, taken from the interval
68+
pub fn validity_range_to_day(validity_range: ValidityRange) -> Int {
69+
expect Finite(start_slot) = validity_range.lower_bound.bound_type
70+
expect Finite(end_slot) = validity_range.upper_bound.bound_type
71+
let start_day = start_slot / one_day
72+
let end_day = end_slot / one_day
73+
expect start_day == end_day
74+
end_slot
75+
}
76+
5477
validator(donation_target: KeyHash, staking_amo: Id, batch_stake: ScriptHash) {
5578
fn mint(redeemer: IdMintRedeemer, ctx: ScriptContext) {
5679
let ScriptContext(tx_info, purpose) = ctx
@@ -96,6 +119,10 @@ validator(donation_target: KeyHash, staking_amo: Id, batch_stake: ScriptHash) {
96119
let otoken_yield =
97120
rational.sub(current_exchange, initial_exchange) |> rational.mul(sotoken)
98121
expect Some(donation_ratio) = rational.new(datum.donation_ratio.1st, datum.donation_ratio.2nd)
122+
let to_donate_base =
123+
otoken_yield
124+
|> rational.mul(donation_ratio)
125+
|> rational.truncate
99126
expect Some(to_donate) =
100127
otoken_yield
101128
|> rational.mul(donation_ratio)
@@ -116,10 +143,16 @@ validator(donation_target: KeyHash, staking_amo: Id, batch_stake: ScriptHash) {
116143
}
117144

118145
and{
119-
(to_donate == redeemer.3rd)?,
120146
list.has(tx_info.extra_signatories, datum.owner)?,
121-
validate_unstake_output(donation_target, to_donate, donation_unstake_output),
122-
validate_unstake_output(datum.owner, rational.truncate(sotoken) - to_donate, change_unstake_output)
147+
burn_own_id(purpose, tx_info.inputs, tx_info.mint),
148+
or{
149+
to_donate_base <= 0,
150+
and{
151+
(to_donate_base == redeemer.3rd)?,
152+
validate_unstake_output(donation_target, to_donate, donation_unstake_output),
153+
validate_unstake_output(datum.owner, rational.truncate(sotoken) - to_donate, change_unstake_output)
154+
}
155+
}
123156
}
124157
}
125158
}
@@ -157,21 +190,27 @@ validator(
157190
fn(output) { value.quantity_of(output.value, policy_id, reference_token_name) == 1 }
158191
)
159192

160-
expect reference_token_datum: Cip68Datum<Int> = get_output_datum(reference_token_output, tx_info.datums)
193+
expect reference_token_datum: Cip68Datum<Void> = get_output_datum(reference_token_output, tx_info.datums)
161194

195+
let timestamp: Data = validity_range_to_day(tx_info.validity_range)
196+
197+
let donated_data: Data = donated
162198
let expected_reference_datum = Cip68Datum{
163199
metadata: [
164200
Pair("description", nft_description),
201+
Pair("donated", donated_data),
165202
Pair("image", nft_image_url),
166203
Pair("name", nft_name),
204+
Pair("time", timestamp)
167205
],
168206
version: 1,
169-
extra: donated
207+
extra: Void
170208
}
171209

172210
let reference_token_locked = reference_token_output.address.payment_credential == reference_holder
173211

174212
and{
213+
(donated > 0)?,
175214
(own_tokens == [Pair(reference_token_name, 1), Pair(user_token_name, 1)])?,
176215
(reference_token_datum == expected_reference_datum)?,
177216
reference_token_locked?
@@ -254,6 +293,13 @@ fn test_donation(
254293
let unburned_sotoken = locked_sotoken * 999 / 1000
255294
// doing this calculation inline just to avoid duplicating the validator code
256295
// as a sanity check
296+
let donated_base =
297+
unburned_sotoken
298+
* (sotoken_backing_1 - sotoken_backing_0)
299+
* donation_datum.donation_ratio.1st
300+
/ donation_datum.donation_ratio.2nd
301+
/ sotoken_amount
302+
let donated_base_data: Data = donated_base
257303
let donated_sotoken =
258304
unburned_sotoken
259305
* (sotoken_backing_1 - sotoken_backing_0)
@@ -283,7 +329,12 @@ fn test_donation(
283329
let nft_description: Data = "Proof of your donation"
284330
let nft_image_url: Data = "https://example.com/image.jpg"
285331

286-
let donate_redeemer_wrapped: Data = WrappedRedeemer((0, 1, donated_sotoken))
332+
let donate_redeemer_wrapped: Data = WrappedRedeemer((0, 1, donated_base))
333+
334+
// arbitrary day in 2025
335+
let start = 365 * one_day * 55 + one_day * 200 + one_day / 3
336+
let end = start + 60 * 60
337+
let end_data: Data = end
287338

288339
let donate_transaction = Transaction {
289340
..transaction.placeholder(),
@@ -319,18 +370,32 @@ fn test_donation(
319370
datum: InlineDatum(Cip68Datum{
320371
metadata: [
321372
Pair("description", nft_description),
373+
Pair("donated", donated_base_data),
322374
Pair("image", nft_image_url),
323-
Pair("name", nft_name)
375+
Pair("name", nft_name),
376+
Pair("time", end_data)
324377
],
325378
version: 1,
326-
extra: donated_sotoken,
379+
extra: Void,
327380
}),
328381
reference_script: None,
329382
}
330383
] |> fn(outputs) { if !include_nft { list.take(outputs, 2) } else { outputs } },
331-
mint: if !include_nft { value.zero() } else { minted_reference_nft |> value.merge(minted_user_nft) } |> value.to_minted_value,
384+
mint: if !include_nft { value.zero() } else { minted_reference_nft |> value.merge(minted_user_nft) }
385+
|> value.merge(value.negate(minted_id))
386+
|> value.to_minted_value,
332387
extra_signatories: [donation_datum.owner],
333-
redeemers: [Pair(Spend(donation_output_ref), donate_redeemer_wrapped)]
388+
redeemers: [Pair(Spend(donation_output_ref), donate_redeemer_wrapped)],
389+
validity_range: Interval {
390+
lower_bound: IntervalBound {
391+
bound_type: Finite(start),
392+
is_inclusive: True,
393+
},
394+
upper_bound: IntervalBound {
395+
bound_type: Finite(end),
396+
is_inclusive: True,
397+
}
398+
}
334399
} |> transform_donate_transaction
335400

336401
expect actual_donation_datum: DonationDatum =
@@ -385,10 +450,10 @@ fn test_donation(
385450
}
386451
}
387452

388-
fn transform_reference_nft_datum(transform: fn(Cip68Datum<Int>) -> Cip68Datum<Int>) -> fn(Transaction) -> Transaction {
453+
fn transform_reference_nft_datum(transform: fn(Cip68Datum<Void>) -> Cip68Datum<Void>) -> fn(Transaction) -> Transaction {
389454
fn(t: Transaction) {
390455
expect Some(reference_nft_output) = list.at(t.outputs, 2)
391-
expect reference_nft_datum: Cip68Datum<Int> = {
456+
expect reference_nft_datum: Cip68Datum<Void> = {
392457
expect InlineDatum(datum) = reference_nft_output.datum
393458
datum
394459
}
@@ -425,10 +490,69 @@ fn transform_batch_stake_datum(output_index: Int, transform: fn(BatchStakeDatum)
425490
}
426491
}
427492

428-
test test_donate() {
493+
fn test_cancel(
494+
transform_mint_transaction: fn(Transaction) -> Transaction,
495+
transform_cancel_transaction: fn(Transaction) -> Transaction,
496+
) {
497+
test_donation(
498+
transform_mint_transaction,
499+
fn(t) {
500+
expect Some(donation_input) = list.at(t.inputs, 0)
501+
expect donation_datum: DonationDatum = {
502+
expect InlineDatum(data) = donation_input.output.datum
503+
data
504+
}
505+
506+
expect Some(staking_amo_input) = list.at(t.reference_inputs, 0)
507+
expect staking_amo_datum: StakingAmoDatum = {
508+
expect InlineDatum(data) = staking_amo_input.output.datum
509+
data
510+
}
511+
512+
let donation_redeemer: Data = WrappedRedeemer((0, 0, 0))
513+
514+
Transaction{
515+
..t,
516+
reference_inputs: [
517+
Input {
518+
..staking_amo_input,
519+
output: Output {
520+
..staking_amo_input.output,
521+
datum: InlineDatum(StakingAmoDatum{
522+
..staking_amo_datum,
523+
sotoken_backing: donation_datum.initial_exchange.1st,
524+
sotoken_amount: donation_datum.initial_exchange.2nd
525+
})
526+
}
527+
}
528+
],
529+
outputs: [
530+
Output {
531+
address: Address{ payment_credential: VerificationKeyCredential(donation_datum.owner), stake_credential: None },
532+
value: donation_input.output.value,
533+
datum: NoDatum,
534+
reference_script: None
535+
}
536+
],
537+
redeemers: [Pair(Spend(donation_input.output_reference), donation_redeemer)],
538+
} |> transform_cancel_transaction
539+
},
540+
False
541+
)
542+
}
543+
544+
fn transform_validity_range(transform: fn(ValidityRange) -> ValidityRange) -> fn(Transaction) -> Transaction {
545+
fn(t: Transaction) { Transaction{ ..t, validity_range: transform(t.validity_range) } }
546+
}
547+
548+
test test_donate_succeeds() {
429549
test_donation(fn(t) {t}, fn(t) {t}, True)
430550
}
431551

552+
test test_cancel_succeeds() {
553+
test_cancel(fn(t) {t}, fn(t) {t})
554+
}
555+
432556
// negative tests below heavily depend on knowledge of the structure and
433557
// assumptions of `test_donation`
434558
!test test_donate_missing_donation_output_fails() {
@@ -437,14 +561,20 @@ test test_donate() {
437561

438562
!test test_donate_incorrect_donation_report_fails() {
439563
let donate_redeemer = (0, 1, 9999999999)
564+
let donate_amount_data: Data = donate_redeemer.3rd
440565
let donate_redeemer_wrapped: Data = WrappedRedeemer(donate_redeemer)
441566
test_donation(fn(t) {t}, fn(t) {
442567
expect [donation_input] = t.inputs
443568
Transaction{
444569
..t,
445570
redeemers: [Pair(Spend(donation_input.output_reference), donate_redeemer_wrapped)]
446571
} |> transform_reference_nft_datum(fn(datum) {
447-
Cip68Datum{ ..datum, extra: 9999999999 }
572+
Cip68Datum{
573+
..datum,
574+
metadata: pairs.map(datum.metadata, fn (key, value: Data) {
575+
if key == "donated" { donate_amount_data } else { value }
576+
})
577+
}
448578
})
449579
}, True)
450580
}
@@ -478,6 +608,52 @@ test test_donate() {
478608
)
479609
}
480610

611+
!test test_donate_bad_validity_range_fails() {
612+
or{
613+
test_donation(
614+
fn(t) {t},
615+
transform_validity_range(fn(validity_range: ValidityRange) {
616+
expect Finite(range_end) = validity_range.upper_bound.bound_type
617+
Interval {
618+
..validity_range,
619+
upper_bound: IntervalBound {
620+
..validity_range.upper_bound,
621+
bound_type: Finite(range_end + one_day)
622+
}
623+
}
624+
}),
625+
True
626+
),
627+
test_donation(
628+
fn(t) {t},
629+
transform_validity_range(fn(validity_range: ValidityRange) {
630+
expect Finite(range_start) = validity_range.lower_bound.bound_type
631+
Interval {
632+
..validity_range,
633+
lower_bound: IntervalBound {
634+
..validity_range.lower_bound,
635+
bound_type: Finite(range_start - one_day)
636+
}
637+
}
638+
}),
639+
True
640+
),
641+
test_donation(
642+
fn(t) {t},
643+
transform_validity_range(fn(validity_range: ValidityRange) {
644+
Interval {
645+
..validity_range,
646+
upper_bound: IntervalBound {
647+
..validity_range.upper_bound,
648+
bound_type: PositiveInfinity
649+
}
650+
}
651+
}),
652+
True
653+
),
654+
}
655+
}
656+
481657
!test test_donate_missing_change_output_fails() {
482658
test_donation(
483659
fn(t) {t},
@@ -529,9 +705,15 @@ test test_donate() {
529705
!test test_donate_bad_reference_nft_datum_fails() {
530706
or{
531707
test_donation(
532-
fn(t) {t},
708+
fn(t) { t },
533709
transform_reference_nft_datum(fn(datum) {
534-
Cip68Datum{ ..datum, extra: 100000000 }
710+
Cip68Datum{
711+
..datum,
712+
metadata: pairs.map(datum.metadata, fn (key, value: Data) {
713+
let donated: Data = 100000000
714+
if key == "donated" { donated } else { value }
715+
})
716+
}
535717
}),
536718
True
537719
),
@@ -586,3 +768,12 @@ test test_donate() {
586768
True // double satisfaction will succeed if we don't mint the NFT, but the donator has nothing to gain from doing this
587769
)
588770
}
771+
772+
validator(token: AssetClass) {
773+
fn token_unlock(_datum: Data, _redeemer: Data, ctx: ScriptContext) {
774+
list.any(
775+
ctx.transaction.inputs,
776+
fn (input) { value.quantity_of(input.output.value, token.policy_id, token.asset_name) > 0 }
777+
)
778+
}
779+
}

test/src/datums.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ export const yieldDonationNftDatumSchema = {
316316
fields: [
317317
[ "metadata", mapEncoder(stringEncoder, stringEncoder) ],
318318
[ "version", bigintEncoder ],
319-
[ "donationAmount", bigintEncoder ]
319+
[ "extraData", rawDataEncoder ]
320320
] as const
321321
}
322322
addTypeSchema(yieldDonationNftDatumSchema)

0 commit comments

Comments
 (0)