This CCIP defines basic variant of ChainCash contracts, where on-chain reserves contain ERGs only, notes exist and do progress on-chain as well.
A basic ChainCash contracts solution described in this CCIP consists of three contracts:
- note contract
- reserve contract
- receipt contract
Note contract is locking a box containing tokens issued when a new note with blank history is created. Each token denotes a milligram of gold redeemable against a reserve. Using tokens was not strictly necessary for accounting, but that allows for simpler accounting (e.g. checking that new monetary units were not created during note spending is checked by the Ergo protocol). In addition to tokens, some registers are used to encode history and current holder of a note. Namely, register R4 contains a digest of an AVL+ tree, R5 contains public key (encoded elliptic curve point) of the issuer, R6 encodes current length of ownership chain.
A note may be spent or redeemed. When a note spent, a new note or two notes (for payment and change) are created. For the note(s) created, registers must be updated correspondingly, and tokens should be preserved. When a note is redeemed, current note owner is getting paid from one of the reserves in ownership-chain, note box is destroyed, and tokens along with registers contents are transferred to a receipt box protected by receipt contract.
To create a note (issue new money accounted in milligrams of gols), one needs to create a box locked with this contract, R4 containing empty AVL+ tree digest, R5 containing public key (encoded elliptic curve point) of the issuer, R6 equals to 0, and tokens slot #0 contains all the tokens issued (maybe in the same transaction). If any of the conditions not met (any register has another value, some tokens sent to other address or contract), the note should be ignored by ChainCash software.
Receipt contract is protecting tokens for about three years (it is allowed to burn them after), and allowing to re-redeem by holder of reserve participated in redemption against any reserve which owner appeared earlier in ownership-chain.
Reserve contract supports redemption for a note or a receipt, top-up (with at least 1 ERG to be added to the reserve), and refund (complete or partial). Refund must be initiated first, and then it can be completed 14,400 (~20 days) after, or cancelled. This delays allows for note holders to see that the reserve may be depleted and redeem before refund done.
Oracle data box NFT: 3c45f29a5165b030fdb5eaf5d81f8108f9d8f507b31487dd51f4ae08fe07cf4a
Note contract P2S: 6pqBNYx2fpxYcivgVERXo9gMwze6Lj6vj937TQgSo11DQaYfFQGsrfwiygNJoNJzBUKRtAV49V7S1Qb2MYQ9PpLDrqM1nnE89tLwBAzYcPT3Fb2HU1tVhFud63wuvp1MTFhG4U5SrfeWBN1YaDsJx9jsLckSqdbrQBEmoh6dMQkfkwB43xw2Q5KXsXhmTkV1bTWPDzmH3enKUfopbMqumwANKcRtzDggNyu7AiaJCKtCXqFXKA5TuQv9kyVnHsQkRJi1B92zt3vqYTM5X8gseoXq4ZAfh3fxve7qSsHDfdfHWvU6WC1LYhKvKjPT2pAPUedFZ42HYmwyRP2U61yM6wkLmjnyamxWsWtccNr8DZLQ138R6YFr6fxfXikKn3TTcxjYnGzbqzR8YoNT8SzaZtT8DCVG6CeySrM22aCV4hd7tQjUZpVY4TJA3hLowCQUkzdh26o9JZccnnH143tBsyKgUkf1tEKYwQYRYxrKiZ4q5LgoMB8aJXFXoFhMXDCEkHyZ27rSsnGPpz4iCyikA79Hy787NUNrsJ4hQrPjq7DCVLJfN7WwzExaXWaWjKeJvXoQWXM6EkyFfexyRdQ4HuZ
Receipt contract P2S: dW86NzEXDeRHC2ESgUAC4SWcAvSuuUgAeChqanNGgnTi3y251FN7B5t5FAQCMAWfDB24vm5FRLN6Fo2z7ndnq5HeJoEGfcTqbHzsogNVkpnPf6w9m8gqvCPmcyjB32eLfHW9JdLwQFBks5y8QDjLgC2ZRhDNKAVFfTLYTK7
Reserve contract P2S: ciWWzxZWkE2fDoN8ypZQfNehuEegocNwVBttcjuWzCnBNHrbd9Zx6seBHoyGBJ2CmtZFRFoUUJiNwqU3qoKy3P8ZXzkauEVtS7UyXaNW1j7ps33gKdp7aA7otN1iwREg5FburKP8jSqZkhajEaUfYV3BK4XTwWarTgHYoVMg6EzbDzqdBgW8bUQSjPqJGiZ4shwjMTL7EJUMMGhzYs9HiCX4AG4b2Lk53wZjg3eVvr5wV7oYLM2aVGc9LJX23prfndTHdJ4b8yB5Yd1ac6vnE4Z7daFUiZjdeii97adCbwumSP2phTU4ci6E76gdpbtk1cZPyZpxBiZ4EW3yo6LGE5Uvs9QizFLLwRyz3zHVUWBuVPN3xjeeRZmTuTz5EswN1pEY5jWCztciV9L2bPZDrBWTUFBH3eRvrPPRm5xtTd235C3PcfeDgbNvwyvPuKTZaU5Ke6wo1gZ42xjwgomYTCsVktux2qtUYa1k3Leao7v5ksXeGmXLRq2BkuKnwaLuARLvs4E86M5PF392NJMrHc14LhR8PGotAQ9qVrzMiJsZ5wtZjwpu7EqdHQ8dBrSPvbNQRUJ18kDyNorndGviP2DMuwcQEFPTy6tirWKZEmPZhQbxHnsW1orMHD7KRqZXSyWrNjHUK8QWXxpLWaq5nR4Jt1SLP4Z3t496V82VjTJ5bEERSA7UQSbJPgrn7wbNq9HjxVD5AYZP
{
// Note contract
// It has two execution paths:
// spend: full spending or with change
// redeem:
// when redemption, receipt is created which is allowing to do another redemption against earlier reserve
// box data:
//
// registers:
// R4 - history of ownership (under AVL+ tree),
// tree contains reserveId as a key, signature as value,
// and message is note value and token id
// R5 - current holder of the note (public key given as a group element)
// R6 - current length of the chain (as long int)
//
// tokens:
// #0 - token which is denoting notes started from an initial one which has all the tokens of this ID
//
// to create a note (issue new money accounted in milligrams of gols), one needs to create a box locked with this
// contract, R4 containing empty AVL+ tree digest, R5 containing public key (encoded elliptic curve point) of the
// issuer, R6 equals to 0, and tokens slot #0 contains all the tokens issued (maybe in the same transaction). If
// any of the conditions not met (any register has another value, some tokens sent to other address or contract),
// the note should be ignored by ChainCash software
val g: GroupElement = groupGenerator
val history = SELF.R4[AvlTree].get
val action = getVar[Byte](0).get // also encodes note output # in tx outputs
val noteTokenId = SELF.tokens(0)._1
val noteValue = SELF.tokens(0)._2
val reserve = CONTEXT.dataInputs(0)
val reserveId = reserve.tokens(0)._1
val holder = SELF.R5[GroupElement].get
if (action >= 0) {
// spending path
val selfOutput = OUTPUTS(action)
val position = SELF.R6[Long].get
val positionBytes = longToByteArray(position)
val noteValueBytes = longToByteArray(noteValue)
val message = positionBytes ++ noteValueBytes ++ noteTokenId
// Computing challenge
val e: Coll[Byte] = blake2b256(message) // weak Fiat-Shamir
val eInt = byteArrayToBigInt(e) // challenge as big integer
// a of signature in (a, z)
val a = getVar[GroupElement](1).get
val aBytes = a.getEncoded
// z of signature in (a, z)
val zBytes = getVar[Coll[Byte]](2).get
val z = byteArrayToBigInt(zBytes)
// Signature is valid if g^z = a * x^e
val properSignature = g.exp(z) == a.multiply(holder.exp(eInt))
val properReserve = holder == reserve.R4[GroupElement].get
val leafValue = aBytes ++ zBytes
val keyVal = (reserveId, leafValue)
val proof = getVar[Coll[Byte]](3).get
val nextTree: Option[AvlTree] = history.insert(Coll(keyVal), proof)
// This will fail if the operation failed or the proof is incorrect due to calling .get on the Option
val outputDigest: Coll[Byte] = nextTree.get.digest
def nextNoteCorrect(noteOut: Box): Boolean = {
val insertionPerformed = noteOut.R4[AvlTree].get.digest == outputDigest
val sameScript = noteOut.propositionBytes == SELF.propositionBytes
val nextHolderDefined = noteOut.R5[GroupElement].isDefined
val valuePreserved = noteOut.value >= SELF.value
val positionIncreased = noteOut.R6[Long].get == (position + 1)
positionIncreased && insertionPerformed && sameScript && nextHolderDefined && valuePreserved
}
val changeIdx = getVar[Byte](4) // optional index of change output
val outputsValid = if(changeIdx.isDefined) {
val changeOutput = OUTPUTS(changeIdx.get)
// strict equality to prevent moving tokens to other contracts
(selfOutput.tokens(0)._2 + changeOutput.tokens(0)._2) == SELF.tokens(0)._2 &&
nextNoteCorrect(selfOutput) &&
nextNoteCorrect(changeOutput)
} else {
selfOutput.tokens(0) == SELF.tokens(0) && nextNoteCorrect(selfOutput)
}
sigmaProp(properSignature && outputsValid)
} else {
// redemption path
// called by setting action variable to any negative value, -1 considered as standard by offchain apps
// we just check current holder's signature here
// it is checked that note token is locked in receipt in the reserve contract
// we check that the note is spent along with a reserve contract box.
// for that, we fix reserve input position @ #1
// we drop version byte during ergotrees comparison
// signature of note holder is also required
val reserveInputErgoTree = INPUTS(1).propositionBytes
val treeHash = blake2b256(reserveInputErgoTree.slice(1, reserveInputErgoTree.size))
val reserveSpent = treeHash == fromBase58("$reserveContractHash")
// we check receipt contract here, and other fields in reserve contract, see comments in reserve.es
val receiptOutputErgoTree = OUTPUTS(1).propositionBytes
val receiptTreeHash = blake2b256(receiptOutputErgoTree.slice(1, receiptOutputErgoTree.size))
val receiptCreated = receiptTreeHash == fromBase58("$receiptContractHash")
proveDlog(holder) && sigmaProp(reserveSpent && receiptCreated)
}
}
{
// ERG variant
// Data:
// - token #0 - identifying singleton token
// - R4 - signing key (as a group element)
// - R5 - refund init height (Int.MaxValue if not set)
//
// Actions:
// - redeem note (#0)
// - top up (#1)
// - init refund (#2)
// - cancel refund (#3)
// - complete refund (#4)
val ownerKey = SELF.R4[GroupElement].get // used in notes and unlock/lock/refund actions
val selfOut = OUTPUTS(0)
val selfPreserved =
selfOut.propositionBytes == SELF.propositionBytes &&
selfOut.tokens == SELF.tokens &&
selfOut.R4[GroupElement].get == SELF.R4[GroupElement].get
val action = getVar[Byte](0).get
if (action == 0) {
// redeem path
// oracle provides gold price in nanoErg per kg in its R4 register
val g: GroupElement = groupGenerator
val receiptMode = getVar[Boolean](10).get
// read note data, id receiptMode == false, receipt data otherwise
val noteInput = INPUTS(0)
val noteTokenId = noteInput.tokens(0)._1
val noteValue = noteInput.tokens(0)._2 // 1 token == 1 mg of gold
val history = noteInput.R4[AvlTree].get
val reserveId = SELF.tokens(0)._1
val goldOracle = CONTEXT.dataInputs(0)
val properOracle = goldOracle.tokens(0)._1 == fromBase58("2DfY1K4rW9zPVaQgaDp2KXgnErjxKPbbKF5mq1851MJE")
val oracleRate = goldOracle.R4[Long].get / 1000000 // normalize to nanoerg per mg of gold
// 2% redemption fee
val nanoergsToRedeem = noteValue * oracleRate * 98 / 100
val redeemCorrect = (SELF.value - selfOut.value) <= nanoergsToRedeem
val proof = getVar[Coll[Byte]](1).get
val value = history.get(reserveId, proof).get
val aBytes = value.slice(0, 33)
val zBytes = value.slice(33, value.size)
val a = decodePoint(aBytes)
val z = byteArrayToBigInt(zBytes)
val maxValueBytes = getVar[Coll[Byte]](2).get
val position = getVar[Long](3).get
val message = longToByteArray(position) ++ maxValueBytes ++ noteTokenId
val maxValue = byteArrayToLong(maxValueBytes)
// Computing challenge
val e: Coll[Byte] = blake2b256(message) // weak Fiat-Shamir
val eInt = byteArrayToBigInt(e) // challenge as big integer
// Signature is valid if g^z = a * x^e
val properSignature = (g.exp(z) == a.multiply(ownerKey.exp(eInt))) &&
noteValue <= maxValue
// we check that receipt is properly formed, but we do not check receipt's contract here,
// to avoid circular dependency as receipt contract depends on (hash of) our contract,
// thus we are checking receipt contract in note and receipt contracts
val receiptOut = OUTPUTS(1)
val properReceipt =
receiptOut.tokens(0) == noteInput.tokens(0) &&
receiptOut.R4[AvlTree].get == history &&
receiptOut.R5[Long].get == position &&
receiptOut.R6[Int].get >= HEIGHT - 20 && // 20 blocks for inclusion
receiptOut.R6[Int].get <= HEIGHT &&
receiptOut.R7[GroupElement].get == ownerKey
val positionCorrect = if (receiptMode) {
position < noteInput.R5[Long].get
} else {
true
}
sigmaProp(selfPreserved && properOracle && redeemCorrect && properSignature && properReceipt && positionCorrect)
} else if (action == 1) {
// top up
sigmaProp(selfPreserved && (selfOut.value - SELF.value >= 1000000000)) // at least 1 ERG added
} else {
// todo: write tests for refund paths, document them
if (action == 2) {
// init refund
val correctHeight = selfOut.R5[Int].get >= HEIGHT - 5
val correctValue = selfOut.value >= SELF.value
sigmaProp(selfPreserved && correctHeight && correctValue) && proveDlog(ownerKey)
} else if (action == 3) {
// cancel refund
val correctHeight = !(selfOut.R5[Int].isDefined)
val correctValue = selfOut.value >= SELF.value
sigmaProp(selfPreserved && correctHeight && correctValue) && proveDlog(ownerKey)
} else if (action == 4) {
// complete refund
val refundNotificationPeriod = 14400 // 20 days
val correctHeight = (SELF.R5[Int].get + refundNotificationPeriod) >= HEIGHT
sigmaProp(correctHeight) && proveDlog(ownerKey) // todo: check is it ok to check no other conditions
} else {
sigmaProp(false)
}
}
}
{
// receipt contract
// it is possible to spend this box 3 years after, with tokens being necessarily burnt
// registers:
// R4 - AvlTree - history of ownership for corresponding redeemed note
// R5 - Long - redeemed position
// R6 - approx. height when this box was created
// R7 - redeemer PK
def noTokens(b: Box) = b.tokens.size == 0
val noTokensInOutputs = OUTPUTS.forall(noTokens)
val creationHeight = SELF.R6[Int].get
val burnPeriod = 788400 // 3 years
val burnDone = (HEIGHT > creationHeight + burnPeriod) && noTokensInOutputs
// we check that the receipt is spent along with a reserve contract box.
// for that, we fix reserve input position @ #1
// we drop version byte during ergotrees comparison
// signature of receipt holder is also required
val reserveInputErgoTree = INPUTS(1).propositionBytes
val treeHash = blake2b256(reserveInputErgoTree.slice(1, reserveInputErgoTree.size))
val reserveSpent = treeHash == fromBase58("$reserveContractHash")
// we check receipt contract here, and other fields in reserve contract, see comments in reserve.es
val receiptOutputErgoTree = OUTPUTS(1).propositionBytes
val receiptCreated = receiptOutputErgoTree == SELF.propositionBytes
val reRedemption = proveDlog(SELF.R7[GroupElement].get) && sigmaProp(reserveSpent && receiptCreated)
burnDone || reRedemption
}