1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[ERC7992] ゼロ知識証明でMLモデルの推論結果をオンチェーンで検証するZKMLの仕組みを理解しよう!

1
Posted at

はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!

今回は、ゼロ知識証明を使ってMLモデルの推論結果をオンチェーンで検証できる標準インターフェースを提案しているERC7992についてまとめていきます!

以下にまとめられているものを解説しながらまとめていきます。

他にも様々なEIP・BIP・SLIP・CAIP・ENSIP・RFC・ACPについてまとめています。

概要

ERC7992は、機械学習(ML)モデルの推論結果をオンチェーンで暗号的に検証するための標準インターフェースです。
ZKML(Zero-Knowledge Machine Learning)と呼ばれるアプローチを使い、あるモデルが特定の入力に対して特定の出力を返したことを、ゼロ知識証明で証明・検証します。

ゼロ知識証明

「ある事実を知っている」ことを、その事実の中身を明かさずに証明する暗号技術です。
例えば「私はこのパスワードを知っています」と、パスワード自体を見せずに証明できます。

この提案は大きく2つのコンポーネントで構成されています。
1つ目はレジストリで、MLモデルの重み・アーキテクチャ・証明回路・検証鍵のハッシュをオンチェーンに登録します。
2つ目は検証器で、提出されたゼロ知識証明を検証し、推論結果の正しさを確認します。

ERC165については以下の記事を参考にしてください。

以下の図は、ZKMLの全体的な処理フローを示しています。

ZKMLの全体フロー

オフチェーンでMLモデルが推論を実行し、その結果と一緒にゼロ知識証明を生成します。
生成された証明はオンチェーンの検証器に提出され、レジストリに登録されたモデルのコミットメントと照合して検証されます。

ERC7992準拠のコントラクトはERC165を実装し、対応するインターフェースの検出を可能にします。

動機

スマートコントラクトとAIの信頼問題

スマートコントラクトは大規模なMLモデルを直接実行することができません。
ガスコストやEVMの計算能力の制約から、数百万パラメータを持つようなモデルをオンチェーンで動かすのは現実的ではありません。

そのため、現在のプロジェクトは以下の3つのアプローチのいずれかを採用していますが、どれも暗号的な保証を提供できていません。

アプローチ 内容 問題点
中央集権サーバーの信頼 オフチェーンのサーバーが推論結果を返す サーバーが嘘をついても検出できない
モデルの簡略化 モデルを極端に小さくしてオンチェーンで実行 精度が大幅に低下し実用に耐えない
社会的委員会 複数の関係者が結果の正しさを合意する 合意コストが高く、暗号的保証がない

例えば、DeFiプロトコルでAIによるリスク評価を使いたい場合、その推論結果が本当に特定のモデルから正しく計算されたものかを検証する手段がないと、不正な結果に基づいて資金が動いてしまうリスクがあります。

ZKMLの標準化が必要な理由

ZKML(Zero-Knowledge Machine Learning)は、ゼロ知識証明を使ってこの信頼のギャップを埋めます。
証明者は「特定のモデルが特定の入力に対して特定の出力を返した」ことを、モデルの重みや入力データを公開せずに証明できます。

しかし、標準インターフェースがないと各dAppと検証器の組み合わせがすべて独自実装になります。
異なるABI、異なるレジストリスキーマ、異なるイベント定義が乱立し、相互運用性が失われます。

ERC7992はこの問題を解決し、検証可能なML推論をEthereumの再利用可能なプリミティブに変えます。
ERC20ERC721がトークンの標準を確立したように、ERC7992はAI出力の検証に対して同じ役割を果たすことを目指しています。

ERC20については以下の記事を参考にしてください。

ERC721については以下の記事を参考にしてください。

仕様

用語とID体系

ERC7992で使われる主要な概念とIDを以下にまとめます。

用語 説明
Model Commitment 構造体 モデルの重み、アーキテクチャ、証明回路、検証鍵のハッシュを束ねたデータ。登録されたモデルの「指紋」にあたる。
modelId uint256 レジストリがモデル登録時に発行する一意の識別子。
inputCommitment bytes32 推論のプライベート入力に対するハッシュコミットメント。ドメイン分離が必要であり、リプレイ保護のためにノンスやソルトを含めるべきとされている。
output bytes 推論のABIエンコードされた公開出力。スキーマはアプリケーションごとに定義される。
proof bytes ゼロ知識証明のバイトデータ。
proofSystemId bytes4 証明システム+曲線+バージョンを識別する4バイトのID。

ドメイン分離

異なる用途のハッシュ値が偶然一致しないようにするための仕組みです。
例えば「ZKML_MODEL_V1」のようなプレフィックスをハッシュ計算に含めることで、他のコンテキストで生成されたハッシュとの衝突を防ぎます。

proofSystemIdの算出方法

proofSystemIdは以下のように算出されます。

bytes4(keccak256(abi.encodePacked(<canonical-proof-system-name-and-version>)))

<canonical-proof-system-name-and-version>は小文字・ハイフン区切りの文字列です。

証明システム 文字列例
Groth16(BN254曲線) "groth16-bn254-v1"
Plonk(BN254曲線) "plonk-bn254-v2"
STARK "stark-airfoo-v1"

この方式により、各実装間で決定論的かつ衝突耐性のある識別子を生成できます。

レジストリ

レジストリはMLモデルのコミットメントを登録・管理するコントラクトです。
モデルの「指紋」をオンチェーンに記録することで、検証器がどのモデルの証明を検証すべきかを参照できるようにします。

以下がレジストリの完全なインターフェースです。

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

interface IERCZKMLRegistry /* is IERC165 */ {
    struct ModelCommitment {
        bytes32 modelHash;     // weights + architecture hash
        bytes32 circuitHash;   // arithmetic circuit / AIR hash
        bytes32 vkHash;        // verifying key hash
        bytes4  proofSystemId; // keccak-based identifier
        string  uri;           // optional off-chain metadata (IPFS/HTTP)
    }

    event ModelRegistered(
        uint256 indexed modelId,
        address indexed owner,
        ModelCommitment commitment
    );

    event ModelUpdated(
        uint256 indexed modelId,
        ModelCommitment oldCommitment,
        ModelCommitment newCommitment
    );

    event ModelDeprecated(uint256 indexed modelId);

    error ModelNotFound(uint256 modelId);
    error NotModelOwner(uint256 modelId, address caller);
    error ModelDeprecated(uint256 modelId);

    function registerModel(ModelCommitment calldata commitment)
        external
        returns (uint256 modelId);

    function updateModel(uint256 modelId, ModelCommitment calldata newCommitment)
        external;

    function deprecateModel(uint256 modelId) external;

    function getModel(uint256 modelId)
        external
        view
        returns (ModelCommitment memory commitment, bool deprecated, address owner);
}

ModelCommitment構造体

ModelCommitmentはモデルの同一性を暗号的に保証するためのデータの集まりです。
各フィールドはモデルの異なる側面のハッシュを保持しており、登録されたモデルが後から改ざんされていないことを検証できます。

フィールド 説明
modelHash bytes32 モデルの重みとアーキテクチャのハッシュ。モデルそのものの「指紋」にあたり、同じモデルであれば常に同じ値になる。
circuitHash bytes32 算術回路またはAIR(Algebraic Intermediate Representation)のハッシュ。モデルの推論をゼロ知識証明で検証するための回路を特定する。
vkHash bytes32 検証鍵のハッシュ。証明の検証に使う鍵の同一性を保証する。
proofSystemId bytes4 使用する証明システムの識別子。Groth16、Plonk、STARKなど、どの証明システムで生成された証明かを示す。
uri string オフチェーンメタデータへのURI(IPFSやHTTP)。モデルの説明、学習データの情報、ライセンスなどを格納できる。省略可能。

registerModel

function registerModel(ModelCommitment calldata commitment)
    external
    returns (uint256 modelId);

新しいモデルのコミットメントをレジストリに登録する関数です。
モデルの所有者がこの関数を呼び出すと、レジストリは一意のmodelIdを割り当てて返します。
登録が完了するとModelRegisteredイベントが発行されます。

  • 引数

    • commitment
      • 登録するモデルのコミットメントデータ。
  • 戻り値

uint256 レジストリが発行した一意のモデルID。

updateModel

function updateModel(uint256 modelId, ModelCommitment calldata newCommitment)
    external;

既存モデルのコミットメントを更新する関数です。
モデルの再学習や回路の改善など、コミットメントの内容を変更する必要がある場合に使います。
この関数はモデルの所有者のみが呼び出せます。
更新が完了するとModelUpdatedイベントが発行されます。

  • 引数
    • modelId
      • 更新対象のモデルID。
    • newCommitment
      • 新しいコミットメントデータ。

コミットメントを更新すると、古いコミットメントに基づいて生成された証明は無効になる可能性があります。
利用者は特定のmodelId + コミットメントハッシュの組み合わせを固定するか、更新前に非推奨フラグを確認する方法が有効です。

deprecateModel

function deprecateModel(uint256 modelId) external;

モデルを非推奨としてマークする関数です。
非推奨になったモデルに対する新しい証明の検証は拒否されます。
この関数もモデルの所有者のみが呼び出せます。
非推奨が設定されるとModelDeprecatedイベントが発行されます。

  • 引数
    • modelId
      • 非推奨にするモデルID。

getModel

function getModel(uint256 modelId)
    external
    view
    returns (ModelCommitment memory commitment, bool deprecated, address owner);

指定したモデルIDのコミットメント情報を取得する関数です。
検証器がモデルの証明を検証する時、この関数を通じてモデルのコミットメントを参照します。

  • 引数

    • modelId
      • 取得対象のモデルID。
  • 戻り値

ModelCommitment モデルのコミットメントデータ。
bool 非推奨フラグ。trueの場合、モデルは非推奨。
address モデルの所有者アドレス。

イベント

ModelRegistered

event ModelRegistered(
    uint256 indexed modelId,
    address indexed owner,
    ModelCommitment commitment
);

新しいモデルがレジストリに登録された時に発行されるイベントです。

パラメータ名 indexed 説明
modelId uint256 yes 割り当てられたモデルID。
owner address yes モデルの所有者アドレス。
commitment ModelCommitment no 登録されたコミットメントデータ。

ModelUpdated

event ModelUpdated(
    uint256 indexed modelId,
    ModelCommitment oldCommitment,
    ModelCommitment newCommitment
);

モデルのコミットメントが更新された時に発行されるイベントです。
更新前と更新後のコミットメントの両方が含まれるため、変更の追跡が可能です。

パラメータ名 indexed 説明
modelId uint256 yes 更新されたモデルID。
oldCommitment ModelCommitment no 更新前のコミットメント。
newCommitment ModelCommitment no 更新後のコミットメント。

ModelDeprecated

event ModelDeprecated(uint256 indexed modelId);

モデルが非推奨になった時に発行されるイベントです。

パラメータ名 indexed 説明
modelId uint256 yes 非推奨になったモデルID。

エラー

エラー名 説明
ModelNotFound(uint256 modelId) 指定されたモデルIDが存在しない場合に返される。
NotModelOwner(uint256 modelId, address caller) 呼び出し元がモデルの所有者ではない場合に返される。updateModeldeprecateModelの権限チェックで使用される。
ModelDeprecated(uint256 modelId) 非推奨のモデルに対して操作が行われた場合に返される。

検証器

検証器はゼロ知識証明を受け取り、MLモデルの推論結果が正しいかどうかをオンチェーンで検証するコントラクトです。
レジストリからモデルのコミットメントを取得し、提出された証明がそのコミットメントに対して有効かどうかを確認します。

以下が検証器の完全なインターフェースです。

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

interface IERCZKMLVerifier /* is IERC165 */ {
    event InferenceVerified(
        uint256 indexed modelId,
        bytes32 indexed inputCommitment,
        bytes   output,
        address indexed caller
    );

    error InvalidProof();
    error ModelMismatch();
    error InputCommitmentMismatch();
    error OutputMismatch();
    error UnsupportedProofSystem(bytes4 proofSystemId);
    error VerificationRefused_ModelDeprecated(uint256 modelId);

    function verifyInference(
        uint256 modelId,
        bytes32 inputCommitment,
        bytes calldata output,
        bytes calldata proof
    ) external;

    function proofSystemOf(uint256 modelId) external view returns (bytes4);
    function registry() external view returns (address registryAddress);
}

verifyInference

function verifyInference(
    uint256 modelId,
    bytes32 inputCommitment,
    bytes calldata output,
    bytes calldata proof
) external;

ZK証明を検証するための中核的な関数です。
この関数はレジストリからモデルのコミットメントを取得し、証明がそのコミットメントに対して有効であるかを確認します。
検証に失敗した場合は必ずリバートし、成功した場合はInferenceVerifiedイベントを発行することが推奨されています。
戻り値がvoid(何も返さない)設計になっているのは、失敗時はリバート・成功時はvoidという二択にすることで、冗長なbool返却を省くためです。
リバートしなければ検証成功、リバートすれば検証失敗と判断できます。

以下はverifyInferenceの処理フローです。

呼び出し元が証明を提出すると、検証器はまずレジストリからモデルのコミットメントを取得します。
モデルが非推奨の場合はリバートし、そうでなければ証明の検証に進みます。
検証に成功するとInferenceVerifiedイベントの発行が推奨されています。

パラメータ名 説明
modelId uint256 レジストリに登録されたモデルの識別子。
inputCommitment bytes32 プライベート入力に対するハッシュコミットメント。リプレイ保護のためにノンスやソルトを含めるべきとされている。
output bytes 推論のABIエンコードされた公開出力。
proof bytes ゼロ知識証明のバイトデータ。

検証器は基本的にステートレス(状態を持たない)であり、イベントの発行のみを行います。
状態の保持が必要な場合は、後述のストレージ拡張を使用します。

proofSystemOf

function proofSystemOf(uint256 modelId) external view returns (bytes4);

指定したモデルが使用する証明システムのIDを返す関数です。
利用者が証明を生成する前に、どの証明システムを使うべきかを確認できます。

  • 引数

    • modelId
      • 対象のモデルID。
  • 戻り値

bytes4 証明システムの識別子。

registry

function registry() external view returns (address registryAddress);

検証器が参照しているレジストリコントラクトのアドレスを返す関数です。

  • 戻り値

address レジストリのコントラクトアドレス。

イベント

InferenceVerified

event InferenceVerified(
    uint256 indexed modelId,
    bytes32 indexed inputCommitment,
    bytes   output,
    address indexed caller
);

推論の検証が成功した時に発行されるイベントです。

パラメータ名 indexed 説明
modelId uint256 yes 検証されたモデルのID。
inputCommitment bytes32 yes 入力のハッシュコミットメント。
output bytes no 推論の公開出力。
caller address yes 検証を呼び出したアドレス。

エラー

エラー名 説明
InvalidProof() 証明が無効な場合に返される。
ModelMismatch() 証明は有効だが、指定されたmodelIdのモデルに紐付いていない場合に返される。
InputCommitmentMismatch() 入力コミットメントが証明と一致しない場合に返される。
OutputMismatch() 出力が証明またはコミットメントと一致しない場合に返される。
UnsupportedProofSystem(bytes4 proofSystemId) 検証器がその証明システムをサポートしていない場合に返される。
VerificationRefused_ModelDeprecated(uint256 modelId) 非推奨モデルに対する検証が拒否された場合に返される。

ストレージ拡張

基本の検証器はステートレスですが、監査可能性や決定論的な決済が必要な場合に、検証済みの推論記録を永続化するためのオプションの拡張インターフェースです。

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

interface IERCZKMLStorageExtension {
    event InferenceStored(bytes32 indexed inferenceId);

    function verifyAndStoreInference(
        uint256 modelId,
        bytes32 inputCommitment,
        bytes calldata output,
        bytes calldata proof
    ) external returns (bytes32 inferenceId);

    function getInference(bytes32 inferenceId)
        external
        view
        returns (uint256 modelId, bytes32 inputCommitment, bytes memory output);
}

verifyAndStoreInference

function verifyAndStoreInference(
    uint256 modelId,
    bytes32 inputCommitment,
    bytes calldata output,
    bytes calldata proof
) external returns (bytes32 inferenceId);

証明を検証し、検証成功した推論記録をオンチェーンに保存する関数です。
証明が無効な場合はリバートします。
保存が完了するとInferenceStoredイベントが発行されます。

inferenceIdkeccak256(abi.encodePacked(modelId, inputCommitment, output))で算出されます。
リプレイが懸念される場合は、inputCommitmentにノンスやソルトを埋め込む必要があります。

  • 戻り値

bytes32 保存された推論記録の一意な識別子。

getInference

function getInference(bytes32 inferenceId)
    external
    view
    returns (uint256 modelId, bytes32 inputCommitment, bytes memory output);

保存済みの推論記録を取得する関数です。
他のコントラクトが過去の検証結果を参照する時に使います。
例えば、DeFiプロトコルが保険金請求の判定にAI推論結果を使う場合、この関数で過去の推論記録を確認し、紛争の解決に利用できます。

  • 引数

    • inferenceId
      • 取得する推論記録のID。
  • 戻り値

uint256 モデルID。
bytes32 入力のハッシュコミットメント。
bytes 推論の公開出力。

補足

レジストリと検証器の分離設計

ERC7992がレジストリと検証器を別々のコントラクトに分けているのは、モジュール性を重視した設計上の決定です。
レジストリはモデルの登録情報を管理し、検証器は証明の検証ロジックに特化します。

レジストリと検証器の分離アーキテクチャ

この分離により、チームは検証器を独立してアップグレードできます。
例えば、新しい証明システム(STARK→次世代の証明方式)に対応する時、レジストリのデータを保持したまま検証器だけを差し替えることが可能です。
逆に、レジストリの管理ポリシーを変更する時も検証器には影響しません。

voidリターンの設計理由

verifyInferenceが戻り値を返さない設計は、OpenZeppelinのSafeERC20と同じパターンです。
失敗時はリバート、成功時はvoidという二択にすることで、冗長なbool返却を省いています。
呼び出し元は単に関数が正常に実行されたかどうかを確認するだけで、検証結果を判断できます。

ノンスによるリプレイ保護

inputCommitmentにノンスやソルトを埋め込むことで、同じモデル・同じ入力・同じ出力に対する証明の再利用を防止します。
リプレイ保護が特に重要なのは以下のような場合です。

  • モデルの出力が非決定論的な場合(同じ入力でも実行ごとに異なる出力を返す可能性がある場合)
  • 推論結果が1回限りの判定に使われる場合(保険金の請求判定など)

ストレージ拡張を使う場合、inferenceIdの衝突を避けるためにもinputCommitmentにノンスを含めることが重要です。

proofSystemIdの決定論的設計

proofSystemIdをkeccakベースの決定論的な方式で算出しているのは、衝突と曖昧性を防ぐためです。
複数の証明システムが混在する検証器では、どの証明システムで検証すべきかを確実に判別する必要があります。
人間が手動でIDを割り当てる方式では重複のリスクが生じますが、正規化された文字列からハッシュを計算することで、誰が計算しても同じIDが得られ、予測可能なディスパッチが実現できます。

証明システムのプラガビリティ

proofoutputbytes型にしているのは、特定の証明システムやスキーマに依存しない設計のためです。
Groth16、Plonk、STARKなど異なる証明システムを使う場合でも、ABIを変更する必要がありません。
proofSystemIdによる識別と内部的なディスパッチにより、検証器は適切な検証ロジックに処理を振り分けます。

互換性

既存規格との関係

ERC7992は既存の規格と完全に後方互換性があります。

規格 関係
ERC165 インターフェースの検出に使用。ERC721ERC1155と同様のパターン。
ERC20/ERC721 トークン所有権規格への依存はない。最小限の衝突で既存プロトコルと共存できる。

レジストリインターフェースはownerを各modelIdに紐づけて返します。
所有権管理の実装方法は自由であり、単純なオーナーストレージ、ERC173、ロールベースの制御など、プロジェクトの要件に合わせて選択できます。

ERC1155については以下の記事を参考にしてください。

セキュリティ

リプレイ攻撃

推論結果の一意性が重要な場合、入力にはノンスやソルトを含める必要があります。
ノンスを含めないと、過去の有効な証明が再利用され、同じ推論結果が何度も「検証済み」として扱われる危険性があります。
ストレージ拡張を使う場合は、消費済みのinferenceIdを追跡することでも対策できます。

モデルコミットメントの更新リスク

updateModelでコミットメントを更新すると、古いコミットメントに対して生成された証明が検証に失敗する可能性があります。
利用者は以下のいずれかの方法で対策する必要があります。

  • 特定のmodelIdとコミットメントハッシュの組み合わせを固定して参照する
  • deprecatedフラグを事前に確認する
  • バージョニングポリシーを実装する(新しいコミットメントには新しいmodelIdを発行する方式)

ハッシュドメイン分離

異なるコンテキスト間でのハッシュ衝突を防ぐため、「ZKML_MODEL_V1」のようなプレフィックスを使用してドメイン分離を行う必要があります。
ドメイン分離がないと、他のプロトコルで生成されたハッシュがZKMLのコミットメントと誤って一致するリスクがあります。

出力データの曖昧性

outputパラメータはbytes型であるため、スキーマが明示されていないと悪意のあるバイト列を出力として送りつけることが可能です。
利用するコントラクトは、出力のスキーマを事前に合意するか、出力コミットメントを追加で検証する必要があります。

DoS攻撃(重い検証処理)

ZK証明の検証はガスコストが高い処理です。
大量の検証リクエストを送りつけることで、コントラクトのガス上限に達してサービスを妨害するDoS攻撃のリスクがあります。
対策としては、以下のアプローチが考えられます。

  • オフチェーンでの事前検証を行い、オンチェーンでは簡潔なアテステーション(証明書)のみを検証する
  • 複数の証明をバッチ処理またはアグリゲーション(集約)して検証する
  • 検証のみの呼び出しとストレージへの書き込みを分離し、利用者がコストを選択できるようにする
  • 監査証跡としてはストレージへの永続化よりイベントの発行の方がガスコストが安いため、永続化が不要な場合はイベントのみで済ませる

参考実装

以下はERC7992の検証器コントラクトの参考実装です。
レジストリからモデルのコミットメントを取得し、証明を検証する基本的な流れを示しています。

contracts/ZKMLVerifier.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

import "./IERCZKMLRegistry.sol";
import "./IERCZKMLVerifier.sol";

contract ZKMLVerifier is IERCZKMLVerifier {
    IERCZKMLRegistry public immutable override registry;

    constructor(IERCZKMLRegistry _registry) {
        registry = _registry;
    }

    function verifyInference(
        uint256 modelId,
        bytes32 inputCommitment,
        bytes calldata output,
        bytes calldata proof
    ) external override {
        (IERCZKMLRegistry.ModelCommitment memory cm, bool deprecated,) =
            registry.getModel(modelId);

        if (deprecated) revert ModelDeprecated(modelId);

        // Dispatch based on proofSystemId. Example only.
        // bool ok = VerifierLib.verify(proof, cm.vkHash, inputCommitment, output);
        bool ok = _dummyVerify(proof, cm.vkHash, inputCommitment, output);
        if (!ok) revert InvalidProof();

        emit InferenceVerified(modelId, inputCommitment, output, msg.sender);
    }

    function proofSystemOf(uint256 modelId) external view override returns (bytes4) {
        (IERCZKMLRegistry.ModelCommitment memory cm,,) = registry.getModel(modelId);
        return cm.proofSystemId;
    }

    function _dummyVerify(
        bytes calldata,
        bytes32,
        bytes32,
        bytes calldata
    ) private pure returns (bool) {
        return true;
    }
}

この参考実装では_dummyVerify関数が常にtrueを返しますが、実際のプロダクション環境ではここにGroth16やPlonkなどの証明検証ロジックを実装します。
proofSystemIdに基づいて適切な検証ライブラリにディスパッチする仕組みを組み込むことで、複数の証明システムに対応できます。

引用

最後に

今回は「ERC7992によるゼロ知識証明を使ったMLモデルの推論検証の仕組み」についてまとめてきました!
いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

Twitter @cardene777

他の媒体でも情報発信しているのでぜひ他も見ていってください!

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?