Skip to content

Commit be27313

Browse files
committed
Fix wrong inputs due to mutable contract outputs
1 parent 635234a commit be27313

File tree

7 files changed

+222
-17
lines changed

7 files changed

+222
-17
lines changed

app/src/main/scala/org/alephium/explorer/persistence/Migrations.scala

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import org.alephium.protocol.model.BlockHash
3636
@SuppressWarnings(Array("org.wartremover.warts.AnyVal"))
3737
object Migrations extends StrictLogging {
3838

39-
val latestVersion: MigrationVersion = MigrationVersion(4)
39+
val latestVersion: MigrationVersion = MigrationVersion(5)
4040

4141
def migration1(implicit ec: ExecutionContext): DBActionAll[Unit] = {
4242
// We retrigger the download of fungible and non-fungible tokens' metadata that have sub-category
@@ -90,7 +90,9 @@ object Migrations extends StrictLogging {
9090
private def migrations(implicit ec: ExecutionContext): Seq[DBActionAll[Unit]] = Seq(
9191
migration1,
9292
migration2,
93-
migration3
93+
migration3,
94+
migration4,
95+
migration5
9496
)
9597

9698
def backgroundCoinbaseMigration()(implicit
@@ -148,6 +150,66 @@ object Migrations extends StrictLogging {
148150
"""
149151
}
150152

153+
/*
154+
* Empty transaction due to the coinbase migration being disabled.
155+
*/
156+
def migration4: DBActionAll[Unit] = DBIOAction.successful(())
157+
158+
/*
159+
* Update the inputs with the correct output amount from the mutable contract outputs.
160+
*/
161+
def migration5(implicit ec: ExecutionContext): DBActionAll[Unit] = {
162+
for {
163+
i1 <- sqlu"""
164+
UPDATE inputs i
165+
SET output_ref_tx_hash = o2.tx_hash, output_ref_amount = o2.amount, output_ref_tokens = o2.tokens
166+
FROM outputs o1
167+
JOIN outputs o2
168+
ON
169+
o1.address = o2.address
170+
AND o1.key = o2.key
171+
AND o1.tx_hash = o2.tx_hash
172+
AND o1.main_chain = FALSE
173+
AND o2.main_chain = TRUE
174+
AND o1.amount <> o2.amount
175+
AND o1.block_hash <> o2.block_hash
176+
AND o1.fixed_output = FALSE
177+
AND o2.fixed_output = FALSE
178+
WHERE
179+
i.output_ref_address = o1.address
180+
AND i.output_ref_key = o1.key
181+
AND i.output_ref_tx_hash = o1.tx_hash
182+
AND i.main_chain = TRUE
183+
AND i.output_ref_amount <> o2.amount;
184+
"""
185+
i2 <- sqlu"""
186+
-- Update inputs with the correct output amount from main_chain = FALSE
187+
UPDATE inputs i
188+
SET output_ref_tx_hash = o1.tx_hash, output_ref_amount = o1.amount, output_ref_tokens = o1.tokens
189+
FROM outputs o1
190+
JOIN outputs o2
191+
ON
192+
o1.address = o2.address
193+
AND o1.key = o2.key
194+
AND o1.tx_hash = o2.tx_hash
195+
AND o1.main_chain = FALSE
196+
AND o2.main_chain = TRUE
197+
AND o1.amount <> o2.amount
198+
AND o1.block_hash <> o2.block_hash
199+
AND o1.fixed_output = FALSE
200+
AND o2.fixed_output = FALSE
201+
WHERE
202+
i.output_ref_address = o1.address
203+
AND i.output_ref_key = o1.key
204+
AND i.output_ref_tx_hash = o1.tx_hash
205+
AND i.main_chain = FALSE
206+
AND i.output_ref_amount <> o1.amount;
207+
"""
208+
} yield {
209+
logger.info(s"Updated ${i1 + i2} inputs with the correct output amount")
210+
}
211+
}
212+
151213
def migrationsQuery(
152214
versionOpt: Option[MigrationVersion]
153215
)(implicit ec: ExecutionContext): DBActionAll[Unit] = {
@@ -183,7 +245,7 @@ object Migrations extends StrictLogging {
183245
case Some(MigrationVersion(current)) if current > latestVersion.version =>
184246
throw new Exception("Incompatible migration versions, please reset your database")
185247
case Some(MigrationVersion(current)) =>
186-
if (current <= 3) {
248+
if (current <= 5) {
187249
logger.info(s"Background migrations needed, but will be done in a future release")
188250
/*
189251
* The coinbase migration is heavy and we had some performance issues due to the increase of users.

app/src/main/scala/org/alephium/explorer/persistence/queries/InputUpdateQueries.scala

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,22 @@ object InputUpdateQueries {
3636
(Address, Option[ArraySeq[Token]], TransactionId, BlockHash, TimeStamp, Int, Boolean)
3737

3838
def updateInputs()(implicit ec: ExecutionContext): DBActionWT[Unit] = {
39+
(for {
40+
fixed <- updateFixedInputs()
41+
mutableContracts <- updateMutableContractInputs()
42+
contracts <- updateContractInputs()
43+
_ <- internalUpdates(fixed ++ contracts ++ mutableContracts)
44+
} yield {}).transactionally
45+
}
46+
47+
/*
48+
* Updates the inputs table with information from fixed (immutable) outputs.
49+
*
50+
* This function handles cases where the output is guaranteed to be immutable, meaning:
51+
* - Each output reference key (`output_ref_key`) is unique.
52+
* - The output's amount, tokens, and address will always remain the same for a given key.
53+
*/
54+
private def updateFixedInputs() = {
3955
sql"""
4056
UPDATE inputs
4157
SET
@@ -46,11 +62,65 @@ object InputUpdateQueries {
4662
FROM outputs
4763
WHERE inputs.output_ref_key = outputs.key
4864
AND inputs.output_ref_amount IS NULL
65+
AND inputs.contract_input = false
66+
AND outputs.fixed_output = true
67+
RETURNING outputs.address, outputs.tokens, inputs.tx_hash, inputs.block_hash, inputs.block_timestamp, inputs.tx_order, inputs.main_chain
68+
"""
69+
.as[UpdateReturn]
70+
}
71+
72+
/*
73+
* Updates the inputs table with information from mutable contract outputs.
74+
*
75+
* This function handles cases where:
76+
* - The referenced contract outputs are mutable, meaning their amount can differ for the same key
77+
* based on blockchain context (e.g., main chain vs. side chains).
78+
* - Each input-output pair may have different amounts, depending on whether it's
79+
* from the main chain, side chain, or an uncle block.
80+
*/
81+
private def updateMutableContractInputs() = {
82+
sql"""
83+
UPDATE inputs
84+
SET
85+
output_ref_tx_hash = outputs.tx_hash,
86+
output_ref_address = outputs.address,
87+
output_ref_amount = outputs.amount,
88+
output_ref_tokens = outputs.tokens
89+
FROM outputs
90+
WHERE inputs.output_ref_key = outputs.key
91+
AND inputs.output_ref_amount IS NULL
92+
AND inputs.main_chain = outputs.main_chain
93+
AND inputs.contract_input = true
94+
AND outputs.fixed_output = false
95+
RETURNING outputs.address, outputs.tokens, inputs.tx_hash, inputs.block_hash, inputs.block_timestamp, inputs.tx_order, inputs.main_chain
96+
"""
97+
.as[UpdateReturn]
98+
}
99+
100+
/*
101+
* Updates the inputs table for contract outputs where the amount is the same
102+
* between main chain and side chain outputs.
103+
*
104+
* This function is similar to `updateMutableContractInputs`, but it does **not**
105+
* require the `main_chain` status to match between inputs and outputs.
106+
* This is useful for general contract outputs not covered by `updateMutableContractInputs`.
107+
*/
108+
private def updateContractInputs() = {
109+
sql"""
110+
UPDATE inputs
111+
SET
112+
output_ref_tx_hash = outputs.tx_hash,
113+
output_ref_address = outputs.address,
114+
output_ref_amount = outputs.amount,
115+
output_ref_tokens = outputs.tokens
116+
FROM outputs
117+
WHERE inputs.output_ref_key = outputs.key
118+
AND inputs.output_ref_amount IS NULL
119+
AND inputs.contract_input = true
120+
AND outputs.fixed_output = false
49121
RETURNING outputs.address, outputs.tokens, inputs.tx_hash, inputs.block_hash, inputs.block_timestamp, inputs.tx_order, inputs.main_chain
50122
"""
51123
.as[UpdateReturn]
52-
.flatMap(internalUpdates)
53-
.transactionally
54124
}
55125

56126
// format: off

app/src/test/scala/org/alephium/explorer/ExplorerSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ trait ExplorerSpec
396396
tx.outputs.exists(_.address == address) || tx.inputs
397397
.exists(_.address == Some(address))
398398
}
399-
}.distinct
399+
}
400400

401401
val res = response.as[ArraySeq[Transaction]]
402402

app/src/test/scala/org/alephium/explorer/GenCoreApi.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import org.alephium.protocol.model.{
4242
GroupIndex,
4343
Hint,
4444
NetworkId,
45+
ScriptHint,
4546
Target
4647
}
4748
import org.alephium.serde._
@@ -144,7 +145,7 @@ object GenCoreApi {
144145
unsigned <- unsignedTxGen
145146
scriptExecutionOk <- arbitrary[Boolean]
146147
contractInputsSize <- Gen.choose(0, 1)
147-
contractInputs <- Gen.listOfN(contractInputsSize, outputRefProtocolGen)
148+
contractInputs <- Gen.listOfN(contractInputsSize, contractOutputRefProtocolGen)
148149
generatedOutputsSize <- Gen.choose(0, 1)
149150
generatedOutputs <- Gen.listOfN(generatedOutputsSize, outputProtocolGen)
150151
inputSignatures <- Gen.listOfN(1, bytesGen)
@@ -358,6 +359,11 @@ object GenCoreApi {
358359
key <- hashGen
359360
} yield OutputRef(hint, key)
360361

362+
val contractOutputRefProtocolGen: Gen[OutputRef] = for {
363+
scriptHash <- hashGen
364+
key <- hashGen
365+
} yield OutputRef(Hint.ofContract(ScriptHint.fromHash(scriptHash)).value, key)
366+
361367
val inputProtocolGen: Gen[AssetInput] = for {
362368
outputRef <- outputRefProtocolGen
363369
unlockScript <- unlockScriptProtocolGen

app/src/test/scala/org/alephium/explorer/GenDBModel.scala

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ object GenDBModel {
8383
fixedOutput = fixedOutput
8484
)
8585

86+
val fixedOutputEntityGen: Gen[OutputEntity] = outputEntityGen.map(_.copy(fixedOutput = true))
87+
val contractOutputEntityGen: Gen[OutputEntity] = outputEntityGen.map(_.copy(fixedOutput = false))
88+
8689
val finalizedOutputEntityGen: Gen[OutputEntity] =
8790
for {
8891
output <- outputEntityGen
@@ -121,10 +124,9 @@ object GenDBModel {
121124
@SuppressWarnings(Array("org.wartremover.warts.DefaultArguments"))
122125
def inputEntityGen(outputEntityGen: Gen[OutputEntity] = outputEntityGen): Gen[InputEntity] =
123126
for {
124-
outputEntity <- outputEntityGen
125-
unlockScript <- Gen.option(unlockScriptGen)
126-
txOrder <- arbitrary[Int]
127-
contractInput <- arbitrary[Boolean]
127+
outputEntity <- outputEntityGen
128+
unlockScript <- Gen.option(unlockScriptGen)
129+
txOrder <- arbitrary[Int]
128130
} yield {
129131
InputEntity(
130132
blockHash = outputEntity.blockHash,
@@ -140,10 +142,18 @@ object GenDBModel {
140142
None,
141143
None,
142144
None,
143-
contractInput
145+
!outputEntity.fixedOutput
144146
)
145147
}
146148

149+
def fixedInputEntityGen(outputEntityGen: Gen[OutputEntity] = outputEntityGen): Gen[InputEntity] =
150+
inputEntityGen(outputEntityGen).map(_.copy(contractInput = false))
151+
152+
def contractInputEntityGen(
153+
outputEntityGen: Gen[OutputEntity] = outputEntityGen
154+
): Gen[InputEntity] =
155+
inputEntityGen(outputEntityGen).map(_.copy(contractInput = true))
156+
147157
@SuppressWarnings(Array("org.wartremover.warts.DefaultArguments"))
148158
def uinputEntityGen(
149159
transactionHash: Gen[TransactionId] = transactionHashGen,

app/src/test/scala/org/alephium/explorer/persistence/queries/InputUpdateQueriesSpec.scala

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package org.alephium.explorer.persistence.queries
1919
import slick.jdbc.PostgresProfile.api._
2020

2121
import org.alephium.explorer.AlephiumFutureSpec
22+
import org.alephium.explorer.GenCoreProtocol._
23+
import org.alephium.explorer.GenCoreUtil._
2224
import org.alephium.explorer.GenDBModel._
2325
import org.alephium.explorer.persistence.{DatabaseFixtureForEach, DBRunner}
2426
import org.alephium.explorer.persistence.schema.{InputSchema, OutputSchema}
@@ -27,8 +29,8 @@ import org.alephium.explorer.persistence.schema.CustomJdbcTypes._
2729
class InputUpdateQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForEach with DBRunner {
2830

2931
"Input Update" should {
30-
"update inputs when address is already set" in {
31-
forAll(outputEntityGen, inputEntityGen()) { case (output, input) =>
32+
"update fixed inputs when address is already set" in {
33+
forAll(fixedOutputEntityGen, fixedInputEntityGen()) { case (output, input) =>
3234
run(for {
3335
_ <- OutputSchema.table += output
3436
_ <- InputSchema.table +=
@@ -51,8 +53,8 @@ class InputUpdateQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForE
5153
}
5254
}
5355

54-
"update inputs when address is not set" in {
55-
forAll(outputEntityGen, inputEntityGen()) { case (output, input) =>
56+
"update fixed inputs when address is not set" in {
57+
forAll(fixedOutputEntityGen, fixedInputEntityGen()) { case (output, input) =>
5658
run(for {
5759
_ <- OutputSchema.table += output
5860
_ <- InputSchema.table +=
@@ -68,5 +70,60 @@ class InputUpdateQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForE
6870
updatedInput.outputRefAmount is Some(output.amount)
6971
}
7072
}
73+
74+
"update contract inputs when two outputs with same key have different value. See issue #584" in {
75+
forAll(contractOutputEntityGen, contractInputEntityGen(), amountGen, amountGen) {
76+
case (output, input, amount1, amount2) =>
77+
val mainChainOutput =
78+
output.copy(mainChain = true, amount = amount1)
79+
val nonMainChainOutput =
80+
output.copy(mainChain = false, amount = amount2, blockHash = blockHashGen.sample.get)
81+
82+
val mainChainInput = input.copy(mainChain = true, outputRefKey = mainChainOutput.key)
83+
val nonMainChainInput = input.copy(
84+
mainChain = false,
85+
outputRefKey = nonMainChainOutput.key,
86+
blockHash = blockHashGen.sample.get
87+
)
88+
run(for {
89+
_ <- OutputSchema.table ++= Seq(mainChainOutput, nonMainChainOutput)
90+
_ <- InputSchema.table ++= Seq(mainChainInput, nonMainChainInput)
91+
} yield ()).futureValue
92+
93+
run(InputUpdateQueries.updateInputs()).futureValue
94+
95+
val updatedInputs =
96+
run(InputSchema.table.filter(_.outputRefKey === output.key).result).futureValue
97+
98+
updatedInputs.find(_.mainChain == true).get.outputRefAddress is Some(output.address)
99+
updatedInputs.find(_.mainChain == true).get.outputRefAmount is Some(amount1)
100+
101+
updatedInputs.find(_.mainChain == false).get.outputRefAddress is Some(output.address)
102+
updatedInputs.find(_.mainChain == false).get.outputRefAmount is Some(amount2)
103+
}
104+
}
105+
106+
"update contract 2 input (main_chain, non_main_chain) using same output with same amoun" in {
107+
forAll(contractOutputEntityGen, contractInputEntityGen()) { case (output, input) =>
108+
val input1 = input.copy(mainChain = true, outputRefKey = output.key)
109+
val input2 = input.copy(
110+
mainChain = false,
111+
outputRefKey = output.key,
112+
blockHash = blockHashGen.sample.get
113+
)
114+
run(for {
115+
_ <- OutputSchema.table += output
116+
_ <- InputSchema.table ++= Seq(input1, input2)
117+
} yield ()).futureValue
118+
119+
run(InputUpdateQueries.updateInputs()).futureValue
120+
121+
val updatedInputs =
122+
run(InputSchema.table.filter(_.outputRefKey === output.key).result).futureValue
123+
124+
updatedInputs.foreach(_.outputRefAddress is Some(output.address))
125+
updatedInputs.foreach(_.outputRefAmount is Some(output.amount))
126+
}
127+
}
71128
}
72129
}

app/src/test/scala/org/alephium/explorer/persistence/queries/TransactionQueriesSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ class TransactionQueriesSpec extends AlephiumFutureSpec with DatabaseFixtureForE
658658
false,
659659
None,
660660
None,
661-
fixedOutput = false
661+
fixedOutput = true
662662
)
663663

664664
def input(hint: Int, outputRefKey: Hash): InputEntity =

0 commit comments

Comments
 (0)