Guides

Guides · Part 4 of 5

Build a sealed PoE

A plain PoE proves that some content existed. A sealed PoE proves the same thing while keeping the content itself secret: you encrypt the bytes to one or more recipient keys, store only the ciphertext, and anchor the record on-chain. Anyone can see that the record exists and verify its structure; only a holder of a matching private key can decrypt the payload. See Sealed PoE for the envelope format and Sealed until claimed for the threat model.

Address your recipients

A recipient is identified by an age-style string. There are two kinds, and the prefix tells them apart:

  • age1… — a classical X25519 key (32 bytes).
  • age1pqc… — an X-Wing hybrid key (ML-KEM-768 + X25519, 1216 bytes).

X-Wing (mlkem768x25519) is the default KEM — it stays secure against a future quantum adversary, and every identity always has an age1pqc… address.

A recipient hands you their string out of band. You decode it back to the raw public key the sealing helper needs with parseAgeRecipient:

import { parseAgeRecipient } from '@cardanowall/sdk-ts';

const them = parseAgeRecipient('age1pqc…'); // { kem: 'mlkem768x25519', publicKey: Uint8Array }

If you hold a 32-byte seed yourself, recipientsFromSeed gives you both of your own addresses — share one so others can seal to you, and include your own key in the recipient list to keep read access to what you send:

import { recipientsFromSeed } from '@cardanowall/sdk-ts';

const me = recipientsFromSeed(mySeed); // { age: 'age1…', age1pqc: 'age1pqc…' }

Seal and publish

Sealing goes through a gateway, which builds and broadcasts the Cardano transaction and stores the ciphertext for you. Point the client at the gateway you use; the SDK is gateway-agnostic.

publishSealed takes raw recipient public keys, so collect the publicKey from each parsed address. All recipients must share one KEM — keep age1pqc… keys together. First lock a price with quote, then publish:

import { Label309Client, parseAgeRecipient } from '@cardanowall/sdk-ts';

const client = new Label309Client({
  baseUrl: 'https://your-gateway.example',
  apiKey: process.env.CW_API_KEY,
});

const content = new TextEncoder().encode('the secret payload');
const recipients = ['age1pqc…recipient', me.age1pqc].map((r) => parseAgeRecipient(r).publicKey);

const quote = await client.poe.quote({
  recordBytes: 512,
  recipientCount: recipients.length,
  fileBytesTotal: content.length,
});

const result = await client.poe.publishSealed({
  content,
  recipients,
  quoteId: quote.quote_id,
  // kem defaults to 'mlkem768x25519' (X-Wing); pass 'x25519' only for age1… keys.
});

console.log(result.tx_hash);

The helper encrypts the content, uploads the ciphertext, binds its ar:// URI and the plaintext hash into a Label 309 record, and submits it. Your seed and the plaintext never leave your machine in the clear.

With Python

cardanowall-sdk is a byte-for-byte twin — same KEM default, same envelope:

import asyncio
import os
from cardanowall import Label309Client, parse_age_recipient

async def main():
    content = b"the secret payload"
    recipients = [parse_age_recipient("age1pqc…recipient").public_key]

    async with Label309Client(
        base_url="https://your-gateway.example",
        api_key=os.environ["CW_API_KEY"],
    ) as client:
        quote = await client.poe.quote(
            record_bytes=512, recipient_count=len(recipients), file_bytes_total=len(content)
        )
        result = await client.poe.publish_sealed(
            content=content, recipients=recipients, quote_id=quote["quote_id"]
        )
        print(result["tx_hash"])

asyncio.run(main())

With Rust

The cardanowall crate seals through the same gateway client. parse_age_recipient decodes each address to the raw key, and kem: None keeps the X-Wing default:

use cardanowall::client::{
    Label309Client, Label309ClientConfig, PublishSealedInput, QuoteInput,
};
use cardanowall::recipient::parse_age_recipient;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Label309Client::new(Label309ClientConfig {
        base_url: Some("https://your-gateway.example".into()),
        api_key: std::env::var("CW_API_KEY").ok(),
    })?;

    let content = b"the secret payload".to_vec();
    let recipients = vec![parse_age_recipient("age1pqc…recipient")?.public_key];

    let quote = client.poe().quote(&QuoteInput {
        record_bytes: 512,
        recipient_count: recipients.len() as u64,
        file_bytes_total: content.len() as u64,
    })?;

    let result = client.poe().publish_sealed(&PublishSealedInput {
        content,
        recipients,
        quote_id: quote.quote_id,
        hash_alg: None,
        kem: None, // defaults to mlkem768x25519 (X-Wing); set Some for x25519
        signer: None,
        idempotency_key: None,
    })?;

    println!("{:?}", result.tx_hash);
    Ok(())
}

The CLI publishes hash-only and Merkle records; sealing is an SDK flow today.

Once the record settles, each recipient discovers it, decrypts the payload with their private key, and recomputes the plaintext hash to close the loop — that's the recipient half of Verify a record.

Seal to yourself too

publishSealed never adds you to the recipient list silently. If you don't include one of your own keys, you publish a record you can never read back. Include me.age1pqc (or me.age) among the recipients whenever you want to keep access to what you sent.