Skip to main content

Extending

The messaging layer is itself an extension of @mysten/sui-groups (see the Sui Groups Extending guide for foundational patterns). You can extend it further at four levels:

Extension pointWhat it controlsInterface
Seal policyWho can decrypt messagesSealPolicy<TApproveContext>
Relayer transportHow messages are delivered and storedRelayerTransport
Attachment storageWhere file attachments are storedStorageAdapter
Recovery transportWhere messages are recovered fromRecoveryTransport

Custom Seal policy

The default Seal policy (DefaultSealPolicy) gates decryption on MessagingReader permission through the seal_approve_reader Move function. To implement custom access control (subscription-based, token-gated, NFT-gated), you write a custom Move seal_approve function and a TypeScript SealPolicy implementation.

The SealPolicy interface

interface SealPolicy<TApproveContext = void> {
/** Package ID used as the Seal encryption namespace. */
readonly packageId: string;

/**
* Build a seal_approve transaction thunk for Seal decryption.
* Called lazily at decrypt time.
*/
sealApproveThunk(
identityBytes: Uint8Array,
groupId: string,
encryptionHistoryId: string,
...context: TApproveContext extends void ? [] : [context: TApproveContext]
): (tx: Transaction) => TransactionResult;
}

The TApproveContext generic lets you pass runtime context (for example, subscription object IDs) through sendMessage, getMessages, and other SDK methods that trigger encryption or decryption.

Example: Subscription-based access (Move)

Key design points:

  • No wrapper around PermissionedGroup is needed. The custom policy references the group by ID.
  • Identity bytes always use the standard format ([groupId][keyVersion]), enforced by validate_identity().
  • This package's ID becomes the Seal encryption namespace (instead of the messaging package).

Example: Subscription-based access (TypeScript)

import type { SealPolicy } from '@mysten/sui-stack-messaging';
import { Transaction, type TransactionResult } from '@mysten/sui/transactions';

const CUSTOM_PKG = '0xYOUR_CUSTOM_SEAL_POLICY_PKG';

interface SubContext {
serviceId: string;
subscriptionId: string;
}

class SubscriptionSealPolicy implements SealPolicy<SubContext> {
readonly packageId = CUSTOM_PKG;

sealApproveThunk(
identityBytes: Uint8Array,
groupId: string,
encryptionHistoryId: string,
context: SubContext,
) {
return (tx: Transaction): TransactionResult => {
return tx.moveCall({
target: `${CUSTOM_PKG}::custom_seal_policy::seal_approve`,
typeArguments: ['0x2::sui::SUI'],
arguments: [
tx.pure.vector('u8', identityBytes),
tx.object(context.subscriptionId),
tx.object(context.serviceId),
tx.object(groupId),
tx.object(encryptionHistoryId),
tx.object('0x6'), // Clock
],
});
};
}
}

Wire it up at client creation:

const client = createMessagingGroupsClient<SubContext>(baseClient, {
encryption: {
sessionKey: { signer: keypair },
sealPolicy: new SubscriptionSealPolicy(),
},
relayer: { relayerUrl: '...' },
});

// TApproveContext flows through to messaging methods
await client.messaging.sendMessage({
signer: keypair,
groupRef: { uuid: 'my-group' },
text: 'Hello!',
approveContext: { serviceId: '0x...', subscriptionId: '0x...' },
});

Token-gated groups (paid join rule)

This example uses the actor object pattern from @mysten/sui-groups to implement payment-gated membership. See the Sui Groups Extending guide for the actor pattern fundamentals.

Move side

Setup flow

// 1. Create group
let (mut group, encryption_history) = messaging::messaging::create_group(...);

// 2. Create paid join rule (1 SUI fee)
let rule = paid_join_rule::new<SUI>(object::id(&group), 1_000_000_000, ctx);
let rule_address = object::id(&rule).to_address();

// 3. Grant the rule actor ExtensionPermissionsAdmin so it can add members
group.grant_permission<Messaging, ExtensionPermissionsAdmin>(rule_address, ctx);

// 4. Grant FundsManager to a treasurer
group.grant_permission<Messaging, FundsManager>(treasurer, ctx);

// 5. Share both objects
transfer::share_object(group);
transfer::share_object(rule);

TypeScript side

const tx = new Transaction();

// Call join with a payment coin
tx.moveCall({
target: `${EXAMPLE_PKG}::paid_join_rule::join`,
typeArguments: ['0x2::sui::SUI'],
arguments: [
tx.object(ruleId),
tx.object(groupId),
tx.object(paymentCoinId),
],
});

await keypair.signAndExecuteTransaction({ transaction: tx, client });

Custom RelayerTransport

Implement RelayerTransport to replace the built-in HTTP polling transport with any delivery backend:

interface RelayerTransport {
sendMessage(params: SendMessageParams): Promise<SendMessageResult>;
fetchMessages(params: FetchMessagesParams): Promise<FetchMessagesResult>;
fetchMessage(params: FetchMessageParams): Promise<RelayerMessage>;
updateMessage(params: UpdateMessageParams): Promise<void>;
deleteMessage(params: DeleteMessageParams): Promise<void>;
subscribe(params: SubscribeParams): AsyncIterable<RelayerMessage>;
disconnect(): void;
}

Provide your implementation through config:

const client = createMessagingGroupsClient(baseClient, {
encryption: { sessionKey: { signer: keypair } },
relayer: { transport: myCustomTransport },
});

Use cases for custom transports:

  • WebSocket or SSE for lower-latency subscriptions
  • On-chain message storage for applications that prefer full verifiability over cost/speed
  • Custom backend with application-specific routing or filtering logic

See Relayer for the full type definitions of all parameter and result types.

Custom attachment StorageAdapter

The StorageAdapter interface controls where file attachments are stored. This is separate from the relayer's Walrus Sync (which handles message archival) and the walrus-discovery-indexer (which handles message recovery). See Attachments for the full attachment encryption flow.

Implement StorageAdapter to replace the built-in Walrus adapter:

interface StorageAdapter {
/** Upload one or more entries as a batch. */
upload(entries: StorageEntry[]): Promise<StorageUploadResult>;
/** Download a single entry by its ID. */
download(id: string): Promise<Uint8Array>;
/** Optional: delete entries by ID. */
delete?(ids: string[]): Promise<void>;
}

The adapter is encryption-unaware: data arrives already encrypted by the SDK. Provide your implementation through config:

import { WalrusHttpStorageAdapter } from '@mysten/sui-stack-messaging';

// Built-in Walrus adapter
const client = createMessagingGroupsClient(baseClient, {
// ...
attachments: {
storageAdapter: new WalrusHttpStorageAdapter({
publisherUrl: 'https://publisher.walrus-testnet.walrus.space',
aggregatorUrl: 'https://aggregator.walrus-testnet.walrus.space',
epochs: 5,
}),
},
});

// Or a custom adapter (e.g., IPFS, S3)
const client = createMessagingGroupsClient(baseClient, {
// ...
attachments: {
storageAdapter: myIpfsAdapter,
},
});

Custom RecoveryTransport

Implement RecoveryTransport to enable message recovery from an alternative storage backend (for example, Walrus):

interface RecoveryTransport {
recoverMessages(params: RecoverMessagesParams): Promise<FetchMessagesResult>;
}

When provided, the client exposes a recoverMessages() method for fetching messages from the recovery backend. Recovery is read-only and does not require a signer. See Archive and Recovery for the full recovery pipeline.

const client = createMessagingGroupsClient(baseClient, {
// ...
recovery: myWalrusRecoveryTransport,
});

// Recover messages from Walrus
const result = await client.messaging.recoverMessages({
groupRef: { uuid: 'my-group' },
});