Context extension is signed in one order and sent in another
appkit 6.0.0 (sigma-state 6.0.2), node 6.0.3
TL;DR: ScalaBridge.isoSpendingProof.from copies the context extension into a java.util.HashMap,
losing the order it was signed in. The node reparses that order and computes a different
messageToSign, so every real signature on the transaction fails. One-line fix: use a
LinkedHashMap.
Symptom
Signs cleanly. Node rejects:
Malformed transaction: Scripts of all transaction inputs should pass verification.
7d97f228…: #1 => Success((false,403))
Success((false, …)) — the script ran, the proof did not verify.
The failing input is not the one carrying the context variables. A proposition reducing to a
constant true has an empty proof that verifies against any message, so contract inputs pass. Only
inputs with real signatures fail — usually the P2PK funding box. Looks like a prover or key bug.
Root cause
ContextExtension.serializer writes entries in map iteration order and does not sort
(sigma/interpreter/ContextExtension.scala):
w.putUByte(obj.values.size)
obj.values.foreach { case (id, v) => w.put(id).putValue(v) }
That extension is part of the signed message: AppkitProvingInterpreter.signReduced calls
proveReduced(reducedInput, unsignedTx.messageToSign), and UnsignedTransactionBuilderImpl.build
rebuilds the tx specifically so extensions are included in it.
Three maps hold the extension between signing and verification. They disagree:
| Stage |
Where |
Structure |
Order |
| signing |
JavaHelpers.isoContextVarsToContextExtension.to |
Scala Map via values += (id -> …) |
insertion (≤ 4 entries) |
| sending |
ScalaBridge.isoSpendingProof.from |
util.HashMap[String, String], keys varId.toString |
String hash |
| verifying |
JsonCodecs.contextExtensionDecoder → cursor.as[Map[Byte, EvaluatedValue[SType]]] |
circe folds the object |
JSON document |
The offending conversion:
override def from(proverResult: ProverResult): SpendingProof = {
val vars = proverResult.extension.values
val extension = new util.HashMap[String, String](vars.size) // <-- drops the signed order
vars.foreach { case (varId, value) =>
extension.put(varId.toString, ErgoAlgos.encode(ValueSerializer.serialize(value)))
}
...
}
Keys are hashed as decimal strings, so the resulting order relates to neither the numeric ids nor
the attachment order.
Repro
No node, no transaction — this is the whole bug:
val ids: Seq[Byte] = Seq(0, 64, 65, 2)
var signing = Map.empty[Byte, String] // appkit signs this order
ids.foreach(id => signing += (id -> s"v$id"))
val json = new java.util.HashMap[String, String](ids.size) // appkit sends this order
ids.foreach(id => json.put(id.toString, s"v$id"))
var node = Map.empty[Byte, String] // node folds the JSON in doc order
json.keySet().asScala.foreach(k => node += (k.toByte -> s"v$k"))
assert(signing.toSeq == node.toSeq) // fails: [0,64,65,2] vs [0,2,64,65]
On a real transaction (4 vars on input 0, 2,674-byte message):
as signed (Scala Map) ids = [0, 64, 65, 2]
as sent (Java HashMap) ids = [0, 2, 64, 65]
extension bytes identical : false
messageToSign identical : false
Same length, permuted content.
Affected range: exactly 2–4 context vars per input
| Vars |
Safe? |
Why |
| 1 |
yes |
no order to get wrong |
| 2–4 |
no |
Scala uses insertion-ordered Map2/3/4; wire order is String hash order |
| 5+ |
yes |
Scala switches to hash-ordered HashMap; both sides key identically and converge |
Context extension is signed in one order and sent in another
appkit 6.0.0 (sigma-state 6.0.2), node 6.0.3
TL;DR:
ScalaBridge.isoSpendingProof.fromcopies the context extension into ajava.util.HashMap,losing the order it was signed in. The node reparses that order and computes a different
messageToSign, so every real signature on the transaction fails. One-line fix: use aLinkedHashMap.Symptom
Signs cleanly. Node rejects:
Success((false, …))— the script ran, the proof did not verify.The failing input is not the one carrying the context variables. A proposition reducing to a
constant
truehas an empty proof that verifies against any message, so contract inputs pass. Onlyinputs with real signatures fail — usually the P2PK funding box. Looks like a prover or key bug.
Root cause
ContextExtension.serializerwrites entries in map iteration order and does not sort(
sigma/interpreter/ContextExtension.scala):w.putUByte(obj.values.size) obj.values.foreach { case (id, v) => w.put(id).putValue(v) }That extension is part of the signed message:
AppkitProvingInterpreter.signReducedcallsproveReduced(reducedInput, unsignedTx.messageToSign), andUnsignedTransactionBuilderImpl.buildrebuilds the tx specifically so extensions are included in it.
Three maps hold the extension between signing and verification. They disagree:
JavaHelpers.isoContextVarsToContextExtension.toMapviavalues += (id -> …)ScalaBridge.isoSpendingProof.fromutil.HashMap[String, String], keysvarId.toStringStringhashJsonCodecs.contextExtensionDecoder→cursor.as[Map[Byte, EvaluatedValue[SType]]]The offending conversion:
Keys are hashed as decimal strings, so the resulting order relates to neither the numeric ids nor
the attachment order.
Repro
No node, no transaction — this is the whole bug:
On a real transaction (4 vars on input 0, 2,674-byte message):
Same length, permuted content.
Affected range: exactly 2–4 context vars per input
Map2/3/4; wire order isStringhash orderHashMap; both sides key identically and converge