Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import org.lfdecentralizedtrust.splice.util.{QualifiedName, TriggerTestUtil}
import com.digitalasset.canton.ScalaFuturesWithPatience
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.integration.EnvironmentSetupPlugin
import com.digitalasset.canton.logging.NamedLoggerFactory
import com.digitalasset.canton.logging.SuppressingLogger
import com.digitalasset.canton.tracing.TraceContext
import org.lfdecentralizedtrust.splice.store.UpdateHistory.BackfillingState
import org.scalatest.{Inspectors, LoneElement}
Expand All @@ -29,6 +29,7 @@ import scala.annotation.tailrec
import scala.collection.mutable
import scala.concurrent.duration.*
import scala.sys.process.ProcessLogger
import scala.util.Try
import scala.util.control.NonFatal

/** Runs `scripts/scan-txlog/scan_txlog.py`, to make sure that we have no transactions that would break it.
Expand All @@ -40,7 +41,7 @@ import scala.util.control.NonFatal
class UpdateHistorySanityCheckPlugin(
ignoredRootCreates: Seq[Identifier],
ignoredRootExercises: Seq[(Identifier, String)],
protected val loggerFactory: NamedLoggerFactory,
protected val loggerFactory: SuppressingLogger,
) extends EnvironmentSetupPlugin[SpliceConfig, SpliceEnvironment]
with Matchers
with Eventually
Expand Down Expand Up @@ -147,7 +148,7 @@ class UpdateHistorySanityCheckPlugin(
private def checkScanTxLogScript(scan: ScanAppBackendReference)(implicit tc: TraceContext) = {
val snapshotRecordTime = scan.forceAcsSnapshotNow()
val amuletRules = scan.getAmuletRules()
val subtractHoldingFees: Boolean =
val amuletIncludesFees: Boolean =
amuletRules.contract.payload.configSchedule.initialValue.packageConfig.amulet
.split("\\.")
.toList match {
Expand All @@ -158,6 +159,10 @@ class UpdateHistorySanityCheckPlugin(
s"Amulet package version is ${amuletRules.contract.payload.configSchedule.initialValue.packageConfig.amulet}, which is not x.y.z"
)
}
// some tests have temporary participants, so the request won't always manage to resolve package support
val compareBalancesWithTotalSupply = loggerFactory.suppressWarningsAndErrors(
Try(scan.lookupInstrument("Amulet")).toOption.flatten.flatMap(_.totalSupply).isDefined
)

val readLines = mutable.Buffer[String]()
val errorProcessor = ProcessLogger(line => readLines.append(line))
Expand All @@ -181,7 +186,11 @@ class UpdateHistorySanityCheckPlugin(
"--compare-acs-with-snapshot",
snapshotRecordTime.toInstant.toString,
) ++ Option
.when(subtractHoldingFees)("--subtract-holding-fees-per-round")
.when(amuletIncludesFees)("--subtract-holding-fees-per-round")
.toList ++ Option
.when(compareBalancesWithTotalSupply && !amuletIncludesFees)(
"--compare-balances-with-total-supply"
)
.toList ++ ignoredRootCreates.flatMap { templateId =>
Seq("--ignore-root-create", QualifiedName(templateId).toString)
} ++ ignoredRootExercises.flatMap { case (templateId, choice) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ class WalletIntegrationTest

"A wallet" should {

"tap stupid amount" in { implicit env =>
// TODO (#2336): unignore this test
"tap stupid amount" ignore { implicit env =>
import com.digitalasset.daml.lf.data.Numeric
val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend)
val round = sv1ScanBackend.getLatestOpenMiningRound(env.environment.clock.now)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,10 @@ class ScanApp(

tokenStandardMetadataHandler = new HttpTokenStandardMetadataHandler(
store,
acsSnapshotStore,
config.spliceInstanceNames,
packageVersionSupport,
clock,
loggerFactory,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,25 @@
package org.lfdecentralizedtrust.splice.scan.admin.http

import cats.data.OptionT
import com.digitalasset.canton.data.CantonTimestamp
import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging}
import com.digitalasset.canton.time.Clock
import com.digitalasset.canton.tracing.{Spanning, TraceContext}
import org.lfdecentralizedtrust.splice.config.SpliceInstanceNamesConfig
import org.lfdecentralizedtrust.splice.environment.PackageVersionSupport
import org.lfdecentralizedtrust.splice.scan.admin.http.HttpTokenStandardMetadataHandler.TotalSupply
import org.lfdecentralizedtrust.tokenstandard.metadata.v1
import org.lfdecentralizedtrust.splice.scan.store.ScanStore
import org.lfdecentralizedtrust.splice.scan.store.{AcsSnapshotStore, ScanStore}

import java.time.ZoneOffset
import java.time.{Instant, ZoneOffset}
import scala.concurrent.{ExecutionContext, Future}

class HttpTokenStandardMetadataHandler(
store: ScanStore,
acsSnapshotStore: AcsSnapshotStore,
spliceInstanceNames: SpliceInstanceNamesConfig,
packageVersionSupport: PackageVersionSupport,
clock: Clock,
protected val loggerFactory: NamedLoggerFactory,
)(implicit ec: ExecutionContext)
extends v1.Handler[TraceContext]
Expand Down Expand Up @@ -60,12 +67,43 @@ class HttpTokenStandardMetadataHandler(
}
}

private def lookupTotalSupply()(implicit ec: ExecutionContext, tc: TraceContext) = (
private def lookupTotalSupplyByLatestRound()(implicit ec: ExecutionContext, tc: TraceContext) =
for {
(latestRoundNr, effectiveAt) <- OptionT(store.lookupRoundOfLatestData())
totalSupply <- OptionT.liftF(store.getTotalAmuletBalance(latestRoundNr))
} yield (totalSupply, effectiveAt)
).value
} yield TotalSupply(amount = totalSupply, asOfTimestamp = effectiveAt)

private def lookupTotalSupplyByLatestAcsSnapshot()(implicit tc: TraceContext) = {
for {
latestSnapshot <- OptionT(
acsSnapshotStore.lookupSnapshotBefore(
acsSnapshotStore.currentMigrationId,
CantonTimestamp.now(),
)
)
unlocked <- OptionT.fromOption[Future](latestSnapshot.unlockedAmuletBalance)
locked <- OptionT.fromOption[Future](latestSnapshot.lockedAmuletBalance)
} yield TotalSupply(
amount = locked + unlocked,
asOfTimestamp = latestSnapshot.snapshotRecordTime.toInstant,
)
}

private def lookupTotalSupply()(implicit tc: TraceContext) = {
for {
noHoldingFeesOnTransfers <- packageVersionSupport.noHoldingFeesOnTransfers(
store.key.dsoParty,
clock.now,
)
deductHoldingFees = !noHoldingFeesOnTransfers.supported
result <-
if (deductHoldingFees) {
lookupTotalSupplyByLatestRound().value
} else {
lookupTotalSupplyByLatestAcsSnapshot().orElse(lookupTotalSupplyByLatestRound()).value
Comment thread
OriolMunoz-da marked this conversation as resolved.
}
} yield result
}

private def getAmuletInstrument()(implicit ec: ExecutionContext, tc: TraceContext) =
for {

@OriolMunoz-da OriolMunoz-da Sep 18, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

called in the line just below this

there aren't any tests... I'm also not sure there's much value in one since there already are for the endpoints that use the 2 options directly, but lmk

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, at least it can be checked in the sanity plugin: #2318 (review)

Expand All @@ -75,8 +113,8 @@ class HttpTokenStandardMetadataHandler(
name = spliceInstanceNames.amuletName,
symbol = spliceInstanceNames.amuletNameAcronym,
decimals = 10,
totalSupply = optSupply.map(_._1.toString()),
totalSupplyAsOf = optSupply.map(_._2.atOffset(ZoneOffset.UTC)),
totalSupply = optSupply.map(_.amount.toString()),
totalSupplyAsOf = optSupply.map(_.asOfTimestamp.atOffset(ZoneOffset.UTC)),
supportedApis = Map(
"splice-api-token-metadata-v1" -> 1,
"splice-api-token-holding-v1" -> 1,
Expand All @@ -89,3 +127,7 @@ class HttpTokenStandardMetadataHandler(
)

}

object HttpTokenStandardMetadataHandler {
case class TotalSupply(amount: BigDecimal, asOfTimestamp: Instant)
}
52 changes: 47 additions & 5 deletions scripts/scan-txlog/scan_txlog.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,19 @@ async def get_acs_snapshot_page_at(
json = await response.json()
return json

async def get_amulet_token_metadata(self):
response = await self.session.get(f"{self.url}/registry/metadata/v1/instruments/Amulet")
try:
response.raise_for_status()
except Exception as e:
text = await response.text()
LOG.error(f"Failed to get amulet token metadata: {e}, response: {text}")
raise e

json = await response.json()
return json



# Daml Decimals have a precision of 38 and a scale of 10, i.e., 10 digits after the decimal point.
# Rounding is round_half_even.
Expand All @@ -353,9 +366,13 @@ def __init__(self, decimal):
Decimal("0.0000000001"), rounding=ROUND_HALF_EVEN
)
else:
self.decimal = decimal.quantize(
Decimal("0.0000000001"), rounding=ROUND_HALF_EVEN
)
try:
self.decimal = decimal.quantize(
Decimal("0.0000000001"), rounding=ROUND_HALF_EVEN
)
except Exception as e:
LOG.error(f"Failed to treat {decimal} as DamlDecimal: {e}")
raise e

def __mul__(self, other):
return DamlDecimal(self.decimal * other.decimal)
Expand Down Expand Up @@ -3881,7 +3898,7 @@ def handle_root_exercised_event(self, transaction, event):
)
return HandleTransactionResult.empty()

def balance_end_of_round(self):
def get_per_party_balances(self):
amulets = self.list_contracts(TemplateQualifiedNames.amulet)
locked_amulets = self.list_contracts(TemplateQualifiedNames.locked_amulet)
per_party_balances = {}
Expand Down Expand Up @@ -3926,6 +3943,16 @@ def effective_for_round(self, round_number):
# we deliberately cap the sum as opposed to each individual amulet to match scan
return max(total, DamlDecimal("0"))

# ignores holding fees
def sum_amounts(self):
total = DamlDecimal("0")
for amulet in self.amulets:
total += amulet.payload.get_amulet_amount().get_expiring_amount_initial_amount()
for locked_amulet in self.locked_amulets:
amulet = locked_amulet.payload.get_locked_amulet_amulet()
total += amulet.get_amulet_amount().get_expiring_amount_initial_amount()
return total


@dataclass
class AppState:
Expand Down Expand Up @@ -4131,6 +4158,11 @@ def _parse_cli_args():
help="Before CIP 78, holding fees reduce the value of Amulets every round. After it, they do not. This flag enables the old behavior.",
action="store_true",
)
parser.add_argument(
"--compare-balances-with-total-supply",
help="Whether to compare the balances with those computed in the Token Standard 'getInstrument' endpoint",
action="store_true",
)
return parser.parse_args()


Expand Down Expand Up @@ -4168,7 +4200,7 @@ async def _check_scan_balance_assertions(
LOG.info(msg)
round_state = app_state.per_round_states[closed_round]
del app_state.per_round_states[closed_round]
balances = round_state.balance_end_of_round()
balances = round_state.get_per_party_balances()
lines = [msg, f"effective balances for closed round: {closed_round}"]
matches = True
scan_party_balances = await scan_client.party_balances(
Expand Down Expand Up @@ -4331,6 +4363,16 @@ async def main():
if len(missing_in_script) > 0:
missing = [found_in_snapshot[cid] for cid in missing_in_script]
LOG.error(f"Contracts missing in script ACS: {missing}")
if args.compare_balances_with_total_supply:
# this will only work if a snapshot was taken, which is guaranteed by compare_acs_with_snapshot=True
token_metadata = await scan_client.get_amulet_token_metadata()
latest_per_party_balances = app_state.state.get_per_party_balances().values()
# sum up all balances
total_balance = sum([p.sum_amounts() for p in latest_per_party_balances], DamlDecimal(0))
if DamlDecimal(token_metadata['totalSupply']) != total_balance:
LOG.error(f"Total supply mismatch: {token_metadata['totalSupply']} in metadata (as of {token_metadata['totalSupplyAsOf']}), {total_balance} in computed balances (as of {app_state.state.record_time})")


duration = time.time() - begin_t
LOG.info(
f"End run. ({duration:.2f} sec., {tx_count} transaction(s), {scan_client.call_count} Scan API call(s), {scan_client.retry_count} retries)"
Expand Down
Loading