Skip to content

Add public single-signer AccountBlock-v1 unsigned preparation, canonical hashing, and verified signature attachment #31

Description

@edgepillar

Add public single-signer AccountBlock-v1 unsigned preparation, canonical hashing, and verified signature attachment

Status

Maintainer-aligned implementation plan. Work may proceed from the current main after #34 as three independently reviewable and shippable phases. At plan acceptance, main was 00a37f824e9eb3376c6063e489b6e412d42c2018.

This is additive work. Existing prepareBlock() and send() behavior must remain unchanged. This issue does not add publication, signer callbacks, wallet lifecycle management, cancellation, Dynamic Plasma, or a redesign of #27 multisig.

This body incorporates the three-phase implementation plan and the resolved contract decisions.

Resolved contract decisions

  • Signature verification must match the pinned Go verifier exactly.
  • Commit every pinned Go vector as a literal test fixture. Crypto.verify ships only if it matches every vector. Noble { zip215: false } is not assumed to be equivalent; any divergence must be reviewed explicitly.
  • Use ZnnAccountBlockException, ZnnAccountBlockExceptionCode, and ZnnAccountBlockExceptionStage, following the SDK's Znn*Exception naming.
  • The public method is zenon.prepareUnsigned(template, payer).
  • The current getTxHash result is a legacy regression vector. A separately and independently sourced canonical vector is also required, and both must pass.
  • PR feat: add mutable protocol-level multisig account support #27 remains separate. Do not change or replace its multisig API; its maintainer will update it after this work lands.

Pinned protocol evidence

  • Chain account-block serialization and verification source: zenon-network/go-zenon@667a69d9e9a418edf7580b08492ba5dcb9efd63a.
  • Go verifier implementation: Go 1.24.1, source commit 339c903a75c3fe936fb4ed6c355d15e6081d6af3.
  • Differential corpus: filippo.io/mostly-harmless/ed25519vectors@v0.0.0-20210322192420-30a2d7243a94, repository commit 30a2d7243a949c5ec04d5026956319569200a40f, 768-vector JSON SHA-256 ed3dd0730ec5ef2dbfd629186fbefeb6299cbbf9a6ed94af0478f07291acacca.
  • The pinned Go expectation accepts 198 vectors and rejects 570 vectors. The literal fixture must preserve source IDs, inputs, flags, and both accepted and rejected outcomes.
  • Ordinary-valid corpus: Go's 128-vector sign.input.gz, SHA-256 ae02ad0b05f2aa54bbc1a90cd87d1733932caa298a243e69d6623f27d1251d5c, plus the official S >= L rejection case.

The fixture is read-only test evidence: no update, capture, regenerate, rewrite, or bless path may derive expected values from the candidate implementation.

Intended public surface

The completed work adds:

  • computeAccountBlockHash
  • computeAccountBlockHashBytes
  • AccountBlockPayerIdentity
  • PreparedUnsignedAccountBlock
  • verifyAccountBlockSignature
  • attachAccountBlockSignature
  • ZnnAccountBlockException
  • ZnnSDKException at the package root
  • Zenon.prepareUnsigned(template, payer)

Internal helpers such as prepareUnsigned, getTxHash, normalizeAccountBlockAmount, prepareUnsignedTemplate, cloneAccountBlockTemplate, bytesEqual, and createPreparedUnsignedAccountBlock are not exported from the package root.

Phase 1 — exception taxonomy, hash module, and the block.ts seam

Part A — hash module

Files:

  • test/utilities/accountBlockHash.spec.ts (new)
  • src/utilities/errors.ts
  • src/utilities/accountBlockHash.ts (new)
  • src/utilities/block.ts

Work:

  • Before moving production code, pin a fully specified current-HEAD getTxHash result as a legacy regression vector.
  • Add a separately sourced canonical AccountBlock-v1 hash vector. It must not be generated by the candidate implementation.
  • Add ZnnAccountBlockExceptionStage, ZnnAccountBlockExceptionCode, and ZnnAccountBlockException below ZnnBlockUtilitiesException.
  • Move getTxHash verbatim from block.ts into the client-free leaf module accountBlockHash.ts.
  • Add AccountBlockHashInput, normalizeAccountBlockAmount, module-private input validation, computeAccountBlockHashBytes, and computeAccountBlockHash.
  • Import getTxHash into block.ts and re-export it there for existing internal consumers. Do not export it from the package root.
  • Keep the canonical serializer in exactly one implementation.

Acceptance:

  • Both the legacy regression vector and independent canonical vector pass before and after the move.
  • test/utilities/block.spec.ts passes without assertion changes.
  • computeAccountBlockHash agrees with getTxHash for well-formed input.
  • Validation failures use the new exception taxonomy and the expected code/stage pairs.
  • The amount parity matrix uses 9007199254740994, the exactly representable Number immediately above 2^53; do not use 2^53 + 1.
  • Returned hash bytes are detached from live Hash.core storage.
  • accountBlockHash.ts has no value dependency on pow/pow.js or zenon.js.
  • Hash field order, widths, encodings, SHA3-256 use, excluded fields, and zero-PoW nonce bytes remain byte-for-byte unchanged.

Part B — unsigned preparation seam

Work:

  • Change checkAndSetFields to accept address: Address and publicKey: Buffer instead of a KeyPair.
  • Add internal prepareUnsignedTemplate, composed from checkAndSetFields and setDifficulty.
  • Keep prepareBlock behavior by calling prepareUnsignedTemplate with the current key pair's address/public key and then calling the existing hash/signature stage.
  • Leave setDifficulty, setHashAndSignature, getTxSignature, getPoWData, autofillTxParameters, send, isSendBlock, and isReceiveBlock behavior unchanged.

Acceptance:

  • Field mutation order, RPC order, zero-PoW nonce, fused Plasma selection, error messages, mutate-in-place behavior, returned object identity, hashing, and signing remain unchanged.
  • Part A and Part B are separate commits within the Phase 1 PR so either can be reverted independently.

Phase 2 — Go-compatible Crypto.verify and Base64 round trips

Files:

  • src/crypto/crypto.ts
  • src/model/nom/accountBlock.ts
  • test/crypto/crypto.spec.ts
  • test/model/nom/accountBlock.spec.ts (new if absent)
  • a committed versioned literal Go-verifier fixture under test/fixtures/

Work:

  • Add Crypto.verify(signature, message, publicKey) and initialize SHA-512 before verification.
  • Preserve exact pinned Go-verifier behavior. The implementation must pass every committed literal Go vector; a Noble option alone is not acceptance evidence.
  • If any pinned vector diverges, stop and review the behavior instead of silently hardening or weakening it.
  • Decode publicKey and signature as Base64 in both account-block fromJson implementations, preserving the empty-buffer fallback.
  • Add valid-signature, tampered-message, wrong-key, first-call initialization, and full pinned-vector tests.
  • Add toJson() → fromJson() byte-equality tests for both AccountBlockTemplate and AccountBlock.

Acceptance:

  • Every pinned Go vector matches.
  • Ordinary valid signatures verify; tampered messages and wrong keys do not.
  • Verification works as the first Crypto call in a fresh process.
  • Both account-block types round-trip the original public-key and signature bytes.
  • The Base64 behavior change is called out explicitly in the commit body.
  • The crypto and Base64 edits remain separately revertible commits.

Phase 3 — offline surface and public API

Part A — offline surface

Files:

  • src/utilities/accountBlockSigning.ts (new)
  • test/utilities/accountBlockSigning.spec.ts (new)

Work:

  • Add bytesEqual, AccountBlockPayerIdentity, PreparedUnsignedAccountBlock, createPreparedUnsignedAccountBlock, cloneAccountBlockTemplate, verifyAccountBlockSignature, and attachAccountBlockSignature.
  • Copy byte inputs and outputs defensively.
  • Make signature verification a total boolean predicate: malformed inputs return false rather than throwing.
  • Recompute and verify the AccountBlock hash before accepting or attaching a signature.
  • Preserve the single-signature/multisig boundary without changing feat: add mutable protocol-level multisig account support #27's public surface.

Acceptance:

  • Clones preserve the complete key set and JSON value while removing mutable aliases.
  • Identity and prepared-handle accessors return detached values.
  • Signature-buffer mutation and key cleanup cannot alter a prepared handle.
  • Attachment uses ZnnAccountBlockException with the defined code/stage pairs and never leaks a bare parser or encoding error.
  • Hash, public key, address, and signature bindings are independently verified.

Part B — online orchestration and exports

Files:

  • src/utilities/unsignedBlock.ts (new)
  • src/zenon.ts
  • src/index.ts
  • test/utilities/unsignedBlock.spec.ts (new)

Work:

  • Add internal prepareUnsigned(zenonInstance, template, payer).
  • Validate address, recipient, and token-standard core lengths before any toString() comparison so malformed cores cannot escape as bare bech32 errors.
  • Add the public two-argument Zenon.prepareUnsigned(template, payer) method.
  • Export the public classes, hash helpers, verification/attachment functions, ZnnAccountBlockException, and ZnnSDKException from the package root.
  • Do not root-export internal orchestration or cloning helpers.

Acceptance:

  • The caller's input template is not mutated.
  • The prepared handle stores detached data and normalizes amount to bigint.
  • Zero-PoW preparation retains the canonical zero nonce behavior.
  • prepareUnsigned plus verified attachment produces the same hash and signature as prepareBlock for equivalent send and receive inputs.
  • verifyAccountBlockSignature never throws for malformed input.
  • Package-root ESM, declarations, packed consumers, and browser imports expose the intended public surface only.
  • Part A and Part B are separate commits within the Phase 3 PR.

Validation and delivery

Each phase is a separate, independently reviewable Draft PR and must pass:

  • targeted tests for the phase;
  • the complete test suite;
  • lint;
  • coverage;
  • the full build;
  • package-root and packed-consumer export checks.

Phase 1 lands before Phase 3. Phase 2 is independent of Phase 1 and may proceed separately, but Phase 3 requires both. Keep every PR limited to its phase.

Non-goals

  • changing existing prepareBlock() or send() semantics;
  • transaction publication;
  • signer callbacks or hardware-wallet lifecycle;
  • cancellation or timeout APIs;
  • Dynamic Plasma;
  • multisig redesign or changes to PR feat: add mutable protocol-level multisig account support #27's public API;
  • consumer deep imports or copied private serializer logic;
  • placeholder signing, double preparation, or production dual execution.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions