Skip to content

Commit 52218f0

Browse files
committed
feat: upgradable proxy validator
1 parent 9b209de commit 52218f0

File tree

3 files changed

+592
-0
lines changed

3 files changed

+592
-0
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package scalus.examples.upgradeableproxy
2+
3+
import scalus.*
4+
import scalus.cardano.onchain.plutus.prelude.*
5+
import scalus.cardano.onchain.plutus.v1.{Credential, PubKeyHash}
6+
import scalus.cardano.onchain.plutus.v2.OutputDatum
7+
import scalus.cardano.onchain.plutus.v3.*
8+
import scalus.uplc.builtin.{Data, FromData, ToData}
9+
import scalus.cardano.onchain.plutus.v3.Validator
10+
11+
/** Upgradeable proxy pattern for Cardano smart contracts.
12+
*
13+
* The pattern works by having a spend validator ensure that a stake validator has been called by
14+
* checking that
15+
* - a withdrawal has been made;
16+
* - the withdrawal has been made from a known trusted script address.
17+
*
18+
* A combination of these conditions ensure that the validator has been called, thus allowing a
19+
* forced script composition.
20+
*
21+
* This also ensures that the logic can be changed (upgraded), by updating the known trusted script
22+
* address, that is stored in the datum. In this illustration, a proxy has an owner, which can
23+
* update the datum. In a real application, a more sophisticated approach should be preferred, to
24+
* ensure trustlessness.
25+
*/
26+
27+
case class ProxyDatum(logicHash: ValidatorHash, owner: PubKeyHash) derives FromData, ToData
28+
29+
enum ProxyRedeemer derives FromData, ToData:
30+
case Call
31+
32+
case Upgrade(newLogicHash: ValidatorHash)
33+
34+
/** Spending validator for the upgradeable proxy.
35+
*
36+
* Stores the active logic script hash in the datum. Two actions:
37+
*
38+
* - `Call` - verifies the logic stake script was withdrawn and the datum is unchanged.
39+
* - `Upgrade` - owner replaces the logic script hash; value must be preserved.
40+
*/
41+
@Compile
42+
object ProxyValidator extends Validator {
43+
44+
inline override def spend(
45+
datum: Option[Data],
46+
redeemer: Data,
47+
tx: TxInfo,
48+
ownRef: TxOutRef
49+
): Unit = {
50+
val d = datum.getOrFail(MissingDatum).to[ProxyDatum]
51+
val r = redeemer.to[ProxyRedeemer]
52+
val ownInput = tx.findOwnInputOrFail(ownRef)
53+
54+
val continuationOutput =
55+
tx.outputs
56+
.filter(out => out.address === ownInput.resolved.address)
57+
.headOption
58+
.getOrFail(MissingContinuation)
59+
60+
val continuationDatum = continuationOutput.datum match
61+
case OutputDatum.OutputDatum(d) => d.to[ProxyDatum]
62+
case _ => fail(ContinuationMustHaveInlineDatum)
63+
64+
require(
65+
continuationOutput.value === ownInput.resolved.value,
66+
ValueMustBePreserved
67+
)
68+
69+
r match
70+
case ProxyRedeemer.Call =>
71+
// Ensure the logic stake validator was called
72+
val logicCredential = Credential.ScriptCredential(d.logicHash)
73+
tx.withdrawals.getOrFail(logicCredential, LogicNotInvoked)
74+
75+
// Ensure the proxy UTxO continues with the same datum (state preserved)
76+
require(continuationDatum.logicHash === d.logicHash, LogicHashChanged)
77+
require(continuationDatum.owner === d.owner, OwnerChanged)
78+
79+
case ProxyRedeemer.Upgrade(newLogicHash) =>
80+
// Only the owner can upgrade the logic
81+
require(tx.isSignedBy(d.owner), NotSignedByOwner)
82+
83+
// Continuation output must carry the updated datum
84+
require(continuationDatum.logicHash === newLogicHash, LogicHashMismatch)
85+
require(continuationDatum.owner === d.owner, OwnerChanged)
86+
}
87+
88+
inline val MissingDatum = "Proxy datum must be present"
89+
inline val LogicNotInvoked = "Logic stake validator must be invoked in this transaction"
90+
inline val MissingContinuation = "Proxy continuation output not found"
91+
inline val ContinuationMustHaveInlineDatum = "Continuation output must have an inline datum"
92+
inline val LogicHashChanged = "Logic hash must not change on Call"
93+
inline val OwnerChanged = "Owner must not change"
94+
inline val NotSignedByOwner = "Transaction must be signed by the proxy owner"
95+
inline val LogicHashMismatch = "Continuation datum logic hash does not match upgrade target"
96+
inline val ValueMustBePreserved = "Proxy value must be preserved"
97+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package scalus.examples.upgradeableproxy
2+
3+
import scalus.*
4+
import scalus.cardano.address.{Address, StakeAddress}
5+
import scalus.cardano.ledger.*
6+
import scalus.cardano.txbuilder.*
7+
import scalus.compiler.Options
8+
import scalus.uplc.PlutusV3
9+
import scalus.uplc.builtin.Data
10+
11+
private object ProxyCompilation:
12+
private given Options = Options.release
13+
lazy val contract = PlutusV3.compile(ProxyValidator.validate)
14+
15+
lazy val ProxyContract = ProxyCompilation.contract
16+
17+
case class ProxyTransactions(
18+
env: CardanoInfo,
19+
evaluator: PlutusScriptEvaluator,
20+
contract: PlutusV3[Data => Unit]
21+
) {
22+
val script: Script.PlutusV3 = contract.script
23+
val scriptAddress: Address = contract.address(env.network)
24+
25+
/** Creates the proxy UTxO at the script address with an inline datum pointing to `logicHash`.
26+
*/
27+
def deploy(
28+
utxos: Utxos,
29+
value: Value,
30+
logicHash: ScriptHash,
31+
owner: AddrKeyHash,
32+
sponsor: Address,
33+
signer: TransactionSigner
34+
): Transaction = {
35+
val datum = ProxyDatum(
36+
logicHash = logicHash,
37+
owner = scalus.cardano.onchain.plutus.v3.PubKeyHash(owner)
38+
)
39+
TxBuilder(env)
40+
.payTo(scriptAddress, value, datum)
41+
.complete(availableUtxos = utxos, sponsor = sponsor)
42+
.sign(signer)
43+
.transaction
44+
}
45+
46+
/** Invokes the proxy by withdrawing from `logicStakeAddress`, triggering the logic script. */
47+
def call(
48+
utxos: Utxos,
49+
proxyUtxo: Utxo,
50+
logicStakeAddress: StakeAddress,
51+
logicWitness: ScriptWitness,
52+
sponsor: Address,
53+
signer: TransactionSigner
54+
): Transaction = {
55+
val datum = proxyUtxo.output.requireInlineDatum
56+
TxBuilder(env, evaluator)
57+
.spend(proxyUtxo, ProxyRedeemer.Call, script)
58+
.payTo(scriptAddress, proxyUtxo.output.value, datum)
59+
.withdrawRewards(logicStakeAddress, Coin.zero, logicWitness)
60+
.complete(availableUtxos = utxos, sponsor = sponsor)
61+
.sign(signer)
62+
.transaction
63+
}
64+
65+
/** Upgrades the proxy to a new logic stake validator; must be signed by `ownerPkh`. */
66+
def upgrade(
67+
utxos: Utxos,
68+
proxyUtxo: Utxo,
69+
newLogicHash: ScriptHash,
70+
ownerPkh: AddrKeyHash,
71+
sponsor: Address,
72+
signer: TransactionSigner
73+
): Transaction = {
74+
val oldDatum = proxyUtxo.output.requireInlineDatum.to[ProxyDatum]
75+
val newDatum = oldDatum.copy(logicHash = newLogicHash)
76+
TxBuilder(env, evaluator)
77+
.spend(proxyUtxo, ProxyRedeemer.Upgrade(newLogicHash), script, Set(ownerPkh))
78+
.payTo(scriptAddress, proxyUtxo.output.value, newDatum)
79+
.complete(availableUtxos = utxos, sponsor = sponsor)
80+
.sign(signer)
81+
.transaction
82+
}
83+
}

0 commit comments

Comments
 (0)