Aller au contenu



build · gpt-5.3-codex 

Spécification technique — PD-53

Smart Contract d’ancrage Merkle roots (ProbatioVaultAnchor)

1. Vue d’ensemble

ProbatioVaultAnchor est un registre on-chain minimaliste, append-only, destiné à ancrer des merkleRoot (bytes32) produits hors chaîne par ProbatioVault. Le contrat ne construit pas l’arbre de Merkle, ne valide pas la cryptographie interne, et ne gère ni TSA ni HSM ; il fournit uniquement une preuve publique d’existence à une date/bloc donnés, via stockage immuable + événement.

Le contrat est non-upgradable, gouverné par Ownable, et déployé sur réseaux EVM (cibles: Polygon/Arbitrum). Seul owner peut ancrer une racine, chaque racine est unique, et un tiers peut vérifier indépendamment l’existence et les métadonnées d’ancrage sans dépendre d’une base centralisée.

flowchart LR
    A[Backend ProbatioVault\nWORM events] --> B[Merkle Root bytes32]
    B --> C[anchor(bytes32)]
    C --> D[ProbatioVaultAnchor (EVM)]
    D --> E[(Storage append-only)]
    D --> F[[Event MerkleRootAnchored]]
    E --> G[isAnchored(root)]
    E --> H[getAnchor(root)]
    F --> I[Explorateurs\nPolygonscan/Arbiscan]
    G --> J[Tiers vérificateur]
    H --> J
    I --> J

Acteurs et rôles

  • Owner (wallet sécurisé / multisig / HSM-backed): unique autorité d’appel anchor.
  • Tiers vérificateur (auditeur, juge, partie adverse): consulte événements + appelle vues isAnchored/getAnchor.
  • Explorateur blockchain: source publique pour logs, tx hash, block timestamp, block number.
  • Backend ProbatioVault (hors périmètre PD-53): calcule la racine, orchestre la soumission on-chain (via PD-52).

2. Interfaces Solidity

2.1 Interface contractuelle (signatures exactes + NatSpec)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title ProbatioVaultAnchor
/// @notice Registre append-only d'ancrage de Merkle roots pour preuve publique.
/// @dev Non-upgradable. Seul owner peut ancrer. Une racine ne peut être ancrée qu'une fois.
contract ProbatioVaultAnchor is Ownable {
    /// @notice Version sémantique du contrat.
    string public constant VERSION = "1.0.0";

    /// @notice Émis à chaque ancrage réussi.
    /// @param merkleRoot Racine Merkle ancrée.
    /// @param anchor Adresse appelante (owner).
    /// @param timestamp Horodatage bloc au moment de l'ancrage.
    /// @param blockNumber Numéro du bloc d'ancrage.
    event MerkleRootAnchored(
        bytes32 indexed merkleRoot,
        address indexed anchor,
        uint256 timestamp,
        uint256 blockNumber
    );

    /// @notice Erreur si la racine est nulle.
    error ZeroMerkleRoot();

    /// @notice Erreur si la racine est déjà ancrée.
    /// @param merkleRoot Racine déjà présente.
    error MerkleRootAlreadyAnchored(bytes32 merkleRoot);

    /// @notice Erreur si la racine n'existe pas dans le registre.
    /// @param merkleRoot Racine absente.
    error MerkleRootNotAnchored(bytes32 merkleRoot);

    /// @notice Erreur si tentative de renonciation de ownership désactivée.
    error RenounceOwnershipDisabled();

    /// @notice Crée le contrat avec owner initial.
    /// @param initialOwner Adresse owner initiale (multisig recommandée).
    constructor(address initialOwner) Ownable(initialOwner) {}

    /// @notice Ancre une nouvelle racine Merkle.
    /// @dev Revert si root nulle ou déjà ancrée.
    /// @param merkleRoot Racine Merkle à ancrer.
    function anchor(bytes32 merkleRoot) external onlyOwner;

    /// @notice Vérifie si une racine a déjà été ancrée.
    /// @param merkleRoot Racine à vérifier.
    /// @return anchored true si la racine est présente.
    function isAnchored(bytes32 merkleRoot) external view returns (bool anchored);

    /// @notice Retourne les métadonnées d'ancrage d'une racine.
    /// @dev Revert si la racine n'est pas ancrée.
    /// @param merkleRoot Racine recherchée.
    /// @return anchorAddress Adresse ayant ancré (owner au moment T).
    /// @return timestamp Horodatage bloc lors de l'ancrage.
    /// @return blockNumber Numéro du bloc d'ancrage.
    function getAnchor(bytes32 merkleRoot)
        external
        view
        returns (address anchorAddress, uint256 timestamp, uint256 blockNumber);

    /// @notice Désactivée pour éviter un contrat ownerless irréversible.
    function renounceOwnership() public view override {
        revert RenounceOwnershipDisabled();
    }

    // Inherited Ownable public/external interface:
    // function owner() public view returns (address);
    // function transferOwnership(address newOwner) public onlyOwner;
}

2.2 Événements

  • MerkleRootAnchored(bytes32 indexed merkleRoot, address indexed anchor, uint256 timestamp, uint256 blockNumber)

2.3 Erreurs custom (revert reasons)

  • ZeroMerkleRoot()
  • MerkleRootAlreadyAnchored(bytes32 merkleRoot)
  • MerkleRootNotAnchored(bytes32 merkleRoot)
  • RenounceOwnershipDisabled()
  • Hérité OpenZeppelin Ownable:
  • OwnableUnauthorizedAccount(address account)
  • OwnableInvalidOwner(address owner)

3. Modèle de données

3.1 Variables d’état

  • string public constant VERSION = "1.0.0";
  • mapping(bytes32 => AnchorRecord) private _anchors;

3.2 Structure de données

struct AnchorRecord {
    address anchorAddress; // 20 bytes
    uint64 timestamp;      // 8 bytes
    uint64 blockNumber;    // 8 bytes
}
// Packing en 1 slot (32 bytes) pour optimiser le gas

3.3 Mapping (clé/valeur)

  • Clé: bytes32 merkleRoot
  • Valeur: AnchorRecord
  • Sentinelle d’existence: timestamp != 0 (possible car block.timestamp ne vaut jamais 0 sur réseaux cibles)

3.4 Constantes / métadonnées

  • VERSION exposée on-chain.
  • Nom contractuel logique: ProbatioVaultAnchor (identité publique).
  • Pas de stockage mutable pour suppression/modification d’entrées.

4. Invariants de sécurité

  1. INV-53-01: seul owner peut exécuter anchor.
  2. INV-53-02: merkleRoot == bytes32(0) est toujours rejetée.
  3. INV-53-03: une racine ancrée ne peut jamais être ré-ancrée.
  4. INV-53-04: aucune fonction ne permet la modification d’un enregistrement existant.
  5. INV-53-05: aucune fonction ne permet la suppression d’un enregistrement existant.
  6. INV-53-06: chaque ancrage réussi émet exactement un MerkleRootAnchored.
  7. INV-53-07: les champs événementiels timestamp et blockNumber reflètent le bloc courant de la tx.
  8. INV-53-08: isAnchored(root) retourne true si et seulement si _anchors[root].timestamp != 0.
  9. INV-53-09: getAnchor(root) retourne des métadonnées cohérentes avec l’événement émis.
  10. INV-53-10: le contrat reste non-upgradable (pas de proxy, pas de delegate upgrade path).
  11. INV-53-11: l’historique des ancrages est append-only et indéfini en taille (limité uniquement par la chaîne).
  12. INV-53-12: renounceOwnership est interdite pour éviter un état ownerless irréversible (continuité opérationnelle).

Conditions interdites (NE JAMAIS se produire)

  • Ancrage d’une racine nulle.
  • Double ancrage d’une même racine.
  • Altération/suppression post-ancrage.
  • Validation dépendante d’une source off-chain propriétaire.

5. Critères d’acceptation (CA)

  1. CA-53-01: QUAND owner appelle anchor(root) avec root != 0x0 non ancrée ALORS la tx réussit.
  2. CA-53-02: QUAND anchor(root) réussit ALORS MerkleRootAnchored est émis avec merkleRoot=root.
  3. CA-53-03: QUAND anchor(root) réussit ALORS event.anchor == msg.sender (owner).
  4. CA-53-04: QUAND anchor(root) réussit ALORS event.timestamp == block.timestamp de la tx.
  5. CA-53-05: QUAND anchor(root) réussit ALORS event.blockNumber == block.number de la tx.
  6. CA-53-06: QUAND root vient d’être ancrée ALORS isAnchored(root) == true.
  7. CA-53-07: QUAND root n’a jamais été ancrée ALORS isAnchored(root) == false.
  8. CA-53-08: QUAND getAnchor(root) est appelée pour une racine ancrée ALORS retourne (anchorAddress,timestamp,blockNumber) non nuls et cohérents.
  9. CA-53-09: QUAND getAnchor(root) est appelée pour une racine absente ALORS revert MerkleRootNotAnchored(root).
  10. CA-53-10: QUAND un non-owner appelle anchor(root) ALORS revert OwnableUnauthorizedAccount.
  11. CA-53-11: QUAND owner appelle anchor(bytes32(0)) ALORS revert ZeroMerkleRoot().
  12. CA-53-12: QUAND owner appelle anchor(root) déjà ancrée ALORS revert MerkleRootAlreadyAnchored(root).
  13. CA-53-13: QUAND owner() est consultée ALORS retourne l’adresse owner courante.
  14. CA-53-14: QUAND owner appelle transferOwnership(newOwner!=0) ALORS owner() devient newOwner.
  15. CA-53-15: QUAND renounceOwnership() est appelée ALORS revert RenounceOwnershipDisabled().
  16. CA-53-16: QUAND VERSION() est consultée ALORS retourne strictement "1.0.0".
  17. CA-53-17: QUAND benchmark gas est exécuté sur anchor (nouvelle root) ALORS gas consommé < 50_000.
  18. CA-53-18: QUAND le contrat est publié sur Amoy/Sepolia ALORS le source code est vérifié sur explorateurs.
  19. CA-53-19: QUAND un tiers lit logs + vues ALORS il peut établir qu’un hash était ancré à une date/bloc donné sans backend ProbatioVault.
  20. CA-53-20: QUAND des ancrages successifs sont réalisés ALORS les entrées précédentes restent accessibles et inchangées.

6. Flux nominaux

6.1 constructor(address initialOwner)

  • Préconditions: initialOwner != address(0).
  • Étapes:
  • Initialiser Ownable(initialOwner).
  • Exposer VERSION.
  • Postconditions: owner défini, contrat prêt.
  • Événement: OwnershipTransferred(address(0), initialOwner) (Ownable).
  • Gas estimé: déploiement dépend bytecode (hors cible ENF-1).

6.2 anchor(bytes32 merkleRoot) external onlyOwner

  • Préconditions:
  • caller = owner
  • merkleRoot != bytes32(0)
  • !isAnchored(merkleRoot)
  • Étapes:
  • Vérifier contrôle d’accès onlyOwner.
  • Vérifier non-nullité.
  • Vérifier unicité.
  • Stocker AnchorRecord(msg.sender,uint64(block.timestamp),uint64(block.number)).
  • Émettre MerkleRootAnchored(merkleRoot,msg.sender,block.timestamp,block.number).
  • Postconditions:
  • isAnchored(merkleRoot)==true
  • getAnchor(merkleRoot) retourne les métadonnées stockées
  • Événement émis: MerkleRootAnchored.
  • Gas estimé: ~42k–48k (objectif < 50k).

6.3 isAnchored(bytes32 merkleRoot) external view returns (bool)

  • Préconditions: aucune.
  • Étapes:
  • Lire _anchors[merkleRoot].timestamp.
  • Retourner timestamp != 0.
  • Postconditions: aucun état modifié.
  • Événement: aucun.
  • Gas estimé: lecture view (RPC call off-chain, coût on-chain marginal si appelé par contrat).

6.4 getAnchor(bytes32 merkleRoot) external view returns (...)

  • Préconditions: racine existante.
  • Étapes:
  • Lire record.
  • Si absent (timestamp==0), revert MerkleRootNotAnchored(merkleRoot).
  • Retourner (anchorAddress,timestamp,blockNumber).
  • Postconditions: aucun état modifié.
  • Événement: aucun.
  • Gas estimé: lecture view.

6.5 transferOwnership(address newOwner) public onlyOwner (hérité)

  • Préconditions: caller owner, newOwner != address(0).
  • Étapes: changement owner.
  • Postconditions: nouveau owner actif pour futurs ancrages.
  • Événement: OwnershipTransferred(oldOwner,newOwner).

6.6 renounceOwnership() (override)

  • Préconditions: aucune utile.
  • Étapes: revert immédiat RenounceOwnershipDisabled().
  • Postconditions: owner inchangé.
  • Événement: aucun.

7. Flux alternatifs et d’erreur

ID Condition déclenchante Fonction Erreur / revert Code
ERR-53-01 caller non-owner anchor OwnableUnauthorizedAccount(caller) OZ-OWN-401
ERR-53-02 merkleRoot == bytes32(0) anchor ZeroMerkleRoot() PV-53-001
ERR-53-03 merkleRoot déjà présente anchor MerkleRootAlreadyAnchored(merkleRoot) PV-53-002
ERR-53-04 racine absente getAnchor MerkleRootNotAnchored(merkleRoot) PV-53-003
ERR-53-05 newOwner == address(0) transferOwnership OwnableInvalidOwner(address(0)) OZ-OWN-400
ERR-53-06 appel renounceOwnership renounceOwnership RenounceOwnershipDisabled() PV-53-004

8. Matrice de traçabilité (exigences → critères)

Exigence Description CA couverts
EF-1 Enregistrement root + rejet null + unicité + event CA-53-01, 02, 03, 04, 05, 11, 12
EF-2 Traçabilité publique événementielle CA-53-02, 03, 04, 05, 19
EF-3 Non-altérabilité / non-suppression CA-53-12, 20
EF-4 Vérifiabilité indépendante (isAnchored, getAnchor) CA-53-06, 07, 08, 09, 19
EF-5 Identité stable (nom/version/adresse non-upgradable/source vérifié) CA-53-16, 18
ENF-1 Gas maîtrisé <50k CA-53-17
ENF-2 Scalabilité append-only, durée longue CA-53-20
ENF-3 Pérennité, indépendance services externes CA-53-18, 19
ENF-4 Simplicité, surface minimale, non-upgradable CA-53-15, 16, 17, 20

Couverture: 100% des EF/ENF mappées à au moins un CA testable.


9. Considérations de sécurité

  • Access control: onlyOwner sur anchor, Ownable OpenZeppelin v5 audité; owner recommandé multisig.
  • Reentrancy: aucune interaction externe, aucun call; risque négligeable.
  • Overflow/underflow: Solidity ^0.8.20 protège nativement; cast uint64 sûr pour horizons temporels/pratiques (> 500 milliards d’années pour timestamp, > 500 milliards de blocs à cadence actuelle).
  • DoS gas: pas de boucles non bornées; complexité O(1) par ancrage.
  • Input validation: rejet explicite root nulle + duplication.
  • Immutabilité probatoire: pas de méthode update/delete; non-upgradable.
  • Key management risk (R-05): utiliser owner multisig; transferOwnership pour rotation contrôlée; renounceOwnership désactivé.
  • Front-running / ordering: non critique (owner unique); l’ordre de blocs est déjà la vérité probatoire.
  • Audit trail juridique:
  • preuve primaire: MerkleRootAnchored (log immuable)
  • preuve contextuelle: tx hash, block hash, block timestamp, block number
  • preuve d’intégrité: lecture croisée explorateur + RPC + getAnchor/isAnchored
  • opposabilité: tiers autonome sans composant propriétaire ProbatioVault.

10. Déploiement

10.1 Configuration Foundry (référence)

  • solc = "0.8.20" (ou ^0.8.20 verrouillé par foundry.toml)
  • Optimizer activé (ex: optimizer = true, optimizer_runs = 200)
  • evm_version = "paris" (ou réseau-compatible)
  • Tests: forge test -vvv
  • Gas snapshot: forge snapshot

10.2 Structure des scripts

  • script/deploy-polygon.s.sol
  • réseau: Polygon Amoy (chainId 80002)
  • lit PRIVATE_KEY + INITIAL_OWNER
  • déploie new ProbatioVaultAnchor(initialOwner)
  • log adresse + tx hash + block
  • script/deploy-arbitrum.s.sol
  • réseau: Arbitrum Sepolia (chainId 421614)
  • même logique

10.3 Vérification explorateurs

  • Polygon Amoy: vérification source (API Polygonscan Amoy)
  • Arbitrum Sepolia: vérification source (API Arbiscan Sepolia)
  • Commandes type:
  • forge script ... --broadcast --verify --verifier etherscan --etherscan-api-key $KEY
  • Exiger publication ABI + metadata compilateur.

10.4 Adresses attendues / déterminisme

  • Par défaut: CREATE classique, adresse différente par réseau.
  • Optionnel (si exigence d’adresse prédictible): CREATE2 via factory déterministe (même deployer + même salt + même bytecode => même adresse cross-chain).
  • Recommandation PD-53: CREATE suffit (EF-5 = stabilité post-déploiement, pas nécessité d’égalité d’adresse inter-chaînes).

10.5 Validation post-déploiement

  1. Appeler VERSION() et vérifier "1.0.0".
  2. Appeler owner() et vérifier adresse attendue.
  3. Exécuter un anchor(testRoot) sur chaque testnet.
  4. Vérifier présence de MerkleRootAnchored dans explorateur.
  5. Vérifier isAnchored(testRoot)==true.
  6. Vérifier getAnchor(testRoot) cohérent avec bloc/tx.
  7. Archiver adresses et tx de déploiement dans la documentation technique.

Si tu veux, je peux enchaîner directement avec la section “tests Foundry dérivés des CA-53-XX” (matrice test unitaire/integration + squelette ProbatioVaultAnchor.t.sol).