Skip to content

CIP-0058 (Script - 2) Add weight as input + test for invalid weight #1587

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,13 @@ class UnclaimedSvRewardsScriptIntegrationTest

val svRewardCouponsCount = 6L
val svRewardCouponsExpiredCount = 3L
val svRewardCouponsClaimedCount = 1L
val svRewardCouponsUnclaimedCount = 2L
val svRewardCouponsClaimedCount = 3L
val svRewardCouponsUnclaimedCount = 0L

val sv1Name = sv1Backend.config.onboarding.map(_.name).getOrElse(fail("sv1 name not found"))
val sv1TotalWeight: Long =
sv1Backend.config.rewardWeightBpsOf(sv1Name).getOrElse(fail("sv1 weight not found"))
sv1TotalWeight should be > 0L

// Some rewards gets created before now
val beginRecordTime = Instant.now().minus(10, ChronoUnit.MINUTES)
Expand All @@ -90,10 +95,13 @@ class UnclaimedSvRewardsScriptIntegrationTest
"All reward coupons got created",
_ => {
val svRewardCoupons = sv1WalletClient.listSvRewardCoupons()
svRewardCoupons should have size (svRewardCouponsCount)
svRewardCoupons should have size svRewardCouponsCount
svRewardCoupons
},
)
// The script will consider all reward coupons created
val endRecordTime = Instant.now()

receiveSvRewardCouponTrigger.pause().futureValue

// Expire
Expand Down Expand Up @@ -123,10 +131,10 @@ class UnclaimedSvRewardsScriptIntegrationTest
triggersToResumeAtStart = Seq(sv1CollectRewardsAndMergeAmuletsTrigger),
) {
actAndCheck(
"Advance round to allow claiming one sv reward coupon",
advanceRoundsByOneTickViaAutomation(),
"Advance rounds to allow claiming the remaining SV reward coupons",
Range(0, 3).foreach(_ => advanceRoundsByOneTickViaAutomation()),
)(
"Coupon for round 3 gets claimed",
"Coupons for rounds 3, 4 and 5 get claimed",
_ =>
sv1WalletClient
.listSvRewardCoupons() should have size
Expand Down Expand Up @@ -154,17 +162,161 @@ class UnclaimedSvRewardsScriptIntegrationTest
(co.payload.round.number.longValue(), BigDecimal(co.payload.issuancePerSvRewardCoupon))
)
)
val rewardExpiredTotalAmount = getTotalAmount(svRewardCouponsExpired, roundInfo)
val rewardClaimedTotalAmount = getTotalAmount(svRewardCouponsClaimed, roundInfo)

// Add some minutes in case discrepancies with ledger time
val endRecordTime = Instant
.now()
.plus(5, ChronoUnit.MINUTES)
val sv1Party = sv1Backend.getDsoInfo().svParty
val readLines = mutable.Buffer[String]()
clue(
"Run unclaimed_sv_rewards.py with invalid weight inputs (effective weight < input weight) " +
"and check warnings and results"
) {
val errorProcessor = ProcessLogger(line => readLines.append(line))
val inputWeight = sv1TotalWeight - 1
val alreadyMintedWeight = 2
val effectiveWeight = sv1TotalWeight - alreadyMintedWeight

val rewardExpiredTotalAmount =
getTotalAmount(svRewardCouponsExpired, roundInfo, effectiveWeight)
val rewardClaimedTotalAmount =
getTotalAmount(svRewardCouponsClaimed, roundInfo, effectiveWeight)

try {
val exitCode = scala.sys.process
.Process(
Seq(
"python",
"scripts/scan-txlog/unclaimed_sv_rewards.py",
sv1ScanBackend.httpClientConfig.url.toString(),
"--grace-period-for-mining-rounds-in-minutes",
"30",
"--loglevel",
"DEBUG",
"--beneficiary",
sv1Party.toProtoPrimitive,
"--begin-migration-id",
"0",
"--begin-record-time",
beginRecordTime.toString,
"--end-record-time",
endRecordTime.toString,
"--weight",
inputWeight.toString,
"--already-minted-weight",
alreadyMintedWeight.toString,
)
)
.!(errorProcessor)

assert(exitCode == 0, s"Script exited with code $exitCode")
readLines.filter(_.startsWith("ERROR:")) shouldBe empty
forExactly(6, readLines) {
_ should include("WARNING:global:Invalid weight input for round")
}

forExactly(1, readLines) {
_ should include(s"reward_expired_count = $svRewardCouponsExpiredCount")
}
forExactly(1, readLines) {
_ should include(f"reward_expired_total_amount = $rewardExpiredTotalAmount%.10f")
}
forExactly(1, readLines) {
_ should include(s"reward_claimed_count = $svRewardCouponsClaimedCount")
}
forExactly(1, readLines) {
_ should include(f"reward_claimed_total_amount = $rewardClaimedTotalAmount%.10f")
}
forExactly(1, readLines) {
_ should include(s"reward_unclaimed_count = $svRewardCouponsUnclaimedCount")
}
} catch {
case NonFatal(ex) =>
readLines.foreach(logger.error(_))
fail("Unexpected failure running script", ex)
}
}

clue(
"Run unclaimed_sv_rewards.py with invalid weight inputs (effective weight == 0) " +
"and check warnings and results"
) {
val inputWeight = 1
val alreadyMintedWeight = sv1TotalWeight + 1
val effectiveWeight = 0L // max(0, sv1TotalWeight - alreadyMintedWeight)

val rewardExpiredTotalAmount =
getTotalAmount(svRewardCouponsExpired, roundInfo, effectiveWeight)
val rewardClaimedTotalAmount =
getTotalAmount(svRewardCouponsClaimed, roundInfo, effectiveWeight)

readLines.clear()
val errorProcessor = ProcessLogger(line => readLines.append(line))
try {
val exitCode = scala.sys.process
.Process(
Seq(
"python",
"scripts/scan-txlog/unclaimed_sv_rewards.py",
sv1ScanBackend.httpClientConfig.url.toString(),
"--grace-period-for-mining-rounds-in-minutes",
"30",
"--loglevel",
"DEBUG",
"--beneficiary",
sv1Party.toProtoPrimitive,
"--begin-migration-id",
"0",
"--begin-record-time",
beginRecordTime.toString,
"--end-record-time",
endRecordTime.toString,
"--weight",
inputWeight.toString,
"--already-minted-weight",
alreadyMintedWeight.toString,
)
)
.!(errorProcessor)

assert(exitCode == 0, s"Script exited with code $exitCode")
readLines.filter(_.startsWith("ERROR:")) shouldBe empty
forExactly(6, readLines) {
_ should include("WARNING:global:Invalid weight input for round")
}

forExactly(1, readLines) {
_ should include(s"reward_expired_count = $svRewardCouponsExpiredCount")
}
forExactly(1, readLines) {
_ should include(f"reward_expired_total_amount = $rewardExpiredTotalAmount%.10f")
}
forExactly(1, readLines) {
_ should include(s"reward_claimed_count = $svRewardCouponsClaimedCount")
}
forExactly(1, readLines) {
_ should include(f"reward_claimed_total_amount = $rewardClaimedTotalAmount%.10f")
}
forExactly(1, readLines) {
_ should include(s"reward_unclaimed_count = $svRewardCouponsUnclaimedCount")
}
} catch {
case NonFatal(ex) =>
readLines.foreach(logger.error(_))
fail("Unexpected failure running script", ex)
}
}

clue(
"Run unclaimed_sv_rewards.py with valid inputs (effective weight == input weight) and check results"
) {
val inputWeight = sv1TotalWeight / 2
val alreadyMintedWeight = inputWeight
val effectiveWeight = inputWeight

val rewardExpiredTotalAmount =
getTotalAmount(svRewardCouponsExpired, roundInfo, effectiveWeight)
val rewardClaimedTotalAmount =
getTotalAmount(svRewardCouponsClaimed, roundInfo, effectiveWeight)

clue("Run unclaimed_sv_rewards.py and check results") {
val sv1Party = sv1Backend.getDsoInfo().svParty
val readLines = mutable.Buffer[String]()
readLines.clear()
val errorProcessor = ProcessLogger(line => readLines.append(line))
try {
val exitCode = scala.sys.process
Expand All @@ -185,6 +337,10 @@ class UnclaimedSvRewardsScriptIntegrationTest
beginRecordTime.toString,
"--end-record-time",
endRecordTime.toString,
"--weight",
inputWeight.toString,
"--already-minted-weight",
alreadyMintedWeight.toString,
)
)
.!(errorProcessor)
Expand All @@ -195,21 +351,21 @@ class UnclaimedSvRewardsScriptIntegrationTest
_ should include(s"reward_expired_count = $svRewardCouponsExpiredCount")
}
forExactly(1, readLines) {
_ should include(s"reward_expired_total_amount = $rewardExpiredTotalAmount")
_ should include(f"reward_expired_total_amount = $rewardExpiredTotalAmount%.10f")
}
forExactly(1, readLines) {
_ should include(s"reward_claimed_count = $svRewardCouponsClaimedCount")
}
forExactly(1, readLines) {
_ should include(s"reward_claimed_total_amount = $rewardClaimedTotalAmount")
_ should include(f"reward_claimed_total_amount = $rewardClaimedTotalAmount%.10f")
}
forExactly(1, readLines) {
_ should include(s"reward_unclaimed_count = $svRewardCouponsUnclaimedCount")
}
} catch {
case NonFatal(ex) =>
readLines.foreach(logger.error(_))
throw new RuntimeException("Failed to run unclaimed_sv_rewards.py", ex)
throw new RuntimeException("Unexpected failure running script", ex)
}
}
}
Expand All @@ -222,11 +378,13 @@ class UnclaimedSvRewardsScriptIntegrationTest
]
],
roundInfo: Map[Long, BigDecimal],
weight: Long,
): BigDecimal = {
coupons.map { co =>
val issuancePerSvRewardCoupon =
roundInfo.getOrElse(co.payload.round.number, fail("Round not found"))
BigDecimal(co.payload.weight.longValue()) * issuancePerSvRewardCoupon
weight should be <= co.payload.weight.longValue()
BigDecimal(weight) * issuancePerSvRewardCoupon
}.sum
}
}
52 changes: 46 additions & 6 deletions scripts/scan-txlog/unclaimed_sv_rewards.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

"""
Summarizes claimed, expired, and unclaimed minting rewards for a given beneficiary
within a specified time range, based on SvRewardCoupon activity.
within a specified time range and weight, based on SvRewardCoupon activity.
"""

import aiohttp
Expand Down Expand Up @@ -53,6 +53,12 @@ def _default_logger(name, loglevel):
# Global logger, always accessible
LOG = _default_logger("global", "INFO")

def non_negative_int(value):
ivalue = int(value)
if ivalue < 0:
raise argparse.ArgumentTypeError(f"{value} is invalid: must be a non-negative integer")
return ivalue

def _parse_cli_args() -> argparse.Namespace:
# Parse command line arguments
parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -96,12 +102,24 @@ def _parse_cli_args() -> argparse.Namespace:
)
parser.add_argument(
"--begin-record-time",
help="Start of the record time range to consider SvRewardCoupon creation. Expected in ISO format: 2025-07-01T10:30:00Z.",
help="Start of the record time range (exclusive) to consider SvRewardCoupon creation. Expected in ISO format: 2025-07-01T10:30:00Z.",
required=True,
)
parser.add_argument(
"--end-record-time",
help="End of the record time range to consider SvRewardCoupon creation. Expected in ISO format: 2025-07-01T12:30:00Z",
help="End of the record time range (inclusive) to consider SvRewardCoupon creation. Expected in ISO format: 2025-07-01T12:30:00Z",
required=True,
)
parser.add_argument(
"--weight",
type=non_negative_int,
help="Weight of sv coupon rewards to consider",
required=True,
)
parser.add_argument(
"--already-minted-weight",
type=non_negative_int,
help="Weight already minted for the time range provided",
required=True,
)
return parser.parse_args()
Expand Down Expand Up @@ -464,6 +482,8 @@ class State:
grace_period_for_mining_rounds_in_minutes: datetime
create_sv_reward_end_record_time: datetime
pagination_key: PaginationKey
weight: int
already_minted_weight: int
reward_summary: RewardSummary

@classmethod
Expand Down Expand Up @@ -495,6 +515,8 @@ def from_args(cls, args: argparse.Namespace):
grace_period_for_mining_rounds_in_minutes = grace_period_for_mining_rounds_in_minutes,
create_sv_reward_end_record_time = datetime.fromisoformat(args.end_record_time),
pagination_key=pagination_key,
weight = args.weight,
already_minted_weight = args.already_minted_weight,
reward_summary = reward_summary,
)

Expand Down Expand Up @@ -608,7 +630,7 @@ def handle_sv_reward_coupon_exercise(self, transaction, event, status):
case None:
self._fail_with_missing_round(reward)
case mining_round_info:
amount = reward.weight * mining_round_info.issuance_per_sv_reward
amount = self._calculate_amount(reward, mining_round_info)
LOG.debug(
f"Updating expired summary with amount {amount}, corresponding to contract {event.contract_id}"
)
Expand All @@ -619,7 +641,7 @@ def handle_sv_reward_coupon_exercise(self, transaction, event, status):
case None:
self._fail_with_missing_round(reward)
case mining_round_info:
amount = reward.weight * mining_round_info.issuance_per_sv_reward
amount = self._calculate_amount(reward, mining_round_info)
LOG.debug(
f"Updating claimed summary with amount {amount}, corresponding to contract {event.contract_id}"
)
Expand All @@ -628,6 +650,20 @@ def handle_sv_reward_coupon_exercise(self, transaction, event, status):

self.process_events(transaction, event.child_event_ids)

def _calculate_amount(self, reward, mining_round_info):
return self._calculate_weight(reward) * mining_round_info.issuance_per_sv_reward

def _calculate_weight(self, reward):
available_weight = max(0, reward.weight - self.already_minted_weight)
if self.weight > available_weight:
LOG.warning(
f"Invalid weight input for round <{reward.round}>: "
f"{self.weight} must be less than or equal to {available_weight}."
f"The amount corresponding to {available_weight} will be computed."
)
return available_weight
return self.weight

def _fail_with_missing_round(self, reward):
self._fail(
f"Fatal: missing round {reward.round} for reward {reward.contract_id}\n"
Expand All @@ -636,7 +672,6 @@ def _fail_with_missing_round(self, reward):
)

def _fail(self, message, cause=None):
LOG.error(message)
raise Exception(
f"Stopping script (error: {message})"
) from cause
Expand Down Expand Up @@ -689,6 +724,11 @@ async def main():
f"active_closed_rounds count: {len(app_state.active_closed_rounds)}"
)

assert not app_state.active_rewards, (
"Some rewards remain unclaimed. The provided grace-period-for-mining-rounds-in-minutes "
"might be too short to include all relevant mining rounds."
)

reward_summary = app_state.reward_summary

LOG.info(f"reward_expired_count = {reward_summary.reward_expired_count}")
Expand Down
Loading