Waivio

Moving Hivevoice invoices to custom JSON with multiple operations

0 comments

beggars869.816 hours agoPeakD6 min read

The updates don't stop coming. I just shipped a change that moves Hivevoice off of blog posts for invoice storage and onto custom JSON operations. The short version is that invoices are now written as a set of small, structured custom_json records that are encrypted and authenticated. This keeps profile feeds clean, reduces blockchain bloat, and makes it easier to query and verify invoice data from an indexer.

Shoutout to @tibfox for proposing the idea to use custom JSON and do it in a way that it would work around the size limitations of custom JSON operation payloads. This reduces a lot of noise.

https://files.peakd.com/file/peakd-hive/beggars/23xAcSZtSKKFRCRSeP8twbjxEE2vzVrbuYtBz8ifzCpUEgKkSBangDtEgCB7KSM9JD2MV.jpeg

What changed

Previously I stored encrypted invoice blobs inside a post body with a simple marker. It worked, but it polluted feeds and created empty comment entities that were not ideal for an app that is not social content. I now write invoices as a sequence of custom_json operations:

  • One header operation for the invoice metadata
  • One line item operation per item
  • Optional status update operations when payments change the state

Each operation is signed with posting authority from the invoice creator, and all sensitive content is encrypted with memo style ECDH so only the creator and recipient can decrypt.

The model at a glance

  • Header op

    • id: hivevoice_invoice_v2
    • action: create_invoice
    • encrypted_data: encrypted header payload
    • metadata: a small, indexable set of details like currency, totals, item count, and timestamps
    • creator and created_timestamp included for authentication and simple replay protection
  • Line item ops

    • id: hivevoice_item_v2
    • action: create_invoice_item
    • one op per item with a sequence number for ordering
    • encrypted_data contains the encrypted item
  • Status update ops

    • id: hivevoice_status_v2
    • action: update_invoice_status
    • records transitions like pending, partial, paid, with optional payment details

Why this reduces noise and spam

  • custom_json operations do not show up in social feeds, so there is no invoice spam on profiles
  • I avoid creating empty comments just to hold encrypted blobs
  • Indexers can filter by the operation id, which is cheap and precise
  • Splitting header and items keeps payload sizes small and predictable, and reduces the chance of hitting limits

How I encrypt

I use memo style ECDH to derive a shared secret between the server’s memo private key and the recipient’s memo public key. That secret encrypts structured JSON. On chain I only store ciphertext and minimal public metadata. Both the creator and recipient can decrypt with their memo keys.

Example header broadcast

This is a compact version of the header op, using @hiveio/dhive and a posting key.

const privateKey = PrivateKey.fromString(POSTING_KEY)

const headerOp = [
  'custom_json',
  {
    required_auths: [],
    required_posting_auths: [username],
    id: 'hivevoice_invoice_v2',
    json: JSON.stringify({
      action: 'create_invoice',
      invoice_id: invoice.id,
      invoice_number: invoice.invoiceNumber,
      version: '2.0',
      encrypted_data: encryptedHeaderPayload,
      creator: username,
      created_timestamp: Date.now(),
      metadata: {
        client_address: recipient.replace('@', ''),
        status: invoice.status,
        currency: invoice.currency,
        total: invoice.total.toString(),
        created: invoice.createdAt.toISOString(),
        due: invoice.dueDate.toISOString(),
        items_count: invoice.items.length
      }
    })
  }
] as const

await client.broadcast.sendOperations([headerOp], privateKey)

Line item broadcast

I post one op per item, with a sequence number for ordering and reconstruction.

const itemOp = [
  'custom_json',
  {
    required_auths: [],
    required_posting_auths: [username],
    id: 'hivevoice_item_v2',
    json: JSON.stringify({
      action: 'create_invoice_item',
      invoice_id: invoice.id,
      item_id: item.id,
      sequence: index + 1,
      encrypted_data: encryptedItemPayload,
      creator: username,
      created_timestamp: Date.now()
    })
  }
] as const

await client.broadcast.sendOperations([itemOp], privateKey)

Status update broadcast

Status updates live as their own custom JSON operations, so an indexer can rebuild state even if a cache is wiped.

const statusOp = [
  'custom_json',
  {
    required_auths: [],
    required_posting_auths: [username],
    id: 'hivevoice_status_v2',
    json: JSON.stringify({
      action: 'update_invoice_status',
      invoice_id,
      new_status: 'paid',
      creator: username,
      created_timestamp: Date.now(),
      payment_details: paymentDetails,
      version: '2.0'
    })
  }
] as const

await client.broadcast.sendOperations([statusOp], privateKey)

Authentication and verification

I rely on the chain to validate signatures, and I also verify authorship when reading:

  • Only accept ops whose required_posting_auths contains the claimed creator
  • For items I require the same creator as the header
  • I enforce a simple rule that header is discovered first, then items must match the header’s creator
  • I use a timestamp to help detect obvious replay attempts

Example verification checks while scanning account history:

if (op.id === 'hivevoice_invoice_v2') {
  const data = JSON.parse(op.json)
  if (
    data.action === 'create_invoice' &&
    op.required_posting_auths.length === 1 &&
    op.required_posting_auths[0] === data.creator
  ) {
    headerPayload = data.encrypted_data
    invoiceCreator = data.creator
  }
}

if (op.id === 'hivevoice_item_v2' && invoiceCreator) {
  const data = JSON.parse(op.json)
  if (
    data.action === 'create_invoice_item' &&
    data.creator === invoiceCreator &&
    op.required_posting_auths.length === 1 &&
    op.required_posting_auths[0] === invoiceCreator
  ) {
    items.push({ seq: data.sequence, payload: data.encrypted_data })
  }
}

Reconstruction and caching

On read, I scan the account history and collect the authenticated header and item payloads. I sort items by sequence, then return the encrypted payloads to the API layer. The API caches a concatenated form in SQLite using a simple delimiter, which speeds up future reads. If the cache is ever lost, I can reconstruct fully from the blockchain.

Backward compatibility

Legacy invoices created as posts are still supported. If I do not find custom JSON data for an invoice, I fall back to the post method and extract the encrypted payload from the body. Decryption logic handles both formats. This means you can upgrade and still serve old invoices without any migration.

A note on keys

  • Posting key signs all custom JSON operations
  • Active key is still used for HIVE or HBD transfers
  • Memo private key is required for encryption and decryption
  • I validate configuration early and raise clear errors for missing or invalid keys

Operational benefits

  • Cleaner profiles and communities, since invoices are not published as blog posts
  • Smaller, structured writes that are easy to filter and index
  • Better scalability, since each line item is its own operation
  • Clear audit trail, since status changes are on chain as their own operations
  • Recovery is simple, since the blockchain is the source of truth and the database is just a cache

What is next

Next up I plan to add richer indexing tools and a small schema version bump for extensible payment metadata. If you run an indexer, you only need to filter on ids that start with hivevoice_ and apply the simple creator check shown above.

If you want to see more code or need helpers for your own Hive app that uses custom JSON for structured data, let me know in the comments.

Comments

Sort byBest