From b003bf373232f0206cba5408bcfd5f25f519ced7 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:30:49 +0200 Subject: [PATCH 01/11] feat(basis): add domain-separated v2 reserve contracts --- contracts/offchain/README.md | 12 + contracts/offchain/basis-token-v2.es | 330 +++++++ contracts/offchain/basis-token-v2.p2s | 1 + .../offchain/basis-v2-reproducibility.md | 83 ++ contracts/offchain/basis-v2-review.md | 96 ++ contracts/offchain/basis-v2.es | 312 +++++++ contracts/offchain/basis-v2.md | 155 ++++ contracts/offchain/basis-v2.p2s | 1 + .../contracts/BasisV2ReceiptPrinter.scala | 21 + .../scala/chaincash/contracts/Constants.scala | 12 + src/test/scala/chaincash/BasisV2Spec.scala | 850 ++++++++++++++++++ 11 files changed, 1873 insertions(+) create mode 100644 contracts/offchain/basis-token-v2.es create mode 100644 contracts/offchain/basis-token-v2.p2s create mode 100644 contracts/offchain/basis-v2-reproducibility.md create mode 100644 contracts/offchain/basis-v2-review.md create mode 100644 contracts/offchain/basis-v2.es create mode 100644 contracts/offchain/basis-v2.md create mode 100644 contracts/offchain/basis-v2.p2s create mode 100644 src/main/scala/chaincash/contracts/BasisV2ReceiptPrinter.scala create mode 100644 src/test/scala/chaincash/BasisV2Spec.scala diff --git a/contracts/offchain/README.md b/contracts/offchain/README.md index 106d97a..33749cf 100644 --- a/contracts/offchain/README.md +++ b/contracts/offchain/README.md @@ -2,3 +2,15 @@ Different ChainCash variants for offchain applications. In most cases, reserves are on-chain, notes are created and making progress offchain. + +## Basis contract generations + +- `basis.es` and `basis-token.es` are the existing v1 sources. +- `basis-v2.es` and `basis-token-v2.es` are a separate candidate family with a + versioned claim domain, mandatory prior-state proofs, exact creditor payouts, + fixed emergency height and input-specific successor lineage. + +See `basis-v2.md` for the v2 ABI and migration boundary, and +`basis-v2-reproducibility.md` for the exact source-to-ErgoTree receipt. V2 is a +review candidate; the repository does not claim deployment or automatic +migration of v1 boxes. diff --git a/contracts/offchain/basis-token-v2.es b/contracts/offchain/basis-token-v2.es new file mode 100644 index 0000000..a23703a --- /dev/null +++ b/contracts/offchain/basis-token-v2.es @@ -0,0 +1,330 @@ +{ + // Basis token reserve, ABI generation v2. + // See basis-v2.md. V1 contracts are intentionally left unchanged. + + val packedOpt = getVar[Byte](0) + val selfRegistersDefined = + SELF.R4[GroupElement].isDefined && + SELF.R5[AvlTree].isDefined && + SELF.R6[Coll[Byte]].isDefined && + SELF.R7[Long].isDefined && + SELF.R8[Long].isDefined && + SELF.R9[Coll[Byte]].isDefined + + if (!packedOpt.isDefined || !selfRegistersDefined) { + sigmaProp(false) + } else { + val packed = packedOpt.get + val action = packed / 10 + val outputIndex = packed % 10 + + val ownerKey = SELF.R4[GroupElement].get + val redeemedTree = SELF.R5[AvlTree].get + val trackerNftId = SELF.R6[Coll[Byte]].get + val refundHeight = SELF.R7[Long].get + val emergencyHeight = SELF.R8[Long].get + val predecessorId = SELF.R9[Coll[Byte]].get + + val reserveTokensShape = + SELF.tokens.size == 2 && + SELF.tokens(0)._2 == 1L && + SELF.tokens(0)._1 != SELF.tokens(1)._1 && + SELF.tokens(1)._2 > 0L + val redeemedTreeShape = + redeemedTree.keyLength == 32 && + redeemedTree.valueLengthOpt.isDefined && + redeemedTree.valueLengthOpt.get == 24 && + redeemedTree.isInsertAllowed && + redeemedTree.isUpdateAllowed && + !redeemedTree.isRemoveAllowed + val selfShape = + packed >= 0 && + reserveTokensShape && + trackerNftId.size == 32 && + predecessorId.size == 32 && + emergencyHeight > 0L && + refundHeight >= 0L && + redeemedTreeShape + + if (!selfShape) { + sigmaProp(false) + } else if (action == 0) { + val receiverOpt = getVar[GroupElement](1) + val reserveSigOpt = getVar[Coll[Byte]](2) + val totalDebtOpt = getVar[Long](3) + val timestampOpt = getVar[Long](4) + val updateProofOpt = getVar[Coll[Byte]](5) + val priorProofOpt = getVar[Coll[Byte]](7) + val outputShape = + outputIndex >= 0 && + OUTPUTS.size > outputIndex + 1 + val contextShape = + receiverOpt.isDefined && + reserveSigOpt.isDefined && + totalDebtOpt.isDefined && + timestampOpt.isDefined && + updateProofOpt.isDefined && + priorProofOpt.isDefined + + if (!outputShape || !contextShape) { + sigmaProp(false) + } else { + val selfOut = OUTPUTS(outputIndex) + val payout = OUTPUTS(outputIndex + 1) + val successorRegistersDefined = + selfOut.R4[GroupElement].isDefined && + selfOut.R5[AvlTree].isDefined && + selfOut.R6[Coll[Byte]].isDefined && + selfOut.R7[Long].isDefined && + selfOut.R8[Long].isDefined && + selfOut.R9[Coll[Byte]].isDefined + val payoutLineageDefined = payout.R4[Coll[Byte]].isDefined + + if (!successorRegistersDefined || !payoutLineageDefined) { + sigmaProp(false) + } else { + val receiver = receiverOpt.get + val reserveSig = reserveSigOpt.get + val totalDebt = totalDebtOpt.get + val timestamp = timestampOpt.get + val updateProof = updateProofOpt.get + val priorProof = priorProofOpt.get + val reserveNftId = SELF.tokens(0)._1 + val reserveTokenId = SELF.tokens(1)._1 + + // "BASIS" || ABI 2 || Ergo-mainnet domain 0 || token kind 1. + val domainTag = fromBase16("4241534953020001") + val claimKey = blake2b256( + domainTag ++ + reserveNftId ++ + reserveTokenId ++ + trackerNftId ++ + ownerKey.getEncoded ++ + receiver.getEncoded + ) + val message = + claimKey ++ + longToByteArray(totalDebt) ++ + longToByteArray(timestamp) + + val reserveSigShape = reserveSig.size >= 64 && reserveSig.size <= 66 + val properReserveSignature = if (reserveSigShape) { + val aBytes = reserveSig.slice(0, 33) + val zBytes = reserveSig.slice(33, reserveSig.size) + val a = decodePoint(aBytes) + val z = byteArrayToBigInt(zBytes) + val e = byteArrayToBigInt( + blake2b256(aBytes ++ message ++ ownerKey.getEncoded) + ) + groupGenerator.exp(z) == a.multiply(ownerKey.exp(e)) + } else { + false + } + + // A proof is mandatory for both membership and non-membership. + val priorOpt = redeemedTree.get(claimKey, priorProof) + val zeroState = + longToByteArray(0L) ++ + longToByteArray(0L) ++ + longToByteArray(0L) + val priorRaw = priorOpt.getOrElse(zeroState) + val priorShape = !priorOpt.isDefined || priorRaw.size == 24 + val priorBytes = if (priorRaw.size == 24) priorRaw else zeroState + val storedTimestamp = byteArrayToLong(priorBytes.slice(0, 8)) + val storedTotalDebt = byteArrayToLong(priorBytes.slice(8, 16)) + val storedRedeemed = byteArrayToLong(priorBytes.slice(16, 24)) + val storedStateValid = + storedTimestamp >= 0L && + storedTotalDebt >= 0L && + storedRedeemed >= 0L && + storedRedeemed <= storedTotalDebt + val claimProgressValid = if (priorOpt.isDefined) { + (timestamp == storedTimestamp && totalDebt == storedTotalDebt) || + (timestamp > storedTimestamp && totalDebt >= storedTotalDebt) + } else { + timestamp > 0L && totalDebt > 0L + } + + val successorTokenShape = + selfOut.tokens.size == 2 && + selfOut.tokens(0)._1 == reserveNftId && + selfOut.tokens(0)._2 == 1L && + selfOut.tokens(1)._1 == reserveTokenId && + selfOut.tokens(1)._2 > 0L + val successorCommon = + selfOut.propositionBytes == SELF.propositionBytes && + successorTokenShape && + selfOut.R4[GroupElement].get == ownerKey && + selfOut.R6[Coll[Byte]].get == trackerNftId && + selfOut.R7[Long].get == refundHeight && + selfOut.R8[Long].get == emergencyHeight && + selfOut.R9[Coll[Byte]].get == SELF.id + val receiverCondition = proveDlog(receiver) + val amount = SELF.tokens(1)._2 - selfOut.tokens(1)._2 + val payoutBound = + payout.propositionBytes == receiverCondition.propBytes && + payout.value > 0L && + payout.tokens.size == 1 && + payout.tokens(0)._1 == reserveTokenId && + payout.tokens(0)._2 == amount && + payout.R4[Coll[Byte]].get == SELF.id + val valueFlowValid = + selfOut.value >= SELF.value && + amount > 0L + val available = if ( + storedRedeemed >= 0L && totalDebt >= storedRedeemed + ) { + totalDebt - storedRedeemed + } else { + 0L + } + val amountValid = amount > 0L && amount <= available + val newRedeemed = if (amountValid) { + storedRedeemed + amount + } else { + storedRedeemed + } + val nextValue = + longToByteArray(timestamp) ++ + longToByteArray(totalDebt) ++ + longToByteArray(newRedeemed) + val nextTree = redeemedTree + .insertOrUpdate(Coll((claimKey, nextValue)), updateProof) + .get + val stateTransitionValid = + nextTree == selfOut.R5[AvlTree].get + + val emergency = HEIGHT >= emergencyHeight + val trackerEvidenceValid = if (emergency) { + true + } else { + val trackerSigOpt = getVar[Coll[Byte]](6) + val trackerProofOpt = getVar[Coll[Byte]](8) + val trackerInputPresent = CONTEXT.dataInputs.size > 0 + if ( + !trackerSigOpt.isDefined || + !trackerProofOpt.isDefined || + !trackerInputPresent + ) { + false + } else { + val tracker = CONTEXT.dataInputs(0) + val trackerRegistersDefined = + tracker.R4[GroupElement].isDefined && + tracker.R5[AvlTree].isDefined + val trackerTokenShape = + tracker.tokens.size == 1 && + tracker.tokens(0)._1 == trackerNftId && + tracker.tokens(0)._2 == 1L + if (!trackerRegistersDefined || !trackerTokenShape) { + false + } else { + val trackerKey = tracker.R4[GroupElement].get + val trackerTree = tracker.R5[AvlTree].get + val trackerTreeShape = + trackerTree.keyLength == 32 && + trackerTree.valueLengthOpt.isDefined && + trackerTree.valueLengthOpt.get == 8 && + trackerTree.isInsertAllowed && + trackerTree.isUpdateAllowed && + !trackerTree.isRemoveAllowed + val trackerDebtOpt = trackerTree.get(claimKey, trackerProofOpt.get) + val trackerDebtValid = if (trackerDebtOpt.isDefined) { + val debtBytes = trackerDebtOpt.get + debtBytes.size == 8 && + byteArrayToLong(debtBytes) >= totalDebt + } else { + false + } + val trackerSig = trackerSigOpt.get + val trackerSigShape = trackerSig.size >= 64 && trackerSig.size <= 66 + val properTrackerSignature = if (trackerSigShape) { + val aBytes = trackerSig.slice(0, 33) + val zBytes = trackerSig.slice(33, trackerSig.size) + val a = decodePoint(aBytes) + val z = byteArrayToBigInt(zBytes) + val e = byteArrayToBigInt( + blake2b256(aBytes ++ message ++ trackerKey.getEncoded) + ) + groupGenerator.exp(z) == a.multiply(trackerKey.exp(e)) + } else { + false + } + trackerTreeShape && trackerDebtValid && properTrackerSignature + } + } + } + + sigmaProp( + successorCommon && + payoutBound && + valueFlowValid && + priorShape && + storedStateValid && + claimProgressValid && + amountValid && + stateTransitionValid && + properReserveSignature && + trackerEvidenceValid + ) && receiverCondition + } + } + } else if (action == 1 || action == 2) { + val outputShape = outputIndex >= 0 && OUTPUTS.size > outputIndex + if (!outputShape) { + sigmaProp(false) + } else { + val selfOut = OUTPUTS(outputIndex) + val successorRegistersDefined = + selfOut.R4[GroupElement].isDefined && + selfOut.R5[AvlTree].isDefined && + selfOut.R6[Coll[Byte]].isDefined && + selfOut.R7[Long].isDefined && + selfOut.R8[Long].isDefined && + selfOut.R9[Coll[Byte]].isDefined + val successorTokenShape = + selfOut.tokens.size == 2 && + selfOut.tokens(0)._1 == SELF.tokens(0)._1 && + selfOut.tokens(0)._2 == 1L && + selfOut.tokens(1)._1 == SELF.tokens(1)._1 && + selfOut.tokens(1)._2 > 0L + if (!successorRegistersDefined || !successorTokenShape) { + sigmaProp(false) + } else { + val successorCommon = + selfOut.propositionBytes == SELF.propositionBytes && + selfOut.R4[GroupElement].get == ownerKey && + selfOut.R5[AvlTree].get == redeemedTree && + selfOut.R6[Coll[Byte]].get == trackerNftId && + selfOut.R8[Long].get == emergencyHeight && + selfOut.R9[Coll[Byte]].get == SELF.id + if (action == 1) { + sigmaProp( + successorCommon && + selfOut.R7[Long].get == refundHeight && + selfOut.value >= SELF.value && + selfOut.tokens(1)._2 - SELF.tokens(1)._2 >= 1L + ) + } else { + sigmaProp( + successorCommon && + refundHeight == 0L && + selfOut.R7[Long].get >= HEIGHT.toLong && + selfOut.R7[Long].get <= HEIGHT.toLong + 30L && + selfOut.value >= SELF.value && + selfOut.tokens(1)._2 >= SELF.tokens(1)._2 + ) && proveDlog(ownerKey) + } + } + } + } else if (action == 3) { + sigmaProp( + refundHeight > 0L && + HEIGHT.toLong >= 43200L && + refundHeight <= HEIGHT.toLong - 43200L + ) && proveDlog(ownerKey) + } else { + sigmaProp(false) + } + } +} diff --git a/contracts/offchain/basis-token-v2.p2s b/contracts/offchain/basis-token-v2.p2s new file mode 100644 index 0000000..eb34e97 --- /dev/null +++ b/contracts/offchain/basis-token-v2.p2s @@ -0,0 +1 @@ +1bf50e6c0100041404140400040404000502040004020402050004400440050005000440043001000400040004020100040201000400040204020e08424153495302000105000430040004100410042004200430050005000500040404000400050204020402050005000402040004000500043005000500050005000500048001048401040004420442010001010400010004000402040004000502010004400410041001000480010484010400044204420100040204040400010004040400040004000502040204020402050001000402040205020500053c04020402040605000580a3050580a3050100d802d601e30002d602c6a7040795ecefe67201efededededede67202e6c6a70564e6c6a7060ee6c6a70705e6c6a70805e6c6a7090ed17300d809d6037ee4720104d604db6308a7d605e4c6a7060ed606e4c6a70805d607e4c6a70705d608e4c6a70564d6099d72037301d60a9e72037302d60be4720295efedededededed9272037303ededed93b172047304938cb27204730500027306948cb27204730700018cb2720473080001918cb2720473090002730a93b17205730b93b1e4c6a7090e730c917206730d927207730eededededed93db64037208730fe6db6404720893e4db640472087310db64057208db64067208efdb64077208d17311959372097312d806d60ce30107d60de3020ed60ee30305d60fe30405d610e3050ed611e3070e95ecefed92720a731391b1a59a720a7314efededededede6720ce6720de6720ee6720fe67210e67211d17315d804d612b2a5720a00d613c672120407d614b2a59a720a731600d615c67214040e95ecefededededede67213e6c672120564e6c67212060ee6c672120705e6c672120805e6c67212090eefe67215d17317d81ad616db63087212d6178cb2720473180001d618b27204731900d6198c721801d61ae4720cd61bcd721ad61c998c7218028cb27216731a0002d61ddb0702720bd61ecbb3b3b3b3b3731b721772197205721ddb0702721ad61fdc640a720802721ee47211d620e6721fd6217a731cd622b3b3722172217221d623e5721f7222d6249593b17223731d72237222d6257cb47224731e731fd6267cb4722473207321d6277cb4722473227323d628e4720fd629e4720ed62aed91721c732490721c95ed9272277325927229722799722972277326d62b7a7228d62c7a7229d62de4720dd62eb1722dd62fb3b3721e722c722bea02d1ededededededededededededededed93c27212c2a7edededed93b172167327938cb27216732800017217938cb2721673290002732a938cb27216732b00017219918cb27216732c0002732d93e47213720b93e4c67212060e720593e4c672120705720793e4c672120805720693e4c67212090ec5a7ededededed93c27214d0721b91c17214732e93b1db63087214732f938cb2db63087214733000017219938cb2db6308721473310002721c93e47215c5a7ed92c17212c1a791721c7332ecef722093b172237333ededed9272257334927226733592722773369072277226957220eced93722872259372297226ed91722872259272297226ed91722873379172297338722a93e4dc641072080283013c0e0e8602721eb3b3722b722c7a95722a9a7227721c7227e47210e4c67212056495ed92722e733990722e733ad801d630b4722d733b733c939fdb6a01dd7bb4722d733d722ea0ee72309f720b7bcbb3b37230722f721d733e95927ea3057206733fd803d630e3060ed631e3080ed632db6501fe95ececefe67230efe6723190b1723273407341d803d633b27232734200d634c672330407d635db6308723395ecefede67234e6c672330564efeded93b172357343938cb27235734400017205938cb272357345000273467347d805d636e4c672330564d637dc640a723602721ee47231d638e47230d639b17238d63ae47234ededededededed93db640372367348e6db6404723693e4db640472367349db64057236db64067236efdb6407723695e67237d801d63be47237ed93b1723b734a927c723b7229734b95ed927239734c907239734dd801d63bb47238734e734f939fdb6a01dd7bb4723873507239a0ee723b9f723a7bcbb3b3723b722fdb0702723a7351721bd801d60c937209735295ec720c937209735395efed92720a735491b1a5720ad17355d803d60db2a5720a00d60ec6720d0407d60fdb6308720d95ecefededededede6720ee6c6720d0564e6c6720d060ee6c6720d0705e6c6720d0805e6c6720d090eefedededed93b1720f7356938cb2720f735700018cb2720473580001938cb2720f73590002735a938cb2720f735b00018cb27204735c0001918cb2720f735d0002735ed1735fd801d610ededededed93c2720dc2a793e4720e720b93e4c6720d0564720893e4c6720d060e720593e4c6720d0805720693e4c6720d090ec5a795720cd1ededed721093e4c6720d0705720792c1720dc1a792998cb2720f736000028cb27204736100027362ea02d1ededededed7210937207736392e4c6720d07057ea30590e4c6720d07059a7ea305736492c1720dc1a7928cb2720f736500028cb2720473660002cd720b959372097367ea02d1eded9172077368927ea3057369907207997ea305736acd720bd1736b diff --git a/contracts/offchain/basis-v2-reproducibility.md b/contracts/offchain/basis-v2-reproducibility.md new file mode 100644 index 0000000..308b38b --- /dev/null +++ b/contracts/offchain/basis-v2-reproducibility.md @@ -0,0 +1,83 @@ +# Basis v2 source-to-ErgoTree receipt + +This receipt binds the local Basis v2 candidate to exact compiler inputs and +full ErgoTree bytes. It is a build-parity record, not a security, deployment or +activation claim. + +## Source identity + +- Repository base commit: + `78475e30362571acf56e4e38276a9d6c0a84ce0c` +- Candidate branch: `a-shannon/basis-v2-contract` +- Source paths: + - `contracts/offchain/basis-v2.es` + - `contracts/offchain/basis-token-v2.es` +- The compiler reads UTF-8 with `getLines.mkString("\n")`. It therefore uses LF + separators and removes the final line terminator independently of checkout + line endings. + +| Compiler input | SHA-256 of normalized UTF-8 bytes | +| --- | --- | +| `basis-v2.es` | `a73f8530b355c26136d2732d6a26766cb8c1cabaa801bc0d2aa30dc548a4d884` | +| `basis-token-v2.es` | `c82d4c5b5fb7648af81b0104110b0289e0e5cf4d3cc9b6ec3e0b49fc30c8cf60` | + +The reviewed Windows checkout used `core.autocrlf=true`. Its raw working-file +SHA-256 values were respectively +`db72e1212f89d90555bc7d1e9914fd598ffca0bd2206d2b1b0d604dbf2a68d5d` +and `7e9c64988c3e9bfbb5577b493d4e6031d858f9014e456b6f7152df2e54d02fa2`. +Those raw hashes are informative; the normalized hashes above identify the +actual compiler input. + +## Compiler closure + +| Input | Version / SHA-256 | +| --- | --- | +| Scala | 2.12.17 | +| SBT | 1.5.2; launcher `b4c0c55d68f11b1510d884641cb1b1456191dac40ddc958bf86c825adc344e16` | +| JDK | Microsoft OpenJDK 17.0.19+10-LTS, 64-bit | +| Ergo Appkit | `org.ergoplatform:ergo-appkit_2.12:6.0.0`; jar `258e2c8e1d3f8d68c82cdb36126d7b1bd5100a70aadf9e4c46dc67fadaf9822b` | +| Sigma State | `org.scorexfoundation:sigma-state_2.12:6.0.2`; jar `0dbd3b31ef94affec83f8f0f6c5a9891c45da1e975ff6016a0574fc5aa1418e6` | +| `build.sbt` | `806c9e6a228f08ef9f1a3c6fb9b11c0aac097157b03925bfbfeaf1e203420ca0` | +| `project/build.properties` | `cb740d250483ffcfe325411426588065e377c7e4ce7a5faf6cfdde97f390ada1` | +| `project/plugins.sbt` | `e1188d30689ed8c50d7e154158aabdac75b5d91612a3f61b89b65fc3a6b88296` | + +Compilation uses `AppkitHelpers.compile` through `Constants.compile`, mainnet +network prefix, and block version 4 (the v6 language lane required by +`AvlTree.insertOrUpdate`). No source substitution parameters are used. + +Commands: + +```text +sbt -batch "runMain chaincash.contracts.BasisV2ReceiptPrinter" +sbt -batch "testOnly chaincash.BasisV2Spec" +``` + +## Full output bindings + +The full serialized bytes, not only their digests, are committed in these +single-line lowercase-hex files: + +| Contract | Full-byte file | Byte length | ErgoTree SHA-256 | +| --- | --- | ---: | --- | +| ERG reserve | `contracts/offchain/basis-v2.p2s` | 1,631 | `49d6f487b69277191ff064e5e036a5a07b343b9b57d76931620157f0b4bfef80` | +| token reserve | `contracts/offchain/basis-token-v2.p2s` | 1,912 | `1f8ba4f6a3ef36799372e7555394274aff5d7fe977a2b5b5b2e7ccd4a7bea5f7` | + +`BasisV2Spec` recompiles both normalized sources, compares the complete byte +arrays to `Constants`, then decodes and compares each committed golden file. +`BasisV2ReceiptPrinter` prints the source hashes, full-byte hashes and complete +hex without updating any expected file. + +## Consumer linkage + +`Constants.basisV2ErgoTree` and `Constants.basisTokenV2ErgoTree` are the named +builder/compiler consumers. V1 constants remain separate. Runtime builders, +tracker state, deployment manifests and observed boxes must fail closed unless +their exact bytes match one of these v2 outputs; those cross-repository and +live-state checks are outside this receipt. + +## Evidence ceiling + +One local matching build plus mockchain fixtures establishes parity for the +recorded closure. It does not establish an independent clean-room rebuild, +compiler trust, target-node acceptance, released-wallet compatibility, current +deployment, live-box lineage, economic safety or production readiness. diff --git a/contracts/offchain/basis-v2-review.md b/contracts/offchain/basis-v2-review.md new file mode 100644 index 0000000..e264138 --- /dev/null +++ b/contracts/offchain/basis-v2-review.md @@ -0,0 +1,96 @@ +# Basis v2 review packet + +This packet describes the exact local candidate implemented by +`basis-v2.es` and `basis-token-v2.es`. It is intended to make contract and +runtime review reproducible without treating a green mockchain suite as a +deployment claim. + +## Box and branch topology + +The reserve input is the sole authority for its continuing state. Redemption +reads tracker data input 0 only before the immutable R8 boundary. A continuing +branch creates one successor at the context-selected output index. Redemption +also creates one creditor payout immediately after that successor. Miner fees +and wallet change are outside the reserve accounting equations. + +| Action | Authorization | Continuing state | Released value | +| --- | --- | --- | --- | +| 0 redemption before R8 | debtor claim signature, tracker NFT/tree/signature, creditor transaction proof | exact v2 successor and authenticated R5 update | exact P2PK payout bound to `SELF.id` | +| 0 redemption at/after R8 | debtor claim signature and creditor transaction proof; no tracker input/proof/signature | same successor and R5 rules | same exact payout | +| 1 top-up | permissionless | exact state preservation plus input-specific R9 | ERG or reserve tokens may only increase | +| 2 refund initiation | reserve-owner transaction proof | exact state, R7 set within the current 30-block construction window | none | +| 3 refund completion | reserve-owner transaction proof after 43,200 blocks | terminal; no successor required | owner controls the signed terminal transaction | + +## Invariant and consumer matrix + +| ID | Invariant / enforcement | Downstream consumer | Relaxation failure | Focused evidence | +| --- | --- | --- | --- | --- | +| I-01 | Claim key commits v2 tag, reserve NFT, tracker NFT, owner, receiver and token id where applicable. Both signatures and tracker AVL use that key. | tracker builder, note signer, both reserve contracts | one signed liability becomes valid in another reserve, asset or generation | wrong-reserve and wrong-domain signatures rejected independently | +| I-02 | Context 7 always supplies an authenticated R5 membership or non-membership proof. | R5 `insertOrUpdate` and later redemptions | existing state is treated as an unauthenticated zero baseline | omitted proof and stale absence proof rejected | +| I-03 | R5 values are fixed 24-byte `(timestamp,totalDebt,redeemed)` tuples; same claim may advance `redeemed`, newer claims cannot reduce debt. | partial-redemption builder and restart reconstruction | repeated settlement is blocked or redeemed progress is erased | two consecutive partial settlements use the original signatures | +| I-04 | Payout is at `successorIndex + 1`, has receiver P2PK bytes, R4=`SELF.id`, and exact released amount. | creditor wallet and multi-input batcher | redirected, underpaid or reused payout | receiver and amount mutations rejected | +| I-05 | ERG redemption requires `successor.value + payout.value == SELF.value`. | reserve collateral and fee builder | reserve collateral silently pays fees/change | reserve-funded fee mutation rejected; external fee input accepted | +| I-06 | Token successor preserves ERG, singleton and reserve-token ids; reserve-token decrease equals the payout's sole token amount. | token creditor, later reserve transitions and refund | ERG drain, token leakage or accidental burn | top-up ERG drain, redemption ERG drain and payout leakage rejected | +| I-07 | Before R8, tracker input has the exact singleton, typed key/tree, fixed tree shape, sufficient committed cumulative debt and valid signature. | normal-redemption liveness and liability admission | forged tracker state or uncommitted claim | exact normal redemption passes; cross-domain evidence rejects | +| I-08 | R8 is mandatory and exactly preserved; tracker fields are not evaluated only when `HEIGHT >= R8`. | emergency builder and acceptance policy | mutable tracker creation height postpones exit, or missing tracker still blocks the branch | tracker-free redemption passes at R8 and rejects one block before | +| I-09 | Reserve singleton shape is mandatory; every successor R9 and payout R4 equal the predecessor box id. | indexer lineage and multi-input settlement | tokenless state or one output satisfies two inputs | tokenless state and shared-successor transaction rejected | +| I-10 | Refund R7 is one-shot, owner-authorized, preserved on other branches and checked with subtraction rather than overflowing addition. | creditor monitoring and terminal refund | backdated/reset timer or overflow-assisted early refund | initiation, mature completion, early completion and `Long.MaxValue` seed exercised | +| I-11 | Constants compile exact normalized source and tests compare full output bytes with committed goldens. | compiler, builders and contract-family selection | readable source, configured P2S and runtime ABI silently diverge | source equality, fresh compile and full-byte golden tests | + +Each negative transaction changes only the field named in its test while its +proofs, signatures, tree roots, tokens and unrelated outputs remain those of a +valid control. The property that groups payout mutations constructs and reduces +three independent transactions; it does not use one compound malformed box. + +## Token and register authority + +- ERG reserve tokens: exactly `[reserveNft -> 1]`. +- Token reserve tokens: exactly + `[reserveNft -> 1, reserveAsset -> positiveAmount]`; ids must differ. +- Tracker data-input tokens: exactly `[configuredTrackerNft -> 1]`. +- Reserve R4-R9 types and meaning are frozen in `basis-v2.md`. +- Reserve R5: key length 32, value length 24, insert/update enabled, remove + disabled. +- Tracker R5: key length 32, value length 8, insert/update enabled, remove + disabled. + +## Test closure + +`sbt -batch "testOnly chaincash.BasisV2Spec"` currently executes 15 tests. They +cover normal and emergency redemption, domain separation, proof omission and +replay, exact payouts, ERG/token conservation, partial settlement, singleton +and output injectivity, both top-up branches, both refund families, source +linkage and full golden bytes. + +The unchanged v1 suites remain a separate regression gate: + +```text +sbt -batch "testOnly chaincash.BasisSpec chaincash.BasisTokenSpec" +``` + +## Evidence vector and residual gates + +| Dimension | Status | +| --- | --- | +| Implementation | matrix-covered by local source and 15 focused mockchain tests | +| Independent review | pending on the exact candidate commit | +| CI | not run | +| Target node | not run; no live or broadcast action authorized | +| Readiness | local draft for maintainer review | + +Remaining integration obligations are explicit: + +- the Rust tracker, note schema, signer and redemption builders must implement + the exact domain key, 24-byte redeemed value, fixed-length AVL metadata, + mandatory context 7 proof, payout R4 marker and R8 semantics; +- reserve admission must require a genuine singleton, a sufficiently distant + R8 and the exact golden ErgoTree; emergency mode intentionally relies on + visible seed policy because the debtor signature is the remaining liability + authority after R8; +- tracker-key rotation and old/new family coexistence need an explicit runtime + policy; no implicit v1/v2 aggregation is allowed; +- v1 contains no migration authorization, so live v1 boxes, if any, must be + inventoried and handled only through their existing branches; +- reduction cost, serialized transaction size, independent clean rebuild, + target-node check, wallet/signer compatibility, reorg/indexer handling and + deployment recovery remain unverified. diff --git a/contracts/offchain/basis-v2.es b/contracts/offchain/basis-v2.es new file mode 100644 index 0000000..7dc2ec7 --- /dev/null +++ b/contracts/offchain/basis-v2.es @@ -0,0 +1,312 @@ +{ + // Basis ERG reserve, ABI generation v2. + // See basis-v2.md. V1 contracts are intentionally left unchanged. + + val packedOpt = getVar[Byte](0) + val selfRegistersDefined = + SELF.R4[GroupElement].isDefined && + SELF.R5[AvlTree].isDefined && + SELF.R6[Coll[Byte]].isDefined && + SELF.R7[Long].isDefined && + SELF.R8[Long].isDefined && + SELF.R9[Coll[Byte]].isDefined + + if (!packedOpt.isDefined || !selfRegistersDefined) { + sigmaProp(false) + } else { + val packed = packedOpt.get + val action = packed / 10 + val outputIndex = packed % 10 + + val ownerKey = SELF.R4[GroupElement].get + val redeemedTree = SELF.R5[AvlTree].get + val trackerNftId = SELF.R6[Coll[Byte]].get + val refundHeight = SELF.R7[Long].get + val emergencyHeight = SELF.R8[Long].get + val predecessorId = SELF.R9[Coll[Byte]].get + + val singletonShape = + SELF.tokens.size == 1 && + SELF.tokens(0)._2 == 1L + val redeemedTreeShape = + redeemedTree.keyLength == 32 && + redeemedTree.valueLengthOpt.isDefined && + redeemedTree.valueLengthOpt.get == 24 && + redeemedTree.isInsertAllowed && + redeemedTree.isUpdateAllowed && + !redeemedTree.isRemoveAllowed + val selfShape = + packed >= 0 && + singletonShape && + trackerNftId.size == 32 && + predecessorId.size == 32 && + emergencyHeight > 0L && + refundHeight >= 0L && + redeemedTreeShape + + if (!selfShape) { + sigmaProp(false) + } else if (action == 0) { + val receiverOpt = getVar[GroupElement](1) + val reserveSigOpt = getVar[Coll[Byte]](2) + val totalDebtOpt = getVar[Long](3) + val timestampOpt = getVar[Long](4) + val updateProofOpt = getVar[Coll[Byte]](5) + val priorProofOpt = getVar[Coll[Byte]](7) + val outputShape = + outputIndex >= 0 && + OUTPUTS.size > outputIndex + 1 + val contextShape = + receiverOpt.isDefined && + reserveSigOpt.isDefined && + totalDebtOpt.isDefined && + timestampOpt.isDefined && + updateProofOpt.isDefined && + priorProofOpt.isDefined + + if (!outputShape || !contextShape) { + sigmaProp(false) + } else { + val selfOut = OUTPUTS(outputIndex) + val payout = OUTPUTS(outputIndex + 1) + val successorRegistersDefined = + selfOut.R4[GroupElement].isDefined && + selfOut.R5[AvlTree].isDefined && + selfOut.R6[Coll[Byte]].isDefined && + selfOut.R7[Long].isDefined && + selfOut.R8[Long].isDefined && + selfOut.R9[Coll[Byte]].isDefined + val payoutLineageDefined = payout.R4[Coll[Byte]].isDefined + + if (!successorRegistersDefined || !payoutLineageDefined) { + sigmaProp(false) + } else { + val receiver = receiverOpt.get + val reserveSig = reserveSigOpt.get + val totalDebt = totalDebtOpt.get + val timestamp = timestampOpt.get + val updateProof = updateProofOpt.get + val priorProof = priorProofOpt.get + val reserveNftId = SELF.tokens(0)._1 + + // "BASIS" || ABI 2 || Ergo-mainnet domain 0 || ERG kind 0. + val domainTag = fromBase16("4241534953020000") + val claimKey = blake2b256( + domainTag ++ + reserveNftId ++ + trackerNftId ++ + ownerKey.getEncoded ++ + receiver.getEncoded + ) + val message = + claimKey ++ + longToByteArray(totalDebt) ++ + longToByteArray(timestamp) + + val reserveSigShape = reserveSig.size >= 64 && reserveSig.size <= 66 + val properReserveSignature = if (reserveSigShape) { + val aBytes = reserveSig.slice(0, 33) + val zBytes = reserveSig.slice(33, reserveSig.size) + val a = decodePoint(aBytes) + val z = byteArrayToBigInt(zBytes) + val e = byteArrayToBigInt( + blake2b256(aBytes ++ message ++ ownerKey.getEncoded) + ) + groupGenerator.exp(z) == a.multiply(ownerKey.exp(e)) + } else { + false + } + + // A proof is mandatory for both membership and non-membership. + val priorOpt = redeemedTree.get(claimKey, priorProof) + val zeroState = + longToByteArray(0L) ++ + longToByteArray(0L) ++ + longToByteArray(0L) + val priorRaw = priorOpt.getOrElse(zeroState) + val priorShape = !priorOpt.isDefined || priorRaw.size == 24 + val priorBytes = if (priorRaw.size == 24) priorRaw else zeroState + val storedTimestamp = byteArrayToLong(priorBytes.slice(0, 8)) + val storedTotalDebt = byteArrayToLong(priorBytes.slice(8, 16)) + val storedRedeemed = byteArrayToLong(priorBytes.slice(16, 24)) + val storedStateValid = + storedTimestamp >= 0L && + storedTotalDebt >= 0L && + storedRedeemed >= 0L && + storedRedeemed <= storedTotalDebt + val claimProgressValid = if (priorOpt.isDefined) { + (timestamp == storedTimestamp && totalDebt == storedTotalDebt) || + (timestamp > storedTimestamp && totalDebt >= storedTotalDebt) + } else { + timestamp > 0L && totalDebt > 0L + } + + val successorCommon = + selfOut.propositionBytes == SELF.propositionBytes && + selfOut.tokens == SELF.tokens && + selfOut.R4[GroupElement].get == ownerKey && + selfOut.R6[Coll[Byte]].get == trackerNftId && + selfOut.R7[Long].get == refundHeight && + selfOut.R8[Long].get == emergencyHeight && + selfOut.R9[Coll[Byte]].get == SELF.id + val receiverCondition = proveDlog(receiver) + val payoutBound = + payout.propositionBytes == receiverCondition.propBytes && + payout.tokens.size == 0 && + payout.R4[Coll[Byte]].get == SELF.id + val amount = SELF.value - selfOut.value + val valueFlowValid = + selfOut.value < SELF.value && + amount > 0L && + payout.value == amount && + selfOut.value + payout.value == SELF.value + val available = if ( + storedRedeemed >= 0L && totalDebt >= storedRedeemed + ) { + totalDebt - storedRedeemed + } else { + 0L + } + val amountValid = amount > 0L && amount <= available + val newRedeemed = if (amountValid) { + storedRedeemed + amount + } else { + storedRedeemed + } + val nextValue = + longToByteArray(timestamp) ++ + longToByteArray(totalDebt) ++ + longToByteArray(newRedeemed) + val nextTree = redeemedTree + .insertOrUpdate(Coll((claimKey, nextValue)), updateProof) + .get + val stateTransitionValid = + nextTree == selfOut.R5[AvlTree].get + + val emergency = HEIGHT >= emergencyHeight + val trackerEvidenceValid = if (emergency) { + true + } else { + val trackerSigOpt = getVar[Coll[Byte]](6) + val trackerProofOpt = getVar[Coll[Byte]](8) + val trackerInputPresent = CONTEXT.dataInputs.size > 0 + if ( + !trackerSigOpt.isDefined || + !trackerProofOpt.isDefined || + !trackerInputPresent + ) { + false + } else { + val tracker = CONTEXT.dataInputs(0) + val trackerRegistersDefined = + tracker.R4[GroupElement].isDefined && + tracker.R5[AvlTree].isDefined + val trackerTokenShape = + tracker.tokens.size == 1 && + tracker.tokens(0)._1 == trackerNftId && + tracker.tokens(0)._2 == 1L + if (!trackerRegistersDefined || !trackerTokenShape) { + false + } else { + val trackerKey = tracker.R4[GroupElement].get + val trackerTree = tracker.R5[AvlTree].get + val trackerTreeShape = + trackerTree.keyLength == 32 && + trackerTree.valueLengthOpt.isDefined && + trackerTree.valueLengthOpt.get == 8 && + trackerTree.isInsertAllowed && + trackerTree.isUpdateAllowed && + !trackerTree.isRemoveAllowed + val trackerDebtOpt = trackerTree.get(claimKey, trackerProofOpt.get) + val trackerDebtValid = if (trackerDebtOpt.isDefined) { + val debtBytes = trackerDebtOpt.get + debtBytes.size == 8 && + byteArrayToLong(debtBytes) >= totalDebt + } else { + false + } + val trackerSig = trackerSigOpt.get + val trackerSigShape = trackerSig.size >= 64 && trackerSig.size <= 66 + val properTrackerSignature = if (trackerSigShape) { + val aBytes = trackerSig.slice(0, 33) + val zBytes = trackerSig.slice(33, trackerSig.size) + val a = decodePoint(aBytes) + val z = byteArrayToBigInt(zBytes) + val e = byteArrayToBigInt( + blake2b256(aBytes ++ message ++ trackerKey.getEncoded) + ) + groupGenerator.exp(z) == a.multiply(trackerKey.exp(e)) + } else { + false + } + trackerTreeShape && trackerDebtValid && properTrackerSignature + } + } + } + + sigmaProp( + successorCommon && + payoutBound && + valueFlowValid && + priorShape && + storedStateValid && + claimProgressValid && + amountValid && + stateTransitionValid && + properReserveSignature && + trackerEvidenceValid + ) && receiverCondition + } + } + } else if (action == 1 || action == 2) { + val outputShape = outputIndex >= 0 && OUTPUTS.size > outputIndex + if (!outputShape) { + sigmaProp(false) + } else { + val selfOut = OUTPUTS(outputIndex) + val successorRegistersDefined = + selfOut.R4[GroupElement].isDefined && + selfOut.R5[AvlTree].isDefined && + selfOut.R6[Coll[Byte]].isDefined && + selfOut.R7[Long].isDefined && + selfOut.R8[Long].isDefined && + selfOut.R9[Coll[Byte]].isDefined + if (!successorRegistersDefined) { + sigmaProp(false) + } else { + val successorCommon = + selfOut.propositionBytes == SELF.propositionBytes && + selfOut.tokens == SELF.tokens && + selfOut.R4[GroupElement].get == ownerKey && + selfOut.R5[AvlTree].get == redeemedTree && + selfOut.R6[Coll[Byte]].get == trackerNftId && + selfOut.R8[Long].get == emergencyHeight && + selfOut.R9[Coll[Byte]].get == SELF.id + if (action == 1) { + sigmaProp( + successorCommon && + selfOut.R7[Long].get == refundHeight && + selfOut.value - SELF.value >= 100000000L + ) + } else { + sigmaProp( + successorCommon && + refundHeight == 0L && + selfOut.R7[Long].get >= HEIGHT.toLong && + selfOut.R7[Long].get <= HEIGHT.toLong + 30L && + selfOut.value >= SELF.value + ) && proveDlog(ownerKey) + } + } + } + } else if (action == 3) { + sigmaProp( + refundHeight > 0L && + HEIGHT.toLong >= 43200L && + refundHeight <= HEIGHT.toLong - 43200L + ) && proveDlog(ownerKey) + } else { + sigmaProp(false) + } + } +} diff --git a/contracts/offchain/basis-v2.md b/contracts/offchain/basis-v2.md new file mode 100644 index 0000000..3ccb7fe --- /dev/null +++ b/contracts/offchain/basis-v2.md @@ -0,0 +1,155 @@ +# Basis reserve contracts, generation v2 + +Status: local candidate for maintainer review. This document defines a new +contract family; it does not change or upgrade `basis.es` or +`basis-token.es`. + +## Design goals + +Generation v2 makes each cumulative claim specific to one reserve and one +asset, requires authenticated AVL membership or non-membership evidence on +every redemption, binds every released asset to a creditor output, and keeps a +fixed emergency boundary in the reserve itself. It also gives every continuing +successor and payout an input-specific lineage marker so one output cannot +satisfy two reserve inputs. + +The contracts deliberately preserve the v1 refund policy: an owner may announce +a full refund, creditors retain the redemption path during the waiting period, +and the owner may complete the refund after 43,200 blocks. + +## Contract family and signed claim domain + +The two source files are independent immutable contract generations: + +| Reserve kind | Source | Domain tag (hex) | Asset component | +| --- | --- | --- | --- | +| ERG | `basis-v2.es` | `4241534953020000` | none | +| token | `basis-token-v2.es` | `4241534953020001` | reserve token id | + +The tag encodes `BASIS`, ABI version 2, the Ergo-mainnet domain code `00`, and +the reserve-kind code (`00` for ERG, `01` for token). + +For an ERG reserve, the 32-byte tracker/claim key is: + +```text +blake2b256(domainTag || reserveNftId || trackerNftId || + ownerKey || receiverKey) +``` + +For a token reserve, `reserveTokenId` is inserted after `reserveNftId`. +The debtor and tracker sign: + +```text +claimKey || longToByteArray(totalDebt) || longToByteArray(timestamp) +``` + +The same `claimKey` indexes the tracker AVL tree and the reserve redemption AVL +tree. A signature for another reserve NFT, reserve token, network or contract +generation therefore does not authorize this reserve. + +The signed claim is cumulative, not a request for one payout. The creditor's +transaction proof and the contract's exact payout predicate authorize each +partial settlement. This permits repeated partial settlement with the original +debtor and tracker signatures. + +## Reserve ABI + +All registers R4 through R9 are mandatory and densely packed. + +| Field | Sigma type | Meaning | Successor rule | +| --- | --- | --- | --- | +| R4 | `GroupElement` | reserve owner signing key | exact preservation | +| R5 | `AvlTree` | redeemed-claim state | branch-specific authenticated update | +| R6 | `Coll[Byte]` | tracker singleton NFT id | exact preservation | +| R7 | `Long` | refund initiation height; `0` means inactive | branch-specific, otherwise preserved | +| R8 | `Long` | fixed emergency start height | exact preservation | +| R9 | `Coll[Byte]` | predecessor box id | successor must equal `SELF.id` | + +The redeemed-state tree uses 32-byte keys, fixed 24-byte values, insert and +update enabled, and remove disabled. Each value is: + +```text +timestamp (8 bytes) || totalDebt (8 bytes) || cumulativeRedeemed (8 bytes) +``` + +Every redemption supplies both a lookup proof against the current tree and an +`insertOrUpdate` proof against that same root. A valid non-membership proof is +required for the first redemption; omission is never interpreted as absence. +For an existing claim, the same `(timestamp, totalDebt)` may be settled again, +or a strictly newer timestamp may increase (never decrease) `totalDebt`. + +The ERG reserve has exactly one token: its singleton NFT at index 0 with amount +1. The token reserve has exactly two tokens: its singleton at index 0 with +amount 1 and its reserve asset at index 1. The two token ids must differ. + +## Tracker ABI + +Before R8, redemption requires data input 0 with exactly the configured tracker +singleton NFT, tracker public key in R4, and the committed debt AVL tree in R5. +The tracker tree uses the generation-v2 `claimKey`, 32-byte keys and fixed +8-byte values. The lookup value must be at least `totalDebt`, and the tracker +signature over the claim message must verify. This preserves an older signed +cumulative claim after the tracker commits a later, larger debt in that domain. + +At or after R8, tracker data, tracker proof and tracker signature are not read. +The debtor claim signature, creditor transaction proof, reserve lookup/update +proofs, exact payout and all successor constraints remain mandatory. R8 is +immutable after reserve creation. Admission software must reject a reserve +whose emergency boundary is missing, already open, or too close for its credit +policy; the script cannot retroactively repair a malformed seed. + +## Context-extension ABI + +| Id | Type | Redemption meaning | +| ---: | --- | --- | +| 0 | `Byte` | packed `action * 10 + successorOutputIndex` | +| 1 | `GroupElement` | receiver key | +| 2 | `Coll[Byte]` | reserve-owner Schnorr signature | +| 3 | `Long` | cumulative total debt | +| 4 | `Long` | claim timestamp | +| 5 | `Coll[Byte]` | redeemed-tree `insertOrUpdate` proof | +| 6 | `Coll[Byte]` | tracker signature; required before R8 | +| 7 | `Coll[Byte]` | mandatory redeemed-tree lookup proof | +| 8 | `Coll[Byte]` | tracker-tree lookup proof; required before R8 | + +Actions remain 0 redemption, 1 top-up, 2 refund initiation and 3 refund +completion. The packed output index is checked before access. A redemption +places its payout immediately after its successor. + +## Output and conservation rules + +Every continuing successor preserves the exact script, reserve identity, +owner, tracker id and emergency height, and sets R9 to `SELF.id`. Every payout +uses the receiver's P2PK proposition, stores `SELF.id` in payout R4, and equals +the reserve decrease exactly. + +- ERG redemption: `successor.value + payout.value == SELF.value`; reserve ERG + cannot fund miner fees or change. +- Token redemption: successor ERG is not lower; the reserve-token decrease + equals the payout's sole token amount. +- ERG top-up: successor value increases by at least 0.1 ERG. +- Token top-up: reserve-token amount increases by at least one and successor + ERG is not lower. + +The predecessor marker is an injective binding: two distinct reserve inputs +have different box ids, so one successor or payout cannot satisfy both even if +they were created with a duplicated or non-singleton token id. + +## Generation and migration boundary + +V1 boxes remain governed exclusively by their v1 ErgoTree. The v1 contracts +require same-script successors and contain no v1-to-v2 migration branch, so +this repository does not claim that an existing v1 box can be rewritten or +migrated. V2 must be deployed as a new reserve family and indexed separately. + +Builders, trackers and indexers must select one exact family manifest and reject +implicit v1/v2 mixing. A deployment receipt must bind source bytes, compiler, +tree version, constants and full ErgoTree bytes before any v2 P2S is treated as +authoritative. + +## Evidence boundary + +Local compiler and mockchain tests can establish source-to-byte consistency and +the tested predicates for the pinned build. They do not establish target-node +admission, wallet compatibility, deployment, live-box lineage, economic safety +or production readiness. Those remain separate review and runtime gates. diff --git a/contracts/offchain/basis-v2.p2s b/contracts/offchain/basis-v2.p2s new file mode 100644 index 0000000..0d6ed5f --- /dev/null +++ b/contracts/offchain/basis-v2.p2s @@ -0,0 +1 @@ +1bdc0c4f010004140414040004020400050204400440050005000440043001000400040004020100040201000e0842415349530200000400050004300400041004100420042004300500050005000400050004300500050005000500050004800104840104000442044201000101040001000400040204000400050201000440041004100100048001048401040004420442010004020404040001000100058084af5f0500053c040605000580a3050580a3050100d802d601e30002d602c6a7040795ecefe67201efededededede67202e6c6a70564e6c6a7060ee6c6a70705e6c6a70805e6c6a7090ed17300d809d6037ee4720104d604db6308a7d605e4c6a7060ed606e4c6a70805d607e4c6a70705d608e4c6a70564d6099d72037301d60a9e72037302d60be4720295efedededededed9272037303ed93b172047304938cb2720473050002730693b17205730793b1e4c6a7090e73089172067309927207730aededededed93db64037208730be6db6404720893e4db64047208730cdb64057208db64067208efdb64077208d1730d95937209730ed806d60ce30107d60de3020ed60ee30305d60fe30405d610e3050ed611e3070e95ecefed92720a730f91b1a59a720a7310efededededede6720ce6720de6720ee6720fe67210e67211d17311d804d612b2a5720a00d613c672120407d614b2a59a720a731200d615c67214040e95ecefededededede67213e6c672120564e6c67212060ee6c672120705e6c672120805e6c67212090eefe67215d17313d818d616e4720cd617cd7216d618c17212d619c1a7d61a9972197218d61bdb0702720bd61ccbb3b3b3b373148cb27204731500017205721bdb07027216d61ddc640a720802721ce47211d61ee6721dd61f7a7316d620b3b3721f721f721fd621e5721d7220d6229593b17221731772217220d6237cb4722273187319d6247cb47222731a731bd6257cb47222731c731dd626e4720fd627e4720ed628ed91721a731e90721a95ed927225731f927227722599722772257320d6297a7226d62a7a7227d62be4720dd62cb1722bd62db3b3721c722a7229ea02d1ededededededededededededededed93c27212c2a793db63087212720493e47213720b93e4c67212060e720593e4c672120705720793e4c672120805720693e4c67212090ec5a7eded93c27214d0721793b1db63087214732193e47215c5a7ededed8f7218721991721a732293c17214721a939a7218c172147219ecef721e93b172217323ededed927223732492722473259272257326907225722495721eeced93722672239372277224ed91722672239272277224ed91722673279172277328722893e4dc641072080283013c0e0e8602721cb3b37229722a7a9572289a7225721a7225e47210e4c67212056495ed92722c732990722c732ad801d62eb4722b732b732c939fdb6a01dd7bb4722b732d722ca0ee722e9f720b7bcbb3b3722e722d721b732e95927ea3057206732fd803d62ee3060ed62fe3080ed630db6501fe95ececefe6722eefe6722f90b1723073307331d803d631b27230733200d632c672310407d633db6308723195ecefede67232e6c672310564efeded93b172337333938cb27233733400017205938cb272337335000273367337d805d634e4c672310564d635dc640a723402721ce4722fd636e4722ed637b17236d638e47232ededededededed93db640372347338e6db6404723493e4db640472347339db64057234db64067234efdb6407723495e67235d801d639e47235ed93b17239733a927c72397227733b95ed927237733c907237733dd801d639b47236733e733f939fdb6a01dd7bb4723673407237a0ee72399f72387bcbb3b37239722ddb0702723873417217d801d60c937209734295ec720c937209734395efed92720a734491b1a5720ad17345d802d60db2a5720a00d60ec6720d040795efededededede6720ee6c6720d0564e6c6720d060ee6c6720d0705e6c6720d0805e6c6720d090ed17346d801d60fedededededed93c2720dc2a793db6308720d720493e4720e720b93e4c6720d0564720893e4c6720d060e720593e4c6720d0805720693e4c6720d090ec5a795720cd1eded720f93e4c6720d070572079299c1720dc1a77347ea02d1edededed720f937207734892e4c6720d07057ea30590e4c6720d07059a7ea305734992c1720dc1a7cd720b95937209734aea02d1eded917207734b927ea305734c907207997ea305734dcd720bd1734e diff --git a/src/main/scala/chaincash/contracts/BasisV2ReceiptPrinter.scala b/src/main/scala/chaincash/contracts/BasisV2ReceiptPrinter.scala new file mode 100644 index 0000000..6a0afbe --- /dev/null +++ b/src/main/scala/chaincash/contracts/BasisV2ReceiptPrinter.scala @@ -0,0 +1,21 @@ +package chaincash.contracts + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +import scorex.util.encode.Base16 + +/** Prints deterministic source and full-ErgoTree material for the Basis v2 receipt. */ +object BasisV2ReceiptPrinter extends App { + private def sha256(bytes: Array[Byte]): String = + Base16.encode(MessageDigest.getInstance("SHA-256").digest(bytes)) + + private def emit(name: String, source: String, treeBytes: Array[Byte]): Unit = { + println(s"$name.source.sha256=${sha256(source.getBytes(StandardCharsets.UTF_8))}") + println(s"$name.ergoTree.sha256=${sha256(treeBytes)}") + println(s"$name.ergoTree.hex=${Base16.encode(treeBytes)}") + } + + emit("basis-v2", Constants.basisV2Contract, Constants.basisV2ErgoTree.bytes) + emit("basis-token-v2", Constants.basisTokenV2Contract, Constants.basisTokenV2ErgoTree.bytes) +} diff --git a/src/main/scala/chaincash/contracts/Constants.scala b/src/main/scala/chaincash/contracts/Constants.scala index 82354b0..1061f49 100644 --- a/src/main/scala/chaincash/contracts/Constants.scala +++ b/src/main/scala/chaincash/contracts/Constants.scala @@ -83,6 +83,16 @@ object Constants { val basisTokenErgoTree = compile(basisTokenContract) val basisTokenAddress = getAddressFromErgoTree(basisTokenErgoTree) + // Basis v2 is a separate immutable contract family. Keeping distinct names + // prevents builders from silently mixing the v1 and v2 register/message ABI. + val basisV2Contract = readContract("offchain/basis-v2.es", Map()) + val basisV2ErgoTree = compile(basisV2Contract) + val basisV2Address = getAddressFromErgoTree(basisV2ErgoTree) + + val basisTokenV2Contract = readContract("offchain/basis-token-v2.es", Map()) + val basisTokenV2ErgoTree = compile(basisTokenV2Contract) + val basisTokenV2Address = getAddressFromErgoTree(basisTokenV2ErgoTree) + // contracts below are experimental and not finished ChainCash-on-Layer2 contracts val redemptionContract = scala.io.Source.fromFile("contracts/layer2-old/redemption.es", "utf-8").getLines.mkString("\n") @@ -97,6 +107,8 @@ object Constants { object Printer extends App { println("Basis p2s address: " + Constants.basisAddress) println("Basis-token p2s address: " + Constants.basisTokenAddress) + println("Basis v2 p2s address: " + Constants.basisV2Address) + println("Basis-token v2 p2s address: " + Constants.basisTokenV2Address) println("Redemption p2s address: " + Constants.redemptionAddress) println("Redemption producer p2s address: " + Constants.redemptionProducerAddress) diff --git a/src/test/scala/chaincash/BasisV2Spec.scala b/src/test/scala/chaincash/BasisV2Spec.scala new file mode 100644 index 0000000..e404b04 --- /dev/null +++ b/src/test/scala/chaincash/BasisV2Spec.scala @@ -0,0 +1,850 @@ +package chaincash + +import chaincash.contracts.Constants +import chaincash.offchain.SigUtils +import chaincash.offchain.SigUtils._ +import com.google.common.primitives.Longs +import org.ergoplatform.P2PKAddress +import org.ergoplatform.appkit.impl.{ErgoTreeContract, OutBoxImpl} +import org.ergoplatform.appkit.{AppkitHelpers, BlockchainContext, ConstantsBuilder, ContextVar, ErgoValue, InputBox, NetworkType, OutBox, OutBoxBuilder, SignedTransaction} +import org.ergoplatform.sdk.ErgoToken +import org.scalatest.{Matchers, PropSpec} +import scorex.crypto.hash.Blake2b256 +import scorex.util.encode.Base16 +import sigma.ast.ErgoTree +import sigma.data.{AvlTreeFlags, ProveDlog} +import sigma.serialization.GroupElementSerializer +import sigma.{AvlTree, GroupElement} +import work.lithos.plasma.PlasmaParameters +import work.lithos.plasma.collections.PlasmaMap + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Paths} +import java.security.MessageDigest +import java.util +import scala.collection.JavaConverters._ + +class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { + + implicit val addressEncoder = Constants.ergoAddressEncoder + + private val ownerSecret = SigUtils.randBigInt + private val ownerPk = Constants.g.exp(ownerSecret.bigInteger) + private val receiverSecret = SigUtils.randBigInt + private val receiverPk = Constants.g.exp(receiverSecret.bigInteger) + private val otherSecret = SigUtils.randBigInt + private val otherPk = Constants.g.exp(otherSecret.bigInteger) + private val trackerSecret = SigUtils.randBigInt + private val trackerPk = Constants.g.exp(trackerSecret.bigInteger) + + private val reserveNftA = hex("4b2d8b7beb3eaac8234d9e61792d270898a43934d6a27275e4f3a044609c9f2a") + private val reserveNftB = hex("5b2d8b7beb3eaac8234d9e61792d270898a43934d6a27275e4f3a044609c9f2b") + private val tokenReserveNft = hex("6b2d8b7beb3eaac8234d9e61792d270898a43934d6a27275e4f3a044609c9f2c") + private val reserveTokenId = hex("7b2d8b7beb3eaac8234d9e61792d270898a43934d6a27275e4f3a044609c9f2d") + private val trackerNft = hex("3c45f29a5165b030fdb5eaf5d81f8108f9d8f507b31487dd51f4ae08fe07cf4a") + private val zeroId = Array.fill[Byte](32)(0) + + private val minValue = 1000000000L + private val feeValue = 1000000L + private val totalDebt = 1000000000L + private val timestamp = 1000000000000L + + private val fakeTxIds = Vector( + "f9e5ce5aa0d95f5d54a7bc89c46730d9662397067250aa18a0039631c0f5b801", + "f9e5ce5aa0d95f5d54a7bc89c46730d9662397067250aa18a0039631c0f5b802", + "f9e5ce5aa0d95f5d54a7bc89c46730d9662397067250aa18a0039631c0f5b803", + "f9e5ce5aa0d95f5d54a7bc89c46730d9662397067250aa18a0039631c0f5b804", + "f9e5ce5aa0d95f5d54a7bc89c46730d9662397067250aa18a0039631c0f5b805", + "f9e5ce5aa0d95f5d54a7bc89c46730d9662397067250aa18a0039631c0f5b806" + ) + private val fakeIndex = 1.toShort + + private val redeemedFlags = AvlTreeFlags(insertAllowed = true, updateAllowed = true, removeAllowed = false) + private val trackerFlags = AvlTreeFlags(insertAllowed = true, updateAllowed = true, removeAllowed = false) + private val redeemedParameters = PlasmaParameters(32, Some(24)) + private val trackerParameters = PlasmaParameters(32, Some(8)) + + private val ergDomain = hex("4241534953020000") + private val tokenDomain = hex("4241534953020001") + + private def hex(value: String): Array[Byte] = Base16.decode(value).get + + private def claimKey( + domain: Array[Byte], + reserveNft: Array[Byte], + assetId: Option[Array[Byte]], + receiver: GroupElement = receiverPk + ): Array[Byte] = + Blake2b256( + domain ++ reserveNft ++ assetId.getOrElse(Array.emptyByteArray) ++ + trackerNft ++ ownerPk.getEncoded.toArray ++ receiver.getEncoded.toArray + ) + + private def message(key: Array[Byte], debt: Long = totalDebt, ts: Long = timestamp): Array[Byte] = + key ++ Longs.toByteArray(debt) ++ Longs.toByteArray(ts) + + private def signatureBytes(messageBytes: Array[Byte], secret: BigInt): Array[Byte] = { + val signature = SigUtils.sign(messageBytes, secret) + GroupElementSerializer.toBytes(signature._1) ++ signature._2.toByteArray + } + + private case class StateStep( + inputTree: ErgoValue[AvlTree], + lookupProof: Array[Byte], + updateProof: Array[Byte], + outputTree: ErgoValue[AvlTree] + ) + + private def stateStep( + key: Array[Byte], + oldValue: Option[(Long, Long, Long)], + newValue: (Long, Long, Long) + ): StateStep = { + val tree = new PlasmaMap[Array[Byte], Array[Byte]](redeemedFlags, redeemedParameters) + oldValue.foreach { case (ts, debt, redeemed) => + tree.insertOrUpdate(key -> stateValue(ts, debt, redeemed)) + } + val inputTree = tree.ergoValue + val lookupProof = tree.lookUp(key).proof.bytes + val updateProof = tree.insertOrUpdate(key -> stateValue(newValue._1, newValue._2, newValue._3)).proof.bytes + StateStep(inputTree, lookupProof, updateProof, tree.ergoValue) + } + + private def stateValue(ts: Long, debt: Long, redeemed: Long): Array[Byte] = + Longs.toByteArray(ts) ++ Longs.toByteArray(debt) ++ Longs.toByteArray(redeemed) + + private case class TrackerState(tree: ErgoValue[AvlTree], lookupProof: Array[Byte]) + + private def trackerState(key: Array[Byte], debt: Long = totalDebt): TrackerState = { + val tree = new PlasmaMap[Array[Byte], Array[Byte]](trackerFlags, trackerParameters) + tree.insertOrUpdate(key -> Longs.toByteArray(debt)) + TrackerState(tree.ergoValue, tree.lookUp(key).proof.bytes) + } + + private def createOut( + tree: ErgoTree, + value: Long, + registers: Array[ErgoValue[_]], + tokens: Array[ErgoToken] + )(implicit ctx: BlockchainContext): OutBoxImpl = { + val candidate = AppkitHelpers.createBoxCandidate(value, tree, tokens, registers, ctx.getHeight) + new OutBoxImpl(candidate) + } + + private def createOut( + contract: String, + value: Long, + registers: Array[ErgoValue[_]], + tokens: Array[ErgoToken] + )(implicit ctx: BlockchainContext): OutBoxImpl = + createOut(ctx.compileContract(ConstantsBuilder.empty(), contract).getErgoTree, value, registers, tokens) + + private def p2pkTree(key: GroupElement): ErgoTree = P2PKAddress(ProveDlog(key)).script + + private def addTokens(builder: OutBoxBuilder)(tokens: java.util.List[ErgoToken]): OutBoxBuilder = + if (tokens.isEmpty) builder else builder.tokens(tokens.asScala: _*) + + private def addRegisters(builder: OutBoxBuilder)(registers: java.util.List[ErgoValue[_]]): OutBoxBuilder = + if (registers.isEmpty) builder else builder.registers(registers.asScala: _*) + + private def createTx( + inputBoxes: Array[InputBox], + dataInputs: Array[InputBox], + boxesToCreate: Array[OutBoxImpl], + fee: Option[Long] = None, + secrets: Array[String] = Array.empty + )(implicit ctx: BlockchainContext): SignedTransaction = { + val txBuilder = ctx.newTxBuilder + val outputs: Array[OutBox] = boxesToCreate.map { box => + val base = txBuilder.outBoxBuilder() + .value(box.getValue) + .creationHeight(box.getCreationHeight) + .contract(new ErgoTreeContract(box.getErgoTree, NetworkType.MAINNET)) + addRegisters(addTokens(base)(box.getTokens))(box.getRegisters).build + } + val unsignedNoFee = ctx.newTxBuilder() + .boxesToSpend(new util.ArrayList[InputBox](inputBoxes.toList.asJava)) + .withDataInputs(new util.ArrayList[InputBox](dataInputs.toList.asJava)) + .outputs(outputs: _*) + .sendChangeTo(P2PKAddress(ProveDlog(ownerPk))) + val unsigned = fee.map(unsignedNoFee.fee).getOrElse(unsignedNoFee).build() + val prover = secrets.map(BigInt(_)).foldLeft(ctx.newProverBuilder()) { + case (builder, secret) => builder.withDLogSecret(secret.bigInteger) + }.build() + prover.sign(unsigned) + } + + private def reserveRegisters( + tree: ErgoValue[AvlTree], + emergencyHeight: Long, + predecessor: Array[Byte] = zeroId, + refundHeight: Long = 0L + ): Array[ErgoValue[_]] = Array( + ErgoValue.of(ownerPk), + tree, + ErgoValue.of(trackerNft), + ErgoValue.of(refundHeight), + ErgoValue.of(emergencyHeight), + ErgoValue.of(predecessor) + ) + + private def redemptionVars( + reserveSignature: Array[Byte], + step: StateStep, + trackerSignature: Option[Array[Byte]], + trackerProof: Option[Array[Byte]], + includePriorProof: Boolean = true, + receiver: GroupElement = receiverPk, + debt: Long = totalDebt, + ts: Long = timestamp + ): Array[ContextVar] = { + val base = Vector( + new ContextVar(0, ErgoValue.of(0: Byte)), + new ContextVar(1, ErgoValue.of(receiver)), + new ContextVar(2, ErgoValue.of(reserveSignature)), + new ContextVar(3, ErgoValue.of(debt)), + new ContextVar(4, ErgoValue.of(ts)), + new ContextVar(5, ErgoValue.of(step.updateProof)) + ) + val withTrackerSig = trackerSignature.fold(base)(sig => base :+ new ContextVar(6, ErgoValue.of(sig))) + val withPrior = if (includePriorProof) withTrackerSig :+ new ContextVar(7, ErgoValue.of(step.lookupProof)) else withTrackerSig + trackerProof.fold(withPrior)(proof => withPrior :+ new ContextVar(8, ErgoValue.of(proof))).toArray + } + + private def ergReserveInput( + txId: String, + reserveNft: Array[Byte], + value: Long, + step: StateStep, + emergencyHeight: Long, + vars: Array[ContextVar], + refundHeight: Long = 0L + )(implicit ctx: BlockchainContext): InputBox = + ctx.newTxBuilder.outBoxBuilder + .value(value) + .tokens(new ErgoToken(reserveNft, 1)) + .registers(reserveRegisters(step.inputTree, emergencyHeight, refundHeight = refundHeight): _*) + .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.basisV2Contract)) + .build() + .convertToInputWith(txId, fakeIndex) + .withContextVars(vars: _*) + + private def tokenReserveInput( + txId: String, + value: Long, + reserveAmount: Long, + step: StateStep, + emergencyHeight: Long, + vars: Array[ContextVar], + refundHeight: Long = 0L + )(implicit ctx: BlockchainContext): InputBox = + ctx.newTxBuilder.outBoxBuilder + .value(value) + .tokens(new ErgoToken(tokenReserveNft, 1), new ErgoToken(reserveTokenId, reserveAmount)) + .registers(reserveRegisters(step.inputTree, emergencyHeight, refundHeight = refundHeight): _*) + .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.basisTokenV2Contract)) + .build() + .convertToInputWith(txId, fakeIndex) + .withContextVars(vars: _*) + + private def trackerInput(state: TrackerState)(implicit ctx: BlockchainContext): InputBox = + ctx.newTxBuilder.outBoxBuilder + .value(minValue) + .tokens(new ErgoToken(trackerNft, 1)) + .registers(ErgoValue.of(trackerPk), state.tree) + .contract(ctx.compileContract(ConstantsBuilder.empty(), "sigmaProp(true)")) + .build() + .convertToInputWith(fakeTxIds(5), fakeIndex) + + private def feeInput( + txId: String, + value: Long = minValue, + tokens: Array[ErgoToken] = Array.empty + )(implicit ctx: BlockchainContext): InputBox = { + val builder = ctx.newTxBuilder.outBoxBuilder.value(value) + .contract(ctx.compileContract(ConstantsBuilder.empty(), "sigmaProp(true)")) + val withTokens = if (tokens.isEmpty) builder else builder.tokens(tokens: _*) + withTokens.build().convertToInputWith(txId, fakeIndex) + } + + private def ergSuccessor( + input: InputBox, + value: Long, + tree: ErgoValue[AvlTree], + reserveNft: Array[Byte], + emergencyHeight: Long, + refundHeight: Long = 0L + )(implicit ctx: BlockchainContext): OutBoxImpl = + createOut( + Constants.basisV2Contract, + value, + reserveRegisters(tree, emergencyHeight, input.getId.getBytes, refundHeight), + Array(new ErgoToken(reserveNft, 1)) + ) + + private def tokenSuccessor( + input: InputBox, + value: Long, + reserveAmount: Long, + tree: ErgoValue[AvlTree], + emergencyHeight: Long, + refundHeight: Long = 0L + )(implicit ctx: BlockchainContext): OutBoxImpl = + createOut( + Constants.basisTokenV2Contract, + value, + reserveRegisters(tree, emergencyHeight, input.getId.getBytes, refundHeight), + Array(new ErgoToken(tokenReserveNft, 1), new ErgoToken(reserveTokenId, reserveAmount)) + ) + + private def ergPayout( + input: InputBox, + value: Long, + receiver: GroupElement = receiverPk + )(implicit ctx: BlockchainContext): OutBoxImpl = + createOut( + p2pkTree(receiver), + value, + Array[ErgoValue[_]](ErgoValue.of(input.getId.getBytes)), + Array.empty[ErgoToken] + ) + + private def tokenPayout( + input: InputBox, + amount: Long, + receiver: GroupElement = receiverPk + )(implicit ctx: BlockchainContext): OutBoxImpl = + createOut( + p2pkTree(receiver), + minValue, + Array[ErgoValue[_]](ErgoValue.of(input.getId.getBytes)), + Array(new ErgoToken(reserveTokenId, amount)) + ) + + property("Basis v2 normal redemption accepts an exact receiver payout with external fee funding") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300000000L + val key = claimKey(ergDomain, reserveNftA, None) + val step = stateStep(key, None, (timestamp, totalDebt, amount)) + // A later tracker commitment may be larger; the older signed cumulative + // claim remains independently redeemable up to its own totalDebt. + val tracker = trackerState(key, totalDebt + 1L) + val claimMessage = message(key) + val input = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt, step, ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(claimMessage, ownerSecret), step, + Some(signatureBytes(claimMessage, trackerSecret)), Some(tracker.lookupProof) + ) + ) + val successor = ergSuccessor(input, input.getValue - amount, step.outputTree, reserveNftA, ctx.getHeight + 1000L) + val payout = ergPayout(input, amount) + + noException should be thrownBy createTx( + Array(input, feeInput(fakeTxIds(1))), Array(trackerInput(tracker)), + Array(successor, payout), fee = Some(feeValue), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 rejects cross-reserve and cross-domain signed claims independently") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300000000L + val keyA = claimKey(ergDomain, reserveNftA, None) + val keyB = claimKey(ergDomain, reserveNftB, None) + val stepB = stateStep(keyB, None, (timestamp, totalDebt, amount)) + val trackerB = trackerState(keyB) + val wrongMessage = message(keyA) + val inputB = ergReserveInput( + fakeTxIds(0), reserveNftB, minValue + totalDebt, stepB, ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(wrongMessage, ownerSecret), stepB, + Some(signatureBytes(wrongMessage, trackerSecret)), Some(trackerB.lookupProof) + ) + ) + + a[Throwable] should be thrownBy createTx( + Array(inputB), Array(trackerInput(trackerB)), + Array( + ergSuccessor(inputB, inputB.getValue - amount, stepB.outputTree, reserveNftB, ctx.getHeight + 1000L), + ergPayout(inputB, amount) + ), + secrets = Array(receiverSecret.toString) + ) + + val expectedKey = claimKey(ergDomain, reserveNftA, None) + val wrongDomainKey = claimKey(tokenDomain, reserveNftA, None) + val expectedStep = stateStep(expectedKey, None, (timestamp, totalDebt, amount)) + val expectedTracker = trackerState(expectedKey) + val wrongDomainMessage = message(wrongDomainKey) + val wrongDomainInput = ergReserveInput( + fakeTxIds(1), reserveNftA, minValue + totalDebt, expectedStep, + ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(wrongDomainMessage, ownerSecret), expectedStep, + Some(signatureBytes(wrongDomainMessage, trackerSecret)), + Some(expectedTracker.lookupProof) + ) + ) + + a[Throwable] should be thrownBy createTx( + Array(wrongDomainInput), Array(trackerInput(expectedTracker)), + Array( + ergSuccessor( + wrongDomainInput, wrongDomainInput.getValue - amount, + expectedStep.outputTree, reserveNftA, ctx.getHeight + 1000L + ), + ergPayout(wrongDomainInput, amount) + ), + secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 rejects an omitted prior-state proof") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300000000L + val key = claimKey(ergDomain, reserveNftA, None) + val step = stateStep(key, None, (timestamp, totalDebt, amount)) + val tracker = trackerState(key) + val claimMessage = message(key) + val input = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt, step, ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(claimMessage, ownerSecret), step, + Some(signatureBytes(claimMessage, trackerSecret)), Some(tracker.lookupProof), + includePriorProof = false + ) + ) + + a[Throwable] should be thrownBy createTx( + Array(input), Array(trackerInput(tracker)), + Array( + ergSuccessor(input, input.getValue - amount, step.outputTree, reserveNftA, ctx.getHeight + 1000L), + ergPayout(input, amount) + ), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 rejects a stale absence proof for an existing redeemed record") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val firstAmount = 300000000L + val secondAmount = 200000000L + val key = claimKey(ergDomain, reserveNftA, None) + val validStep = stateStep( + key, + Some((timestamp, totalDebt, firstAmount)), + (timestamp, totalDebt, firstAmount + secondAmount) + ) + val emptyStep = stateStep(key, None, (timestamp, totalDebt, secondAmount)) + val staleProofStep = validStep.copy(lookupProof = emptyStep.lookupProof) + val tracker = trackerState(key) + val claimMessage = message(key) + val input = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt - firstAmount, + staleProofStep, ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(claimMessage, ownerSecret), staleProofStep, + Some(signatureBytes(claimMessage, trackerSecret)), Some(tracker.lookupProof) + ) + ) + + a[Throwable] should be thrownBy createTx( + Array(input), Array(trackerInput(tracker)), + Array( + ergSuccessor(input, input.getValue - secondAmount, validStep.outputTree, reserveNftA, ctx.getHeight + 1000L), + ergPayout(input, secondAmount) + ), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 rejects mutated receiver, payout amount, and reserve-funded fee independently") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300000000L + val key = claimKey(ergDomain, reserveNftA, None) + val step = stateStep(key, None, (timestamp, totalDebt, amount)) + val tracker = trackerState(key) + val claimMessage = message(key) + + def input(txId: String) = ergReserveInput( + txId, reserveNftA, minValue + totalDebt, step, ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(claimMessage, ownerSecret), step, + Some(signatureBytes(claimMessage, trackerSecret)), Some(tracker.lookupProof) + ) + ) + + val wrongReceiverInput = input(fakeTxIds(0)) + a[Throwable] should be thrownBy createTx( + Array(wrongReceiverInput), Array(trackerInput(tracker)), + Array( + ergSuccessor(wrongReceiverInput, wrongReceiverInput.getValue - amount, step.outputTree, reserveNftA, ctx.getHeight + 1000L), + ergPayout(wrongReceiverInput, amount, otherPk) + ), secrets = Array(receiverSecret.toString) + ) + + val wrongAmountInput = input(fakeTxIds(1)) + a[Throwable] should be thrownBy createTx( + Array(wrongAmountInput), Array(trackerInput(tracker)), + Array( + ergSuccessor(wrongAmountInput, wrongAmountInput.getValue - amount, step.outputTree, reserveNftA, ctx.getHeight + 1000L), + ergPayout(wrongAmountInput, amount - feeValue) + ), secrets = Array(receiverSecret.toString) + ) + + val feeDrainInput = input(fakeTxIds(2)) + a[Throwable] should be thrownBy createTx( + Array(feeDrainInput), Array(trackerInput(tracker)), + Array( + ergSuccessor(feeDrainInput, feeDrainInput.getValue - amount - feeValue, step.outputTree, reserveNftA, ctx.getHeight + 1000L), + ergPayout(feeDrainInput, amount) + ), fee = Some(feeValue), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 permits repeated partial settlement with the original signed cumulative claim") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val firstAmount = 300000000L + val secondAmount = 400000000L + val key = claimKey(ergDomain, reserveNftA, None) + val tracker = trackerState(key) + val claimMessage = message(key) + val ownerSignature = signatureBytes(claimMessage, ownerSecret) + val trackerSignature = signatureBytes(claimMessage, trackerSecret) + + val firstStep = stateStep(key, None, (timestamp, totalDebt, firstAmount)) + val firstInput = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt, firstStep, ctx.getHeight + 1000L, + redemptionVars(ownerSignature, firstStep, Some(trackerSignature), Some(tracker.lookupProof)) + ) + val firstTx = createTx( + Array(firstInput), Array(trackerInput(tracker)), + Array( + ergSuccessor(firstInput, firstInput.getValue - firstAmount, firstStep.outputTree, reserveNftA, ctx.getHeight + 1000L), + ergPayout(firstInput, firstAmount) + ), secrets = Array(receiverSecret.toString) + ) + + val secondStep = stateStep( + key, + Some((timestamp, totalDebt, firstAmount)), + (timestamp, totalDebt, firstAmount + secondAmount) + ) + val secondInput = firstTx.getOutputsToSpend.get(0).withContextVars( + redemptionVars(ownerSignature, secondStep, Some(trackerSignature), Some(tracker.lookupProof)): _* + ) + + noException should be thrownBy createTx( + Array(secondInput), Array(trackerInput(tracker)), + Array( + ergSuccessor(secondInput, secondInput.getValue - secondAmount, secondStep.outputTree, reserveNftA, ctx.getHeight + 1000L), + ergPayout(secondInput, secondAmount) + ), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 emergency redemption needs no tracker input, proof, or signature only at the fixed boundary") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300000000L + val key = claimKey(ergDomain, reserveNftA, None) + val step = stateStep(key, None, (timestamp, totalDebt, amount)) + val claimMessage = message(key) + val ownerSignature = signatureBytes(claimMessage, ownerSecret) + + val emergencyInput = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt, step, ctx.getHeight.toLong, + redemptionVars(ownerSignature, step, None, None) + ) + noException should be thrownBy createTx( + Array(emergencyInput), Array.empty, + Array( + ergSuccessor(emergencyInput, emergencyInput.getValue - amount, step.outputTree, reserveNftA, ctx.getHeight.toLong), + ergPayout(emergencyInput, amount) + ), secrets = Array(receiverSecret.toString) + ) + + val preBoundaryInput = ergReserveInput( + fakeTxIds(1), reserveNftA, minValue + totalDebt, step, ctx.getHeight + 1L, + redemptionVars(ownerSignature, step, None, None) + ) + a[Throwable] should be thrownBy createTx( + Array(preBoundaryInput), Array.empty, + Array( + ergSuccessor(preBoundaryInput, preBoundaryInput.getValue - amount, step.outputTree, reserveNftA, ctx.getHeight + 1L), + ergPayout(preBoundaryInput, amount) + ), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 rejects tokenless reserve state and two reserve inputs sharing one successor") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val key = claimKey(ergDomain, reserveNftA, None) + val step = stateStep(key, None, (timestamp, totalDebt, 1L)) + val emergency = ctx.getHeight + 1000L + + val tokenless = ctx.newTxBuilder.outBoxBuilder + .value(minValue) + .registers(reserveRegisters(step.inputTree, emergency): _*) + .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.basisV2Contract)) + .build().convertToInputWith(fakeTxIds(0), fakeIndex) + .withContextVars(new ContextVar(0, ErgoValue.of(10: Byte))) + val tokenlessOut = createOut( + Constants.basisV2Contract, minValue + 100000000L, + reserveRegisters(step.inputTree, emergency, tokenless.getId.getBytes), Array.empty[ErgoToken] + ) + a[Throwable] should be thrownBy createTx(Array(tokenless), Array.empty, Array(tokenlessOut)) + + def topUpInput(txId: String) = ctx.newTxBuilder.outBoxBuilder + .value(minValue) + .tokens(new ErgoToken(reserveNftA, 1)) + .registers(reserveRegisters(step.inputTree, emergency): _*) + .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.basisV2Contract)) + .build().convertToInputWith(txId, fakeIndex) + .withContextVars(new ContextVar(0, ErgoValue.of(10: Byte))) + + val first = topUpInput(fakeTxIds(1)) + val second = topUpInput(fakeTxIds(2)) + val shared = ergSuccessor(first, minValue + 100000000L, step.inputTree, reserveNftA, emergency) + a[Throwable] should be thrownBy createTx(Array(first, second), Array.empty, Array(shared)) + } + } + + property("Basis token v2 preserves ERG on top-up and redemption") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300L + val reserveAmount = 1000L + val emergency = ctx.getHeight + 1000L + val key = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId)) + val step = stateStep(key, None, (timestamp, totalDebt, amount)) + + val topUpInput = tokenReserveInput( + fakeTxIds(0), minValue * 2, reserveAmount, step, emergency, + Array(new ContextVar(0, ErgoValue.of(10: Byte))) + ) + val tokenFunding = feeInput(fakeTxIds(1), tokens = Array(new ErgoToken(reserveTokenId, 1))) + val drainingTopUp = tokenSuccessor( + topUpInput, minValue, reserveAmount + 1, step.inputTree, emergency + ) + a[Throwable] should be thrownBy createTx( + Array(topUpInput, tokenFunding), Array.empty, Array(drainingTopUp) + ) + + val tracker = trackerState(key) + val claimMessage = message(key) + val redemptionInput = tokenReserveInput( + fakeTxIds(2), minValue * 2, reserveAmount, step, emergency, + redemptionVars( + signatureBytes(claimMessage, ownerSecret), step, + Some(signatureBytes(claimMessage, trackerSecret)), Some(tracker.lookupProof) + ) + ) + val drainingSuccessor = tokenSuccessor( + redemptionInput, minValue, reserveAmount - amount, step.outputTree, emergency + ) + a[Throwable] should be thrownBy createTx( + Array(redemptionInput), Array(trackerInput(tracker)), + Array(drainingSuccessor, tokenPayout(redemptionInput, amount)), + secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis token v2 accepts exact token payout and rejects token leakage to change") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300L + val reserveAmount = 1000L + val emergency = ctx.getHeight + 1000L + val key = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId)) + val step = stateStep(key, None, (timestamp, totalDebt, amount)) + val tracker = trackerState(key) + val claimMessage = message(key) + + def input(txId: String) = tokenReserveInput( + txId, minValue * 2, reserveAmount, step, emergency, + redemptionVars( + signatureBytes(claimMessage, ownerSecret), step, + Some(signatureBytes(claimMessage, trackerSecret)), Some(tracker.lookupProof) + ) + ) + + val validInput = input(fakeTxIds(0)) + noException should be thrownBy createTx( + Array(validInput, feeInput(fakeTxIds(1))), Array(trackerInput(tracker)), + Array( + tokenSuccessor(validInput, validInput.getValue, reserveAmount - amount, step.outputTree, emergency), + tokenPayout(validInput, amount) + ), secrets = Array(receiverSecret.toString) + ) + + val leakingInput = input(fakeTxIds(2)) + a[Throwable] should be thrownBy createTx( + Array(leakingInput, feeInput(fakeTxIds(3))), Array(trackerInput(tracker)), + Array( + tokenSuccessor(leakingInput, leakingInput.getValue, reserveAmount - amount, step.outputTree, emergency), + tokenPayout(leakingInput, amount - 1) + ), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 top-up branches preserve state and accept only externally funded increases") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val emergency = ctx.getHeight + 1000L + val ergKey = claimKey(ergDomain, reserveNftA, None) + val ergStep = stateStep(ergKey, None, (timestamp, totalDebt, 1L)) + val ergInput = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue, ergStep, emergency, + Array(new ContextVar(0, ErgoValue.of(10: Byte))) + ) + noException should be thrownBy createTx( + Array(ergInput, feeInput(fakeTxIds(1))), Array.empty, + Array(ergSuccessor(ergInput, minValue + 100000000L, ergStep.inputTree, reserveNftA, emergency)) + ) + + val tokenKey = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId)) + val tokenStep = stateStep(tokenKey, None, (timestamp, totalDebt, 1L)) + val tokenInput = tokenReserveInput( + fakeTxIds(2), minValue, 1000L, tokenStep, emergency, + Array(new ContextVar(0, ErgoValue.of(10: Byte))) + ) + noException should be thrownBy createTx( + Array( + tokenInput, + feeInput(fakeTxIds(3), tokens = Array(new ErgoToken(reserveTokenId, 1L))) + ), + Array.empty, + Array(tokenSuccessor(tokenInput, minValue, 1001L, tokenStep.inputTree, emergency)) + ) + } + } + + property("Basis v2 refund initiation and completion enforce owner proof and overflow-safe delay") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val emergency = ctx.getHeight + 100000L + val key = claimKey(ergDomain, reserveNftA, None) + val step = stateStep(key, None, (timestamp, totalDebt, 1L)) + + val initiateInput = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue, step, emergency, + Array(new ContextVar(0, ErgoValue.of(20: Byte))) + ) + noException should be thrownBy createTx( + Array(initiateInput), Array.empty, + Array( + ergSuccessor( + initiateInput, minValue, step.inputTree, reserveNftA, emergency, + refundHeight = ctx.getHeight.toLong + ) + ), secrets = Array(ownerSecret.toString) + ) + + val maturedHeight = ctx.getHeight.toLong - 43200L + val completeInput = ergReserveInput( + fakeTxIds(1), reserveNftA, minValue, step, emergency, + Array(new ContextVar(0, ErgoValue.of(30: Byte))), refundHeight = maturedHeight + ) + val ownerOutput = createOut( + p2pkTree(ownerPk), minValue, Array.empty[ErgoValue[_]], + Array(new ErgoToken(reserveNftA, 1L)) + ) + noException should be thrownBy createTx( + Array(completeInput), Array.empty, Array(ownerOutput), + secrets = Array(ownerSecret.toString) + ) + + val earlyInput = ergReserveInput( + fakeTxIds(2), reserveNftA, minValue, step, emergency, + Array(new ContextVar(0, ErgoValue.of(30: Byte))), + refundHeight = ctx.getHeight.toLong - 43199L + ) + a[Throwable] should be thrownBy createTx( + Array(earlyInput), Array.empty, Array(ownerOutput), + secrets = Array(ownerSecret.toString) + ) + + val overflowSeed = ergReserveInput( + fakeTxIds(3), reserveNftA, minValue, step, emergency, + Array(new ContextVar(0, ErgoValue.of(30: Byte))), refundHeight = Long.MaxValue + ) + a[Throwable] should be thrownBy createTx( + Array(overflowSeed), Array.empty, Array(ownerOutput), + secrets = Array(ownerSecret.toString) + ) + } + } + + property("Basis token v2 refund branches preserve both token roles until owner-authorized completion") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val emergency = ctx.getHeight + 100000L + val key = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId)) + val step = stateStep(key, None, (timestamp, totalDebt, 1L)) + val reserveAmount = 1000L + + val initiateInput = tokenReserveInput( + fakeTxIds(0), minValue, reserveAmount, step, emergency, + Array(new ContextVar(0, ErgoValue.of(20: Byte))) + ) + noException should be thrownBy createTx( + Array(initiateInput), Array.empty, + Array( + tokenSuccessor( + initiateInput, minValue, reserveAmount, step.inputTree, emergency, + refundHeight = ctx.getHeight.toLong + ) + ), secrets = Array(ownerSecret.toString) + ) + + val completeInput = tokenReserveInput( + fakeTxIds(1), minValue, reserveAmount, step, emergency, + Array(new ContextVar(0, ErgoValue.of(30: Byte))), + refundHeight = ctx.getHeight.toLong - 43200L + ) + val ownerOutput = createOut( + p2pkTree(ownerPk), minValue, Array.empty[ErgoValue[_]], + Array( + new ErgoToken(tokenReserveNft, 1L), + new ErgoToken(reserveTokenId, reserveAmount) + ) + ) + noException should be thrownBy createTx( + Array(completeInput), Array.empty, Array(ownerOutput), + secrets = Array(ownerSecret.toString) + ) + } + } + + property("Basis v2 source constants compile from the exact repository files") { + val root = Paths.get(sys.props("user.dir")) + val ergSource = new String(Files.readAllBytes(root.resolve("contracts/offchain/basis-v2.es")), StandardCharsets.UTF_8) + .replace("\r\n", "\n").stripSuffix("\n") + val tokenSource = new String(Files.readAllBytes(root.resolve("contracts/offchain/basis-token-v2.es")), StandardCharsets.UTF_8) + .replace("\r\n", "\n").stripSuffix("\n") + Constants.basisV2Contract shouldEqual ergSource + Constants.basisTokenV2Contract shouldEqual tokenSource + + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + Constants.basisV2ErgoTree.bytes shouldEqual + ctx.compileContract(ConstantsBuilder.empty(), ergSource).getErgoTree.bytes + Constants.basisTokenV2ErgoTree.bytes shouldEqual + ctx.compileContract(ConstantsBuilder.empty(), tokenSource).getErgoTree.bytes + } + } + + property("Basis v2 full ErgoTree bytes match the committed golden files") { + val root = Paths.get(sys.props("user.dir")) + def expected(path: String): Array[Byte] = + hex(new String(Files.readAllBytes(root.resolve(path)), StandardCharsets.US_ASCII).trim) + Constants.basisV2ErgoTree.bytes shouldEqual expected("contracts/offchain/basis-v2.p2s") + Constants.basisTokenV2ErgoTree.bytes shouldEqual expected("contracts/offchain/basis-token-v2.p2s") + + // Keep the digest calculation in the test so reviewers can independently + // compare the full bytes before relying on the shorter receipt hashes. + MessageDigest.getInstance("SHA-256").digest(Constants.basisV2ErgoTree.bytes).length shouldEqual 32 + MessageDigest.getInstance("SHA-256").digest(Constants.basisTokenV2ErgoTree.bytes).length shouldEqual 32 + } +} From 2e51e2534ba8426a987dc1bbc704ffd23156b2ae Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:37:34 +0200 Subject: [PATCH 02/11] test(basis): isolate payout lineage mutation --- contracts/offchain/basis-v2-review.md | 2 +- src/test/scala/chaincash/BasisV2Spec.scala | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/contracts/offchain/basis-v2-review.md b/contracts/offchain/basis-v2-review.md index e264138..a598fac 100644 --- a/contracts/offchain/basis-v2-review.md +++ b/contracts/offchain/basis-v2-review.md @@ -28,7 +28,7 @@ and wallet change are outside the reserve accounting equations. | I-01 | Claim key commits v2 tag, reserve NFT, tracker NFT, owner, receiver and token id where applicable. Both signatures and tracker AVL use that key. | tracker builder, note signer, both reserve contracts | one signed liability becomes valid in another reserve, asset or generation | wrong-reserve and wrong-domain signatures rejected independently | | I-02 | Context 7 always supplies an authenticated R5 membership or non-membership proof. | R5 `insertOrUpdate` and later redemptions | existing state is treated as an unauthenticated zero baseline | omitted proof and stale absence proof rejected | | I-03 | R5 values are fixed 24-byte `(timestamp,totalDebt,redeemed)` tuples; same claim may advance `redeemed`, newer claims cannot reduce debt. | partial-redemption builder and restart reconstruction | repeated settlement is blocked or redeemed progress is erased | two consecutive partial settlements use the original signatures | -| I-04 | Payout is at `successorIndex + 1`, has receiver P2PK bytes, R4=`SELF.id`, and exact released amount. | creditor wallet and multi-input batcher | redirected, underpaid or reused payout | receiver and amount mutations rejected | +| I-04 | Payout is at `successorIndex + 1`, has receiver P2PK bytes, R4=`SELF.id`, and exact released amount. | creditor wallet and multi-input batcher | redirected, underpaid or reused payout | receiver, amount and lineage mutations rejected independently | | I-05 | ERG redemption requires `successor.value + payout.value == SELF.value`. | reserve collateral and fee builder | reserve collateral silently pays fees/change | reserve-funded fee mutation rejected; external fee input accepted | | I-06 | Token successor preserves ERG, singleton and reserve-token ids; reserve-token decrease equals the payout's sole token amount. | token creditor, later reserve transitions and refund | ERG drain, token leakage or accidental burn | top-up ERG drain, redemption ERG drain and payout leakage rejected | | I-07 | Before R8, tracker input has the exact singleton, typed key/tree, fixed tree shape, sufficient committed cumulative debt and valid signature. | normal-redemption liveness and liability admission | forged tracker state or uncommitted claim | exact normal redemption passes; cross-domain evidence rejects | diff --git a/src/test/scala/chaincash/BasisV2Spec.scala b/src/test/scala/chaincash/BasisV2Spec.scala index e404b04..91213a0 100644 --- a/src/test/scala/chaincash/BasisV2Spec.scala +++ b/src/test/scala/chaincash/BasisV2Spec.scala @@ -460,7 +460,7 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { } } - property("Basis v2 rejects mutated receiver, payout amount, and reserve-funded fee independently") { + property("Basis v2 rejects mutated receiver, payout amount, payout lineage, and reserve-funded fee independently") { createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => val amount = 300000000L val key = claimKey(ergDomain, reserveNftA, None) @@ -494,6 +494,20 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { ), secrets = Array(receiverSecret.toString) ) + val wrongLineageInput = input(fakeTxIds(3)) + val wrongLineagePayout = createOut( + p2pkTree(receiverPk), amount, + Array[ErgoValue[_]](ErgoValue.of(reserveNftB)), + Array.empty[ErgoToken] + ) + a[Throwable] should be thrownBy createTx( + Array(wrongLineageInput), Array(trackerInput(tracker)), + Array( + ergSuccessor(wrongLineageInput, wrongLineageInput.getValue - amount, step.outputTree, reserveNftA, ctx.getHeight + 1000L), + wrongLineagePayout + ), secrets = Array(receiverSecret.toString) + ) + val feeDrainInput = input(fakeTxIds(2)) a[Throwable] should be thrownBy createTx( Array(feeDrainInput), Array(trackerInput(tracker)), From 5b8daf7d50be157c52f9a999d97447c936192f09 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:40:19 +0200 Subject: [PATCH 03/11] test(basis): isolate emergency and refund boundaries --- contracts/offchain/basis-v2-review.md | 4 +- src/test/scala/chaincash/BasisV2Spec.scala | 45 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/contracts/offchain/basis-v2-review.md b/contracts/offchain/basis-v2-review.md index a598fac..a61a0eb 100644 --- a/contracts/offchain/basis-v2-review.md +++ b/contracts/offchain/basis-v2-review.md @@ -32,9 +32,9 @@ and wallet change are outside the reserve accounting equations. | I-05 | ERG redemption requires `successor.value + payout.value == SELF.value`. | reserve collateral and fee builder | reserve collateral silently pays fees/change | reserve-funded fee mutation rejected; external fee input accepted | | I-06 | Token successor preserves ERG, singleton and reserve-token ids; reserve-token decrease equals the payout's sole token amount. | token creditor, later reserve transitions and refund | ERG drain, token leakage or accidental burn | top-up ERG drain, redemption ERG drain and payout leakage rejected | | I-07 | Before R8, tracker input has the exact singleton, typed key/tree, fixed tree shape, sufficient committed cumulative debt and valid signature. | normal-redemption liveness and liability admission | forged tracker state or uncommitted claim | exact normal redemption passes; cross-domain evidence rejects | -| I-08 | R8 is mandatory and exactly preserved; tracker fields are not evaluated only when `HEIGHT >= R8`. | emergency builder and acceptance policy | mutable tracker creation height postpones exit, or missing tracker still blocks the branch | tracker-free redemption passes at R8 and rejects one block before | +| I-08 | R8 is mandatory and exactly preserved; tracker fields are not evaluated only when `HEIGHT >= R8`. | emergency builder and acceptance policy | mutable tracker creation height postpones exit, or missing tracker still blocks the branch | tracker-free redemption passes at R8, rejects one block before, and rejects a mutated successor R8 | | I-09 | Reserve singleton shape is mandatory; every successor R9 and payout R4 equal the predecessor box id. | indexer lineage and multi-input settlement | tokenless state or one output satisfies two inputs | tokenless state and shared-successor transaction rejected | -| I-10 | Refund R7 is one-shot, owner-authorized, preserved on other branches and checked with subtraction rather than overflowing addition. | creditor monitoring and terminal refund | backdated/reset timer or overflow-assisted early refund | initiation, mature completion, early completion and `Long.MaxValue` seed exercised | +| I-10 | Refund R7 is one-shot, owner-authorized, preserved on other branches and checked with subtraction rather than overflowing addition. | creditor monitoring and terminal refund | backdated/reset timer or overflow-assisted early refund | ownerless ERG/token initiation, mature completion, early completion and `Long.MaxValue` seed exercised | | I-11 | Constants compile exact normalized source and tests compare full output bytes with committed goldens. | compiler, builders and contract-family selection | readable source, configured P2S and runtime ABI silently diverge | source equality, fresh compile and full-byte golden tests | Each negative transaction changes only the field named in its test while its diff --git a/src/test/scala/chaincash/BasisV2Spec.scala b/src/test/scala/chaincash/BasisV2Spec.scala index 91213a0..65297d0 100644 --- a/src/test/scala/chaincash/BasisV2Spec.scala +++ b/src/test/scala/chaincash/BasisV2Spec.scala @@ -561,7 +561,7 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { } } - property("Basis v2 emergency redemption needs no tracker input, proof, or signature only at the fixed boundary") { + property("Basis v2 emergency redemption is tracker-free only at the fixed immutable boundary") { createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => val amount = 300000000L val key = claimKey(ergDomain, reserveNftA, None) @@ -592,6 +592,21 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { ergPayout(preBoundaryInput, amount) ), secrets = Array(receiverSecret.toString) ) + + val mutatedBoundaryInput = ergReserveInput( + fakeTxIds(2), reserveNftA, minValue + totalDebt, step, + ctx.getHeight.toLong, redemptionVars(ownerSignature, step, None, None) + ) + a[Throwable] should be thrownBy createTx( + Array(mutatedBoundaryInput), Array.empty, + Array( + ergSuccessor( + mutatedBoundaryInput, mutatedBoundaryInput.getValue - amount, + step.outputTree, reserveNftA, ctx.getHeight + 1L + ), + ergPayout(mutatedBoundaryInput, amount) + ), secrets = Array(receiverSecret.toString) + ) } } @@ -757,6 +772,20 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { ), secrets = Array(ownerSecret.toString) ) + val unauthorizedInitiateInput = ergReserveInput( + fakeTxIds(4), reserveNftA, minValue, step, emergency, + Array(new ContextVar(0, ErgoValue.of(20: Byte))) + ) + a[Throwable] should be thrownBy createTx( + Array(unauthorizedInitiateInput), Array.empty, + Array( + ergSuccessor( + unauthorizedInitiateInput, minValue, step.inputTree, reserveNftA, + emergency, refundHeight = ctx.getHeight.toLong + ) + ) + ) + val maturedHeight = ctx.getHeight.toLong - 43200L val completeInput = ergReserveInput( fakeTxIds(1), reserveNftA, minValue, step, emergency, @@ -813,6 +842,20 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { ), secrets = Array(ownerSecret.toString) ) + val unauthorizedInitiateInput = tokenReserveInput( + fakeTxIds(2), minValue, reserveAmount, step, emergency, + Array(new ContextVar(0, ErgoValue.of(20: Byte))) + ) + a[Throwable] should be thrownBy createTx( + Array(unauthorizedInitiateInput), Array.empty, + Array( + tokenSuccessor( + unauthorizedInitiateInput, minValue, reserveAmount, + step.inputTree, emergency, refundHeight = ctx.getHeight.toLong + ) + ) + ) + val completeInput = tokenReserveInput( fakeTxIds(1), minValue, reserveAmount, step, emergency, Array(new ContextVar(0, ErgoValue.of(30: Byte))), From 9a274396d5f78f7be5ed76bacee5329c42570317 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:16:39 +0200 Subject: [PATCH 04/11] fix(basis): reject identity group elements --- contracts/offchain/basis-token-v2.es | 7 + contracts/offchain/basis-token-v2.p2s | 2 +- contracts/offchain/basis-v2-review.md | 22 +- contracts/offchain/basis-v2.es | 7 + contracts/offchain/basis-v2.md | 5 + contracts/offchain/basis-v2.p2s | 2 +- src/test/scala/chaincash/BasisV2Spec.scala | 429 +++++++++++++++++++-- 7 files changed, 438 insertions(+), 36 deletions(-) diff --git a/contracts/offchain/basis-token-v2.es b/contracts/offchain/basis-token-v2.es index a23703a..48a00b6 100644 --- a/contracts/offchain/basis-token-v2.es +++ b/contracts/offchain/basis-token-v2.es @@ -19,6 +19,7 @@ val outputIndex = packed % 10 val ownerKey = SELF.R4[GroupElement].get + val identity = groupGenerator.exp(byteArrayToBigInt(fromBase16("00"))) val redeemedTree = SELF.R5[AvlTree].get val trackerNftId = SELF.R6[Coll[Byte]].get val refundHeight = SELF.R7[Long].get @@ -44,6 +45,7 @@ predecessorId.size == 32 && emergencyHeight > 0L && refundHeight >= 0L && + ownerKey != identity && redeemedTreeShape if (!selfShape) { @@ -116,6 +118,7 @@ val e = byteArrayToBigInt( blake2b256(aBytes ++ message ++ ownerKey.getEncoded) ) + a != identity && groupGenerator.exp(z) == a.multiply(ownerKey.exp(e)) } else { false @@ -160,6 +163,7 @@ selfOut.R8[Long].get == emergencyHeight && selfOut.R9[Coll[Byte]].get == SELF.id val receiverCondition = proveDlog(receiver) + val receiverValid = receiver != identity val amount = SELF.tokens(1)._2 - selfOut.tokens(1)._2 val payoutBound = payout.propositionBytes == receiverCondition.propBytes && @@ -246,10 +250,12 @@ val e = byteArrayToBigInt( blake2b256(aBytes ++ message ++ trackerKey.getEncoded) ) + a != identity && groupGenerator.exp(z) == a.multiply(trackerKey.exp(e)) } else { false } + trackerKey != identity && trackerTreeShape && trackerDebtValid && properTrackerSignature } } @@ -257,6 +263,7 @@ sigmaProp( successorCommon && + receiverValid && payoutBound && valueFlowValid && priorShape && diff --git a/contracts/offchain/basis-token-v2.p2s b/contracts/offchain/basis-token-v2.p2s index eb34e97..debc245 100644 --- a/contracts/offchain/basis-token-v2.p2s +++ b/contracts/offchain/basis-token-v2.p2s @@ -1 +1 @@ -1bf50e6c0100041404140400040404000502040004020402050004400440050005000440043001000400040004020100040201000400040204020e08424153495302000105000430040004100410042004200430050005000500040404000400050204020402050005000402040004000500043005000500050005000500048001048401040004420442010001010400010004000402040004000502010004400410041001000480010484010400044204420100040204040400010004040400040004000502040204020402050001000402040205020500053c04020402040605000580a3050580a3050100d802d601e30002d602c6a7040795ecefe67201efededededede67202e6c6a70564e6c6a7060ee6c6a70705e6c6a70805e6c6a7090ed17300d809d6037ee4720104d604db6308a7d605e4c6a7060ed606e4c6a70805d607e4c6a70705d608e4c6a70564d6099d72037301d60a9e72037302d60be4720295efedededededed9272037303ededed93b172047304938cb27204730500027306948cb27204730700018cb2720473080001918cb2720473090002730a93b17205730b93b1e4c6a7090e730c917206730d927207730eededededed93db64037208730fe6db6404720893e4db640472087310db64057208db64067208efdb64077208d17311959372097312d806d60ce30107d60de3020ed60ee30305d60fe30405d610e3050ed611e3070e95ecefed92720a731391b1a59a720a7314efededededede6720ce6720de6720ee6720fe67210e67211d17315d804d612b2a5720a00d613c672120407d614b2a59a720a731600d615c67214040e95ecefededededede67213e6c672120564e6c67212060ee6c672120705e6c672120805e6c67212090eefe67215d17317d81ad616db63087212d6178cb2720473180001d618b27204731900d6198c721801d61ae4720cd61bcd721ad61c998c7218028cb27216731a0002d61ddb0702720bd61ecbb3b3b3b3b3731b721772197205721ddb0702721ad61fdc640a720802721ee47211d620e6721fd6217a731cd622b3b3722172217221d623e5721f7222d6249593b17223731d72237222d6257cb47224731e731fd6267cb4722473207321d6277cb4722473227323d628e4720fd629e4720ed62aed91721c732490721c95ed9272277325927229722799722972277326d62b7a7228d62c7a7229d62de4720dd62eb1722dd62fb3b3721e722c722bea02d1ededededededededededededededed93c27212c2a7edededed93b172167327938cb27216732800017217938cb2721673290002732a938cb27216732b00017219918cb27216732c0002732d93e47213720b93e4c67212060e720593e4c672120705720793e4c672120805720693e4c67212090ec5a7ededededed93c27214d0721b91c17214732e93b1db63087214732f938cb2db63087214733000017219938cb2db6308721473310002721c93e47215c5a7ed92c17212c1a791721c7332ecef722093b172237333ededed9272257334927226733592722773369072277226957220eced93722872259372297226ed91722872259272297226ed91722873379172297338722a93e4dc641072080283013c0e0e8602721eb3b3722b722c7a95722a9a7227721c7227e47210e4c67212056495ed92722e733990722e733ad801d630b4722d733b733c939fdb6a01dd7bb4722d733d722ea0ee72309f720b7bcbb3b37230722f721d733e95927ea3057206733fd803d630e3060ed631e3080ed632db6501fe95ececefe67230efe6723190b1723273407341d803d633b27232734200d634c672330407d635db6308723395ecefede67234e6c672330564efeded93b172357343938cb27235734400017205938cb272357345000273467347d805d636e4c672330564d637dc640a723602721ee47231d638e47230d639b17238d63ae47234ededededededed93db640372367348e6db6404723693e4db640472367349db64057236db64067236efdb6407723695e67237d801d63be47237ed93b1723b734a927c723b7229734b95ed927239734c907239734dd801d63bb47238734e734f939fdb6a01dd7bb4723873507239a0ee723b9f723a7bcbb3b3723b722fdb0702723a7351721bd801d60c937209735295ec720c937209735395efed92720a735491b1a5720ad17355d803d60db2a5720a00d60ec6720d0407d60fdb6308720d95ecefededededede6720ee6c6720d0564e6c6720d060ee6c6720d0705e6c6720d0805e6c6720d090eefedededed93b1720f7356938cb2720f735700018cb2720473580001938cb2720f73590002735a938cb2720f735b00018cb27204735c0001918cb2720f735d0002735ed1735fd801d610ededededed93c2720dc2a793e4720e720b93e4c6720d0564720893e4c6720d060e720593e4c6720d0805720693e4c6720d090ec5a795720cd1ededed721093e4c6720d0705720792c1720dc1a792998cb2720f736000028cb27204736100027362ea02d1ededededed7210937207736392e4c6720d07057ea30590e4c6720d07059a7ea305736492c1720dc1a7928cb2720f736500028cb2720473660002cd720b959372097367ea02d1eded9172077368927ea3057369907207997ea305736acd720bd1736b +1ba80f6d01000e0100041404140400040404000502040004020402050004400440050005000440043001000400040004020100040201000400040204020e08424153495302000105000430040004100410042004200430050005000500040404000400050204020402050005000402040004000500043005000500050005000500048001048401040004420442010001010400010004000402040004000502010004400410041001000480010484010400044204420100040204040400010004040400040004000502040204020402050001000402040205020500053c04020402040605000580a3050580a3050100d802d601e30002d602c6a7040795ecefe67201efededededede67202e6c6a70564e6c6a7060ee6c6a70705e6c6a70805e6c6a7090ed17300d80bd6037ee4720104d604db6308a7d605e4c6a7060ed606e4c6a70805d607e4c6a70705d608e47202d609db6a01ddd60a9f72097b7301d60be4c6a70564d60c9d72037302d60d9e7203730395efededededededed9272037304ededed93b172047305938cb27204730600027307948cb27204730800018cb2720473090001918cb27204730a0002730b93b17205730c93b1e4c6a7090e730d917206730e927207730f947208720aededededed93db6403720b7310e6db6404720b93e4db6404720b7311db6405720bdb6406720befdb6407720bd173129593720c7313d806d60ee30107d60fe3020ed610e30305d611e30405d612e3050ed613e3070e95ecefed92720d731491b1a59a720d7315efededededede6720ee6720fe67210e67211e67212e67213d17316d804d614b2a5720d00d615c672140407d616b2a59a720d731700d617c67216040e95ecefededededede67215e6c672140564e6c67214060ee6c672140705e6c672140805e6c67214090eefe67217d17318d81ad618db63087214d6198cb2720473190001d61ab27204731a00d61b8c721a01d61ce4720ed61dcd721cd61e998c721a028cb27218731b0002d61fdb07027208d620cbb3b3b3b3b3731c7219721b7205721fdb0702721cd621dc640a720b027220e47213d622e67221d6237a731dd624b3b3722372237223d625e572217224d6269593b17225731e72257224d6277cb47226731f7320d6287cb4722673217322d6297cb4722673237324d62ae47211d62be47210d62ced91721e732590721e95ed927229732692722b722999722b72297327d62d7a722ad62e7a722bd62fe4720fd630b1722fd631b3b37220722e722dea02d1edededededededededededededededed93c27214c2a7edededed93b172187328938cb27218732900017219938cb27218732a0002732b938cb27218732c0001721b918cb27218732d0002732e93e47215720893e4c67214060e720593e4c672140705720793e4c672140805720693e4c67214090ec5a794721c720aededededed93c27216d0721d91c17216732f93b1db630872167330938cb2db6308721673310001721b938cb2db6308721673320002721e93e47217c5a7ed92c17214c1a791721e7333ecef722293b172257334ededed9272277335927228733692722973379072297228957222eced93722a722793722b7228ed91722a722792722b7228ed91722a733891722b7339722c93e4dc6410720b0283013c0e0e86027220b3b3722d722e7a95722c9a7229721e7229e47212e4c67214056495ed927230733a907230733bd802d632b4722f733c733dd633ee7232ed947233720a939f72097bb4722f733e7230a072339f72087bcbb3b372327231721f733f95927ea30572067340d803d632e3060ed633e3080ed634db6501fe95ececefe67232efe6723390b1723473417342d803d635b27234734300d636c672350407d637db6308723595ecefede67236e6c672350564efeded93b172377344938cb27237734500017205938cb272377346000273477348d805d638e47236d639e4c672350564d63adc640a7239027220e47233d63be47232d63cb1723bededed947238720aededededed93db640372397349e6db6404723993e4db64047239734adb64057239db64067239efdb6407723995e6723ad801d63de4723aed93b1723d734b927c723d722b734c95ed92723c734d90723c734ed802d63db4723b734f7350d63eee723ded94723e720a939f72097bb4723b7351723ca0723e9f72387bcbb3b3723d7231db070272387352721dd801d60e93720c735395ec720e93720c735495efed92720d735591b1a5720dd17356d803d60fb2a5720d00d610c6720f0407d611db6308720f95ecefededededede67210e6c6720f0564e6c6720f060ee6c6720f0705e6c6720f0805e6c6720f090eefedededed93b172117357938cb27211735800018cb2720473590001938cb27211735a0002735b938cb27211735c00018cb27204735d0001918cb27211735e0002735fd17360d801d612ededededed93c2720fc2a793e47210720893e4c6720f0564720b93e4c6720f060e720593e4c6720f0805720693e4c6720f090ec5a795720ed1ededed721293e4c6720f0705720792c1720fc1a792998cb27211736100028cb27204736200027363ea02d1ededededed7212937207736492e4c6720f07057ea30590e4c6720f07059a7ea305736592c1720fc1a7928cb27211736600028cb2720473670002cd72089593720c7368ea02d1eded9172077369927ea305736a907207997ea305736bcd7208d1736c diff --git a/contracts/offchain/basis-v2-review.md b/contracts/offchain/basis-v2-review.md index a61a0eb..f76b763 100644 --- a/contracts/offchain/basis-v2-review.md +++ b/contracts/offchain/basis-v2-review.md @@ -25,17 +25,18 @@ and wallet change are outside the reserve accounting equations. | ID | Invariant / enforcement | Downstream consumer | Relaxation failure | Focused evidence | | --- | --- | --- | --- | --- | -| I-01 | Claim key commits v2 tag, reserve NFT, tracker NFT, owner, receiver and token id where applicable. Both signatures and tracker AVL use that key. | tracker builder, note signer, both reserve contracts | one signed liability becomes valid in another reserve, asset or generation | wrong-reserve and wrong-domain signatures rejected independently | +| I-01 | Claim key commits v2 tag, reserve NFT, tracker NFT, owner, receiver and token id where applicable. Both signatures and tracker AVL use that key. | tracker builder, note signer, both reserve contracts | one signed liability becomes valid in another reserve, asset or generation | owner-only wrong-reserve and tracker-only wrong-domain signatures rejected independently | | I-02 | Context 7 always supplies an authenticated R5 membership or non-membership proof. | R5 `insertOrUpdate` and later redemptions | existing state is treated as an unauthenticated zero baseline | omitted proof and stale absence proof rejected | -| I-03 | R5 values are fixed 24-byte `(timestamp,totalDebt,redeemed)` tuples; same claim may advance `redeemed`, newer claims cannot reduce debt. | partial-redemption builder and restart reconstruction | repeated settlement is blocked or redeemed progress is erased | two consecutive partial settlements use the original signatures | +| I-03 | R5 values are fixed 24-byte `(timestamp,totalDebt,redeemed)` tuples; same claim may advance `redeemed`, newer claims cannot reduce debt. | partial-redemption builder and restart reconstruction | repeated settlement is blocked, redeemed progress is erased or excess collateral is released | repeated partial settlement passes; same-timestamp debt mutation and over-redemption reject independently | | I-04 | Payout is at `successorIndex + 1`, has receiver P2PK bytes, R4=`SELF.id`, and exact released amount. | creditor wallet and multi-input batcher | redirected, underpaid or reused payout | receiver, amount and lineage mutations rejected independently | | I-05 | ERG redemption requires `successor.value + payout.value == SELF.value`. | reserve collateral and fee builder | reserve collateral silently pays fees/change | reserve-funded fee mutation rejected; external fee input accepted | | I-06 | Token successor preserves ERG, singleton and reserve-token ids; reserve-token decrease equals the payout's sole token amount. | token creditor, later reserve transitions and refund | ERG drain, token leakage or accidental burn | top-up ERG drain, redemption ERG drain and payout leakage rejected | -| I-07 | Before R8, tracker input has the exact singleton, typed key/tree, fixed tree shape, sufficient committed cumulative debt and valid signature. | normal-redemption liveness and liability admission | forged tracker state or uncommitted claim | exact normal redemption passes; cross-domain evidence rejects | +| I-07 | Before R8, tracker input has the exact singleton, typed non-identity key/tree, fixed tree shape, sufficient committed cumulative debt and valid signature. | normal-redemption liveness and liability admission | forged tracker state or uncommitted claim | exact normal redemption passes; NFT, flags, value shape, debt and signature-domain mutations reject independently | | I-08 | R8 is mandatory and exactly preserved; tracker fields are not evaluated only when `HEIGHT >= R8`. | emergency builder and acceptance policy | mutable tracker creation height postpones exit, or missing tracker still blocks the branch | tracker-free redemption passes at R8, rejects one block before, and rejects a mutated successor R8 | -| I-09 | Reserve singleton shape is mandatory; every successor R9 and payout R4 equal the predecessor box id. | indexer lineage and multi-input settlement | tokenless state or one output satisfies two inputs | tokenless state and shared-successor transaction rejected | -| I-10 | Refund R7 is one-shot, owner-authorized, preserved on other branches and checked with subtraction rather than overflowing addition. | creditor monitoring and terminal refund | backdated/reset timer or overflow-assisted early refund | ownerless ERG/token initiation, mature completion, early completion and `Long.MaxValue` seed exercised | +| I-09 | Reserve singleton shape is mandatory; every successor R9 and payout R4 equal the predecessor box id. | indexer lineage and multi-input settlement | tokenless state or one successor/payout pair satisfies two inputs | externally balanced tokenless state and shared successor/payout transactions rejected | +| I-10 | Refund R7 is one-shot, owner-authorized, preserved on other branches and checked with subtraction rather than overflowing addition. | creditor monitoring and terminal refund | backdated/reset timer or overflow-assisted early refund | ownerless ERG/token initiation and completion, mature completion, early completion and `Long.MaxValue` seed exercised | | I-11 | Constants compile exact normalized source and tests compare full output bytes with committed goldens. | compiler, builders and contract-family selection | readable source, configured P2S and runtime ABI silently diverge | source equality, fresh compile and full-byte golden tests | +| I-12 | Owner, receiver, tracker and received Schnorr commitments are not the group identity. | reserve admission, creditor authorization and manual Schnorr verification | identity owner/tracker signatures are forgeable and an identity receiver has a publicly known witness | ERG and token transactions mutate owner, receiver, tracker and commitment independently | Each negative transaction changes only the field named in its test while its proofs, signatures, tree roots, tokens and unrelated outputs remain those of a @@ -56,11 +57,12 @@ three independent transactions; it does not use one compound malformed box. ## Test closure -`sbt -batch "testOnly chaincash.BasisV2Spec"` currently executes 15 tests. They +`sbt -batch "testOnly chaincash.BasisV2Spec"` currently executes 21 tests. They cover normal and emergency redemption, domain separation, proof omission and -replay, exact payouts, ERG/token conservation, partial settlement, singleton -and output injectivity, both top-up branches, both refund families, source -linkage and full golden bytes. +replay, non-identity group boundaries, tracker shape and debt admission, R5 +progression, exact payouts, ERG/token conservation, partial settlement, +singleton and output injectivity, both top-up branches, both refund families, +source linkage and full golden bytes. The unchanged v1 suites remain a separate regression gate: @@ -72,7 +74,7 @@ sbt -batch "testOnly chaincash.BasisSpec chaincash.BasisTokenSpec" | Dimension | Status | | --- | --- | -| Implementation | matrix-covered by local source and 15 focused mockchain tests | +| Implementation | matrix-covered by local source and 21 focused mockchain tests | | Independent review | pending on the exact candidate commit | | CI | not run | | Target node | not run; no live or broadcast action authorized | diff --git a/contracts/offchain/basis-v2.es b/contracts/offchain/basis-v2.es index 7dc2ec7..47b3e06 100644 --- a/contracts/offchain/basis-v2.es +++ b/contracts/offchain/basis-v2.es @@ -19,6 +19,7 @@ val outputIndex = packed % 10 val ownerKey = SELF.R4[GroupElement].get + val identity = groupGenerator.exp(byteArrayToBigInt(fromBase16("00"))) val redeemedTree = SELF.R5[AvlTree].get val trackerNftId = SELF.R6[Coll[Byte]].get val refundHeight = SELF.R7[Long].get @@ -42,6 +43,7 @@ predecessorId.size == 32 && emergencyHeight > 0L && refundHeight >= 0L && + ownerKey != identity && redeemedTreeShape if (!selfShape) { @@ -112,6 +114,7 @@ val e = byteArrayToBigInt( blake2b256(aBytes ++ message ++ ownerKey.getEncoded) ) + a != identity && groupGenerator.exp(z) == a.multiply(ownerKey.exp(e)) } else { false @@ -150,6 +153,7 @@ selfOut.R8[Long].get == emergencyHeight && selfOut.R9[Coll[Byte]].get == SELF.id val receiverCondition = proveDlog(receiver) + val receiverValid = receiver != identity val payoutBound = payout.propositionBytes == receiverCondition.propBytes && payout.tokens.size == 0 && @@ -235,10 +239,12 @@ val e = byteArrayToBigInt( blake2b256(aBytes ++ message ++ trackerKey.getEncoded) ) + a != identity && groupGenerator.exp(z) == a.multiply(trackerKey.exp(e)) } else { false } + trackerKey != identity && trackerTreeShape && trackerDebtValid && properTrackerSignature } } @@ -246,6 +252,7 @@ sigmaProp( successorCommon && + receiverValid && payoutBound && valueFlowValid && priorShape && diff --git a/contracts/offchain/basis-v2.md b/contracts/offchain/basis-v2.md index 3ccb7fe..b2edb48 100644 --- a/contracts/offchain/basis-v2.md +++ b/contracts/offchain/basis-v2.md @@ -47,6 +47,11 @@ The same `claimKey` indexes the tracker AVL tree and the reserve redemption AVL tree. A signature for another reserve NFT, reserve token, network or contract generation therefore does not authorize this reserve. +All owner, receiver and tracker `GroupElement` values must be non-identity. +Both manual Schnorr verifiers also reject an identity commitment before +checking the response equation. This is a semantic group check; a 33-byte +encoding alone is not treated as evidence that a point is non-identity. + The signed claim is cumulative, not a request for one payout. The creditor's transaction proof and the contract's exact payout predicate authorize each partial settlement. This permits repeated partial settlement with the original diff --git a/contracts/offchain/basis-v2.p2s b/contracts/offchain/basis-v2.p2s index 0d6ed5f..f806b34 100644 --- a/contracts/offchain/basis-v2.p2s +++ b/contracts/offchain/basis-v2.p2s @@ -1 +1 @@ -1bdc0c4f010004140414040004020400050204400440050005000440043001000400040004020100040201000e0842415349530200000400050004300400041004100420042004300500050005000400050004300500050005000500050004800104840104000442044201000101040001000400040204000400050201000440041004100100048001048401040004420442010004020404040001000100058084af5f0500053c040605000580a3050580a3050100d802d601e30002d602c6a7040795ecefe67201efededededede67202e6c6a70564e6c6a7060ee6c6a70705e6c6a70805e6c6a7090ed17300d809d6037ee4720104d604db6308a7d605e4c6a7060ed606e4c6a70805d607e4c6a70705d608e4c6a70564d6099d72037301d60a9e72037302d60be4720295efedededededed9272037303ed93b172047304938cb2720473050002730693b17205730793b1e4c6a7090e73089172067309927207730aededededed93db64037208730be6db6404720893e4db64047208730cdb64057208db64067208efdb64077208d1730d95937209730ed806d60ce30107d60de3020ed60ee30305d60fe30405d610e3050ed611e3070e95ecefed92720a730f91b1a59a720a7310efededededede6720ce6720de6720ee6720fe67210e67211d17311d804d612b2a5720a00d613c672120407d614b2a59a720a731200d615c67214040e95ecefededededede67213e6c672120564e6c67212060ee6c672120705e6c672120805e6c67212090eefe67215d17313d818d616e4720cd617cd7216d618c17212d619c1a7d61a9972197218d61bdb0702720bd61ccbb3b3b3b373148cb27204731500017205721bdb07027216d61ddc640a720802721ce47211d61ee6721dd61f7a7316d620b3b3721f721f721fd621e5721d7220d6229593b17221731772217220d6237cb4722273187319d6247cb47222731a731bd6257cb47222731c731dd626e4720fd627e4720ed628ed91721a731e90721a95ed927225731f927227722599722772257320d6297a7226d62a7a7227d62be4720dd62cb1722bd62db3b3721c722a7229ea02d1ededededededededededededededed93c27212c2a793db63087212720493e47213720b93e4c67212060e720593e4c672120705720793e4c672120805720693e4c67212090ec5a7eded93c27214d0721793b1db63087214732193e47215c5a7ededed8f7218721991721a732293c17214721a939a7218c172147219ecef721e93b172217323ededed927223732492722473259272257326907225722495721eeced93722672239372277224ed91722672239272277224ed91722673279172277328722893e4dc641072080283013c0e0e8602721cb3b37229722a7a9572289a7225721a7225e47210e4c67212056495ed92722c732990722c732ad801d62eb4722b732b732c939fdb6a01dd7bb4722b732d722ca0ee722e9f720b7bcbb3b3722e722d721b732e95927ea3057206732fd803d62ee3060ed62fe3080ed630db6501fe95ececefe6722eefe6722f90b1723073307331d803d631b27230733200d632c672310407d633db6308723195ecefede67232e6c672310564efeded93b172337333938cb27233733400017205938cb272337335000273367337d805d634e4c672310564d635dc640a723402721ce4722fd636e4722ed637b17236d638e47232ededededededed93db640372347338e6db6404723493e4db640472347339db64057234db64067234efdb6407723495e67235d801d639e47235ed93b17239733a927c72397227733b95ed927237733c907237733dd801d639b47236733e733f939fdb6a01dd7bb4723673407237a0ee72399f72387bcbb3b37239722ddb0702723873417217d801d60c937209734295ec720c937209734395efed92720a734491b1a5720ad17345d802d60db2a5720a00d60ec6720d040795efededededede6720ee6c6720d0564e6c6720d060ee6c6720d0705e6c6720d0805e6c6720d090ed17346d801d60fedededededed93c2720dc2a793db6308720d720493e4720e720b93e4c6720d0564720893e4c6720d060e720593e4c6720d0805720693e4c6720d090ec5a795720cd1eded720f93e4c6720d070572079299c1720dc1a77347ea02d1edededed720f937207734892e4c6720d07057ea30590e4c6720d07059a7ea305734992c1720dc1a7cd720b95937209734aea02d1eded917207734b927ea305734c907207997ea305734dcd720bd1734e +1b8f0d5001000e010004140414040004020400050204400440050005000440043001000400040004020100040201000e0842415349530200000400050004300400041004100420042004300500050005000400050004300500050005000500050004800104840104000442044201000101040001000400040204000400050201000440041004100100048001048401040004420442010004020404040001000100058084af5f0500053c040605000580a3050580a3050100d802d601e30002d602c6a7040795ecefe67201efededededede67202e6c6a70564e6c6a7060ee6c6a70705e6c6a70805e6c6a7090ed17300d80bd6037ee4720104d604db6308a7d605e4c6a7060ed606e4c6a70805d607e4c6a70705d608e47202d609db6a01ddd60a9f72097b7301d60be4c6a70564d60c9d72037302d60d9e7203730395efededededededed9272037304ed93b172047305938cb2720473060002730793b17205730893b1e4c6a7090e7309917206730a927207730b947208720aededededed93db6403720b730ce6db6404720b93e4db6404720b730ddb6405720bdb6406720befdb6407720bd1730e9593720c730fd806d60ee30107d60fe3020ed610e30305d611e30405d612e3050ed613e3070e95ecefed92720d731091b1a59a720d7311efededededede6720ee6720fe67210e67211e67212e67213d17312d804d614b2a5720d00d615c672140407d616b2a59a720d731300d617c67216040e95ecefededededede67215e6c672140564e6c67214060ee6c672140705e6c672140805e6c67214090eefe67217d17314d818d618e4720ed619cd7218d61ac17214d61bc1a7d61c99721b721ad61ddb07027208d61ecbb3b3b3b373158cb27204731600017205721ddb07027218d61fdc640a720b02721ee47213d620e6721fd6217a7317d622b3b3722172217221d623e5721f7222d6249593b17223731872237222d6257cb472247319731ad6267cb47224731b731cd6277cb47224731d731ed628e47211d629e47210d62aed91721c731f90721c95ed9272277320927229722799722972277321d62b7a7228d62c7a7229d62de4720fd62eb1722dd62fb3b3721e722c722bea02d1edededededededededededededededed93c27214c2a793db63087214720493e47215720893e4c67214060e720593e4c672140705720793e4c672140805720693e4c67214090ec5a7947218720aeded93c27216d0721993b1db63087216732293e47217c5a7ededed8f721a721b91721c732393c17216721c939a721ac17216721becef722093b172237324ededed9272257325927226732692722773279072277226957220eced93722872259372297226ed91722872259272297226ed91722873289172297329722a93e4dc6410720b0283013c0e0e8602721eb3b3722b722c7a95722a9a7227721c7227e47212e4c67214056495ed92722e732a90722e732bd802d630b4722d732c732dd631ee7230ed947231720a939f72097bb4722d732e722ea072319f72087bcbb3b37230722f721d732f95927ea30572067330d803d630e3060ed631e3080ed632db6501fe95ececefe67230efe6723190b1723273317332d803d633b27232733300d634c672330407d635db6308723395ecefede67234e6c672330564efeded93b172357334938cb27235733500017205938cb272357336000273377338d805d636e47234d637e4c672330564d638dc640a723702721ee47231d639e47230d63ab17239ededed947236720aededededed93db640372377339e6db6404723793e4db64047237733adb64057237db64067237efdb6407723795e67238d801d63be47238ed93b1723b733b927c723b7229733c95ed92723a733d90723a733ed802d63bb47239733f7340d63cee723bed94723c720a939f72097bb472397341723aa0723c9f72367bcbb3b3723b722fdb0702723673427219d801d60e93720c734395ec720e93720c734495efed92720d734591b1a5720dd17346d802d60fb2a5720d00d610c6720f040795efededededede67210e6c6720f0564e6c6720f060ee6c6720f0705e6c6720f0805e6c6720f090ed17347d801d611edededededed93c2720fc2a793db6308720f720493e47210720893e4c6720f0564720b93e4c6720f060e720593e4c6720f0805720693e4c6720f090ec5a795720ed1eded721193e4c6720f070572079299c1720fc1a77348ea02d1edededed7211937207734992e4c6720f07057ea30590e4c6720f07059a7ea305734a92c1720fc1a7cd72089593720c734bea02d1eded917207734c927ea305734d907207997ea305734ecd7208d1734f diff --git a/src/test/scala/chaincash/BasisV2Spec.scala b/src/test/scala/chaincash/BasisV2Spec.scala index 65297d0..c71a181 100644 --- a/src/test/scala/chaincash/BasisV2Spec.scala +++ b/src/test/scala/chaincash/BasisV2Spec.scala @@ -14,6 +14,7 @@ import scorex.util.encode.Base16 import sigma.ast.ErgoTree import sigma.data.{AvlTreeFlags, ProveDlog} import sigma.serialization.GroupElementSerializer +import sigma.crypto.SecP256K1Group import sigma.{AvlTree, GroupElement} import work.lithos.plasma.PlasmaParameters import work.lithos.plasma.collections.PlasmaMap @@ -36,6 +37,7 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { private val otherPk = Constants.g.exp(otherSecret.bigInteger) private val trackerSecret = SigUtils.randBigInt private val trackerPk = Constants.g.exp(trackerSecret.bigInteger) + private val identityPk = Constants.g.exp(java.math.BigInteger.ZERO) private val reserveNftA = hex("4b2d8b7beb3eaac8234d9e61792d270898a43934d6a27275e4f3a044609c9f2a") private val reserveNftB = hex("5b2d8b7beb3eaac8234d9e61792d270898a43934d6a27275e4f3a044609c9f2b") @@ -73,11 +75,12 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { domain: Array[Byte], reserveNft: Array[Byte], assetId: Option[Array[Byte]], - receiver: GroupElement = receiverPk + receiver: GroupElement = receiverPk, + owner: GroupElement = ownerPk ): Array[Byte] = Blake2b256( domain ++ reserveNft ++ assetId.getOrElse(Array.emptyByteArray) ++ - trackerNft ++ ownerPk.getEncoded.toArray ++ receiver.getEncoded.toArray + trackerNft ++ owner.getEncoded.toArray ++ receiver.getEncoded.toArray ) private def message(key: Array[Byte], debt: Long = totalDebt, ts: Long = timestamp): Array[Byte] = @@ -88,6 +91,19 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { GroupElementSerializer.toBytes(signature._1) ++ signature._2.toByteArray } + private def forgeIdentityKeySignature(messageBytes: Array[Byte]): Array[Byte] = { + val z = SigUtils.randBigInt + val a = Constants.g.exp(z.bigInteger) + GroupElementSerializer.toBytes(a) ++ z.toByteArray + } + + private def identityCommitmentSignature(messageBytes: Array[Byte], secret: BigInt): Array[Byte] = { + val aBytes = GroupElementSerializer.toBytes(identityPk) + val e = BigInt(Blake2b256(aBytes ++ messageBytes ++ Constants.g.exp(secret.bigInteger).getEncoded.toArray)) + val z = (secret * e).mod(SecP256K1Group.q) + aBytes ++ z.toByteArray + } + private case class StateStep( inputTree: ErgoValue[AvlTree], lookupProof: Array[Byte], @@ -115,9 +131,15 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { private case class TrackerState(tree: ErgoValue[AvlTree], lookupProof: Array[Byte]) - private def trackerState(key: Array[Byte], debt: Long = totalDebt): TrackerState = { - val tree = new PlasmaMap[Array[Byte], Array[Byte]](trackerFlags, trackerParameters) - tree.insertOrUpdate(key -> Longs.toByteArray(debt)) + private def trackerState( + key: Array[Byte], + debt: Long = totalDebt, + flags: AvlTreeFlags = trackerFlags, + parameters: PlasmaParameters = trackerParameters, + encodedDebt: Option[Array[Byte]] = None + ): TrackerState = { + val tree = new PlasmaMap[Array[Byte], Array[Byte]](flags, parameters) + tree.insertOrUpdate(key -> encodedDebt.getOrElse(Longs.toByteArray(debt))) TrackerState(tree.ergoValue, tree.lookUp(key).proof.bytes) } @@ -178,11 +200,13 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { tree: ErgoValue[AvlTree], emergencyHeight: Long, predecessor: Array[Byte] = zeroId, - refundHeight: Long = 0L + refundHeight: Long = 0L, + owner: GroupElement = ownerPk, + trackerId: Array[Byte] = trackerNft ): Array[ErgoValue[_]] = Array( - ErgoValue.of(ownerPk), + ErgoValue.of(owner), tree, - ErgoValue.of(trackerNft), + ErgoValue.of(trackerId), ErgoValue.of(refundHeight), ErgoValue.of(emergencyHeight), ErgoValue.of(predecessor) @@ -218,12 +242,14 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { step: StateStep, emergencyHeight: Long, vars: Array[ContextVar], - refundHeight: Long = 0L + refundHeight: Long = 0L, + owner: GroupElement = ownerPk, + trackerId: Array[Byte] = trackerNft )(implicit ctx: BlockchainContext): InputBox = ctx.newTxBuilder.outBoxBuilder .value(value) .tokens(new ErgoToken(reserveNft, 1)) - .registers(reserveRegisters(step.inputTree, emergencyHeight, refundHeight = refundHeight): _*) + .registers(reserveRegisters(step.inputTree, emergencyHeight, refundHeight = refundHeight, owner = owner, trackerId = trackerId): _*) .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.basisV2Contract)) .build() .convertToInputWith(txId, fakeIndex) @@ -236,22 +262,29 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { step: StateStep, emergencyHeight: Long, vars: Array[ContextVar], - refundHeight: Long = 0L + refundHeight: Long = 0L, + owner: GroupElement = ownerPk, + trackerId: Array[Byte] = trackerNft )(implicit ctx: BlockchainContext): InputBox = ctx.newTxBuilder.outBoxBuilder .value(value) .tokens(new ErgoToken(tokenReserveNft, 1), new ErgoToken(reserveTokenId, reserveAmount)) - .registers(reserveRegisters(step.inputTree, emergencyHeight, refundHeight = refundHeight): _*) + .registers(reserveRegisters(step.inputTree, emergencyHeight, refundHeight = refundHeight, owner = owner, trackerId = trackerId): _*) .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.basisTokenV2Contract)) .build() .convertToInputWith(txId, fakeIndex) .withContextVars(vars: _*) - private def trackerInput(state: TrackerState)(implicit ctx: BlockchainContext): InputBox = + private def trackerInput( + state: TrackerState, + key: GroupElement = trackerPk, + nftId: Array[Byte] = trackerNft, + nftAmount: Long = 1L + )(implicit ctx: BlockchainContext): InputBox = ctx.newTxBuilder.outBoxBuilder .value(minValue) - .tokens(new ErgoToken(trackerNft, 1)) - .registers(ErgoValue.of(trackerPk), state.tree) + .tokens(new ErgoToken(nftId, nftAmount)) + .registers(ErgoValue.of(key), state.tree) .contract(ctx.compileContract(ConstantsBuilder.empty(), "sigmaProp(true)")) .build() .convertToInputWith(fakeTxIds(5), fakeIndex) @@ -273,12 +306,14 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { tree: ErgoValue[AvlTree], reserveNft: Array[Byte], emergencyHeight: Long, - refundHeight: Long = 0L + refundHeight: Long = 0L, + owner: GroupElement = ownerPk, + trackerId: Array[Byte] = trackerNft )(implicit ctx: BlockchainContext): OutBoxImpl = createOut( Constants.basisV2Contract, value, - reserveRegisters(tree, emergencyHeight, input.getId.getBytes, refundHeight), + reserveRegisters(tree, emergencyHeight, input.getId.getBytes, refundHeight, owner, trackerId), Array(new ErgoToken(reserveNft, 1)) ) @@ -288,12 +323,14 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { reserveAmount: Long, tree: ErgoValue[AvlTree], emergencyHeight: Long, - refundHeight: Long = 0L + refundHeight: Long = 0L, + owner: GroupElement = ownerPk, + trackerId: Array[Byte] = trackerNft )(implicit ctx: BlockchainContext): OutBoxImpl = createOut( Constants.basisTokenV2Contract, value, - reserveRegisters(tree, emergencyHeight, input.getId.getBytes, refundHeight), + reserveRegisters(tree, emergencyHeight, input.getId.getBytes, refundHeight, owner, trackerId), Array(new ErgoToken(tokenReserveNft, 1), new ErgoToken(reserveTokenId, reserveAmount)) ) @@ -347,7 +384,199 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { } } - property("Basis v2 rejects cross-reserve and cross-domain signed claims independently") { + property("Basis ERG v2 rejects identity owner, receiver, and tracker independently") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300000000L + + val ownerIdentityKey = claimKey(ergDomain, reserveNftA, None, owner = identityPk) + val ownerIdentityStep = stateStep(ownerIdentityKey, None, (timestamp, totalDebt, amount)) + val ownerIdentityMessage = message(ownerIdentityKey) + val ownerIdentityInput = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt, ownerIdentityStep, + ctx.getHeight.toLong, + redemptionVars(forgeIdentityKeySignature(ownerIdentityMessage), ownerIdentityStep, None, None), + owner = identityPk + ) + a[Throwable] should be thrownBy createTx( + Array(ownerIdentityInput), Array.empty, + Array( + ergSuccessor( + ownerIdentityInput, ownerIdentityInput.getValue - amount, + ownerIdentityStep.outputTree, reserveNftA, ctx.getHeight.toLong, + owner = identityPk + ), + ergPayout(ownerIdentityInput, amount) + ), secrets = Array(receiverSecret.toString) + ) + + val receiverIdentityKey = claimKey(ergDomain, reserveNftA, None, receiver = identityPk) + val receiverIdentityStep = stateStep(receiverIdentityKey, None, (timestamp, totalDebt, amount)) + val receiverIdentityMessage = message(receiverIdentityKey) + val receiverIdentityInput = ergReserveInput( + fakeTxIds(1), reserveNftA, minValue + totalDebt, receiverIdentityStep, + ctx.getHeight.toLong, + redemptionVars( + signatureBytes(receiverIdentityMessage, ownerSecret), receiverIdentityStep, + None, None, receiver = identityPk + ) + ) + a[Throwable] should be thrownBy createTx( + Array(receiverIdentityInput), Array.empty, + Array( + ergSuccessor( + receiverIdentityInput, receiverIdentityInput.getValue - amount, + receiverIdentityStep.outputTree, reserveNftA, ctx.getHeight.toLong + ), + ergPayout(receiverIdentityInput, amount, identityPk) + ), secrets = Array("0") + ) + + val trackerIdentityKey = claimKey(ergDomain, reserveNftA, None) + val trackerIdentityStep = stateStep(trackerIdentityKey, None, (timestamp, totalDebt, amount)) + val trackerIdentityState = trackerState(trackerIdentityKey) + val trackerIdentityMessage = message(trackerIdentityKey) + val trackerIdentityInput = ergReserveInput( + fakeTxIds(2), reserveNftA, minValue + totalDebt, trackerIdentityStep, + ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(trackerIdentityMessage, ownerSecret), trackerIdentityStep, + Some(forgeIdentityKeySignature(trackerIdentityMessage)), + Some(trackerIdentityState.lookupProof) + ) + ) + a[Throwable] should be thrownBy createTx( + Array(trackerIdentityInput), Array(trackerInput(trackerIdentityState, key = identityPk)), + Array( + ergSuccessor( + trackerIdentityInput, trackerIdentityInput.getValue - amount, + trackerIdentityStep.outputTree, reserveNftA, ctx.getHeight + 1000L + ), + ergPayout(trackerIdentityInput, amount) + ), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis token v2 rejects identity owner, receiver, and tracker independently") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300L + val reserveAmount = 1000L + + val ownerIdentityKey = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId), owner = identityPk) + val ownerIdentityStep = stateStep(ownerIdentityKey, None, (timestamp, totalDebt, amount)) + val ownerIdentityMessage = message(ownerIdentityKey) + val ownerIdentityInput = tokenReserveInput( + fakeTxIds(0), minValue * 2, reserveAmount, ownerIdentityStep, + ctx.getHeight.toLong, + redemptionVars(forgeIdentityKeySignature(ownerIdentityMessage), ownerIdentityStep, None, None), + owner = identityPk + ) + a[Throwable] should be thrownBy createTx( + Array(ownerIdentityInput, feeInput(fakeTxIds(4))), Array.empty, + Array( + tokenSuccessor( + ownerIdentityInput, ownerIdentityInput.getValue, reserveAmount - amount, + ownerIdentityStep.outputTree, ctx.getHeight.toLong, owner = identityPk + ), + tokenPayout(ownerIdentityInput, amount) + ), secrets = Array(receiverSecret.toString) + ) + + val receiverIdentityKey = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId), receiver = identityPk) + val receiverIdentityStep = stateStep(receiverIdentityKey, None, (timestamp, totalDebt, amount)) + val receiverIdentityMessage = message(receiverIdentityKey) + val receiverIdentityInput = tokenReserveInput( + fakeTxIds(1), minValue * 2, reserveAmount, receiverIdentityStep, + ctx.getHeight.toLong, + redemptionVars( + signatureBytes(receiverIdentityMessage, ownerSecret), receiverIdentityStep, + None, None, receiver = identityPk + ) + ) + a[Throwable] should be thrownBy createTx( + Array(receiverIdentityInput, feeInput(fakeTxIds(4))), Array.empty, + Array( + tokenSuccessor( + receiverIdentityInput, receiverIdentityInput.getValue, reserveAmount - amount, + receiverIdentityStep.outputTree, ctx.getHeight.toLong + ), + tokenPayout(receiverIdentityInput, amount, identityPk) + ), secrets = Array("0") + ) + + val trackerIdentityKey = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId)) + val trackerIdentityStep = stateStep(trackerIdentityKey, None, (timestamp, totalDebt, amount)) + val trackerIdentityState = trackerState(trackerIdentityKey) + val trackerIdentityMessage = message(trackerIdentityKey) + val trackerIdentityInput = tokenReserveInput( + fakeTxIds(2), minValue * 2, reserveAmount, trackerIdentityStep, + ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(trackerIdentityMessage, ownerSecret), trackerIdentityStep, + Some(forgeIdentityKeySignature(trackerIdentityMessage)), + Some(trackerIdentityState.lookupProof) + ) + ) + a[Throwable] should be thrownBy createTx( + Array(trackerIdentityInput, feeInput(fakeTxIds(4))), + Array(trackerInput(trackerIdentityState, key = identityPk)), + Array( + tokenSuccessor( + trackerIdentityInput, trackerIdentityInput.getValue, reserveAmount - amount, + trackerIdentityStep.outputTree, ctx.getHeight + 1000L + ), + tokenPayout(trackerIdentityInput, amount) + ), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis ERG and token v2 reject an identity Schnorr commitment independently") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val ergAmount = 300000000L + val ergKey = claimKey(ergDomain, reserveNftA, None) + val ergStep = stateStep(ergKey, None, (timestamp, totalDebt, ergAmount)) + val ergMessage = message(ergKey) + val ergInput = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt, ergStep, + ctx.getHeight.toLong, + redemptionVars(identityCommitmentSignature(ergMessage, ownerSecret), ergStep, None, None) + ) + a[Throwable] should be thrownBy createTx( + Array(ergInput), Array.empty, + Array( + ergSuccessor( + ergInput, ergInput.getValue - ergAmount, + ergStep.outputTree, reserveNftA, ctx.getHeight.toLong + ), + ergPayout(ergInput, ergAmount) + ), secrets = Array(receiverSecret.toString) + ) + + val tokenAmount = 300L + val reserveAmount = 1000L + val tokenKey = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId)) + val tokenStep = stateStep(tokenKey, None, (timestamp, totalDebt, tokenAmount)) + val tokenMessage = message(tokenKey) + val tokenInput = tokenReserveInput( + fakeTxIds(1), minValue * 2, reserveAmount, tokenStep, + ctx.getHeight.toLong, + redemptionVars(identityCommitmentSignature(tokenMessage, ownerSecret), tokenStep, None, None) + ) + a[Throwable] should be thrownBy createTx( + Array(tokenInput, feeInput(fakeTxIds(4))), Array.empty, + Array( + tokenSuccessor( + tokenInput, tokenInput.getValue, reserveAmount - tokenAmount, + tokenStep.outputTree, ctx.getHeight.toLong + ), + tokenPayout(tokenInput, tokenAmount) + ), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 rejects a cross-reserve owner signature with a correct tracker signature") { createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => val amount = 300000000L val keyA = claimKey(ergDomain, reserveNftA, None) @@ -359,7 +588,7 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { fakeTxIds(0), reserveNftB, minValue + totalDebt, stepB, ctx.getHeight + 1000L, redemptionVars( signatureBytes(wrongMessage, ownerSecret), stepB, - Some(signatureBytes(wrongMessage, trackerSecret)), Some(trackerB.lookupProof) + Some(signatureBytes(message(keyB), trackerSecret)), Some(trackerB.lookupProof) ) ) @@ -372,6 +601,12 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { secrets = Array(receiverSecret.toString) ) + } + } + + property("Basis v2 rejects a cross-domain tracker signature with a correct owner signature") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300000000L val expectedKey = claimKey(ergDomain, reserveNftA, None) val wrongDomainKey = claimKey(tokenDomain, reserveNftA, None) val expectedStep = stateStep(expectedKey, None, (timestamp, totalDebt, amount)) @@ -381,7 +616,7 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { fakeTxIds(1), reserveNftA, minValue + totalDebt, expectedStep, ctx.getHeight + 1000L, redemptionVars( - signatureBytes(wrongDomainMessage, ownerSecret), expectedStep, + signatureBytes(message(expectedKey), ownerSecret), expectedStep, Some(signatureBytes(wrongDomainMessage, trackerSecret)), Some(expectedTracker.lookupProof) ) @@ -427,6 +662,118 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { } } + property("Basis v2 rejects tracker singleton, tree flags, value shape, and insufficient debt independently") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val amount = 300000000L + val key = claimKey(ergDomain, reserveNftA, None) + val step = stateStep(key, None, (timestamp, totalDebt, amount)) + val claimMessage = message(key) + + def input(txId: String, tracker: TrackerState) = ergReserveInput( + txId, reserveNftA, minValue + totalDebt, step, ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(claimMessage, ownerSecret), step, + Some(signatureBytes(claimMessage, trackerSecret)), Some(tracker.lookupProof) + ) + ) + + def outputs(reserveInput: InputBox) = Array( + ergSuccessor( + reserveInput, reserveInput.getValue - amount, + step.outputTree, reserveNftA, ctx.getHeight + 1000L + ), + ergPayout(reserveInput, amount) + ) + + val correctTracker = trackerState(key) + val wrongNftInput = input(fakeTxIds(0), correctTracker) + a[Throwable] should be thrownBy createTx( + Array(wrongNftInput), + Array(trackerInput(correctTracker, nftId = reserveNftB)), + outputs(wrongNftInput), secrets = Array(receiverSecret.toString) + ) + + val insertOnlyTracker = trackerState( + key, flags = AvlTreeFlags(insertAllowed = true, updateAllowed = false, removeAllowed = false) + ) + val wrongFlagsInput = input(fakeTxIds(1), insertOnlyTracker) + a[Throwable] should be thrownBy createTx( + Array(wrongFlagsInput), Array(trackerInput(insertOnlyTracker)), + outputs(wrongFlagsInput), secrets = Array(receiverSecret.toString) + ) + + val malformedValueTracker = trackerState( + key, + parameters = PlasmaParameters(32, Some(16)), + encodedDebt = Some(Longs.toByteArray(totalDebt) ++ Longs.toByteArray(0L)) + ) + val wrongValueInput = input(fakeTxIds(2), malformedValueTracker) + a[Throwable] should be thrownBy createTx( + Array(wrongValueInput), Array(trackerInput(malformedValueTracker)), + outputs(wrongValueInput), secrets = Array(receiverSecret.toString) + ) + + val insufficientTracker = trackerState(key, debt = totalDebt - 1L) + val insufficientDebtInput = input(fakeTxIds(3), insufficientTracker) + a[Throwable] should be thrownBy createTx( + Array(insufficientDebtInput), Array(trackerInput(insufficientTracker)), + outputs(insufficientDebtInput), secrets = Array(receiverSecret.toString) + ) + } + } + + property("Basis v2 rejects invalid R5 claim progression and over-redemption independently") { + createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => + val priorRedeemed = 100000000L + val amount = 100000000L + val key = claimKey(ergDomain, reserveNftA, None) + + val regressingStep = stateStep( + key, + Some((timestamp, totalDebt, priorRedeemed)), + (timestamp, totalDebt + 1L, priorRedeemed + amount) + ) + val regressingMessage = message(key, totalDebt + 1L, timestamp) + val regressingInput = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt - priorRedeemed, + regressingStep, ctx.getHeight.toLong, + redemptionVars( + signatureBytes(regressingMessage, ownerSecret), regressingStep, + None, None, debt = totalDebt + 1L + ) + ) + a[Throwable] should be thrownBy createTx( + Array(regressingInput), Array.empty, + Array( + ergSuccessor( + regressingInput, regressingInput.getValue - amount, + regressingStep.outputTree, reserveNftA, ctx.getHeight.toLong + ), + ergPayout(regressingInput, amount) + ), secrets = Array(receiverSecret.toString) + ) + + val overAmount = totalDebt + 1L + val overStep = stateStep(key, None, (timestamp, totalDebt, overAmount)) + val claimMessage = message(key) + val overInput = ergReserveInput( + fakeTxIds(1), reserveNftA, minValue + overAmount, overStep, + ctx.getHeight.toLong, + redemptionVars(signatureBytes(claimMessage, ownerSecret), overStep, None, None) + ) + a[Throwable] should be thrownBy createTx( + Array(overInput), Array.empty, + Array( + ergSuccessor( + overInput, overInput.getValue - overAmount, + overStep.outputTree, reserveNftA, ctx.getHeight.toLong + ), + ergPayout(overInput, overAmount) + ), secrets = Array(receiverSecret.toString) + ) + } + } + property("Basis v2 rejects a stale absence proof for an existing redeemed record") { createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => val firstAmount = 300000000L @@ -610,7 +957,7 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { } } - property("Basis v2 rejects tokenless reserve state and two reserve inputs sharing one successor") { + property("Basis v2 rejects tokenless reserve state and two reserve inputs sharing one successor or payout pair") { createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => val key = claimKey(ergDomain, reserveNftA, None) val step = stateStep(key, None, (timestamp, totalDebt, 1L)) @@ -626,7 +973,11 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { Constants.basisV2Contract, minValue + 100000000L, reserveRegisters(step.inputTree, emergency, tokenless.getId.getBytes), Array.empty[ErgoToken] ) - a[Throwable] should be thrownBy createTx(Array(tokenless), Array.empty, Array(tokenlessOut)) + a[Throwable] should be thrownBy createTx( + Array(tokenless, feeInput(fakeTxIds(5), value = 100000000L)), + Array.empty, + Array(tokenlessOut) + ) def topUpInput(txId: String) = ctx.newTxBuilder.outBoxBuilder .value(minValue) @@ -640,6 +991,30 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { val second = topUpInput(fakeTxIds(2)) val shared = ergSuccessor(first, minValue + 100000000L, step.inputTree, reserveNftA, emergency) a[Throwable] should be thrownBy createTx(Array(first, second), Array.empty, Array(shared)) + + val amount = 300000000L + val redemptionStep = stateStep(key, None, (timestamp, totalDebt, amount)) + val tracker = trackerState(key) + val claimMessage = message(key) + def redemptionInput(txId: String) = ergReserveInput( + txId, reserveNftA, minValue + totalDebt, redemptionStep, emergency, + redemptionVars( + signatureBytes(claimMessage, ownerSecret), redemptionStep, + Some(signatureBytes(claimMessage, trackerSecret)), Some(tracker.lookupProof) + ) + ) + val firstRedemption = redemptionInput(fakeTxIds(3)) + val secondRedemption = redemptionInput(fakeTxIds(4)) + a[Throwable] should be thrownBy createTx( + Array(firstRedemption, secondRedemption), Array(trackerInput(tracker)), + Array( + ergSuccessor( + firstRedemption, firstRedemption.getValue - amount, + redemptionStep.outputTree, reserveNftA, emergency + ), + ergPayout(firstRedemption, amount) + ), secrets = Array(receiverSecret.toString) + ) } } @@ -799,6 +1174,9 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { Array(completeInput), Array.empty, Array(ownerOutput), secrets = Array(ownerSecret.toString) ) + a[Throwable] should be thrownBy createTx( + Array(completeInput), Array.empty, Array(ownerOutput) + ) val earlyInput = ergReserveInput( fakeTxIds(2), reserveNftA, minValue, step, emergency, @@ -872,6 +1250,9 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { Array(completeInput), Array.empty, Array(ownerOutput), secrets = Array(ownerSecret.toString) ) + a[Throwable] should be thrownBy createTx( + Array(completeInput), Array.empty, Array(ownerOutput) + ) } } From 1d41a50050b8241715da316d1eb597f8b04b637b Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:17:25 +0200 Subject: [PATCH 05/11] docs(basis): pin v2 source receipt --- .../offchain/basis-v2-reproducibility.md | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/contracts/offchain/basis-v2-reproducibility.md b/contracts/offchain/basis-v2-reproducibility.md index 308b38b..7cc9e3e 100644 --- a/contracts/offchain/basis-v2-reproducibility.md +++ b/contracts/offchain/basis-v2-reproducibility.md @@ -8,25 +8,25 @@ activation claim. - Repository base commit: `78475e30362571acf56e4e38276a9d6c0a84ce0c` -- Candidate branch: `a-shannon/basis-v2-contract` -- Source paths: - - `contracts/offchain/basis-v2.es` - - `contracts/offchain/basis-token-v2.es` +- Immutable source and golden commit: + `9a274396d5f78f7be5ed76bacee5329c42570317` + +| Path | Git blob id | +| --- | --- | +| `contracts/offchain/basis-v2.es` | `47b3e0647f90db2dd8e667641294d66e287d8f6a` | +| `contracts/offchain/basis-token-v2.es` | `48a00b612fe6f4345b322d22408c48100f2cd442` | +| `contracts/offchain/basis-v2.p2s` | `f806b34edab4b18b89a395625db753d7acdf94db` | +| `contracts/offchain/basis-token-v2.p2s` | `debc24531f0adc26292f897fff6b53ed06ff4374` | +| `src/test/scala/chaincash/BasisV2Spec.scala` | `c71a1817cb2f9c0e9b4070670bd0712f7d558c5c` | + - The compiler reads UTF-8 with `getLines.mkString("\n")`. It therefore uses LF separators and removes the final line terminator independently of checkout line endings. | Compiler input | SHA-256 of normalized UTF-8 bytes | | --- | --- | -| `basis-v2.es` | `a73f8530b355c26136d2732d6a26766cb8c1cabaa801bc0d2aa30dc548a4d884` | -| `basis-token-v2.es` | `c82d4c5b5fb7648af81b0104110b0289e0e5cf4d3cc9b6ec3e0b49fc30c8cf60` | - -The reviewed Windows checkout used `core.autocrlf=true`. Its raw working-file -SHA-256 values were respectively -`db72e1212f89d90555bc7d1e9914fd598ffca0bd2206d2b1b0d604dbf2a68d5d` -and `7e9c64988c3e9bfbb5577b493d4e6031d858f9014e456b6f7152df2e54d02fa2`. -Those raw hashes are informative; the normalized hashes above identify the -actual compiler input. +| `basis-v2.es` | `31ff4271b1c79302064df83a6bfa3d4f6f5f153747002c1c0206b2f3d88b507a` | +| `basis-token-v2.es` | `c7e5a0cf6a12aaedefba79ecd012ac7839a48f683931d1d4e10371d287c58573` | ## Compiler closure @@ -59,8 +59,8 @@ single-line lowercase-hex files: | Contract | Full-byte file | Byte length | ErgoTree SHA-256 | | --- | --- | ---: | --- | -| ERG reserve | `contracts/offchain/basis-v2.p2s` | 1,631 | `49d6f487b69277191ff064e5e036a5a07b343b9b57d76931620157f0b4bfef80` | -| token reserve | `contracts/offchain/basis-token-v2.p2s` | 1,912 | `1f8ba4f6a3ef36799372e7555394274aff5d7fe977a2b5b5b2e7ccd4a7bea5f7` | +| ERG reserve | `contracts/offchain/basis-v2.p2s` | 1,682 | `2690634924efb22359a776f89f5274d77e067bd8ad0619a6e358a2f96697a0c2` | +| token reserve | `contracts/offchain/basis-token-v2.p2s` | 1,963 | `ba1df64e7d95ecffc4f3d49fcada8baebe59a676eb617737cd010bdb52381cb3` | `BasisV2Spec` recompiles both normalized sources, compares the complete byte arrays to `Constants`, then decodes and compares each committed golden file. From efaaf58c08e7ee0a2c14ac73ad548b97976ed5b7 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:00:37 +0200 Subject: [PATCH 06/11] test(basis): close v2 cryptographic matrix --- .../offchain/basis-v2-reproducibility.md | 10 +- contracts/offchain/basis-v2-review.md | 4 +- src/test/scala/chaincash/BasisV2Spec.scala | 202 ++++++++++++++---- 3 files changed, 166 insertions(+), 50 deletions(-) diff --git a/contracts/offchain/basis-v2-reproducibility.md b/contracts/offchain/basis-v2-reproducibility.md index 7cc9e3e..0f9f437 100644 --- a/contracts/offchain/basis-v2-reproducibility.md +++ b/contracts/offchain/basis-v2-reproducibility.md @@ -8,16 +8,20 @@ activation claim. - Repository base commit: `78475e30362571acf56e4e38276a9d6c0a84ce0c` -- Immutable source and golden commit: +- Immutable contract source and golden commit: `9a274396d5f78f7be5ed76bacee5329c42570317` -| Path | Git blob id | +| Path | Exact candidate Git blob id | | --- | --- | | `contracts/offchain/basis-v2.es` | `47b3e0647f90db2dd8e667641294d66e287d8f6a` | | `contracts/offchain/basis-token-v2.es` | `48a00b612fe6f4345b322d22408c48100f2cd442` | | `contracts/offchain/basis-v2.p2s` | `f806b34edab4b18b89a395625db753d7acdf94db` | | `contracts/offchain/basis-token-v2.p2s` | `debc24531f0adc26292f897fff6b53ed06ff4374` | -| `src/test/scala/chaincash/BasisV2Spec.scala` | `c71a1817cb2f9c0e9b4070670bd0712f7d558c5c` | +| `src/test/scala/chaincash/BasisV2Spec.scala` | `10613d30d499a02941f8b8a9c8b55c06568e796b` | + +The contract sources and golden byte files above are immutable at the named +commit. The test blob is pinned independently because later closeout commits +may strengthen the negative matrix without changing either contract or golden. - The compiler reads UTF-8 with `getLines.mkString("\n")`. It therefore uses LF separators and removes the final line terminator independently of checkout diff --git a/contracts/offchain/basis-v2-review.md b/contracts/offchain/basis-v2-review.md index f76b763..016ba41 100644 --- a/contracts/offchain/basis-v2-review.md +++ b/contracts/offchain/basis-v2-review.md @@ -31,12 +31,12 @@ and wallet change are outside the reserve accounting equations. | I-04 | Payout is at `successorIndex + 1`, has receiver P2PK bytes, R4=`SELF.id`, and exact released amount. | creditor wallet and multi-input batcher | redirected, underpaid or reused payout | receiver, amount and lineage mutations rejected independently | | I-05 | ERG redemption requires `successor.value + payout.value == SELF.value`. | reserve collateral and fee builder | reserve collateral silently pays fees/change | reserve-funded fee mutation rejected; external fee input accepted | | I-06 | Token successor preserves ERG, singleton and reserve-token ids; reserve-token decrease equals the payout's sole token amount. | token creditor, later reserve transitions and refund | ERG drain, token leakage or accidental burn | top-up ERG drain, redemption ERG drain and payout leakage rejected | -| I-07 | Before R8, tracker input has the exact singleton, typed non-identity key/tree, fixed tree shape, sufficient committed cumulative debt and valid signature. | normal-redemption liveness and liability admission | forged tracker state or uncommitted claim | exact normal redemption passes; NFT, flags, value shape, debt and signature-domain mutations reject independently | +| I-07 | Before R8, tracker input has the exact singleton, typed non-identity key/tree, fixed tree shape, sufficient committed cumulative debt and valid signature. | normal-redemption liveness and liability admission | forged tracker state or uncommitted claim | exact normal redemption passes; NFT, key length, fixed/variable value length, each operation flag, debt and signature-domain mutations reject independently | | I-08 | R8 is mandatory and exactly preserved; tracker fields are not evaluated only when `HEIGHT >= R8`. | emergency builder and acceptance policy | mutable tracker creation height postpones exit, or missing tracker still blocks the branch | tracker-free redemption passes at R8, rejects one block before, and rejects a mutated successor R8 | | I-09 | Reserve singleton shape is mandatory; every successor R9 and payout R4 equal the predecessor box id. | indexer lineage and multi-input settlement | tokenless state or one successor/payout pair satisfies two inputs | externally balanced tokenless state and shared successor/payout transactions rejected | | I-10 | Refund R7 is one-shot, owner-authorized, preserved on other branches and checked with subtraction rather than overflowing addition. | creditor monitoring and terminal refund | backdated/reset timer or overflow-assisted early refund | ownerless ERG/token initiation and completion, mature completion, early completion and `Long.MaxValue` seed exercised | | I-11 | Constants compile exact normalized source and tests compare full output bytes with committed goldens. | compiler, builders and contract-family selection | readable source, configured P2S and runtime ABI silently diverge | source equality, fresh compile and full-byte golden tests | -| I-12 | Owner, receiver, tracker and received Schnorr commitments are not the group identity. | reserve admission, creditor authorization and manual Schnorr verification | identity owner/tracker signatures are forgeable and an identity receiver has a publicly known witness | ERG and token transactions mutate owner, receiver, tracker and commitment independently | +| I-12 | Owner, receiver, tracker and received Schnorr commitments are not the group identity. | reserve admission, creditor authorization and manual Schnorr verification | identity owner/tracker signatures are forgeable and an identity receiver has a publicly known witness | ERG and token transactions mutate owner, receiver and tracker keys independently; owner and tracker commitment points are isolated separately | Each negative transaction changes only the field named in its test while its proofs, signatures, tree roots, tokens and unrelated outputs remain those of a diff --git a/src/test/scala/chaincash/BasisV2Spec.scala b/src/test/scala/chaincash/BasisV2Spec.scala index c71a181..10613d3 100644 --- a/src/test/scala/chaincash/BasisV2Spec.scala +++ b/src/test/scala/chaincash/BasisV2Spec.scala @@ -12,7 +12,7 @@ import org.scalatest.{Matchers, PropSpec} import scorex.crypto.hash.Blake2b256 import scorex.util.encode.Base16 import sigma.ast.ErgoTree -import sigma.data.{AvlTreeFlags, ProveDlog} +import sigma.data.{AvlTreeData, AvlTreeFlags, CAvlTree, ProveDlog} import sigma.serialization.GroupElementSerializer import sigma.crypto.SecP256K1Group import sigma.{AvlTree, GroupElement} @@ -23,6 +23,7 @@ import java.nio.charset.StandardCharsets import java.nio.file.{Files, Paths} import java.security.MessageDigest import java.util +import scala.annotation.tailrec import scala.collection.JavaConverters._ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { @@ -86,22 +87,48 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { private def message(key: Array[Byte], debt: Long = totalDebt, ts: Long = timestamp): Array[Byte] = key ++ Longs.toByteArray(debt) ++ Longs.toByteArray(ts) + @tailrec private def signatureBytes(messageBytes: Array[Byte], secret: BigInt): Array[Byte] = { val signature = SigUtils.sign(messageBytes, secret) - GroupElementSerializer.toBytes(signature._1) ++ signature._2.toByteArray + val zBytes = signature._2.toByteArray + if (zBytes.length == 32) + GroupElementSerializer.toBytes(signature._1) ++ zBytes + else + signatureBytes(messageBytes, secret) } - private def forgeIdentityKeySignature(messageBytes: Array[Byte]): Array[Byte] = { - val z = SigUtils.randBigInt + private def forgeIdentityKeySignature(_messageBytes: Array[Byte]): Array[Byte] = { + val z = BigInt(1) val a = Constants.g.exp(z.bigInteger) - GroupElementSerializer.toBytes(a) ++ z.toByteArray + GroupElementSerializer.toBytes(a) ++ Array.fill[Byte](31)(0) ++ z.toByteArray } - private def identityCommitmentSignature(messageBytes: Array[Byte], secret: BigInt): Array[Byte] = { + private case class IdentityCommitmentWitness( + publicKey: GroupElement, + message: Array[Byte], + signature: Array[Byte] + ) + + @tailrec + private def identityCommitmentWitness( + messageForPublicKey: GroupElement => Array[Byte], + candidateSecret: BigInt = BigInt(1) + ): IdentityCommitmentWitness = { + val secret = candidateSecret.mod(SecP256K1Group.q) + val publicKey = Constants.g.exp(secret.bigInteger) + val messageBytes = messageForPublicKey(publicKey) val aBytes = GroupElementSerializer.toBytes(identityPk) - val e = BigInt(Blake2b256(aBytes ++ messageBytes ++ Constants.g.exp(secret.bigInteger).getEncoded.toArray)) + val e = BigInt(Blake2b256(aBytes ++ messageBytes ++ publicKey.getEncoded.toArray)) val z = (secret * e).mod(SecP256K1Group.q) - aBytes ++ z.toByteArray + val rawZ = z.toByteArray + if (secret != 0 && z.bitLength <= 255 && rawZ.length <= 32) { + val signature = aBytes ++ Array.fill[Byte](32 - rawZ.length)(0) ++ rawZ + require(signature.length == 65) + require(SigUtils.verify(messageBytes, publicKey, identityPk, z)) + IdentityCommitmentWitness(publicKey, messageBytes, signature) + } else { + identityCommitmentWitness(messageForPublicKey, candidateSecret + 1) + } } private case class StateStep( @@ -143,6 +170,17 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { TrackerState(tree.ergoValue, tree.lookUp(key).proof.bytes) } + private def trackerStateWithShape( + state: TrackerState, + flags: AvlTreeFlags, + keyLength: Int, + valueLengthOpt: Option[Int] + ): TrackerState = { + val current = state.tree.getValue.asInstanceOf[CAvlTree] + val mutated: AvlTreeData = AvlTreeData(current.digest, flags, keyLength, valueLengthOpt) + TrackerState(ErgoValue.of(mutated), state.lookupProof) + } + private def createOut( tree: ErgoTree, value: Long, @@ -531,46 +569,107 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { } } - property("Basis ERG and token v2 reject an identity Schnorr commitment independently") { + property("Basis ERG and token v2 reject identity owner and tracker Schnorr commitments independently") { createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => val ergAmount = 300000000L - val ergKey = claimKey(ergDomain, reserveNftA, None) - val ergStep = stateStep(ergKey, None, (timestamp, totalDebt, ergAmount)) - val ergMessage = message(ergKey) - val ergInput = ergReserveInput( - fakeTxIds(0), reserveNftA, minValue + totalDebt, ergStep, + val ergOwnerWitness = identityCommitmentWitness { owner => + message(claimKey(ergDomain, reserveNftA, None, owner = owner)) + } + val ergOwnerKey = claimKey(ergDomain, reserveNftA, None, owner = ergOwnerWitness.publicKey) + val ergOwnerStep = stateStep(ergOwnerKey, None, (timestamp, totalDebt, ergAmount)) + val ergOwnerInput = ergReserveInput( + fakeTxIds(0), reserveNftA, minValue + totalDebt, ergOwnerStep, ctx.getHeight.toLong, - redemptionVars(identityCommitmentSignature(ergMessage, ownerSecret), ergStep, None, None) + redemptionVars(ergOwnerWitness.signature, ergOwnerStep, None, None), + owner = ergOwnerWitness.publicKey ) a[Throwable] should be thrownBy createTx( - Array(ergInput), Array.empty, + Array(ergOwnerInput), Array.empty, Array( ergSuccessor( - ergInput, ergInput.getValue - ergAmount, - ergStep.outputTree, reserveNftA, ctx.getHeight.toLong + ergOwnerInput, ergOwnerInput.getValue - ergAmount, + ergOwnerStep.outputTree, reserveNftA, ctx.getHeight.toLong, + owner = ergOwnerWitness.publicKey ), - ergPayout(ergInput, ergAmount) + ergPayout(ergOwnerInput, ergAmount) ), secrets = Array(receiverSecret.toString) ) val tokenAmount = 300L val reserveAmount = 1000L + val tokenOwnerWitness = identityCommitmentWitness { owner => + message(claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId), owner = owner)) + } + val tokenOwnerKey = claimKey( + tokenDomain, tokenReserveNft, Some(reserveTokenId), owner = tokenOwnerWitness.publicKey + ) + val tokenOwnerStep = stateStep(tokenOwnerKey, None, (timestamp, totalDebt, tokenAmount)) + val tokenOwnerInput = tokenReserveInput( + fakeTxIds(1), minValue * 2, reserveAmount, tokenOwnerStep, + ctx.getHeight.toLong, + redemptionVars(tokenOwnerWitness.signature, tokenOwnerStep, None, None), + owner = tokenOwnerWitness.publicKey + ) + a[Throwable] should be thrownBy createTx( + Array(tokenOwnerInput, feeInput(fakeTxIds(4))), Array.empty, + Array( + tokenSuccessor( + tokenOwnerInput, tokenOwnerInput.getValue, reserveAmount - tokenAmount, + tokenOwnerStep.outputTree, ctx.getHeight.toLong, + owner = tokenOwnerWitness.publicKey + ), + tokenPayout(tokenOwnerInput, tokenAmount) + ), secrets = Array(receiverSecret.toString) + ) + + val ergKey = claimKey(ergDomain, reserveNftA, None) + val ergStep = stateStep(ergKey, None, (timestamp, totalDebt, ergAmount)) + val ergMessage = message(ergKey) + val ergTracker = trackerState(ergKey) + val ergTrackerWitness = identityCommitmentWitness(_ => ergMessage) + val ergTrackerInput = ergReserveInput( + fakeTxIds(2), reserveNftA, minValue + totalDebt, ergStep, + ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(ergMessage, ownerSecret), ergStep, + Some(ergTrackerWitness.signature), + Some(ergTracker.lookupProof) + ) + ) + a[Throwable] should be thrownBy createTx( + Array(ergTrackerInput), Array(trackerInput(ergTracker, key = ergTrackerWitness.publicKey)), + Array( + ergSuccessor( + ergTrackerInput, ergTrackerInput.getValue - ergAmount, + ergStep.outputTree, reserveNftA, ctx.getHeight + 1000L + ), + ergPayout(ergTrackerInput, ergAmount) + ), secrets = Array(receiverSecret.toString) + ) + val tokenKey = claimKey(tokenDomain, tokenReserveNft, Some(reserveTokenId)) val tokenStep = stateStep(tokenKey, None, (timestamp, totalDebt, tokenAmount)) val tokenMessage = message(tokenKey) - val tokenInput = tokenReserveInput( - fakeTxIds(1), minValue * 2, reserveAmount, tokenStep, - ctx.getHeight.toLong, - redemptionVars(identityCommitmentSignature(tokenMessage, ownerSecret), tokenStep, None, None) + val tokenTracker = trackerState(tokenKey) + val tokenTrackerWitness = identityCommitmentWitness(_ => tokenMessage) + val tokenTrackerInput = tokenReserveInput( + fakeTxIds(3), minValue * 2, reserveAmount, tokenStep, + ctx.getHeight + 1000L, + redemptionVars( + signatureBytes(tokenMessage, ownerSecret), tokenStep, + Some(tokenTrackerWitness.signature), + Some(tokenTracker.lookupProof) + ) ) a[Throwable] should be thrownBy createTx( - Array(tokenInput, feeInput(fakeTxIds(4))), Array.empty, + Array(tokenTrackerInput, feeInput(fakeTxIds(4))), + Array(trackerInput(tokenTracker, key = tokenTrackerWitness.publicKey)), Array( tokenSuccessor( - tokenInput, tokenInput.getValue, reserveAmount - tokenAmount, - tokenStep.outputTree, ctx.getHeight.toLong + tokenTrackerInput, tokenTrackerInput.getValue, reserveAmount - tokenAmount, + tokenStep.outputTree, ctx.getHeight + 1000L ), - tokenPayout(tokenInput, tokenAmount) + tokenPayout(tokenTrackerInput, tokenAmount) ), secrets = Array(receiverSecret.toString) ) } @@ -693,25 +792,38 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { outputs(wrongNftInput), secrets = Array(receiverSecret.toString) ) - val insertOnlyTracker = trackerState( - key, flags = AvlTreeFlags(insertAllowed = true, updateAllowed = false, removeAllowed = false) - ) - val wrongFlagsInput = input(fakeTxIds(1), insertOnlyTracker) - a[Throwable] should be thrownBy createTx( - Array(wrongFlagsInput), Array(trackerInput(insertOnlyTracker)), - outputs(wrongFlagsInput), secrets = Array(receiverSecret.toString) - ) - - val malformedValueTracker = trackerState( - key, - parameters = PlasmaParameters(32, Some(16)), - encodedDebt = Some(Longs.toByteArray(totalDebt) ++ Longs.toByteArray(0L)) - ) - val wrongValueInput = input(fakeTxIds(2), malformedValueTracker) - a[Throwable] should be thrownBy createTx( - Array(wrongValueInput), Array(trackerInput(malformedValueTracker)), - outputs(wrongValueInput), secrets = Array(receiverSecret.toString) + val shapeMutants = Seq( + "key length" -> trackerStateWithShape(correctTracker, trackerFlags, 31, Some(8)), + "fixed value length" -> trackerStateWithShape(correctTracker, trackerFlags, 32, Some(16)), + "variable value length" -> trackerStateWithShape(correctTracker, trackerFlags, 32, None), + "insert flag" -> trackerStateWithShape( + correctTracker, + AvlTreeFlags(insertAllowed = false, updateAllowed = true, removeAllowed = false), + 32, + Some(8) + ), + "update flag" -> trackerStateWithShape( + correctTracker, + AvlTreeFlags(insertAllowed = true, updateAllowed = false, removeAllowed = false), + 32, + Some(8) + ), + "remove flag" -> trackerStateWithShape( + correctTracker, + AvlTreeFlags(insertAllowed = true, updateAllowed = true, removeAllowed = true), + 32, + Some(8) + ) ) + shapeMutants.zipWithIndex.foreach { case ((label, mutant), index) => + val mutatedInput = input(fakeTxIds(1 + (index % 4)), mutant) + withClue(s"tracker $label: ") { + a[Throwable] should be thrownBy createTx( + Array(mutatedInput), Array(trackerInput(mutant)), + outputs(mutatedInput), secrets = Array(receiverSecret.toString) + ) + } + } val insufficientTracker = trackerState(key, debt = totalDebt - 1L) val insufficientDebtInput = input(fakeTxIds(3), insufficientTracker) From 04031626f09c6590a20ad20d5583c6eccc14412d Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:32:54 +0200 Subject: [PATCH 07/11] test: bind Basis v2 claim keys to runtime vectors --- src/test/scala/chaincash/BasisV2Spec.scala | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/test/scala/chaincash/BasisV2Spec.scala b/src/test/scala/chaincash/BasisV2Spec.scala index 10613d3..52cf88c 100644 --- a/src/test/scala/chaincash/BasisV2Spec.scala +++ b/src/test/scala/chaincash/BasisV2Spec.scala @@ -396,6 +396,28 @@ class BasisV2Spec extends PropSpec with Matchers with HttpClientTesting { Array(new ErgoToken(reserveTokenId, amount)) ) + property("Basis v2 claim-key vectors match the runtime ABI") { + val fixedReserveNft = Array.fill[Byte](32)(1) + val fixedTrackerNft = Array.fill[Byte](32)(2) + val fixedTokenId = Array.fill[Byte](32)(5) + val fixedOwner = Constants.g.exp(java.math.BigInteger.ONE) + val fixedReceiver = Constants.g.exp(java.math.BigInteger.valueOf(2L)) + + val ergKey = Blake2b256( + ergDomain ++ fixedReserveNft ++ fixedTrackerNft ++ + fixedOwner.getEncoded.toArray ++ fixedReceiver.getEncoded.toArray + ) + val tokenKey = Blake2b256( + tokenDomain ++ fixedReserveNft ++ fixedTokenId ++ fixedTrackerNft ++ + fixedOwner.getEncoded.toArray ++ fixedReceiver.getEncoded.toArray + ) + + Base16.encode(ergKey) shouldBe + "656c938601a973fe7dd8b5984b70430bf2c885b69a0d91268b0f5c4383a02d73" + Base16.encode(tokenKey) shouldBe + "db33c7176e8d11041a971258458e6be92b59b56865e06ebfbe8e44e9e007f4a1" + } + property("Basis v2 normal redemption accepts an exact receiver payout with external fee funding") { createMockedErgoClient(MockData(Nil, Nil)).execute { implicit ctx => val amount = 300000000L From 8d0f9373f1995720ce5019fa1896aa340ca8c484 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:54:32 +0200 Subject: [PATCH 08/11] Retire legacy ChainCash contract prototypes --- AGENTS.md | 22 ++- README.md | 37 +++-- build.sbt | 2 - docs/legacy-contract-retirement.md | 43 ++++++ .../chaincash/contracts/BasisDeployer.scala | 4 +- .../scala/chaincash/contracts/Constants.scala | 51 ++----- .../contracts/ContractsPrinter.scala | 51 ------- src/main/scala/chaincash/contracts/README.md | 18 +-- .../scala/chaincash/offchain/NoteUtils.scala | 133 ------------------ .../chaincash/offchain/ReserveUtils.scala | 57 -------- .../scala/chaincash/offchain/Tester.scala | 16 --- .../resources/contracts/historical/README.md | 22 +++ .../contracts/historical}/layer2-old/note.es | 0 .../historical}/layer2-old/redemption.es | 0 .../historical}/layer2-old/redproducer.es | 0 .../historical}/layer2-old/reserve.es | 0 .../contracts/historical}/onchain/note.es | 0 .../contracts/historical}/onchain/receipt.es | 0 .../contracts/historical}/onchain/reserve.es | 0 src/test/scala/chaincash/ChainCashSpec.scala | 70 ++++----- .../HistoricalContractFixtures.scala | 82 +++++++++++ .../LegacyContractRetirementSpec.scala | 81 +++++++++++ .../contracts/BasisDeployerSpec.scala | 4 +- 23 files changed, 332 insertions(+), 361 deletions(-) create mode 100644 docs/legacy-contract-retirement.md delete mode 100644 src/main/scala/chaincash/contracts/ContractsPrinter.scala delete mode 100644 src/main/scala/chaincash/offchain/NoteUtils.scala delete mode 100644 src/main/scala/chaincash/offchain/ReserveUtils.scala delete mode 100644 src/main/scala/chaincash/offchain/Tester.scala create mode 100644 src/test/resources/contracts/historical/README.md rename {contracts => src/test/resources/contracts/historical}/layer2-old/note.es (100%) rename {contracts => src/test/resources/contracts/historical}/layer2-old/redemption.es (100%) rename {contracts => src/test/resources/contracts/historical}/layer2-old/redproducer.es (100%) rename {contracts => src/test/resources/contracts/historical}/layer2-old/reserve.es (100%) rename {contracts => src/test/resources/contracts/historical}/onchain/note.es (100%) rename {contracts => src/test/resources/contracts/historical}/onchain/receipt.es (100%) rename {contracts => src/test/resources/contracts/historical}/onchain/reserve.es (100%) create mode 100644 src/test/scala/chaincash/HistoricalContractFixtures.scala create mode 100644 src/test/scala/chaincash/LegacyContractRetirementSpec.scala diff --git a/AGENTS.md b/AGENTS.md index 6284195..577d741 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,21 @@ This document describes the agent-based architecture of the ChainCash protocol - ChainCash implements a decentralized monetary system where different agents manage the lifecycle of digital notes backed by collateral and trust. The system enables self-sovereign banking where each participant can issue, transfer, and redeem digital currency. -## Core On-Chain Agents +## Current Lifecycle Boundary + +Only the Basis and Basis-token prototypes remain in the production contract +surface. The original `contracts/onchain` family, `contracts/layer2-old` +experiments, their transaction builders, and their address/scan-rule printers +are retired. Exact contract source snapshots now live under +`src/test/resources` solely for historical regression tests; never use them to +derive an address, build a transaction, or infer deployment status. The removed +Scala helpers remain available in Git history at the pinned commit below. + +The sections describing the original ChainCash on-chain agents and flows below +are historical design context. Their old paths refer to the source layout at +commit `78475e30362571acf56e4e38276a9d6c0a84ce0c`, not active production files. + +## Historical On-Chain Agents ### Reserve Contract Agent **File**: `contracts/onchain/reserve.es` @@ -57,6 +71,10 @@ ChainCash implements a decentralized monetary system where different agents mana ## Off-Chain Management Agents +The legacy reserve/note construction helpers described in this section are +historical and are no longer compiled. Read-only tracking code remains only to +support a future, separately reviewed inventory of any old state. + ### Wallet Agent **File**: `src/main/scala/chaincash/offchain/WalletUtils.scala` @@ -307,4 +325,4 @@ See test files in `src/test/scala/chaincash/` for detailed agent testing. --- -*This architecture enables a global monetary system with decentralized issuance where each participant can define their own acceptance rules while maintaining collective backing through the spending chain.* \ No newline at end of file +*This architecture enables a global monetary system with decentralized issuance where each participant can define their own acceptance rules while maintaining collective backing through the spending chain.* diff --git a/README.md b/README.md index 0b3d8fc..a100417 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ This repository contains whitepaper and some prototyping code for ChainCash, a protocol to create money in self-sovereign way via trust or collateral, with collective backing and individual acceptance. +## Protocol Lifecycle + +The original on-chain reserve/note/receipt prototype and the experimental +Layer-2 prototype are historical, test-only sources. They are not compiled, +addressed, or activated by production code. See +[`docs/legacy-contract-retirement.md`](docs/legacy-contract-retirement.md). + +The production-code contract surface in this branch contains the Basis and +Basis-token prototypes. Their presence is not a deployment or production- +readiness claim; use a separately reviewed, versioned release before handling +funds. + ## Intro We consider money as a set of digital notes, and every note is collectively backed @@ -74,15 +86,18 @@ Initial version of [design document](docs/server.md) is also available. The serv * Whitepaper - https://github.com/ChainCashLabs/chaincash/blob/master/docs/conf/conf.pdf High-level description of ChainCash protocol and its implementation -* Contracts - https://github.com/kushti/chaincash/tree/master/contracts - note and reserve contracts in ErgoScript +* Contracts - `contracts/offchain/` contains the current Basis prototype + sources. The former on-chain and Layer-2 prototypes are pinned historical + fixtures under `src/test/resources/contracts/historical/`. * Modelling - https://github.com/kushti/chaincash/tree/master/src/main/scala/chaincash/model Contract-less and blockchain-less models of ChainCash entities and one of notes collateralization estimation options. -* Tests - https://github.com/kushti/chaincash/blob/master/src/test/scala/kiosk/ChainCashSpec.scala - Kiosk-based tests for transactions involving note - contracts (note creation, spending, redemption) -* Offchain part - https://github.com/kushti/chaincash/tree/master/src/main/scala/chaincash/offchain - on-chain data tracking, - persistence, transaction builders. This is very rough prototype, at the moment better to look into ChainCash Server which +* Tests - `src/test/scala/chaincash/ChainCashSpec.scala` replays the historical + note-contract transitions through test-only fixtures. +* Offchain part - `src/main/scala/chaincash/offchain/` retains rough prototype + tracking and persistence code, but no production legacy transaction builder. + For application development, prefer ChainCash Server, which is available at [https://github.com/BetterMoneyLabs/chaincash-rs](https://github.com/BetterMoneyLabs/chaincash-rs) . ## Communications @@ -102,7 +117,7 @@ The repository includes deployment utilities for the Basis reserve contract: sbt 'runMain chaincash.contracts.BasisDeployer' // Or use the contract printer -sbt 'runMain chaincash.contracts.Constants$Printer' +sbt 'runMain chaincash.contracts.Printer' ``` This generates deployment requests for the Basis reserve contract, which supports: @@ -113,11 +128,5 @@ This generates deployment requests for the Basis reserve contract, which support See `src/main/scala/chaincash/contracts/README.md` for detailed usage. -## TODO - -* update ReserveData.liabilities and reserveKeys in offchain code -* offchain code for note redemption -* support few spendings of a note in the same block (offchain tracking of it) -* support other tokens in reserves, e.g. SigUSD -* efficient persistence for own notes (currently, all the notes in the system are iterated over) -* check ERG preservation in note contracts +The printer emits only entries in `Constants.publishedContracts`. Historical +contract addresses are deliberately unavailable from production tooling. diff --git a/build.sbt b/build.sbt index dcc7aac..24cfbd0 100644 --- a/build.sbt +++ b/build.sbt @@ -4,8 +4,6 @@ version := "0.2.1" organization := "org.ergoplatform" scalaVersion := "2.12.17" -Compile / unmanagedClasspath += baseDirectory.value / "contracts" - resolvers ++= Seq( "Sonatype Releases" at "https://oss.sonatype.org/content/repositories/releases/", "SonaType" at "https://oss.sonatype.org/content/groups/public", diff --git a/docs/legacy-contract-retirement.md b/docs/legacy-contract-retirement.md new file mode 100644 index 0000000..519105f --- /dev/null +++ b/docs/legacy-contract-retirement.md @@ -0,0 +1,43 @@ +# Legacy ChainCash prototype retirement + +## Status + +The original `onchain` reserve/note/receipt family and the experimental +`layer2-old` family are retired from the production build. Their exact sources +remain test-only historical fixtures. Production code must not compile them, +derive or print their addresses, or build transactions that activate them. + +This is a quarantine boundary, not an in-place contract repair. An immutable +box already protected by one of the old ErgoTrees would still be governed by +that old tree. The repository does not contain a complete deployment inventory, +so this change makes no claim that no such boxes exist. + +## Production boundary + +- `Constants` exposes only the Basis and Basis-token entries through + `publishedContracts`. +- The production printer derives its output exclusively from that registry. +- The old reserve/note construction helpers and the legacy scan-rule printer + are absent from the production classpath. +- The old ErgoScript files live under `src/test/resources` and are loaded only + by historical tests. Removed Scala helpers remain recoverable from the pinned + Git history, but are not copied into a runtime or resource artifact. +- Read-only prototype tracking remains available for inventory work, but it is + not a supported admission, construction, settlement, or migration path. + +## Validation matrix + +| Invariant | Producer / enforcement | Consumer | Failure if relaxed | Regression | +| --- | --- | --- | --- | --- | +| No legacy-specific source, tree, or address getter is exported | `Constants` production API | Address and deployment tooling | A caller can accidentally publish an unreviewed legacy P2S | Reflection rejects every retired getter | +| Address output is allowlisted | `publishedContracts` | `Printer` | A historical address can be presented as deployable | Captured printer output contains only the two registry entries | +| Direct legacy activation helpers are not compiled | SBT source layout | Downstream applications | Old transaction builders or scan-rule generation remain callable | Clean test classpath cannot load the four retired classes | +| Historical regression inputs remain exact | Test-resource SHA-256 manifest | `ChainCashSpec` | Tests silently exercise changed source while retaining the old name | All seven fixture digests are checked before use; the historical suite still runs | +| Existing immutable boxes are not treated as migrated | Operational retirement rule | Any future recovery tool | New code incorrectly assumes authority over an old box | Recovery requires a separate inventory and replay of an authorization branch in the exact old tree | + +## Recovery boundary + +If a legacy box is discovered, first identify its exact proposition bytes, +network, confirmation and reorg status, role tokens, and register schema. Any +exit or migration must be authorized by the box's existing ErgoTree. Merely +moving these sources or removing their builders creates no spend authority. diff --git a/src/main/scala/chaincash/contracts/BasisDeployer.scala b/src/main/scala/chaincash/contracts/BasisDeployer.scala index e894f78..2555166 100644 --- a/src/main/scala/chaincash/contracts/BasisDeployer.scala +++ b/src/main/scala/chaincash/contracts/BasisDeployer.scala @@ -36,7 +36,7 @@ object BasisDeployer extends App { val ergoAddressEncoder = new ErgoAddressEncoder(networkPrefix) // Basis contract configuration - val basisContractScript = Constants.readContract("offchain/basis.es", Map.empty) + val basisContractScript = Constants.basisContract val basisErgoTree = Constants.compile(basisContractScript) val basisAddress = Constants.getAddressFromErgoTree(basisErgoTree) @@ -176,4 +176,4 @@ object BasisConstants { // Refund waiting period (2 months in blocks, assuming ~2 min per block) val REFUND_PERIOD_BLOCKS: Int = 43200 -} \ No newline at end of file +} diff --git a/src/main/scala/chaincash/contracts/Constants.scala b/src/main/scala/chaincash/contracts/Constants.scala index 82354b0..98f25db 100644 --- a/src/main/scala/chaincash/contracts/Constants.scala +++ b/src/main/scala/chaincash/contracts/Constants.scala @@ -3,13 +3,9 @@ package chaincash.contracts import chaincash.offchain.SigUtils._ import org.ergoplatform.ErgoAddressEncoder import org.ergoplatform.appkit.{AppkitHelpers, ErgoValue, NetworkType} -import scorex.crypto.hash.Blake2b256 -import scorex.util.encode.Base58 import sigma.crypto.CryptoConstants import sigma.data.AvlTreeFlags import sigma.ast.ErgoTree -import sigma.compiler.{CompilerSettings, SigmaCompiler} -import sigma.ast.TransformingSigmaBuilder import sigma.{AvlTree, GroupElement} import work.lithos.plasma.PlasmaParameters import work.lithos.plasma.collections.PlasmaMap @@ -21,17 +17,16 @@ object Constants { val networkType = NetworkType.MAINNET val networkPrefix = networkType.networkPrefix val ergoAddressEncoder = new ErgoAddressEncoder(networkPrefix) - private val compiler = SigmaCompiler(CompilerSettings(networkPrefix, TransformingSigmaBuilder, lowerMethodCalls = true)) def getAddressFromErgoTree(ergoTree: ErgoTree) = ergoAddressEncoder.fromProposition(ergoTree).get - def substitute(contract: String, substitutionMap: Map[String, String] = Map.empty): String = { + private def substitute(contract: String, substitutionMap: Map[String, String] = Map.empty): String = { substitutionMap.foldLeft(contract){case (c, (k,v)) => c.replace("$"+k, v) } } - def readContract(path: String, substitutionMap: Map[String, String] = Map.empty) = { + private def readActiveContract(path: String, substitutionMap: Map[String, String] = Map.empty) = { val contract = scala.io.Source.fromFile("contracts/" + path, "utf-8").getLines.mkString("\n") substitute(contract, substitutionMap) } @@ -55,50 +50,28 @@ object Constants { val g: GroupElement = CryptoConstants.dlogGroup.generator - val reserveContract = readContract("onchain/reserve.es", Map.empty) - val reserveErgoTree = compile(reserveContract) - val reserveAddress = getAddressFromErgoTree(reserveErgoTree) - val reserveContractHash = Blake2b256(reserveErgoTree.bytes.tail) - val reserveContractHashString = Base58.encode(reserveContractHash) - - val receiptContract = readContract("onchain/receipt.es", Map("reserveContractHash" -> reserveContractHashString)) - val receiptErgoTree = compile(receiptContract) - val receiptAddress = getAddressFromErgoTree(receiptErgoTree) - val receiptContractHash = Blake2b256(receiptErgoTree.bytes.tail) - val receiptContractHashString = Base58.encode(receiptContractHash) - - val noteContract = readContract("onchain/note.es", - Map("reserveContractHash" -> reserveContractHashString, "receiptContractHash" -> receiptContractHashString)) - val noteErgoTree = compile(noteContract) - val noteAddress = getAddressFromErgoTree(noteErgoTree) - // Basis contracts - val basisContract = readContract("offchain/basis.es", Map()) + val basisContract = readActiveContract("offchain/basis.es") val basisErgoTree = compile(basisContract) val basisAddress = getAddressFromErgoTree(basisErgoTree) // Basis-token contract (token-based reserve) - val basisTokenContract = readContract("offchain/basis-token.es", Map()) + val basisTokenContract = readActiveContract("offchain/basis-token.es") val basisTokenErgoTree = compile(basisTokenContract) val basisTokenAddress = getAddressFromErgoTree(basisTokenErgoTree) - // contracts below are experimental and not finished ChainCash-on-Layer2 contracts - - val redemptionContract = scala.io.Source.fromFile("contracts/layer2-old/redemption.es", "utf-8").getLines.mkString("\n") - val redemptionErgoTree = compile(redemptionContract) - val redemptionAddress = getAddressFromErgoTree(redemptionErgoTree) - - val redemptionProducerContract = scala.io.Source.fromFile("contracts/layer2-old/redproducer.es", "utf-8").getLines.mkString("\n") - val redemptionProducerErgoTree = compile(redemptionProducerContract) - val redemptionProducerAddress = getAddressFromErgoTree(redemptionProducerErgoTree) + /** Contracts intentionally exposed by production address tooling. */ + val publishedContracts = Vector( + "Basis" -> basisAddress, + "Basis-token" -> basisTokenAddress + ) } object Printer extends App { - println("Basis p2s address: " + Constants.basisAddress) - println("Basis-token p2s address: " + Constants.basisTokenAddress) - println("Redemption p2s address: " + Constants.redemptionAddress) - println("Redemption producer p2s address: " + Constants.redemptionProducerAddress) + Constants.publishedContracts.foreach { case (name, address) => + println(s"$name p2s address: $address") + } // Example deployment info println("\nTo deploy Basis reserve:") diff --git a/src/main/scala/chaincash/contracts/ContractsPrinter.scala b/src/main/scala/chaincash/contracts/ContractsPrinter.scala deleted file mode 100644 index 894d444..0000000 --- a/src/main/scala/chaincash/contracts/ContractsPrinter.scala +++ /dev/null @@ -1,51 +0,0 @@ -package chaincash.contracts - -import Constants._ -import scorex.util.encode.Base16 -import sigma.ast.ByteArrayConstant -import sigma.serialization.ValueSerializer - -object ContractsPrinter extends App { - - println(s"Note contract address: $noteAddress") - - println(s"Receipt contract address: $receiptAddress") - - println(s"Reserve contract address: $reserveAddress") - - - val noteScriptBa = ByteArrayConstant(noteErgoTree.bytes) - val reserveScriptBa = ByteArrayConstant(reserveErgoTree.bytes) - - - val noteContractTrackingRule = s""" - |{ - | "scanName": "Note tracker", - | "walletInteraction": "off", - | "removeOffchain": false, - | "trackingRule": { - | "predicate": "equals", - | "value": "${Base16.encode(ValueSerializer.serialize(noteScriptBa))}" - | } - |} - """.stripMargin - - val reserveContractTrackingRule = s""" - | - |{ - | "scanName": "Reserve tracker", - | "walletInteraction": "off", - | "removeOffchain": false, - | "trackingRule": { - | "predicate": "equals", - | "value": "${Base16.encode(ValueSerializer.serialize(reserveScriptBa))}" - | } - |} - |""".stripMargin - - println("==========Note tracking rule================") - println(noteContractTrackingRule) - println("==========Reserve tracking rule==============") - println(reserveContractTrackingRule) - -} diff --git a/src/main/scala/chaincash/contracts/README.md b/src/main/scala/chaincash/contracts/README.md index 12369b6..0b71b9d 100644 --- a/src/main/scala/chaincash/contracts/README.md +++ b/src/main/scala/chaincash/contracts/README.md @@ -65,11 +65,12 @@ println(deploymentRequest) 3. **Reserve Token ID**: Singleton NFT identifying the reserve 4. **Initial Collateral**: Minimum 1 ERG (1000000000 nanoERG) -### Core ChainCash Contracts +### Historical ChainCash Contracts -- **Reserve Contract**: On-chain collateral management -- **Note Contract**: Digital currency issuance and transfer -- **Receipt Contract**: Redemption receipt management +The original reserve, note, and receipt contracts are test-only historical +fixtures. Production code does not compile them, derive their addresses, or +provide transaction builders for them. See +`docs/legacy-contract-retirement.md` for the retirement and recovery boundary. ## Testing @@ -81,8 +82,8 @@ sbt test ## Deployment Process -1. **Compile Contracts**: Use `Constants` object to compile contracts -2. **Generate Addresses**: Get pay-to-script addresses for each contract +1. **Compile Basis Contracts**: Use the versioned Basis source selected for the deployment review +2. **Generate Addresses**: Confirm the pay-to-script address against the reviewed source-to-byte receipt 3. **Create Deployment Requests**: Use deployment utilities 4. **Submit Transactions**: Send deployment transactions to Ergo blockchain 5. **Monitor**: Use scan requests to monitor contract states @@ -94,7 +95,8 @@ sbt test ## Security Notes -- Always test on testnet before mainnet deployment +- Repository compilation alone is not a production-readiness or deployment claim +- Always test a reviewed version on testnet before considering mainnet deployment - Verify contract addresses before deployment - Use proper key management for reserve owners -- Monitor tracker services for availability \ No newline at end of file +- Monitor tracker services for availability diff --git a/src/main/scala/chaincash/offchain/NoteUtils.scala b/src/main/scala/chaincash/offchain/NoteUtils.scala deleted file mode 100644 index d6cf611..0000000 --- a/src/main/scala/chaincash/offchain/NoteUtils.scala +++ /dev/null @@ -1,133 +0,0 @@ -package chaincash.offchain - -import SigUtils._ -import chaincash.contracts.Constants -import chaincash.contracts.Constants.{noteErgoTree, reserveErgoTree} -import chaincash.offchain.TrackingTypes.NoteData -import com.google.common.primitives.Longs -import io.circe.syntax.EncoderOps -import org.ergoplatform.ErgoBox.{R4, R5, R6} -import org.ergoplatform.sdk.wallet.Constants.eip3DerivationPath -import org.ergoplatform.sdk.wallet.secrets.ExtendedSecretKey -import org.ergoplatform.sdk.wallet.settings.EncryptionSettings -import org.ergoplatform.sdk.SecretString -import org.ergoplatform.wallet.secrets.JsonSecretStorage -import org.ergoplatform.wallet.settings.SecretStorageSettings -import org.ergoplatform.{DataInput, ErgoBoxCandidate, P2PKAddress, UnsignedErgoLikeTransaction, UnsignedInput} -import scorex.crypto.hash.Digest32 -import scorex.util.encode.Base16 -import sigma.ast.{AvlTreeConstant, ByteArrayConstant, ByteConstant, GroupElementConstant, LongConstant} -import sigma.Colls -import sigma.interpreter.ContextExtension -import sigma.serialization.GroupElementSerializer -import sigma.GroupElement -import sigma.data.Digest32Coll - -trait NoteUtils extends WalletUtils { - // create note with nominal of `amountMg` mg of gold - def createNote(amountMg: Long, ownerPubkey: GroupElement, changeAddress: P2PKAddress): Unit = { - val inputs = fetchInputs().take(60) - val creationHeight = inputs.map(_.creationHeight).max - val noteTokenId = Digest32 @@ inputs.head.id.toArray - - val inputValue = inputs.map(_.value).sum - require(inputValue >= feeValue * 21) - - val noteAmount = feeValue * 20 - - val noteOut = new ErgoBoxCandidate( - noteAmount, - noteErgoTree, - creationHeight, - Colls.fromItems((Digest32Coll @@ Colls.fromArray(noteTokenId)) -> amountMg), - Map( - R4 -> AvlTreeConstant(Constants.emptyTree), - R5 -> GroupElementConstant(ownerPubkey), - R6 -> LongConstant(0) - ) - ) - val feeOut = createFeeOut(creationHeight) - val changeOutOpt = if(inputValue > 21 * feeValue) { - val changeValue = inputValue - (21 * feeValue) - Some(new ErgoBoxCandidate(changeValue, changeAddress.script, creationHeight)) - } else { - None - } - - val unsignedInputs = inputs.map(box => new UnsignedInput(box.id, ContextExtension.empty)) - val outs = Seq(noteOut, feeOut) ++ changeOutOpt.toSeq - val tx = new UnsignedErgoLikeTransaction(unsignedInputs.toIndexedSeq, IndexedSeq.empty, outs.toIndexedSeq) - println(tx.asJson) - } - - def createNote(amountMg: Long): Unit = { - val changeAddress = fetchChangeAddress() - createNote(amountMg, changeAddress.pubkey.value, changeAddress) - } - - private def readSecret(): ExtendedSecretKey ={ - val sss = SecretStorageSettings("secrets", EncryptionSettings("HmacSHA256", 128000, 256)) - val jss = JsonSecretStorage.readFile(sss).get - jss.unlock(SecretString.create("wpass")) - val masterKey = jss.secret.get - masterKey.derive(eip3DerivationPath) - } - - def sendNote(noteData: NoteData, to: GroupElement) = { - val changeAddress = fetchChangeAddress() - - val noteInputBox = noteData.currentUtxo - val p2pkInputs = fetchInputs().take(5) // to pay fees - val inputs = Seq(noteInputBox) ++ p2pkInputs - val creationHeight = inputs.map(_.creationHeight).max - - val inputValue = inputs.map(_.value).sum - - val noteRecord = noteInputBox.additionalTokens.toArray.head - val noteTokenId = noteRecord._1 - val noteAmount = noteRecord._2 - - val secret = readSecret() - val msg: Array[Byte] = Longs.toByteArray(noteAmount) ++ noteTokenId.toArray - val sig = SigUtils.sign(msg, secret.privateInput.w) - secret.zeroSecret() - val sigBytes = GroupElementSerializer.toBytes(sig._1) ++ sig._2.toByteArray - - // todo: likely should be passed from outside - val reserveId = myReserveIds().head - val reserveIdBytes = Base16.decode(reserveId).get - val reserveBox = DbEntities.reserves.get(reserveId).get.reserveBox - - val prover = noteData.restoreProver - val insertProof = prover.insert(reserveIdBytes -> sigBytes).proof - val updTree = prover.ergoValue.getValue - - val noteOut = new ErgoBoxCandidate( - noteInputBox.value, - noteErgoTree, - creationHeight, - Colls.fromItems(noteTokenId -> noteAmount), - Map(R4 -> AvlTreeConstant(updTree), R5 -> GroupElementConstant(to)) - ) - - val noteInput = new UnsignedInput(noteInputBox.id, ContextExtension(Map( - 0.toByte -> ByteConstant(0), - 1.toByte -> GroupElementConstant(sig._1), - 2.toByte -> ByteArrayConstant(sig._2.toByteArray), - 3.toByte -> ByteArrayConstant(insertProof.bytes) - ))) - - val feeOut = createFeeOut(creationHeight) - val changeValue = inputValue - noteOut.value - feeOut.value - val changeOut = new ErgoBoxCandidate(changeValue, changeAddress.script, creationHeight) - val outs = IndexedSeq(noteOut, changeOut, feeOut) - - val unsignedInputs = Seq(noteInput) ++ p2pkInputs.map(box => new UnsignedInput(box.id, ContextExtension.empty)) - - val dataInputs = IndexedSeq(DataInput(reserveBox.id)) - - val tx = new UnsignedErgoLikeTransaction(unsignedInputs.toIndexedSeq, dataInputs, outs.toIndexedSeq) - println(tx.asJson) - } - -} diff --git a/src/main/scala/chaincash/offchain/ReserveUtils.scala b/src/main/scala/chaincash/offchain/ReserveUtils.scala deleted file mode 100644 index 3848478..0000000 --- a/src/main/scala/chaincash/offchain/ReserveUtils.scala +++ /dev/null @@ -1,57 +0,0 @@ -package chaincash.offchain - -import SigUtils._ -import io.circe.syntax.EncoderOps -import org.ergoplatform.ErgoBox.R4 -import org.ergoplatform.sdk.JsonCodecs -import org.ergoplatform.{ErgoBoxCandidate, P2PKAddress, UnsignedErgoLikeTransaction, UnsignedInput} -import scorex.crypto.hash.Digest32 -import sigma.ast.GroupElementConstant -import sigma.Colls -import sigma.GroupElement -import sigma.interpreter.ContextExtension -import sigma.data.Digest32Coll - -trait ReserveUtils extends WalletUtils with JsonCodecs { - import chaincash.contracts.Constants.reserveErgoTree - - // create reserve with `amount` nanoerg associated with `pubkey` - def createReserve(pubKey: GroupElement, amount: Long, changeAddress: P2PKAddress): Unit = { - val inputs = fetchInputs().take(60) - val creationHeight = inputs.map(_.creationHeight).max - val reserveInputNft = Digest32 @@ inputs.head.id.toArray - - val inputValue = inputs.map(_.value).sum - require(inputValue >= amount + feeValue) - - val reserveOut = new ErgoBoxCandidate( - amount, - reserveErgoTree, - creationHeight, - Colls.fromItems((Digest32Coll @@ Colls.fromArray(reserveInputNft)) -> 1L), - Map(R4 -> GroupElementConstant(pubKey)) - ) - val feeOut = createFeeOut(creationHeight) - val changeOutOpt = if(inputValue > amount + feeValue) { - val changeValue = inputValue - (amount + feeValue) - Some(new ErgoBoxCandidate(changeValue, changeAddress.script, creationHeight)) - } else { - None - } - - val unsignedInputs = inputs.map(box => new UnsignedInput(box.id, ContextExtension.empty)) - val outs = Seq(reserveOut, feeOut) ++ changeOutOpt.toSeq - val tx = new UnsignedErgoLikeTransaction(unsignedInputs.toIndexedSeq, IndexedSeq.empty, outs.toIndexedSeq) - println(tx.asJson) - } - - def createReserve(address: P2PKAddress, amount: Long): Unit = { - createReserve(address.pubkey.value, amount, address) - } - - def createReserve(amount: Long): Unit = { - val changeAddress = fetchChangeAddress() - createReserve(changeAddress, amount) - } - -} diff --git a/src/main/scala/chaincash/offchain/Tester.scala b/src/main/scala/chaincash/offchain/Tester.scala deleted file mode 100644 index 78562e0..0000000 --- a/src/main/scala/chaincash/offchain/Tester.scala +++ /dev/null @@ -1,16 +0,0 @@ -package chaincash.offchain - -import SigUtils._ - -object Tester extends App with TrackingUtils with NoteUtils { - override val serverUrl: String = "http://127.0.0.1:9053" - - println(fetchNodeHeight()) - - processBlocks() - - println("my balance: " + myBalance()) - - sendNote(DbEntities.unspentNotes.head.get._2, myPoint) - -} \ No newline at end of file diff --git a/src/test/resources/contracts/historical/README.md b/src/test/resources/contracts/historical/README.md new file mode 100644 index 0000000..54ef0be --- /dev/null +++ b/src/test/resources/contracts/historical/README.md @@ -0,0 +1,22 @@ +# Historical ChainCash contract fixtures + +These ErgoScript files are retained only to replay the original ChainCash +prototype tests. They are not production resources, supported deployment +targets, or address-generation inputs. + +The files were moved without content changes from commit +`78475e30362571acf56e4e38276a9d6c0a84ce0c`. Their raw SHA-256 digests are: + +| Fixture | SHA-256 | +| --- | --- | +| `onchain/reserve.es` | `14ac7744339c70f6d1252948aa85ce9684be1b99b32faae9993d6387e19f724e` | +| `onchain/receipt.es` | `c35f0aa6bbd9e272f11cabbab055c96fd47bfee2fb4d0b159f5ec18f0d131509` | +| `onchain/note.es` | `eeb985b7cd06b99b62b08fdb540c227656984c2de9ff29a4ef31804805b26c56` | +| `layer2-old/reserve.es` | `19b513ce118aa536fd95e13c7b41231caeda74c5a3c51d137ccbdf4093d4de8a` | +| `layer2-old/redemption.es` | `e6aa669a19b32e365ea7f980bfb97798c21e496eb889aeebb8dd88f27a30a930` | +| `layer2-old/redproducer.es` | `9c86583f3a3987ecdbe39f7ac2780c13d3c69c79414b3d5f6ff5a45fe3468a61` | +| `layer2-old/note.es` | `4a7a2e7cb806f0311275c6e41cb8c27b53f180d8511900f6ec09abf68a51319f` | + +`HistoricalContractFixtures` verifies these digests before exposing the source +to historical tests. Changing a fixture requires an explicit new lineage and +review; it must not silently become a production contract generation. diff --git a/contracts/layer2-old/note.es b/src/test/resources/contracts/historical/layer2-old/note.es similarity index 100% rename from contracts/layer2-old/note.es rename to src/test/resources/contracts/historical/layer2-old/note.es diff --git a/contracts/layer2-old/redemption.es b/src/test/resources/contracts/historical/layer2-old/redemption.es similarity index 100% rename from contracts/layer2-old/redemption.es rename to src/test/resources/contracts/historical/layer2-old/redemption.es diff --git a/contracts/layer2-old/redproducer.es b/src/test/resources/contracts/historical/layer2-old/redproducer.es similarity index 100% rename from contracts/layer2-old/redproducer.es rename to src/test/resources/contracts/historical/layer2-old/redproducer.es diff --git a/contracts/layer2-old/reserve.es b/src/test/resources/contracts/historical/layer2-old/reserve.es similarity index 100% rename from contracts/layer2-old/reserve.es rename to src/test/resources/contracts/historical/layer2-old/reserve.es diff --git a/contracts/onchain/note.es b/src/test/resources/contracts/historical/onchain/note.es similarity index 100% rename from contracts/onchain/note.es rename to src/test/resources/contracts/historical/onchain/note.es diff --git a/contracts/onchain/receipt.es b/src/test/resources/contracts/historical/onchain/receipt.es similarity index 100% rename from contracts/onchain/receipt.es rename to src/test/resources/contracts/historical/onchain/receipt.es diff --git a/contracts/onchain/reserve.es b/src/test/resources/contracts/historical/onchain/reserve.es similarity index 100% rename from contracts/onchain/reserve.es rename to src/test/resources/contracts/historical/onchain/reserve.es diff --git a/src/test/scala/chaincash/ChainCashSpec.scala b/src/test/scala/chaincash/ChainCashSpec.scala index dc1a9c7..443af21 100644 --- a/src/test/scala/chaincash/ChainCashSpec.scala +++ b/src/test/scala/chaincash/ChainCashSpec.scala @@ -172,7 +172,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(noteTokenId, noteValue)) .registers(Constants.emptyTreeErgoValue, ErgoValue.of(holderPk), ErgoValue.of(position)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.noteContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.noteContract)) .build() .convertToInputWith(fakeTxId1, fakeIndex) .withContextVars( @@ -189,12 +189,12 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserveNFTBytes, 1)) .registers(ErgoValue.of(holderPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId2, fakeIndex) val noteOutput = createOut( - Constants.noteContract, + HistoricalContractFixtures.noteContract, minValue, Array(outTree, ErgoValue.of(holderPk), ErgoValue.of(position + 1)), Array(new ErgoToken(noteTokenId, 1)) @@ -243,7 +243,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue + feeValue) .tokens(new ErgoToken(noteTokenId, noteValue)) .registers(historyTree, ErgoValue.of(holderPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.noteContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.noteContract)) .build() .convertToInputWith(fakeTxId1, fakeIndex) .withContextVars( @@ -257,7 +257,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserveNFTBytes, 1)) .registers(ErgoValue.of(holderPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId2, fakeIndex) .withContextVars( @@ -291,14 +291,14 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .convertToInputWith(fakeTxId3, fakeIndex) val reserveOutput = createOut( - Constants.reserveContract, + HistoricalContractFixtures.reserveContract, minValue - oracleRate * 98 / 100, registers = Array(ErgoValue.of(holderPk)), tokens = Array(new ErgoToken(reserveNFT, 1)) ) val receiptOutput = createOut( - Constants.receiptContract, + HistoricalContractFixtures.receiptContract, minValue, registers = Array(historyTree, ErgoValue.of(0L), ErgoValue.of(ctx.getHeight - 5), ErgoValue.of(holderPk)), tokens = Array(new ErgoToken(noteTokenId, noteValue)) @@ -407,7 +407,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue + feeValue) .tokens(new ErgoToken(noteTokenId, finalNoteValue)) .registers(historyTree, ErgoValue.of(holderPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.noteContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.noteContract)) .build() .convertToInputWith(fakeTxId1, fakeIndex) .withContextVars( @@ -424,7 +424,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserveBNFT, 1)) .registers(ErgoValue.of(reserveBPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId2, fakeIndex) .withContextVars( @@ -446,14 +446,14 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .convertToInputWith(fakeTxId5, fakeIndex) val reserveOutput = createOut( - Constants.reserveContract, + HistoricalContractFixtures.reserveContract, minValue - oracleRate * 98 / 100, registers = Array(ErgoValue.of(reserveBPk)), tokens = Array(new ErgoToken(reserveBNFT, 1)) ) val receiptOutput = createOut( - Constants.receiptContract, + HistoricalContractFixtures.receiptContract, minValue, registers = Array(historyTree, ErgoValue.of(bPosition), ErgoValue.of(ctx.getHeight - 5), ErgoValue.of(reserveBPk)), tokens = Array(new ErgoToken(noteTokenId, finalNoteValue)) @@ -493,7 +493,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserveANFT, 1)) .registers(ErgoValue.of(reserveAPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId5, fakeIndex) .withContextVars( @@ -505,14 +505,14 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty ) val reserveAOutput = createOut( - Constants.reserveContract, + HistoricalContractFixtures.reserveContract, minValue - oracleRate * 98 / 100, registers = Array(ErgoValue.of(reserveAPk)), tokens = Array(new ErgoToken(reserveANFT, 1)) ) val receiptAOutput = createOut( - Constants.receiptContract, + HistoricalContractFixtures.receiptContract, minValue, registers = Array(historyTree, ErgoValue.of(aPosition), ErgoValue.of(ctx.getHeight - 5), ErgoValue.of(reserveAPk)), tokens = Array(new ErgoToken(noteTokenId, finalNoteValue)) @@ -612,7 +612,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .registers(historyTree, ErgoValue.of(bPosition), ErgoValue.of(ctx.getHeight - 5), ErgoValue.of(reserveBPk)) .tokens(new ErgoToken(noteTokenId, bValue)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.receiptContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.receiptContract)) .build() .convertToInputWith(fakeTxId4, fakeIndex) @@ -626,7 +626,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserveCNFT, 1)) .registers(ErgoValue.of(reserveCPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId5, fakeIndex) .withContextVars( @@ -648,14 +648,14 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .convertToInputWith(fakeTxId2, fakeIndex) val reserveCOutput = createOut( - Constants.reserveContract, + HistoricalContractFixtures.reserveContract, minValue - oracleRate * 98 / 100, registers = Array(ErgoValue.of(reserveCPk)), tokens = Array(new ErgoToken(reserveCNFT, 1)) ) val receiptCOutput = createOut( - Constants.receiptContract, + HistoricalContractFixtures.receiptContract, minValue, registers = Array(historyTree, ErgoValue.of(cPosition), ErgoValue.of(ctx.getHeight - 5), ErgoValue.of(reserveCPk)), tokens = Array(new ErgoToken(noteTokenId, finalNoteValue)) @@ -743,7 +743,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue + feeValue) .tokens(new ErgoToken(note1TokenId, noteValue)) .registers(historyTree1, ErgoValue.of(holder1Pk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.noteContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.noteContract)) .build() .convertToInputWith(fakeTxId1, fakeIndex) .withContextVars( @@ -757,7 +757,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue + feeValue) .tokens(new ErgoToken(note2TokenId, noteValue)) .registers(historyTree2, ErgoValue.of(holder2Pk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.noteContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.noteContract)) .build() .convertToInputWith(fakeTxId2, fakeIndex) .withContextVars( @@ -771,7 +771,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserve1NFTBytes, 1)) .registers(ErgoValue.of(holder1Pk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId3, fakeIndex) .withContextVars( @@ -790,7 +790,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserve2NFTBytes, 1)) .registers(ErgoValue.of(holder2Pk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId4, fakeIndex) .withContextVars( @@ -815,28 +815,28 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .convertToInputWith(fakeTxId3, fakeIndex) val reserve1Output = createOut( - Constants.reserveContract, + HistoricalContractFixtures.reserveContract, minValue, registers = Array(ErgoValue.of(holder1Pk)), tokens = Array(new ErgoToken(reserve1NFT, 1)) ) val reserve2Output = createOut( - Constants.reserveContract, + HistoricalContractFixtures.reserveContract, minValue, registers = Array(ErgoValue.of(holder2Pk)), tokens = Array(new ErgoToken(reserve2NFT, 1)) ) val receipt1Output = createOut( - Constants.receiptContract, + HistoricalContractFixtures.receiptContract, minValue, registers = Array(historyTree1, ErgoValue.of(0L), ErgoValue.of(ctx.getHeight - 5), ErgoValue.of(holder1Pk)), tokens = Array(new ErgoToken(note1TokenId, noteValue)) ) val receipt2Output = createOut( - Constants.receiptContract, + HistoricalContractFixtures.receiptContract, minValue, registers = Array(historyTree2, ErgoValue.of(0L), ErgoValue.of(ctx.getHeight - 5), ErgoValue.of(holder2Pk)), tokens = Array(new ErgoToken(note2TokenId, noteValue)) @@ -897,7 +897,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(firstNoteTokenId, firstNoteValue)) .registers(Constants.emptyTreeErgoValue, ErgoValue.of(holderPk), ErgoValue.of(firstPosition)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.noteContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.noteContract)) .build() .convertToInputWith(fakeTxId1, fakeIndex) .withContextVars( @@ -915,7 +915,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(secondNoteTokenId, secondNoteValue)) .registers(Constants.emptyTreeErgoValue, ErgoValue.of(holderPk), ErgoValue.of(secondPosition)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.noteContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.noteContract)) .build() .convertToInputWith(fakeTxId1, fakeIndex) .withContextVars( @@ -942,33 +942,33 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserveNFTBytes, 1)) .registers(ErgoValue.of(holderPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId2, fakeIndex) val note1Output = createOut( - Constants.noteContract, + HistoricalContractFixtures.noteContract, minValue, registers = Array(outTree1, ErgoValue.of(holderPk), ErgoValue.of(firstPosition + 1)), tokens = Array(new ErgoToken(firstNoteTokenId, 50)) ) val note1Change = createOut( - Constants.noteContract, + HistoricalContractFixtures.noteContract, minValue, registers = Array(outTree1, ErgoValue.of(holderPk), ErgoValue.of(firstPosition + 1)), tokens = Array(new ErgoToken(firstNoteTokenId, 5)) ) val note2Output = createOut( - Constants.noteContract, + HistoricalContractFixtures.noteContract, minValue, registers = Array(outTree2, ErgoValue.of(holderPk), ErgoValue.of(secondPosition + 1)), tokens = Array(new ErgoToken(secondNoteTokenId, 50)) ) val note2Change = createOut( - Constants.noteContract, + HistoricalContractFixtures.noteContract, minValue, registers = Array(outTree2, ErgoValue.of(holderPk), ErgoValue.of(secondPosition + 1)), tokens = Array(new ErgoToken(secondNoteTokenId, 10)) @@ -1001,7 +1001,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .value(minValue) .tokens(new ErgoToken(reserveNFTBytes, 1)) .registers(ErgoValue.of(holderPk)) - .contract(ctx.compileContract(ConstantsBuilder.empty(), Constants.reserveContract)) + .contract(ctx.compileContract(ConstantsBuilder.empty(), HistoricalContractFixtures.reserveContract)) .build() .convertToInputWith(fakeTxId2, fakeIndex) .withContextVars( @@ -1018,7 +1018,7 @@ class ChainCashSpec extends PropSpec with Matchers with ScalaCheckDrivenProperty .convertToInputWith(fakeTxId1, fakeIndex) val reserveOutput = createOut( - Constants.reserveContract, + HistoricalContractFixtures.reserveContract, minValue, registers = Array(ErgoValue.of(holderPk), ErgoValue.of(ctx.getHeight - 2)), tokens = Array(new ErgoToken(reserveNFT, 1)) diff --git a/src/test/scala/chaincash/HistoricalContractFixtures.scala b/src/test/scala/chaincash/HistoricalContractFixtures.scala new file mode 100644 index 0000000..18fa0a1 --- /dev/null +++ b/src/test/scala/chaincash/HistoricalContractFixtures.scala @@ -0,0 +1,82 @@ +package chaincash + +import chaincash.contracts.Constants +import scorex.crypto.hash.Blake2b256 +import scorex.util.encode.Base58 + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** + * Exact source fixtures for the retired ChainCash prototypes. + * + * These sources are loaded only from the test classpath. Production code must + * not expose their contract text, ErgoTrees, addresses, or transaction builders. + */ +private[chaincash] object HistoricalContractFixtures { + + val expectedSha256: Map[String, String] = Map( + "onchain/reserve.es" -> "14ac7744339c70f6d1252948aa85ce9684be1b99b32faae9993d6387e19f724e", + "onchain/receipt.es" -> "c35f0aa6bbd9e272f11cabbab055c96fd47bfee2fb4d0b159f5ec18f0d131509", + "onchain/note.es" -> "eeb985b7cd06b99b62b08fdb540c227656984c2de9ff29a4ef31804805b26c56", + "layer2-old/reserve.es" -> "19b513ce118aa536fd95e13c7b41231caeda74c5a3c51d137ccbdf4093d4de8a", + "layer2-old/redemption.es" -> "e6aa669a19b32e365ea7f980bfb97798c21e496eb889aeebb8dd88f27a30a930", + "layer2-old/redproducer.es" -> "9c86583f3a3987ecdbe39f7ac2780c13d3c69c79414b3d5f6ff5a45fe3468a61", + "layer2-old/note.es" -> "4a7a2e7cb806f0311275c6e41cb8c27b53f180d8511900f6ec09abf68a51319f" + ) + + def archivedSource(relativePath: String): String = { + val resourcePath = s"/contracts/historical/$relativePath" + val stream = Option(getClass.getResourceAsStream(resourcePath)) + .getOrElse(throw new IllegalStateException(s"Missing historical fixture: $resourcePath")) + val bytes = try { + val output = new java.io.ByteArrayOutputStream() + val buffer = new Array[Byte](8192) + var count = stream.read(buffer) + while (count != -1) { + output.write(buffer, 0, count) + count = stream.read(buffer) + } + output.toByteArray + } finally { + stream.close() + } + + val expected = expectedSha256.getOrElse( + relativePath, + throw new IllegalArgumentException(s"Unregistered historical fixture: $relativePath") + ) + val actual = MessageDigest.getInstance("SHA-256").digest(bytes) + .map(byte => f"${byte & 0xff}%02x") + .mkString + require(actual == expected, s"Historical fixture digest mismatch for $relativePath") + + new String(bytes, StandardCharsets.UTF_8) + .replace("\r\n", "\n") + .stripSuffix("\n") + } + + private def substitute(contract: String, values: Map[String, String]): String = + values.foldLeft(contract) { case (source, (name, value)) => + source.replace("$" + name, value) + } + + lazy val reserveContract: String = archivedSource("onchain/reserve.es") + private lazy val reserveErgoTree = Constants.compile(reserveContract) + private lazy val reserveContractHash = Base58.encode(Blake2b256(reserveErgoTree.bytes.tail)) + + lazy val receiptContract: String = substitute( + archivedSource("onchain/receipt.es"), + Map("reserveContractHash" -> reserveContractHash) + ) + private lazy val receiptErgoTree = Constants.compile(receiptContract) + private lazy val receiptContractHash = Base58.encode(Blake2b256(receiptErgoTree.bytes.tail)) + + lazy val noteContract: String = substitute( + archivedSource("onchain/note.es"), + Map( + "reserveContractHash" -> reserveContractHash, + "receiptContractHash" -> receiptContractHash + ) + ) +} diff --git a/src/test/scala/chaincash/LegacyContractRetirementSpec.scala b/src/test/scala/chaincash/LegacyContractRetirementSpec.scala new file mode 100644 index 0000000..eabb906 --- /dev/null +++ b/src/test/scala/chaincash/LegacyContractRetirementSpec.scala @@ -0,0 +1,81 @@ +package chaincash + +import chaincash.contracts.{Constants, Printer} +import org.scalatest.{Matchers, PropSpec} + +import java.io.{ByteArrayOutputStream, PrintStream} +import java.nio.charset.StandardCharsets +import scala.util.Try + +class LegacyContractRetirementSpec extends PropSpec with Matchers { + + property("production Constants exports only the published Basis contract family") { + Constants.publishedContracts.map(_._1) shouldEqual Vector("Basis", "Basis-token") + + val exportedMethods = Constants.getClass.getMethods.map(_.getName).toSet + val retiredGetters = Set( + "readContract", + "reserveContract", + "reserveErgoTree", + "reserveAddress", + "receiptContract", + "receiptErgoTree", + "receiptAddress", + "noteContract", + "noteErgoTree", + "noteAddress", + "redemptionContract", + "redemptionErgoTree", + "redemptionAddress", + "redemptionProducerContract", + "redemptionProducerErgoTree", + "redemptionProducerAddress" + ) + + exportedMethods.intersect(retiredGetters) shouldBe empty + } + + property("production classpath has no legacy address printer or transaction builders") { + val retiredClasses = Seq( + "chaincash.contracts.ContractsPrinter$", + "chaincash.offchain.NoteUtils", + "chaincash.offchain.ReserveUtils", + "chaincash.offchain.Tester$" + ) + + retiredClasses.foreach { className => + withClue(className) { + Try(Class.forName(className)).isFailure shouldBe true + } + } + } + + property("production printer emits only the published Basis addresses") { + val bytes = new ByteArrayOutputStream() + val stream = new PrintStream(bytes, true, StandardCharsets.UTF_8.name()) + try { + Console.withOut(stream) { + Printer.main(Array.empty) + } + } finally { + stream.close() + } + + val output = new String(bytes.toByteArray, StandardCharsets.UTF_8) + output should include (s"Basis p2s address: ${Constants.basisAddress}") + output should include (s"Basis-token p2s address: ${Constants.basisTokenAddress}") + output should not include "Redemption p2s address" + output should not include "Redemption producer p2s address" + output should not include "Note contract address" + output should not include "Receipt contract address" + output should not include "Reserve contract address" + } + + property("historical contract sources retain their pinned upstream bytes") { + HistoricalContractFixtures.expectedSha256.keys.foreach { path => + withClue(path) { + HistoricalContractFixtures.archivedSource(path) should not be empty + } + } + } +} diff --git a/src/test/scala/chaincash/contracts/BasisDeployerSpec.scala b/src/test/scala/chaincash/contracts/BasisDeployerSpec.scala index dc1156e..724184e 100644 --- a/src/test/scala/chaincash/contracts/BasisDeployerSpec.scala +++ b/src/test/scala/chaincash/contracts/BasisDeployerSpec.scala @@ -8,7 +8,7 @@ class BasisDeployerSpec extends PropSpec with Matchers { property("BasisDeployer should compile Basis contract successfully") { // This test verifies that the Basis contract can be compiled - val basisContract = Constants.readContract("offchain/basis.es", Map.empty) + val basisContract = Constants.basisContract basisContract should not be empty val basisErgoTree = Constants.compile(basisContract) @@ -42,4 +42,4 @@ class BasisDeployerSpec extends PropSpec with Matchers { BasisConstants.EMERGENCY_REDEMPTION_TIME_IN_BLOCKS shouldBe 2160 // 3 days in blocks (3 * 720) BasisConstants.REFUND_PERIOD_BLOCKS shouldBe 43200 // 2 months in blocks (60 * 720) } -} \ No newline at end of file +} From 4e1a1d40d294e12b1c510f6e8aca541679b4e205 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:10:48 +0200 Subject: [PATCH 09/11] Make historical fixture hashes cross-platform --- demo/basis/simple/src/BasisDeployer.scala | 4 +- docs/legacy-contract-retirement.md | 2 +- .../resources/contracts/historical/README.md | 27 ++++++++------ src/test/scala/chaincash/BasisSpec.scala | 2 +- .../HistoricalContractFixtures.scala | 37 ++++++++++++------- .../LegacyContractRetirementSpec.scala | 15 +++++++- 6 files changed, 55 insertions(+), 32 deletions(-) diff --git a/demo/basis/simple/src/BasisDeployer.scala b/demo/basis/simple/src/BasisDeployer.scala index 79ba740..e490e75 100644 --- a/demo/basis/simple/src/BasisDeployer.scala +++ b/demo/basis/simple/src/BasisDeployer.scala @@ -36,7 +36,7 @@ object BasisDeployer extends App { val ergoAddressEncoder = new ErgoAddressEncoder(networkPrefix) // Basis contract configuration - val basisContractScript = Constants.readContract("offchain/basis.es", Map.empty) + val basisContractScript = Constants.basisContract val basisErgoTree = Constants.compile(basisContractScript) val basisAddress = Constants.getAddressFromErgoTree(basisErgoTree) @@ -170,4 +170,4 @@ object BasisConstants { // Emergency redemption time (3 days in blocks, assuming ~2.5 min per block) val EMERGENCY_REDEMPTION_TIME_IN_BLOCKS: Int = 3 * 720 -} \ No newline at end of file +} diff --git a/docs/legacy-contract-retirement.md b/docs/legacy-contract-retirement.md index 519105f..a286974 100644 --- a/docs/legacy-contract-retirement.md +++ b/docs/legacy-contract-retirement.md @@ -32,7 +32,7 @@ so this change makes no claim that no such boxes exist. | No legacy-specific source, tree, or address getter is exported | `Constants` production API | Address and deployment tooling | A caller can accidentally publish an unreviewed legacy P2S | Reflection rejects every retired getter | | Address output is allowlisted | `publishedContracts` | `Printer` | A historical address can be presented as deployable | Captured printer output contains only the two registry entries | | Direct legacy activation helpers are not compiled | SBT source layout | Downstream applications | Old transaction builders or scan-rule generation remain callable | Clean test classpath cannot load the four retired classes | -| Historical regression inputs remain exact | Test-resource SHA-256 manifest | `ChainCashSpec` | Tests silently exercise changed source while retaining the old name | All seven fixture digests are checked before use; the historical suite still runs | +| Historical regression inputs remain exact across checkout line endings | Canonical UTF-8 LF SHA-256 manifest | `ChainCashSpec` | Tests silently exercise changed source while retaining the old name, or fail only because Git checked out CRLF | All seven canonical fixture digests are checked before use; LF and CRLF produce the same identity; the historical suite still runs | | Existing immutable boxes are not treated as migrated | Operational retirement rule | Any future recovery tool | New code incorrectly assumes authority over an old box | Recovery requires a separate inventory and replay of an authorization branch in the exact old tree | ## Recovery boundary diff --git a/src/test/resources/contracts/historical/README.md b/src/test/resources/contracts/historical/README.md index 54ef0be..2ccd2d4 100644 --- a/src/test/resources/contracts/historical/README.md +++ b/src/test/resources/contracts/historical/README.md @@ -4,19 +4,22 @@ These ErgoScript files are retained only to replay the original ChainCash prototype tests. They are not production resources, supported deployment targets, or address-generation inputs. -The files were moved without content changes from commit -`78475e30362571acf56e4e38276a9d6c0a84ce0c`. Their raw SHA-256 digests are: +The files were moved without source changes from commit +`78475e30362571acf56e4e38276a9d6c0a84ce0c`. Their canonical UTF-8 LF +SHA-256 digests are shown below. CRLF is normalized to LF before hashing, while +the final newline is retained. | Fixture | SHA-256 | | --- | --- | -| `onchain/reserve.es` | `14ac7744339c70f6d1252948aa85ce9684be1b99b32faae9993d6387e19f724e` | -| `onchain/receipt.es` | `c35f0aa6bbd9e272f11cabbab055c96fd47bfee2fb4d0b159f5ec18f0d131509` | -| `onchain/note.es` | `eeb985b7cd06b99b62b08fdb540c227656984c2de9ff29a4ef31804805b26c56` | -| `layer2-old/reserve.es` | `19b513ce118aa536fd95e13c7b41231caeda74c5a3c51d137ccbdf4093d4de8a` | -| `layer2-old/redemption.es` | `e6aa669a19b32e365ea7f980bfb97798c21e496eb889aeebb8dd88f27a30a930` | -| `layer2-old/redproducer.es` | `9c86583f3a3987ecdbe39f7ac2780c13d3c69c79414b3d5f6ff5a45fe3468a61` | -| `layer2-old/note.es` | `4a7a2e7cb806f0311275c6e41cb8c27b53f180d8511900f6ec09abf68a51319f` | +| `onchain/reserve.es` | `3f39c13879748230cd08ee10d9970370506509e966229b735af77486843fa335` | +| `onchain/receipt.es` | `69c24be8426e7c7c8fdf77ab248e4549b8cc9832122aed010a54e304357869ea` | +| `onchain/note.es` | `5a145dfa334efa60a26fe25ed136611992f207807ecb628c4c8ec196d940df16` | +| `layer2-old/reserve.es` | `026c29b169f39f75a262590388364aea926a270c6fa78511199c1ae1512336e4` | +| `layer2-old/redemption.es` | `b87614ec6aacbed34a0bf7950629d505cc6f204d1b4e7db52c5b43a133e122c6` | +| `layer2-old/redproducer.es` | `9e9842716a49d631bc67ecf201da42c020d441e3329b9fd7778ea14c9dcc8351` | +| `layer2-old/note.es` | `797b69c823ff392d5a941cbd02b9297341c5926c2ce721ab6735f71f3c181e1b` | -`HistoricalContractFixtures` verifies these digests before exposing the source -to historical tests. Changing a fixture requires an explicit new lineage and -review; it must not silently become a production contract generation. +`HistoricalContractFixtures` verifies these canonical digests before exposing +the source to historical tests. Changing a fixture requires an explicit new +lineage and review; it must not silently become a production contract +generation. diff --git a/src/test/scala/chaincash/BasisSpec.scala b/src/test/scala/chaincash/BasisSpec.scala index 3b845b0..f049afa 100644 --- a/src/test/scala/chaincash/BasisSpec.scala +++ b/src/test/scala/chaincash/BasisSpec.scala @@ -1707,7 +1707,7 @@ class BasisSpec extends PropSpec with Matchers with ScalaCheckDrivenPropertyChec require(Files.exists(contractPath), s"Missing contract file: $contractPath (user.dir=${sys.props("user.dir")})") val fileBytes = Files.readAllBytes(contractPath) val fileText = new String(fileBytes, StandardCharsets.UTF_8) - // Constants.readContract normalizes to \n via getLines.mkString("\n") + // The Constants source loader normalizes to \n via getLines.mkString("\n") Constants.basisContract shouldEqual fileText.replace("\r\n", "\n").stripSuffix("\n") } diff --git a/src/test/scala/chaincash/HistoricalContractFixtures.scala b/src/test/scala/chaincash/HistoricalContractFixtures.scala index 18fa0a1..5b059cd 100644 --- a/src/test/scala/chaincash/HistoricalContractFixtures.scala +++ b/src/test/scala/chaincash/HistoricalContractFixtures.scala @@ -15,16 +15,27 @@ import java.security.MessageDigest */ private[chaincash] object HistoricalContractFixtures { - val expectedSha256: Map[String, String] = Map( - "onchain/reserve.es" -> "14ac7744339c70f6d1252948aa85ce9684be1b99b32faae9993d6387e19f724e", - "onchain/receipt.es" -> "c35f0aa6bbd9e272f11cabbab055c96fd47bfee2fb4d0b159f5ec18f0d131509", - "onchain/note.es" -> "eeb985b7cd06b99b62b08fdb540c227656984c2de9ff29a4ef31804805b26c56", - "layer2-old/reserve.es" -> "19b513ce118aa536fd95e13c7b41231caeda74c5a3c51d137ccbdf4093d4de8a", - "layer2-old/redemption.es" -> "e6aa669a19b32e365ea7f980bfb97798c21e496eb889aeebb8dd88f27a30a930", - "layer2-old/redproducer.es" -> "9c86583f3a3987ecdbe39f7ac2780c13d3c69c79414b3d5f6ff5a45fe3468a61", - "layer2-old/note.es" -> "4a7a2e7cb806f0311275c6e41cb8c27b53f180d8511900f6ec09abf68a51319f" + val expectedCanonicalLfSha256: Map[String, String] = Map( + "onchain/reserve.es" -> "3f39c13879748230cd08ee10d9970370506509e966229b735af77486843fa335", + "onchain/receipt.es" -> "69c24be8426e7c7c8fdf77ab248e4549b8cc9832122aed010a54e304357869ea", + "onchain/note.es" -> "5a145dfa334efa60a26fe25ed136611992f207807ecb628c4c8ec196d940df16", + "layer2-old/reserve.es" -> "026c29b169f39f75a262590388364aea926a270c6fa78511199c1ae1512336e4", + "layer2-old/redemption.es" -> "b87614ec6aacbed34a0bf7950629d505cc6f204d1b4e7db52c5b43a133e122c6", + "layer2-old/redproducer.es" -> "9e9842716a49d631bc67ecf201da42c020d441e3329b9fd7778ea14c9dcc8351", + "layer2-old/note.es" -> "797b69c823ff392d5a941cbd02b9297341c5926c2ce721ab6735f71f3c181e1b" ) + private[chaincash] def canonicalLfBytes(bytes: Array[Byte]): Array[Byte] = + new String(bytes, StandardCharsets.UTF_8) + .replace("\r\n", "\n") + .getBytes(StandardCharsets.UTF_8) + + private[chaincash] def canonicalLfSha256(bytes: Array[Byte]): String = + MessageDigest.getInstance("SHA-256") + .digest(canonicalLfBytes(bytes)) + .map(byte => f"${byte & 0xff}%02x") + .mkString + def archivedSource(relativePath: String): String = { val resourcePath = s"/contracts/historical/$relativePath" val stream = Option(getClass.getResourceAsStream(resourcePath)) @@ -42,17 +53,15 @@ private[chaincash] object HistoricalContractFixtures { stream.close() } - val expected = expectedSha256.getOrElse( + val expected = expectedCanonicalLfSha256.getOrElse( relativePath, throw new IllegalArgumentException(s"Unregistered historical fixture: $relativePath") ) - val actual = MessageDigest.getInstance("SHA-256").digest(bytes) - .map(byte => f"${byte & 0xff}%02x") - .mkString + val canonicalBytes = canonicalLfBytes(bytes) + val actual = canonicalLfSha256(bytes) require(actual == expected, s"Historical fixture digest mismatch for $relativePath") - new String(bytes, StandardCharsets.UTF_8) - .replace("\r\n", "\n") + new String(canonicalBytes, StandardCharsets.UTF_8) .stripSuffix("\n") } diff --git a/src/test/scala/chaincash/LegacyContractRetirementSpec.scala b/src/test/scala/chaincash/LegacyContractRetirementSpec.scala index eabb906..41dcde2 100644 --- a/src/test/scala/chaincash/LegacyContractRetirementSpec.scala +++ b/src/test/scala/chaincash/LegacyContractRetirementSpec.scala @@ -71,11 +71,22 @@ class LegacyContractRetirementSpec extends PropSpec with Matchers { output should not include "Reserve contract address" } - property("historical contract sources retain their pinned upstream bytes") { - HistoricalContractFixtures.expectedSha256.keys.foreach { path => + property("historical contract sources retain their pinned canonical-LF bytes") { + HistoricalContractFixtures.expectedCanonicalLfSha256.keys.foreach { path => withClue(path) { HistoricalContractFixtures.archivedSource(path) should not be empty } } } + + property("historical source identity is equal for LF and CRLF checkouts") { + val lf = "first line\nsecond line\n".getBytes(StandardCharsets.UTF_8) + val crlf = "first line\r\nsecond line\r\n".getBytes(StandardCharsets.UTF_8) + val changed = "first line\nchanged line\n".getBytes(StandardCharsets.UTF_8) + + HistoricalContractFixtures.canonicalLfSha256(crlf) shouldEqual + HistoricalContractFixtures.canonicalLfSha256(lf) + HistoricalContractFixtures.canonicalLfSha256(changed) should not equal + HistoricalContractFixtures.canonicalLfSha256(lf) + } } From fd95832119c94fac90db2c1b8b89dd5f82e526b7 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:27:51 +0200 Subject: [PATCH 10/11] Complete legacy API retirement checks --- src/main/scala/chaincash/contracts/Constants.scala | 2 +- src/test/scala/chaincash/LegacyContractRetirementSpec.scala | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/scala/chaincash/contracts/Constants.scala b/src/main/scala/chaincash/contracts/Constants.scala index 98f25db..4261b7f 100644 --- a/src/main/scala/chaincash/contracts/Constants.scala +++ b/src/main/scala/chaincash/contracts/Constants.scala @@ -20,7 +20,7 @@ object Constants { def getAddressFromErgoTree(ergoTree: ErgoTree) = ergoAddressEncoder.fromProposition(ergoTree).get - private def substitute(contract: String, substitutionMap: Map[String, String] = Map.empty): String = { + def substitute(contract: String, substitutionMap: Map[String, String] = Map.empty): String = { substitutionMap.foldLeft(contract){case (c, (k,v)) => c.replace("$"+k, v) } diff --git a/src/test/scala/chaincash/LegacyContractRetirementSpec.scala b/src/test/scala/chaincash/LegacyContractRetirementSpec.scala index 41dcde2..44e08d9 100644 --- a/src/test/scala/chaincash/LegacyContractRetirementSpec.scala +++ b/src/test/scala/chaincash/LegacyContractRetirementSpec.scala @@ -16,9 +16,13 @@ class LegacyContractRetirementSpec extends PropSpec with Matchers { val retiredGetters = Set( "readContract", "reserveContract", + "reserveContractHash", + "reserveContractHashString", "reserveErgoTree", "reserveAddress", "receiptContract", + "receiptContractHash", + "receiptContractHashString", "receiptErgoTree", "receiptAddress", "noteContract", From 4343361f70ce82327b8b13672f011fa4025f8566 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:48:13 +0200 Subject: [PATCH 11/11] docs: refresh Basis v2 review closure --- contracts/offchain/basis-v2-review.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/offchain/basis-v2-review.md b/contracts/offchain/basis-v2-review.md index 016ba41..1b8d02d 100644 --- a/contracts/offchain/basis-v2-review.md +++ b/contracts/offchain/basis-v2-review.md @@ -57,7 +57,7 @@ three independent transactions; it does not use one compound malformed box. ## Test closure -`sbt -batch "testOnly chaincash.BasisV2Spec"` currently executes 21 tests. They +`sbt -batch "testOnly chaincash.BasisV2Spec"` currently executes 22 tests. They cover normal and emergency redemption, domain separation, proof omission and replay, non-identity group boundaries, tracker shape and debt admission, R5 progression, exact payouts, ERG/token conservation, partial settlement, @@ -74,8 +74,8 @@ sbt -batch "testOnly chaincash.BasisSpec chaincash.BasisTokenSpec" | Dimension | Status | | --- | --- | -| Implementation | matrix-covered by local source and 21 focused mockchain tests | -| Independent review | pending on the exact candidate commit | +| Implementation | matrix-covered by local source and 22 focused mockchain tests | +| Independent review | passed locally for the exact contract/golden candidate and the claim-key vector delta | | CI | not run | | Target node | not run; no live or broadcast action authorized | | Readiness | local draft for maintainer review |