Skip to main content

Overview

The Announcements contract (identipay::announcements) provides a simple but critical function: emitting events that allow recipients to detect stealth payments without scanning the entire blockchain. When a sender pays to a one-time stealth address, they emit an announcement containing:
  • Ephemeral public key R
  • View tag (1-byte filter)
  • Stealth address
  • Optional metadata
Recipients scan these announcements to detect incoming payments, as described in the whitepaper section 4.4.

Source Code

Location: contracts/sources/announcements.move:6

How It Works

1

Payment Derivation

Sender derives a one-time stealth address P using recipient’s meta-address and ephemeral key r.
2

Payment Send

Sender transfers tokens to the stealth address P.
3

Announcement Emission

Sender calls announce() with ephemeral public key R = r*G, view tag, and stealth address.
4

Event Indexing

Off-chain indexers capture the StealthAnnouncement event.
5

Recipient Scanning

Recipient’s wallet scans announcements, filtering by view tag, then checking full ECDH computation.
6

Payment Detection

If the computed address matches, recipient derives the private key and can spend the funds.

Data Structures

StealthAnnouncement

Event emitted when a payment is sent to a stealth address.
ephemeral_pubkey
vector<u8>
Ephemeral public key R = r*G (32 bytes). Recipients use this with their viewing private key to derive the shared secret and detect payments.
view_tag
u8
First byte of the ECDH shared secret. Fast 1-byte filter reduces full ECDH computations by ~256x during scanning.
stealth_address
address
The derived one-time stealth address where the payment was sent.
metadata
vector<u8>
Optional encrypted memo (e.g., payment reason, sender hint). Can be empty.
public struct StealthAnnouncement has copy, drop {
    ephemeral_pubkey: vector<u8>,
    view_tag: u8,
    stealth_address: address,
    metadata: vector<u8>,
}

Entry Functions

announce

Emit a stealth payment announcement. Called by anyone sending to a stealth address. Function Signature:
entry fun announce(
    ephemeral_pubkey: vector<u8>,
    view_tag: u8,
    stealth_address: address,
    metadata: vector<u8>,
    _ctx: &mut TxContext,
)
ephemeral_pubkey
vector<u8>
required
Ephemeral public key (exactly 32 bytes)
view_tag
u8
required
First byte of ECDH shared secret for fast filtering
stealth_address
address
required
Derived one-time stealth address
metadata
vector<u8>
Optional encrypted metadata. Pass empty vector if no metadata.
Errors:
  • EInvalidEphemeralPubkey (0): Ephemeral pubkey is not 32 bytes
Location: announcements.move:38-53

Usage Example

import { TransactionBlock } from '@mysten/sui.js/transactions';
import { deriveStealthAddress, deriveECDH } from '@identipay/crypto';

// Resolve recipient's meta-address
const [spendPubkey, viewPubkey] = await identiPayClient.resolveName(
  recipientName
);

// Generate ephemeral keypair
const ephemeralPrivateKey = randomBytes(32);
const ephemeralPubkey = derivePublicKey(ephemeralPrivateKey);

// Derive stealth address
const stealthAddress = deriveStealthAddress(
  spendPubkey,
  viewPubkey,
  ephemeralPrivateKey
);

// Compute view tag (first byte of shared secret)
const sharedSecret = deriveECDH(ephemeralPrivateKey, viewPubkey);
const viewTag = sharedSecret[0];

// Optional: encrypt metadata
const metadata = encryptMetadata(
  { memo: 'Coffee payment', sender: '@alice' },
  sharedSecret
);

// Build transaction: transfer + announce
const tx = new TransactionBlock();

// 1. Transfer tokens to stealth address
const [coin] = tx.splitCoins(tx.gas, [tx.pure(amount)]);
tx.transferObjects([coin], tx.pure(stealthAddress));

// 2. Emit announcement
tx.moveCall({
  target: `${PACKAGE_ID}::announcements::announce`,
  arguments: [
    tx.pure(Array.from(ephemeralPubkey)),
    tx.pure(viewTag),
    tx.pure(stealthAddress),
    tx.pure(Array.from(metadata)),
  ],
});

await wallet.signAndExecuteTransactionBlock({ transactionBlock: tx });

View Tag Optimization

The view tag is a critical optimization that reduces scanning costs by ~256x.

Without View Tag

Recipients must:
  1. For each announcement, compute full ECDH: S = k_view * R
  2. Derive stealth address: P = K_spend + Hash(S)*G
  3. Compare with announcement’s stealth_address
For 1,000 announcements/day, this requires 1,000 ECDH computations (expensive).

With View Tag

Recipients:
  1. For each announcement, check view_tag == S[0] (cheap byte comparison)
  2. Only if match, compute full ECDH and derive address
For 1,000 announcements/day with 1% match rate, this requires:
  • 1,000 byte comparisons (very cheap)
  • ~4 ECDH computations (256x fewer)
This makes mobile wallet scanning practical.

Event Indexing

Announcements are indexed off-chain for efficient querying:
import { SuiEventFilter } from '@mysten/sui.js/client';

const filter: SuiEventFilter = {
  MoveEventType: `${PACKAGE_ID}::announcements::StealthAnnouncement`,
};

// Subscribe to events
const unsubscribe = await suiClient.subscribeEvent({
  filter,
  onMessage: (event) => {
    const announcement = event.parsedJson;
    processAnnouncement(announcement);
  },
});

Privacy Considerations

Announcements reveal stealth addresses but not recipient identities. Observers cannot link multiple stealth addresses to the same recipient.
Optional metadata should be encrypted using the ECDH shared secret. Only the sender and recipient can decrypt it.
The view tag leaks 8 bits of the shared secret. This is acceptable as the full shared secret is 256 bits, leaving 248 bits of entropy.
Announcements do not identify the sender. If sender privacy is needed, send from a stealth address and encrypt sender info in metadata.

Integration Patterns

Pattern 1: Settlement Module

The Settlement contract does not emit announcements directly. Instead, the buyer’s wallet emits the announcement as part of the PTB:
const tx = new TransactionBlock();

// 1. Execute settlement (transfers receipt to stealth address)
tx.moveCall({
  target: `${PACKAGE_ID}::settlement::execute_commerce`,
  arguments: [/* ... */],
});

// 2. Emit announcement for receipt detection
tx.moveCall({
  target: `${PACKAGE_ID}::announcements::announce`,
  arguments: [
    tx.pure(ephemeralPubkey),
    tx.pure(viewTag),
    tx.pure(buyerStealthAddress),
    tx.pure([]),
  ],
});

Pattern 2: P2P Transfer

Direct stealth payments between users:
const tx = new TransactionBlock();

// 1. Transfer tokens
const [coin] = tx.splitCoins(tx.gas, [tx.pure(amount)]);
tx.transferObjects([coin], tx.pure(stealthAddress));

// 2. Announce
tx.moveCall({
  target: `${PACKAGE_ID}::announcements::announce`,
  arguments: [
    tx.pure(ephemeralPubkey),
    tx.pure(viewTag),
    tx.pure(stealthAddress),
    tx.pure(encryptedMemo),
  ],
});

Pattern 3: Shielded Pool Withdrawal

No announcement needed for pool withdrawals since the recipient address is chosen by the user:
// User chooses their own withdrawal address (typically a fresh stealth)
await shieldedPool.withdraw({
  recipient: myFreshStealthAddress,
  // ...
});

// No announcement - user already knows about their own withdrawal

Security Considerations

Timing Analysis: Announcements are emitted in the same transaction as the payment. This links the payment to the announcement timing-wise. For maximum privacy, batch multiple payments in one transaction.
Event Reliability: Sui guarantees event emission if the transaction succeeds. Failed transactions do not emit events, ensuring announcements always correspond to real payments.
Gas Costs: Emitting an announcement costs ~1,000 gas units. This is negligible compared to the overall transaction cost.

Meta-Address Registry

Resolve names to public keys for stealth derivation

Settlement

Uses stealth addresses for receipt delivery

Build docs developers (and LLMs) love