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
4 changes: 2 additions & 2 deletions src/main/resources/bifrost-plutus-min.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -415,15 +415,20 @@ case class DeployBridgeCommand(
tmScriptHash = tmNftPolicy,
pegInScriptHash = pegInWithdrawHash,
pegOutScriptHash = pegOutWithdrawHash,
// Federation identity (config #8-11; publishing these is what lets an SPO join this
// Federation identity (config #8-12; publishing these is what lets an SPO join this
// bridge with NO ban or registry configuration, spec [CFG-3]).
spoBansPolicyId = ByteString.fromArray(spoBansContract.policyId.bytes),
sposRegistryPolicyId = registryPolicy,
treasuryInfoPolicyId = ByteString.fromArray(treasuryInfoContract.policyId.bytes),
// #11: read ON-CHAIN by treasury.ak's Update-Y federation branch ([UY-5]). Every SPO
// also rebuilds the treasury Taproot tree from it, so a wrong value derives a
// well-formed address holding nothing.
yFederation = yFederation
yFederation = yFederation,
// #12: the outpoint the three ids above were derived FROM — the same value printed
// below as `federation-one-shot-ref` and as heimdall's registry/treasury bootstrap.
// Publishing it is what removes the last hand-copied build input from an SPO's config:
// ids identify a script, this rebuilds it.
federationOneShot = federationRef
)

Console.info("Oracle policy", oraclePolicyId.toHex)
Expand Down
144 changes: 78 additions & 66 deletions src/main/scala/binocular/cli/commands/DeployScriptRefsCommand.scala
Original file line number Diff line number Diff line change
Expand Up @@ -121,74 +121,86 @@ case class DeployScriptRefsCommand(dryRun: Boolean = false) extends Command {
//
// `register_spo` would otherwise carry the ~6.7 kB registry script twice and miss the 16 kB
// limit, and `apply-ban` carries spo_bans plus a fault verifier. Their scripts cannot be
// rebuilt from the Config, which publishes only the finished policy ids, so this needs the
// federation one-shot — and the ban schedule, which is an INPUT to the ban policy id and is
// therefore read back from the deployed Config rather than from local config.
val federationScripts: List[(String, Script.PlutusV3)] =
config.bridge.federationOneShotRef.map(_.trim).filter(_.nonEmpty) match {
case None =>
Console.warn(
"bridge.federation-one-shot-ref is not set — publishing the completion half " +
"only. The SPO half (spos_registry, spo_bans, 3 fault verifiers) needs " +
"it; set it to the outpoint deploy-bridge printed and re-run."
)
Nil
case Some(refStr) =>
val fedInput = parseRef("federation-one-shot-ref", refStr)
val configAddress =
Address(
network,
Credential.ScriptHash(ScriptHash.fromHex(cfg.configNftPolicyId))
)
val (_, deployed) = BridgeSweepSetup
.loadConfig(
provider,
configAddress,
ScriptHash.fromHex(cfg.configNftPolicyId),
AssetName(configNftAsset),
timeout
)
.valueOr { err =>
Console.error(err); break(1)
}
val federation = FederationScripts.derive(
blueprint,
ByteString.fromArray(fedInput.transactionId.bytes),
BigInt(fedInput.index),
configNftPolicy,
(
deployed.params.baseBanDurationMs,
deployed.params.maxFaultsBeforePermanent,
deployed.params.maxValidityWindowMs
)
// rebuilt from a policy id, so this needs the federation one-shot — which the Config now
// publishes at #12, alongside the ban schedule (an INPUT to the ban policy id) it already
// published. Both come from the deployed Config, so this half is no longer conditional on
// an operator having set a local key: it used to default to OFF, which meant a default
// deployment published no registry reference script at all and every SPO deployed their
// own copy.
val federationScripts: List[(String, Script.PlutusV3)] = {
val configAddress =
Address(
network,
Credential.ScriptHash(ScriptHash.fromHex(cfg.configNftPolicyId))
)
val (_, deployed) = BridgeSweepSetup
.loadConfig(
provider,
configAddress,
ScriptHash.fromHex(cfg.configNftPolicyId),
AssetName(configNftAsset),
timeout
)
.valueOr { err =>
Console.error(err); break(1)
}
val fedInput = deployed.federationOneShot
// The local key is retired but still parsed, so a stale one is caught rather
// than ignored: it used to be the only source, and silently preferring the
// chain over a value someone deliberately typed would hide a real
// disagreement about which bridge this is.
config.bridge.federationOneShotRef.map(_.trim).filter(_.nonEmpty).foreach { refStr =>
val local = parseRef("bridge.federation-one-shot-ref", refStr)
val same = local.transactionId.bytes.sameElements(
fedInput.id.hash.bytes
) && BigInt(local.index) == fedInput.idx
if !same then {
Console.error(
s"bridge.federation-one-shot-ref = $refStr disagrees with the " +
s"deployed Config #12 = ${fedInput.id.hash.toHex}#${fedInput.idx}. " +
"The Config is authoritative; unset the local key."
)
FederationScripts
.verifyAgainstConfig(federation, deployed)
.valueOr { err =>
Console.error(err); break(1)
}
Console.info("spos_registry script hash", federation.registry.policyId.toHex)
Console.info("spo_bans script hash", federation.bans.policyId.toHex)
Console.info("(verified against the deployed Config)", "#8 / #9 / #10")
println()
// treasury_info is NOT published: nothing ever spends it with the script
// inlined at size — its spend paths are small, and the state UTxO is read as a
// reference input everywhere else.
("spos_registry", federation.registry.script) ::
("spo_bans", federation.bans.script) ::
FaultVerifierContract.Titles.zipWithIndex.map { case (title, i) =>
val label =
List("fault_round1", "fault_round2", "fault_equivocation")(i)
(
label,
FaultVerifierContract(
blueprint,
title,
ByteString.fromArray(federation.registry.policyId.bytes)
).script
)
}
break(1)
}
}
val federation = FederationScripts.derive(
blueprint,
fedInput.id.hash,
fedInput.idx,
configNftPolicy,
(
deployed.params.baseBanDurationMs,
deployed.params.maxFaultsBeforePermanent,
deployed.params.maxValidityWindowMs
)
)
FederationScripts
.verifyAgainstConfig(federation, deployed)
.valueOr { err =>
Console.error(err); break(1)
}
Console.info("spos_registry script hash", federation.registry.policyId.toHex)
Console.info("spo_bans script hash", federation.bans.policyId.toHex)
Console.info("(verified against the deployed Config)", "#8 / #9 / #10 / #12")
println()
// treasury_info is NOT published: nothing ever spends it with the script
// inlined at size — its spend paths are small, and the state UTxO is read as a
// reference input everywhere else.
("spos_registry", federation.registry.script) ::
("spo_bans", federation.bans.script) ::
FaultVerifierContract.Titles.zipWithIndex.map { case (title, i) =>
val label =
List("fault_round1", "fault_round2", "fault_equivocation")(i)
(
label,
FaultVerifierContract(
blueprint,
title,
ByteString.fromArray(federation.registry.policyId.bytes)
).script
)
}
}

if dryRun then {
Console.success("Dry-run complete (computed hashes, not submitting)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ object UpdateConfigCommand {
}

/** Field count of the rev-5.5 Config datum (spec §Config datum). */
val ConfigFieldCount = 12
val ConfigFieldCount = 13

/** Decode the deployed Config datum for an UPDATE, refusing any Constr arity other than
* [[ConfigFieldCount]]. Appends are the legal datum evolution and read-only consumers ignore
Expand Down Expand Up @@ -358,7 +358,8 @@ object UpdateConfigCommand {
"spo_bans_policy_id",
"spos_registry_policy_id",
"treasury_info_policy_id",
"y_federation"
"y_federation",
"federation_one_shot"
)

/** The governed parameter edits (config fields 7-10 and inside field 14). All optional: `None`
Expand Down
14 changes: 12 additions & 2 deletions src/main/scala/binocular/watchtower/ConfigTypes.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package binocular.watchtower

import scalus.cardano.onchain.plutus.v3.TxOutRef
import scalus.uplc.builtin.*
import scalus.uplc.builtin.Data.{FromData, ToData}

Expand Down Expand Up @@ -35,7 +36,7 @@ import scalus.uplc.builtin.Data.{FromData, ToData}
// Gone in rev 5.5: treasuryInfoAssetName — the Treasury state NFT name is the [CFG-4] constant
// [[ConfigDatum.TreasuryInfoAssetName]].
//
// Must mirror ft `config.ak::ConfigDatum` (12 fields), because `config.config`'s genesis path
// Must mirror ft `config.ak::ConfigDatum` (13 fields), because `config.config`'s genesis path
// full-casts the datum, so deploy-bridge must write all of them.
case class ConfigDatum(
updateAuth: scalus.cardano.onchain.plutus.prelude.Option[AuthorizationMethod],
Expand All @@ -49,7 +50,16 @@ case class ConfigDatum(
spoBansPolicyId: ByteString,
sposRegistryPolicyId: ByteString,
treasuryInfoPolicyId: ByteString,
yFederation: ByteString
yFederation: ByteString,
// #12: the one-shot outpoint every federation script is compile-parameterized by, so the three
// policy ids above are FUNCTIONS of it. The ids serve a node that only READS the bridge;
// producing script bytes needs the inputs behind them, and a hash cannot be inverted. Without
// this field every operator hand-copied the outpoint from the deployer's terminal into their
// own config — to deploy a reference script, or to spend treasury_info, which is embedded
// rather than referenced. NO on-chain reader; the UTxO was consumed at genesis and is gone,
// only its identity matters. Encodes as Constr(0, [B(txId), I(idx)]) — Aiken's V3
// OutputReference, the same shape `banBootstrapRedeemer` writes by hand.
federationOneShot: TxOutRef
) derives FromData,
ToData

Expand Down
50 changes: 27 additions & 23 deletions src/test/scala/binocular/BifrostContractsTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import java.nio.file.{Files, Path, Paths}
* `tm_nft_policy_id` param (3 params now). `completed_peg_outs_merkle_tree_validator` is WITHDRAWN
* in the ft tree (replaced by `bridge-state.ak`); the min-json keeps its LAST published
* compiledCode for the interim TM Confirm trie flow, so its pin did not move.
*
* Refreshed again for WI-090 (2026-08-14): ConfigDatum gained #12 `federation_one_shot`, and the
* genesis full cast moved `config.config` once more, cascading through the config policy into all
* five pins below. The min-json re-vendor also absorbed `treasury_info`'s pre-existing build
* drift, which is why [[RebuildDrift]] could go empty in the same commit.
*/
class BifrostContractsTest extends AnyFunSuite {

Expand Down Expand Up @@ -79,20 +84,20 @@ class BifrostContractsTest extends AnyFunSuite {

test("config NFT policy matches the deployed value") {
assert(
hex(configContract.policyId) == "1e442c11fa84a722a5c9a4e59b23a72945124399fb6c4090744a24dd"
hex(configContract.policyId) == "c4402d1ada1c5db4af451082f8ef2caf214097dd919148c5efe4c909"
)
}

test("bridged_token policy matches the deployed value") {
val bt = BridgedTokenContract(blueprint, configPolicy)
assert(hex(bt.policyId) == "231e3baf3e8921534c1bb59fe70fa69947ff970fe1187b8d60a83330")
assert(hex(bt.policyId) == "84c085c4fa43eec1de31a7f459fa12d18f857e4bb671bf0bb0ed665b")
}

test("completed-peg-ins policy + asset name match the Variant B rebuild") {
// policyId regression lock over the Variant B rebuild. The asset name is now the constant
// "CPI" (bytes 435049), independent of the one-shot ref and the compiledCode.
val cpi = CompletedPegInsContract(blueprint, configPolicy, cpiRef)
assert(hex(cpi.policyId) == "7567b9ff44e7c51dea69683da0f45accffd3b9ea0903b37cc20e6a86")
assert(hex(cpi.policyId) == "fa4698345161667fe72decbc075e2be8325f458069d2205455cc9026")
assert(CompletedPegInsContract.assetName == ByteString.fromString("CPI"))
}

Expand All @@ -111,7 +116,7 @@ class BifrostContractsTest extends AnyFunSuite {
// hash moved; ConfigDatum field 5 must name the new one at deployment.
val pegIn =
PegInContract(blueprint, oraclePolicy, configPolicy)
assert(hex(pegIn.policyId) == "1510d62783c1f3511d009162aa5bef425f8e1f895f7aca80bd66e035")
assert(hex(pegIn.policyId) == "aa767109f00f937fae6299315bae9a98de6a4406765f94fe3425b42c")
}

test("peg_out policy (= withdraw hash) is stable for the trie-v2 2-param encoding") {
Expand All @@ -126,7 +131,7 @@ class BifrostContractsTest extends AnyFunSuite {
// [[PegOutCompleteCekTest]] runs them — so the stale copy would have made every completion
// fail on-chain. No other validator's compiledCode changed, so no other pin moved.
val pegOut = PegOutContract(blueprint, configPolicy)
assert(hex(pegOut.policyId) == "a5a39919b3a0fb699e0f44d98a6039825c21d317f4cbe24fd54cf143")
assert(hex(pegOut.policyId) == "0fc35057187dccea740a3f6f5742e85ac89854eec3f8066b274eaa89")
}

// --- determinism + parameter-sensitivity ---
Expand Down Expand Up @@ -233,28 +238,27 @@ class BifrostContractsTest extends AnyFunSuite {
).map(Paths.get(_)).find(Files.isReadable)
}

/** Validators whose vendored bytes are allowed to differ from ft's committed `plutus.json`,
* because the difference is BUILD DRIFT and not a source change.
/** Validators exempt from the freshness check below. EMPTY, and that is the point.
*
* ft's `plutus.json` does not reproduce from its own source: the committed copy was built by a
* local `aiken v1.1.23+unknown` while CI installs `v1.1.23+8949565`, and the two emit
* different bytes for the same validators. So every ft commit that rebuilds the blueprint
* moves `config` and `treasury` whether or not anything in `config.ak` / `treasury.ak` changed
* — WI-073 is the case in point: `git diff ad04ecb..ft-main -- onchain/` touches only
* `bitcoin.ak` and `peg-in.ak`, yet both these hashes moved with it.
* It used to hold `config` and `treasury`, because ft's `plutus.json` did not reproduce from
* its own source — the committed copy came from a local `aiken v1.1.23+unknown` while CI
* installs `v1.1.23+8949565`, so every ft commit that rebuilt the blueprint moved those two
* whether or not their `.ak` changed. Carrying the deployed bytes and exempting them here was
* the honest position while that held, since `config`'s hash IS the config NFT policy id and
* absorbing a rebuild-only change re-identifies the whole bridge for no semantic reason.
*
* Taking those bytes anyway is not free. `config`'s hash IS the config NFT policy id, and that
* id parameterizes bridged_token, completed_peg_ins, peg_in and peg_out, so vendoring a
* rebuild-only change re-identifies the entire bridge and forces a redeploy for no semantic
* reason. Until ft's blueprint builds deterministically, the honest position is to carry the
* bytes we deployed against and exempt them HERE, in the open, rather than let the check go
* green by accident.
* Measured 2026-08-14 and no longer true: a pristine rebuild at the pinned `compiler =
* "v1.1.23"` (local `v1.1.23+8949565`, the version CI installs) reproduces ft's committed
* blueprint byte for byte — only the validator actually edited moves. The exemption is
* therefore removed as its own comment asked, and a difference here is real again.
*
* REMOVE both entries the moment ft pins its aiken build — at that point a difference here is
* real again.
* The one-time cost of removing it was absorbed by the WI-090 re-vendor: `treasury_info` was
* carrying pre-existing drift, which re-identifies its policy id. That is free HERE only
* because `treasury_info` is parameterized by the config policy id, which the WI-090 datum
* append moves regardless — a new bridge instance either way. Do not read it as a precedent
* for absorbing drift cheaply.
*/
private val RebuildDrift: Set[String] =
Set("bitcoin/config.config.mint", "bitcoin/treasury.treasury_info.mint")
private val RebuildDrift: Set[String] = Set.empty

test("every vendored compiledCode is ft's current one") {
// Freshness against ft, for the WHOLE vendored set — a stale peg_in or peg_out is just
Expand Down
Loading
Loading