【sui 】フルオンチェーン 有料記事配信 Dapp
🎯 背景
開発背景
- 2025/9にMainnetにローンチされた暗号技術「Seal」の理解を深めるため
- 単なる技術検証に留まらず、「アクセス制御をビジネス価値に転換できる仕組み」としてSealが活用できるかの検証のため
テーマを決定した背景
- Sealは、暗号化されたデータへのアクセス権限を柔軟に制御できる点が特徴であり、その特性を活かせるテーマとして、タイトルのDappsを作成することにした
- また、ブロックチェーン上での安全なデータ流通の実現可能性を検証する上でも適切な第一歩だと判断したため
💡 このDappでできること
全員向け
- 本DAppは、ウォレットを通じたオンチェーン認証・分散型ストレージ・暗号化を組み合わせることで、コンテンツの安全な販売とアクセス制御を実現しています。
管理者向け
-
ウォレットを接続することで、独自の記事コンテンツを作成し、0.01 SUI で購読者向けに販売・公開できます。
-
「管理者」とは、記事コンテンツを作成・配信するユーザーを指します。
(※2025/11/1時点で、管理者は私のウォレットのみ) -
管理者の判定は、接続されたウォレットアドレスによって自動的に行われます。
購読者向け
-
ウォレットを接続することで、管理者が作成した記事を 0.01 SUI で購読・閲覧できます。
-
「購読者」とは、管理者が公開したコンテンツを購入・閲覧するユーザーを指します。
-
購読者の判定は、接続されたウォレットアドレスによって自動的に行われます。
🛠️ 利用している技術
- ブロックチェーン: Sui
- 暗号化: @mysten/seal
- ストレージ: Walrus
- UI: TypeScript + React + Vite + Tailwind CSS
⚙️ システム構成
管理者向け
購読者向け
🧩 主要機能の技術解説
管理者向け
1.管理者認証(ウォレット検証)
- useCurrentAccount() フックを使用し、現在接続中のウォレットアドレスを取得。
- ADMIN_ADDRESS(定義済み)と照合し、一致するアドレスのみが管理者コンソールへアクセス可能。
if (!acct || acct.address.toLowerCase() !== ADMIN_ADDRESS.toLowerCase()) {
// 省略(管理者のみ管理者コンソールへ)
}
✅ 目的:フロントエンドのみでの簡易アクセス制御(オンチェーン署名を伴わない権限判定)。
ℹ️ 補足:なりすましは可能だが、管理者コンソール画面にアクセスされても問題はない。また、作成した記事自体は暗号化されているため閲覧不可。
2.記事(サービス)作成
- 管理者が記事本文を入力後、「記事(サービス)作成」ボタンを押すと
「Service」および「Cap」オブジェクトをsui上に発行。
public struct Service has key {
id: UID,
fee: u64,
ttl: u64,
owner: address,
name: String,
}
public struct Cap has key {
id: UID,
service_id: ID,
}
✅ 目的:
記事データを紐づけるメタデータを「Service」オブジェクトとしてブロックチェーン上に発行。
なりすましを防ぐために「Cap」オブジェクトとしてブロックチェーン上に発行。(Capを持っているユーザーだけがServiceを更新できるようにすることで誰でもServiceを呼び出して更新できるわけではない。)
ℹ️ 補足:この時、記事の内容は載せない。
3.Seal 暗号化処理
暗号化処理
const seal = new SealClient({
suiClient,
keyServers: serverObjectIds.map((id) => ({ id, weight: 1 })),
serverConfigs,
verifyKeyServers: false,
});
const data = new TextEncoder().encode(plaintext);
const encrypted = await seal.encrypt({
packageId: pkgId,
id: serviceId,
data,
threshold: 1,
});
復号条件
fun approve_internal(id: vector<u8>, sub: &Subscription, service: &Service, c: &Clock): bool {
if (object::id(service) != sub.service_id) {
return false
};
if (c.timestamp_ms() > sub.created_at + service.ttl) {
return false
};
// Check if the id has the right prefix
is_prefix(service.id.to_bytes(), id)
}
entry fun seal_approve(id: vector<u8>, sub: &Subscription, service: &Service, c: &Clock) {
assert!(approve_internal(id, sub, service, c), ENoAccess);
}
4. 分散型ストレージ(Walrus)へのアップロード
async function walrusPutBlob(bytes: Uint8Array, epochs: number) {
const res = await fetch(publisherUrl(`blobs?epochs=${epochs}`), {
method: 'PUT',
body: bytes,
});
if (!res.ok) throw new Error(`Publisher error: ${res.status}`);
return res.json();
}
5. Sui への公開処理
- Move関数 subscription::publish を呼び出し、先ほど作成したService と Blob ID を紐づけます。
const tx = buildPublishBlobTx(pkgId, serviceId, capId, blobIdHex);
await signAndExecute({ transaction: tx });
✅ 目的:先述の手順で作成した「Service」オブジェクトにBlobIDを紐付ける。
ℹ️ 補足:このとき、Cap を引数に渡すことで、管理者本人であること(正当な発行者)をMove側で検証。
6. Registryに反映
- Registry(共有オブジェクト)に、現在の Service ID と Blob ID を記録します。
✅ 目的:購読者コンソール画面で、この Registry を参照して「最新記事(Blob)」を取得するため。
ℹ️ 補足:Registyを共有オブジェクトとして誰でも参照可能にしておくことで、購読者が読むべき記事が確定し、購読前のService整合性が保証される。(=記事が作成されていないのに、購読者が支払されるのを防ぐため。)
購読者向け機能
1.Registry から最新記事情報を取得
- Registry は「どのService(記事)」が現在有効か、「どのBlob(暗号化データ)」が最新かを管理する共有オブジェクトです。
- current_service → 最新の記事を管理する Service オブジェクトのID
- latest_blob → 暗号化済みの本文を格納した Walrus 上のBlob ID
useEffect(() => {
const obj = await client.getObject({ id: REGISTRY_ID, options: { showContent: true } });
const fields = (obj?.data?.content as any)?.fields as RegistryFields | undefined;
const s = fields?.current_service;
const bBytes = fields?.latest_blob;
✅ 目的:
購読者が読むべき記事(ServiceとBlobの対応関係)をオンチェーンから自動取得します。
また、管理者が記事を作成していない状態で、購読者が購読処理されないようにするため。
ℹ️ 補足:
管理者が新しい記事を投稿すると、Registryのcurrent_serviceとlatest_blobが更新されます。
購読者はこのRegistryを参照することで、常に最新の記事にアクセスできます。
2. 0.01 SUI の支払いと購読
const tx = buildSubscribeTx(pkgId, serviceId, acct.address);
const res = await signAndExecute({ transaction: tx });
await client.waitForTransaction({ digest: res.digest });
✅ 目的:
購読料(0.01 SUI)をブロックチェーン上で支払い、購読権限(Subscription)を獲得する。
ℹ️ 補足:
支払いが完了すると購読者は Subscription を所有することになり、
次ステップ(Sealによる復号承認)でこのオブジェクトを提示してアクセス権を証明します。
3. 記事データ(Blob)の取得
- 暗号化データをWalrus(Aggregator)から取得
const res = await fetch(aggregatorUrl(`blobs/${blobId}`));
const buf = await res.arrayBuffer();
const encryptedObject = EncryptedObject.parse(new Uint8Array(buf));
✅ 目的:
オンチェーンではなく、分散ストレージ(Walrus)上の暗号化データを取得します。
4. SessionKey(復号権限セッション)を生成
- 購読者が一時的に復号処理を行うための SessionKey を生成。
- ユーザーのウォレットで署名し、本人確認を行う。
- TTL(有効期限)を 10分 に制限し、短時間のみ有効な復号権限を作成。
const sessionKey = await SessionKey.create({
address: acct.address,
packageId: pkgId,
ttlMin: 10,
suiClient,
});
const message = sessionKey.getPersonalMessage();
const { signature } = await signPersonalMessage({ message });
await sessionKey.setPersonalMessageSignature(signature);
✅ 目的:
ウォレット署名により「本人確認された購読者」として復号処理を安全に行う。
ℹ️ 補足:
このSessionKeyはウォレットとは独立した一時鍵で、再利用できません。
署名によりSuiアカウントの正当性を保証しています。
5. seal_approve トランザクションを構築
- 復号要求を Move の subscription::seal_approve に送信。
- Move 側では次の条件を満たすか検証します:
- Subscription.service_id == Service.id
- 有効期限 (ttl) 内である
- 暗号データIDが Service.id のプレフィックスを持つ
const tx = new Transaction();
tx.moveCall({
target: `${pkgId}::subscription::seal_approve`,
arguments: [
tx.pure.vector("u8", fromHex(encryptedObject.id)),
tx.object(subscriptionId),
tx.object(serviceId),
tx.object(SUI_CLOCK_OBJECT_ID),
],
});
const txBytes = await tx.build({ client: suiClient, onlyTransactionKind: true });
✅ 目的:
Sealによる復号権限をオンチェーンロジックで検証する。
ℹ️ 補足:
seal_approve が成功すると、Sealネットワーク(KeyServer)は復号鍵を生成できるようになります。
6. SealClient による復号処理
- sealClient.decrypt() がKeyServerから復号鍵を取得し、記事データを復号。
- このとき Move 側で承認された seal_approve トランザクションを検証に使用します。
const decryptedBytes = await sealClient.decrypt({
data: encryptedBytes,
sessionKey,
txBytes,
});
✅ 目的:
購読者が正規ユーザーであることを確認した上で、
暗号化記事を安全に復号して本文を取得。
ℹ️ 補足:
txBytes(seal_approveの署名付きデータ)がないと復号できません。
これは暗号レベルで購読者以外の復号を不可能にしています。
🔒 セキュリティと設計上の工夫
(TBD)
🧠 開発中に直面した課題と解決
(TBD)
🚀 今後の展望
- オープンソース化
- UI/UX改善
Discussion