Implémentation des commentaires de transaction

Suite du sujet Protocole pour les commentaires de transaction (Ğ1v2 protocol) mais cette fois dans la catégorie Duniter-v2s puisqu’on passe vraiment à l’implémentation.

Le ticket en question est #235, et je me lance dans une pallet “describe” qui est une sorte d’hybride entre utility::batch et remark des pallets du frame.

Dans ce fil, je vais faire comme pour Implémentation des quotas, écrire en langage humain les choix d’implémentation que je suis en train de faire pour pouvoir les discuter le plus en amont possible et changer de direction tôt si nécessaire.


Vidéos d’avancement :

Je découvre dans les primitives substrate les interfaces TransactionIndex “Interface that provides transaction indexing API.” et OffchainIndex “Interface that provides functions to access the Offchain DB.”. On dirait un angle mort de ma compréhension de substrate qui permettrait de demander des choses au client substrate non obligatoires car hors du storage on-chain. Peut-être qu’on pourrait les utiliser pour interfacer proprement les actions onchain et offchain. → c’est tout ce qui concerne les offchain workers (ocw), mais en terme d’architecture, si la blockchain n’utilise jamais ces données, ça n’est pas utile de passer par ça, et plus simple de développer une autre stack déconnectée comme les datapods.

Voici le call store de la pallet remark :

#[pallet::call_index(0)]
#[pallet::weight(T::WeightInfo::store(remark.len() as u32))]
pub fn store(origin: OriginFor<T>, remark: Vec<u8>) -> DispatchResultWithPostInfo {
	ensure!(!remark.is_empty(), Error::<T>::Empty);
	let sender = ensure_signed(origin)?;
	let content_hash = sp_io::hashing::blake2_256(&remark);
	let extrinsic_index = <frame_system::Pallet<T>>::extrinsic_index()
		.ok_or_else(|| Error::<T>::BadContext)?;
	sp_io::transaction_index::index(extrinsic_index, remark.len() as u32, content_hash);
	Self::deposit_event(Event::Stored {
		sender,
		content_hash: content_hash.into(),
	});
	Ok(().into())
}

Si je comprends bien :

  1. extrinsic_index permet de référencer précisément l’extrinsic commenté avec remark (donc par exemple le batch qui contient remark et des transfers)
  2. sp_io::transaction_index::index() est utilisé pour stocker le content_hash dans le stockage offchain (par contre je ne vois pas les cas d’usage)

Ce serait plus souple de forker remark pour plus de comptabilité avec le CID IPFS, et peut-être plus simple d’utiliser un wrapper plutôt que de reposer sur batch dont le sens est moins précis.

Mais dans un premier temps on pourrait se contenter d’utiliser batch et remark pour éviter de développer une nouvelle pallet. Mais comme seul un hash blake2_256 est publié, il faudrait le convertir en CID hors chaîne (ajouter multibase et multicodec).


Si on se contente de ça, on a déjà ce qu’il nous faut dans le runtime :

cf le bloc 1639903 où je fais un batch avec un commentaire de transaction :

Voici mon extrinsic :

  • utility.batch
    • system.remarkWithEvent(“commentaire de transaction”)
    • balances.transferKeepAlive(Daigongen, 123)

Et je peux remplacer “commentaire de transaction” par un CID si je veux pouvoir demander la suppression de mon commentaire, ou juste utiliser moins de ressources blockchain pour un long message chiffré qui ne concerne de toute façon que moi ou mon destinataire.

L’événement publié est un hash blake2_256 : 0x911d0d1f204f5f8bced666c8619a686a3fc75940aa34b6f026a1e85aa2fe5ee0 qui correspond au hash de mon commentaire de transaction.


J’ai fait un deuxième commentaire pour faciliter l’ajout sur IPFS en utilisant un fichier qui finit par 0x0a : 1640537.

On voit que le hash est 0xfcbbe2e60bee2b1b0b7429997993d7e486df936c3b7d841f1ed5b932003cdc23. C’est un hash blake two 256.

On peut le convertir en CID comme ça :

import { CID } from 'multiformats/cid'
import * as multihash from 'multiformats/hashes/digest'
import { blake2b256 } from '@multiformats/blake2/blake2b'

const comment = 'commentaire de transaction' + String.fromCharCode(0x0a)
const commentBytes = Buffer.from(comment, 'ascii')
const commentHash = await blake2b256.encode(commentBytes)
const commentHashHex = Buffer.from(commentHash).toString('hex')

console.log(commentHashHex)

const hexHash = commentHashHex
const hashBytes = Buffer.from(hexHash, 'hex')
const mh = multihash.create(blake2b256.code, hashBytes)
const cid = CID.createV1(0x55, mh)

console.log(cid.toString())

On obtient bien le même hash, et le CID suivant : bafk2bzaced6lxyxgbpxcwgyloquzs6mt27sinx4tnq5x3ba7d3k3smqahtocg.

On peut donc retrouver le commentaire de transaction sur une passerelle IPFS :
:point_right: https://bafk2bzaced6lxyxgbpxcwgyloquzs6mt27sinx4tnq5x3ba7d3k3smqahtocg.ipfs.pagu.re/

En faisant ça, le message est inscrit en blockchain pour toujours. Mais si au lieu d’un commentaire de transaction on met un CID, on peut ensuite faire disparaître la donnée. Ce serait quand même plus confortable de :

  • préciser si le commentaire de transaction doit être interprété comme une donnée brute ou comme un CID (ou autre chose si on veut dans le futur). L’événement pourrait alors contenir soit le hash de la donnée brute, soit le CID directement.
  • wrapper le call à commenter dans le commentateur plutôt que d’avoir recours à un batch

Mais pour éviter de prendre du retard, je vais déjà tenter une implémentation complète sur ce schéma, c’est-à-dire :

  • dans duniter-squid pour collecter les hash annoncés et les convertir en CID pour graphql
  • dans ddd-ui pour soumettre un commentaire de transaction sur IPFS en même temps qu’un transfert sur Duniter

Petite vidéo point d’étape pour les commentaires de transaction :

00:00 intro
00:24 résumé sur les commentaires de transaction v1
01:20 ce qu’on aurait pu faire en v1 (et dont on a discuté)
02:34 ce qu’on envisage de faire en v2
04:44 parenthèse sur l’interopérabilité avec des systèmes automatisés
05:15 démonstration technique du début d’implémentation
06:12 batch call pour grouper la remarque avec les transactions
06:38 encodage hexadécimal du commentaire de transaction
08:28 examen du bloc incluant le batch avec commentaire
09:25 examen des données indexées par subsquid
10:13 au programme pour la suite
12:30 à bientôt


Dans un premier temps, je propose de ne rien implémenter côté Duniter et de partir sur :

  • si la “remark” est au format /ipfs/<cid> c’est une donnée “offchain”
  • sinon la “remark” est interprétée comme donnée “onchain”

Pour ce qui est des messages crowdfunding Ğchange par exemple, c’est au choix d’implémentation :

  • soit c’est onchain, dans ce cas pas de droit à l’oubli et c’est mieux si c’est plus petit que 256 bits
  • soit c’est offchain, dans ce cas liberté totale

Pour écouter un événement il suffit par exemple de produire une donnée unique et d’écouter les événements remark avec son hash.

Dans un second temps, j’aimerais bien implémenter côté Duniter une pallet “describe” similaire à “remark” mais :

  • qui limite le wrapping à un seul call et pas un array pour faciliter l’implémentation downstream (mais on peut prendre en charge le batch commentés si on veut)
  • qui permet de qualifier nativement le type de donnée, par exemple avec un enum :
    • en clair → événement avec un hash (blake two 256 par exemple)
    • hors chaîne IPFS → événement avec un CID

Mais j’ai d’abord besoin de faire une PoC complète et de plus d’échanges sur ce fil.

2 Likes

Si on part sur une solution qui n’implémente rien côté Duniter, et donc qu’on peut faire un batch qui inclut plusieurs remark et plusieurs transfers, comment l’interpréter dans un indexeur par exemple ? Ce serait plus simple de se limiter à une seule remark par batch, et donc d’avoir un wrapper custom.

Ensuite, on peut interpréter les bytes hors blockchain (par exemple avec un système de préfixe custom), mais ce serait plus simple d’avoir directement cette indication en blockchain. Voici les cas d’usage que j’identifie :

  • stockage “onchain” (pas de droit à l’oubli, utilisation probablement plus haute de la blockchain)
    • bytes à interpréter comme chaîne utf8 en clair
    • bytes à interpréter comme un identifiant standardisé (crowdfunding gchange, paiement pour un item x de référence donnée…)
    • bytes à interpréter comme message chiffré binaire
  • stockage “offchain” (“droit à l’oubli”, compatible avec une donnée plus volumineuse, mais nécessite une autre couche, par exemple via les datapods ipfs)
    • bytes à interpréter comme un CID IPFS (longueur connue d’avance)
    • bytes à interpréter comme un autre système de référencement hors chaîne (uuid, URL…)

Évidemment, tout ceci n’est qu’indicatif, puisque quelle que soit la catégorie qu’on définisse, on peut mettre les bytes qu’on veut, et inversement une plateforme peut décider d’encoder ses références comme un ascii suivant un format propre à elle. Mais définir ces catégories permet d’avoir plusieurs événements plutôt qu’un seul “remarkWithEvent” (un événement avec le hash d’un CID n’est pas très utile) :

  • bytes comme chaîne en clair → événement vide
  • bytes d’identifiant standardisé → événement avec une enum Rust
  • bytes comme CID → événement avec le CID

On pourrait aussi distinguer en fonction de ce qu’on commente. Par exemple, pour le call createIdentity on pourrait mettre un commentaire avec le pseudo que nous a donné la personne. Même si dans un premier temps on ne commente que les transferts (éventuellement tous les transferts d’un batch), c’est intéressant d’imaginer des évolutions pour ce système.

PS : si vous êtes intéressés par l’histoire de remarkWithEvent, c’est ici : emit event on remark by xlc · Pull Request #8120 · paritytech/substrate · GitHub

Pour ceux qui voudraient des nouvelles en avant-première, cf les branches hugo/tx-comments sur gcli et duniter-squid. Pour les autres, je ferai un retour détaillé plus tard.

Suite aux discussions sur Remboursement des frais de transaction en cas de bloc non-plein - #20 by tuxmain, il semble qu’il soit important d’ajouter une pallet (“describe” par exemple) permettant de commenter un call afin de pouvoir limiter la taille du commentaire (contrairement à system.remark()), ce qui apparaît nécessaire dans le cas de transactions gratuites.

Cela permettrait par la même occasion :

  • de commenter de manière plus claire que via un batch (un seul commentaire par call)
  • d’adapter l’événement aux cas d’usage (CID ou simple hash)

Je suis un peu à plat, donc c’est pas hyper clair, mais voici une deuxième vidéo sur les commentaires de transaction :

00:00 début
00:16 #commentaires sur le forum
01:26 Ǧcli version 0.2.14 commentaire onchain
02:30 commentaire offchain : soumission d’un CID
03:55 inspection des commentaires dans l’indexeur
06:00 stockage sur un noeud local ou demande d’indexation utilisant les datapods
09:17 ce qu’il y a à faire du point de vue des clients
11:00 l’idée : indiquer comment décoder les données mises en blockchain → nouvelle pallet Duniter
12:22 fin

Il faudra discuter de ce qu’on met en place pour la soumission de la donnée des commentaires de transaction offchain. Pour l’instant je suis parti sur le réseau datapod puisque qu’il était présent, mais on pourrait partir sur un autre système d’épinglage des données sur IPFS (pour l’instant je n’en connais aucun décentralisé).

4 Likes

L’implémentation des commentaires de transaction est actuellement faite à partir de remark, qui offre un point d’attaque non négligeable pour un attaquant, car la taille de cet extrinsic n’est pas limitée. Contrairement au poids d’un extrinsic, aucun contrôle n’est effectué en pré-dispatch, hormis celui de vérifier que l’extrinsic entre dans le bloc.

Trois solutions évidentes s’offrent à nous pour corriger ce problème :

  1. Modifier le LengthToFee (conversion de la taille d’un extrinsic en frais) de notre chaîne de manière à facturer des frais directement lorsque la taille de l’extrinsic dépasse un certain seuil, et ce, quel que soit le remplissage du bloc.
  2. Modifier le remark existant dans notre fork du polkadot-sdk pour limiter la taille maximale de l’extrinsic.
  3. Créer une pallet dédiée aux commentaires de transaction.
Solution Avantages Inconvénients
Solution 1 Rapide à implémenter, facile à maintenir. Possibilité d’autoriser les commentaires plus longs moyennant des frais supplémentaires. La limite fixe en longueur impactera tous les extrinsics de la chaîne qui dépassent cette valeur (ce qui ne devait pas poser trop de problème dans notre cas).
Solution 2 Ne touche que l’extrinsic de remark. Il est nécessaire d’implémenter également un benchmark pour la logique de ce nouvel extrinsic. Nécessite un travail supplémentaire lors des mises à jour pour cherry picker les modifications.
Solution 3 Très flexible Conception et implémentation plus longues

J’aurais tendance à privilégier la solution 1, qui permettrait de stabiliser rapidement les commentaires de transaction tout en garantissant la sécurité de la chaîne, et de prévoir dans un second temps une pallet dédiée pour ajouter des fonctionnalités.

3 Likes

Au vu de ce tableau, je suis assez favorable à la solution 1. L’idée est bien d’avoir la gratuité des frais tant que l’utilisation ne nuit pas, mais des remark trop longues sont justement nuisibles, donc le frais est légitime ! À noter que les runtime upgrade dépasseront probablement largement cette limite et qu’il nous faudra prévoir la monnaie pour en couvrir les frais :smiley:

Est-ce que la solution 1 signifie qu’on pourra envoyer des remark gratuitement même sans transfert ? Si oui le spam est encore possible.

Pourquoi ne pas appliquer des frais dans tous les cas, et compter sur le quota pour la gratuité ?

Oui, mais on en revient à la discussion sur les transactions gratuites. Le spam est possible jusqu’à un certain point, au-delà duquel il est découragé par les frais. Cela reviendrait au même avec la solution 2.

C’est prêt dans la MR !278. La restriction sur l’utilisation de system.remark est levée, avec un comportement additionnel des frais pour se prémunir contre une attaque, donc je l’ai mentionné dans le sujet sur la gratuité des frais : Remboursement des frais de transaction en cas de bloc non-plein - #25 by HugoTrentesaux.

2 Likes

Donc si je comprends bien la MR !278 :

  1. Les commentaires courts sont gratuits :

    • Jusqu’à 146 octets de données
    • Plus 110 octets techniques
    • Total max gratuit = 256 octets
    • En gros en utf8 ça fait jusqu’à 146 caractères gratuits non accentués
  2. Les commentaires plus longs sont payants :

    • Prix proportionnel à la longueur
    • Limite max = taille d’un bloc (5 Mo)
    • Remplir un bloc = 115 ĞDev
  3. Protection anti-spam :

    • Max 3 840 commentaires gratuits par bloc
    • Au-delà, tout devient payant
    • Prix minimum = 10 mĞDev pour 256 octets

Est-il possible pour un client de prédire le coût d’un commentaire de tx ?
Par défaut on va comptabiliser en nombre de caractère, donc il va falloir trouver une astuce pour prédire les coûts en bytes.

Le plus simple étant de n’autoriser que 36 caractères max côté client, étant donné qu’un caractère peut valoir 4 octets max en utf8 si je ne m’abuse, consédirant que c’est suffisant pour un commentaire de tx. Au pire avec une case à cocher “Permettre jusqu’a 1.3 millions de caractères mais t’aura peut être des frais inch’alla”

2 Likes

Comme un commentaire est une transaction Remark, on peut en prédire le coût de la même manière qu’un extrinsic (api.call.transactionPaymentApi.queryInfo).

1 Like

Je viens d’y penser : que fait-on si un petit plaisantin s’amuse à utiliser les remarks gratuits comme support de stockage de masse à des fins personnelles ? Ça fait 146*3840*86400/6 octets/jour soit 7 Go/jour si la blockchain est peu utilisée par ailleurs.

Les nœuds archives ne vont pas aimer.

Quelles seraient les conséquences sur les indexeurs, est-ce qu’on peut les DoS par ce biais ?

Le client peut facilement obtenir la longueur en octets d’une chaîne UTF-8 et afficher cette longueur. Les gens ne devraient pas être trop surpris qu’une lettre accentuée, un idéogramme ou un smiley prenne plus de place.

Si tu veux vraiment optimiser pour les langues latines tu peux utiliser l’encodage latin-1 qui code la plupart des lettres accentuées sur un seul octet, mais il y a des collisions avec UTF-8 qu’on ne peut pas détecter automatiquement donc ça oblige à inventer un standard pour définir l’encodage (donc d’y dédier un octet supplémentaire), et ça complique les opérations pour les clients.

Et si on utilise ce champ pour mettre un identifiant IPFS, ce n’est plus un problème.

1 Like

Effectivement, si c’est le cas on pleure. On sature l’espace disque des nœuds qui ne font pas de block pruning (pas seulement des nœuds archive), on sature l’espace disque des indexeurs. On en revient toujours au même problème (Comment partager équitablement cette ressource commune qu'est la blockchain Ğ1?).

Je suppose que la solution qui nous correspondrait le mieux serait d’ajuster le seuil par bloc au-delà duquel les frais sont déclenchés. Actuellement on a :

// Maximal weight proportion of normal extrinsics per block
pub const NORMAL_DISPATCH_RATIO: Perbill = Perbill::from_percent(75);

/// We allow for 2 seconds of compute with a 6 second average block time.
pub BlockWeights: frame_system::limits::BlockWeights = frame_system::limits::BlockWeights::with_sensible_defaults(Weight::from_parts(WEIGHT_REF_TIME_PER_SECOND * 2u64, u64::MAX), NORMAL_DISPATCH_RATIO);
pub BlockLength: frame_system::limits::BlockLength = frame_system::limits::BlockLength::max_with_normal_ratio(5 * 1024 * 1024, NORMAL_DISPATCH_RATIO);

let normal_max_length = *length.max.get(DispatchClass::Normal) as u64;

if current_block_weight
    .get(DispatchClass::Normal)
    .all_lt(Target::get() * normal_max_weight)
    && current_block_length < (Target::get() * normal_max_length)
    && fee_multiplier.is_one()
    && length_in_bytes.ref_time() < MAX_EXTRINSIC_LENGTH
{
    0u32.into()
} else {
    Self::Balance::saturated_from(
        length_in_bytes.ref_time() / BYTES_PER_UNIT + BASE_EXTRINSIC_LENGTH_COST,
    )
}

On peut modifier la ligne

&& current_block_length < (Target::get() * normal_max_length)

en remplaçant normal_max_length par un truc du genre target_max_length calculé pour que le débit de données soit acceptable.

On peut également réduire directement la taille maximale du bloc (en mode DispatchClass::Normal, afin de ne pas interférer avec les runtime upgrades et autres opérations critiques). Actuellement, cette taille est définie par défaut par Substrate (BlockLength in frame_system::limits - Rust pour un max de 5 * 1024 * 1024 bytes), mais elle n’est probablement pas adaptée à une chaîne qui accepte des données stockées on-chain et qui accepte des transactions gratuites sachant que toute transaction a une taille même si minime.

Comptez sur moi.


Pourrions-nous avoir quelques précisions sur la balance complexité/flexibilité de cette option ?

Naïvement on pourrait se dire qu’il ne s’agirait que d’une copie de la palette remark, mais spécialisé pour les besoins des commentaires de tx.

Dans l’absolu, est-ce que cette approche nous permettrait de mieux répondre au besoin de limitation sans impacter le reste du fonctionnement de la palette remark ?

Si oui, est-ce que ça resterait toujours plus simple que de concevoir un système de commentaires hors chaîne efficace avec tout ses effets de bords ?

Juste par curiosité, pour essayer de sonder le problème et nos options.


A priori, j’ai le sentiment que ce système de quota de tx gratuite pour tous nous offre un ticket direct vers un bruit de fond résiduel permanant inévitable, avec augmentation de la taille de la Blockchain rapide, meme si nous réduisons la taille des blocs. Une attaque simple est de remplir tout les blocs juste en dessous du quota gratuit, laissant ainsi juste la marge pour des TX légitimes payantes, et des saturations de blocs plus facilement atteignables.

2 Likes

Une autre idée à creuser serait d’ajouter des “txextensions” (comme celles qui vérifient le nonce ou la mortalité). Elles pourraient permettre de poser des limites par compte, par exemple un nombre max de transactions gratuites sur une période donnée, un débit max en taille/poids, etc. Je pense peut-être faire un petit POC pour voir si ça pourrait coller à notre usage, mais c’est un peu exploratoire.

Reste que si les commentaires sont on-chain (même sur une pallet dédiées), il faudra toujours prévoir une blockchain qui peut grossir. À titre d’exemple, Kusama, qui a démarré en 2019, nécessite actuellement environ 600GB pour faire tourner un nœud archive, donc ça peut grossir vite même si on n’accepte pas beaucoup de données.

4 Likes