Skip to content

Inconsistent Context Extension Ordering #256

Description

@K-Singh

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.contextExtensionDecodercursor.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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions