Implémentation des quotas

Je reprends la réflexion sur l’exonération des frais et l’implémentation de quotas. Les sujets liés sont regroupés sur le post Fees. Le sujet qui prend le problème à la racine est Comment partager équitablement cette ressource commune qu'est la blockchain Ğ1?, je recommande de relire les premiers posts attentivement.

Voici mes hypothèses :

  • le problème “comment partager équitablement cette ressource commune qu’est la blockchain” est compliqué et on n’est pas prêt de trouver une solution qui satisfasse tout le monde
  • l’idée de frais de transaction en Ğ1 est une solution robuste qui garantit une certaine protection contre le spam, c’est ce que font toutes les autres cryptos, et qu’ils soient abordables (Solana :wink: @flodef) ou exagérés et critiqués (bitcoin, ethereum…), ça fonctionne
  • le concept de frais est connoté négativement : il fait penser aux frais bancaires souvent abusifs (frais de gestion de compte, frais de virement instantané, frais de carte bleue, frais de paiement à l’étranger…)
  • l’introduction de frais de transaction dans la Ğ1 provoquerait le rejet d’une partie de la communauté et compliquerait son adoption en augmentant la méfiance des personnes qui découvrent la monnaie libre, non pas à cause de leur coût, mais de leur image

C’est pourquoi je pense qu’une bonne solution consisterait à :

  • conserver l’idée des frais de transaction (technique éprouvée pour le partage de ressource blockchain)
  • offrir les frais de transaction dans certains cas comme gage de confiance

Attention, il ne s’agit pas du remboursement des frais avant ou après leur paiement (qui répond au problème du coût mais pas de l’image) mais bien leur exonération au moment de la transaction.

L’idée serait par exemple d’avoir 1 Ğ1 (gift) de frais offerts pour une durée d’une semaine (period) pour chaque identité membre de la toile de confiance principale.


En pseudo-code

On ajoute les champs suivants

  • un champ linked_idty: Option<IdtyIndex> dans AccountData qui peut être modifié par l’identité en question
  • un champ exempted_fees: (since: BlockNumber, fees: Balance) dans IdtyValue initialisé à (0,0)

À chaque extrinsic on applique les conditions suivantes jusqu’à :red_square: paiement des frais ou :green_square: exonération des frais

  • si linked_idty est à None, le compte paie les frais :red_square:
  • si linked_idty est à Some(index), on regarde s’il reste des quotas gratuits à l’identité associée :arrow_down_small:
  • si l’identité n’est plus membre, le compte paie les frais et linked_idty redevient None :red_square:
  • si fees est inférieur à gift, les frais sont exonérés, fees est incrémenté :green_square:
  • on regarde ensuite la valeur de interval = current_block - since, la durée qu’il a fallu à l’identité pour épuiser ses quotas gratuits :arrow_down_small:
  • si interval est supérieur à period, on réinitialise exempted_fees à (current_block, value) avec value < gift et les frais sont exonérés, fees est incrémenté :green_square:
  • l’identité a dépensé son quota trop rapidement et le compte doit payer des frais ou attendre :red_square:

On a trois paramètres

  • la valeur de gift
  • la valeur de period
  • la valeur de value qui peut être fonction de interval

Par exemple on pourrait mettre value = gift × period / interval pour donner mois de quotas supplémentaires aux personnes qui ont utilisé trop vite tous leurs quotas. On peut aussi tout simplement mettre 0.


C’est pas du tout mature comme idée, mais je partage dès maintenant les idées pour éviter d’aller trop loin dans une mauvaise direction.

3 Likes

C’est ce que j’avais proposé dans ce message, à part que l’exonération est binaire et non partielle en cas de dépassement, et que ça ajoute une dépendance de duniter-account à identity.

Si les comptes exonérés sont exactement les comptes membres, il n’y a pas besoin d’ajouter un champ identité dans duniter-account, il y a déjà cette map dans identity. Je verrais plutôt un provider AccountId->Option donné en paramètre à duniter-account.

2 Likes

Ok, donc je note les points suivants :

  • exonération partielle ou binaire de la transaction qui amène à un dépassement
  • nécessité ou non d’exonérer des comptes non membres
  • manière d’ajouter des données dans duniter-account

Pour ce dernier point, comment fonctionnerait le provider ? Parce que si c’est une Map, ça fait une recherche dans la map pour chaque transaction, ça me paraît un peu lourd.


Je reformuler ta proposition pour pouvoir comparer plus facilement

On ajoute le champ

  • quotas: (last_use: BlockNumber, amount: Balance) dans IdtyValue initialisé à (0,0)

À chaque extrinsic on applique les conditions suivantes jusqu’à :red_square: paiement des frais ou :green_square: exonération des frais

  • si account_id n’est pas présent dans la map account_id → idty_index le compte paie les frais :red_square:
  • pour l’identité reliée au compte, on incrémente amount de (current_block - last_use) × factor avec une valeur maximale égale à Q :arrow_down_small:
  • on met à jour last_use à current_block :arrow_down_small:
  • on décrémente amount d’une valeur x égale au minimum :arrow_down_small: entre la valeur des frais et la valeur de amount
  • le compte paie les frais d’une valeur réduite de x (:green_square: / :red_square: en fonction du montant)

On a deux paramètres

  • la valeur de Q (similaire à gift dans l’implémentation précédente)
  • la valeur de factor (qui est égale à 1 / period de l’implémentation précédente)

On peut toujours faire de factor une fonction de last_use et de amount si on veut changer le comportement.

1 Like

Un des arguments que j’avais en faveur de l’exonération binaire était que l’implémentation était plus simple (juste à régler la valeur de pays_fee), mais en fait ça a l’air tout aussi simple de changer le actual_weight :

/// Weight information that is only available post dispatch.
/// NOTE: This can only be used to reduce the weight or fee, not increase it.
#[derive(Clone, Copy, Eq, PartialEq, Default, RuntimeDebug, Encode, Decode, TypeInfo)]
pub struct PostDispatchInfo {
	/// Actual weight consumed by a call or `None` which stands for the worst case static weight.
	pub actual_weight: Option<Weight>,
	/// Whether this transaction should pay fees when all is said and done.
	pub pays_fee: Pays,
}

Donc à première vue, il n’y a pas de quoi discriminer entre les deux.


Par rapport à l’exonération des comptes membres uniquement ou portefeuille également, je ne pense pas qu’il faille s’inquiéter du couplage duniter-account - identity. Vu ce qu’apporte Duniter, je trouve même ça normal.
Et j’aimerais bien qu’on puisse recommander aux gens de mieux sécuriser leur compte membre et d’utiliser des comptes portefeuille au quotidien sans qu’ils aient à payer des frais pour ça.


Par rapport à la manière de stocker la donnée compte → identité :

  • si on ajoute un champ dans les AccountData, ça ajoute effectivement un champ None pour de potentiellement très nombreux comptes portefeuilles non rattachés à un compte membre
  • si on ajoute une storage map AccountId → IdtyIndex, on se retrouve avec un map qui contient une entrée potentiellement pour la quasi totalité des comptes

Le tout est de savoir dans quelle situation on a le plus de chance de se trouver et les inconvénients de choisir une solution inadaptée. Vu le public de Duniter, je pencherais plutôt pour la première solution.

1 Like

Est-ce que changer actual_weight n’est pas risqué en terme de dépassement de la capacité du bloc ?

Je ne sais pas où on peut utiliser PostDispatchInfo, mais si c’est à un endroit où on a déjà lu une entrée db correspondant au compte alors c’est plus intéressant en effet (on économise une lecture).

Sinon dans l’hypothèse exonération partielle, tout ce code devra être dans OnChargeTransaction (on pourra reprendre la structure de oneshot-account et le brancher juste avant ce dernier). Je pensais plutôt faire une crate quota avec une map AccountId -> (last_use, amount). Ainsi on fait une seule lecture db. Cette map sera mise à jour (ajout/retrait d’identités) soit par les handlers de duniter-wot, soit dans des handlers de runtime-common.

Je ne sais pas si c’est risqué, tu as sûrement plus d’expérience que moi après avoir travaillé sur OnChargeTransaction.

Donc une pallet de plus ? Et cette map vaut uniquement pour les comptes membre, j’imagine. On ne peux pas l’étendre aux délégations aux portefeuilles.

Une palette de plus, mais comme elle est découplée de l’identité on pourra facilement ajouter le partage de quota avec des comptes portefeuille.

Cette solution peut être efficace si le partage de quota est fixe (on déplace 20% du quota de son compte membre à un compte portefeuille) car il suffit d’ajouter une entrée dans la map pour le nouveau compte avec quota, et mettre à jour le quota du compte membre (il faut donc ajouter un champ pour le quota maximum du compte).

Par contre elle l’est moins si on veut un quota fluide entre plusieurs comptes (chaque compte portefeuille autorisé peut piocher dans le quota de l’identité). Dans ce cas-là il faut stocker le quota dans l’identité et transformer cette map en AccountId -> IdtyId.

Dans ce dernier cas on peut anticiper et faire un truc plus générique qui permettra plus de délégation (autoriser non seulement le partage de quota, mais aussi certains calls au nom du compte principal). Cependant, le quota étant très particulier (pas un call, et il faut vérifier l’état de membre), ce n’est pas forcément pertinent (introduit des traitements différents, alors que pour les calls le traitement sera plus uniforme).

Je veux dire que je ne sais pas si ça influence uniquement le calcul des frais, ou aussi les règles de remplissage du bloc et le benchmark.

À mon avis, il vaut mieux éviter de faire une pallet de plus, et la pallet duniter-account ne faisant quasiment rien, ça me paraît être le bon endroit pour implémenter les quotas (qu’on veut dans l’idée garantir pour tous les comptes qui déclarent leur identité).

D’un point de vue sémantique, ma proposition linked_idty n’a aucun rapport avec les quotas, c’est juste une manière de dire qu’un compte est rattaché à une identité (unique).

Par contre, effectivement, IdtyValue n’est pas forcément le bon endroit pour ranger une information de quotas. Ce serait mieux de le mettre dans IdtyData.

Effectivement, changer le weight c’est pour rendre compte de ce qui est réellement consommé : https://docs.substrate.io/build/tx-weights-fees/#post-dispatch-weight-correction

Il y a un choix que je n’ai pas bien compris dans l’implémentation des oneshot account. Pourquoi wrapper CheckNonce et OnChargeTransaction plutôt que de se contenter de configurer respectivement SignedExtra et pallet_transaction_payment au niveau du runtime ?

Pour moi, les implémentations suivantes devraient être propres au runtime.

impl CheckNonce
{
    // [...]
    fn additional_signed(&self) {
        self.0.additional_signed()
    }

    fn pre_dispatch(/* [...] */) {
        if let Some(
            crate::Call::consume_oneshot_account { .. }
            | crate::Call::consume_oneshot_account_with_remaining { .. },
        ) = call.is_sub_type()
        {
            Ok(())
        } else {
            self.0.pre_dispatch(/* [...] */)
        }
    }

    fn validate(&self, /* [...] */) {
        self.0.validate(/* [...] */)
    }
}
impl OnChargeTransaction
{
  // [...]
  fn withdraw_fee(/* [...] */) {
        if let Some(
            Call::consume_oneshot_account { .. }
            | Call::consume_oneshot_account_with_remaining { .. },
        ) = call.is_sub_type()
        {
          // [...] (d'ailleurs il reste un TODO dedans)
        } else {
            T::InnerOnChargeTransaction::withdraw_fee(/* [...] */)
        }
    }
}

Si on ajoute une autre pallet qui configure davantage OnChargeTransaction selon le même schéma, il faudra choisir dans quel ordre on wrappe, ce qui n’a pas de sens je trouve :

// état actuel
impl pallet_transaction_payment::Config for Runtime {
    type OnChargeTransaction = OneshotAccount; // pourquoi ??
}
impl pallet_oneshot_account::Config for Runtime {
    type InnerOnChargeTransaction = CurrencyAdapter<Balances, HandleFees>;
}
// si on customise davantage, en wrappant dans cet ordre
// OnChargeTransaction → OneshotAccount → Other → CurrencyAdapter
impl pallet_other::Config for Runtime {
    type InnerOnChargeTransaction = CurrencyAdapter<Balances, HandleFees>; 
}
impl pallet_oneshot_account::Config for Runtime {
    type InnerOnChargeTransaction = Other;
}
impl pallet_transaction_payment::Config for Runtime {
    type OnChargeTransaction = OneshotAccount;
}
// on pourrait inverser OneshotAccount et Other 
1 Like

Ce devait être Élois qui m’avait dit de faire comme ça, dans un esprit de modularité.

Mais en effet il sera plus pratique d’éviter d’imbriquer des wrappers quand il y en aura plus.

1 Like

Ok, merci pour la réponse.

Je me rends compte qu’avant d’implémenter des quotas, il y a pas mal de tests à ajouter pour vérifier le comportement des frais, notamment que tout est viré vers la trésorerie plutôt que de disparaître. Et le tip doit être versé au validateur s’il est défini à la trésorerie aussi, c’est plus simple.

1 Like

Un risque avec cette implémentation des quotas est que si le weight est ajusté et que par conséquent le fee est revu à la baisse, correct_and_deposit_fee risque de rembourser en Ğ1 des frais qui ont été payés avec des quotas et par conséquent créer de la monnaie de nulle part.

Pour éviter ça, je pense qu’il faudrait éviter de mélanger les registres “poids, frais, monnaie, quotas” et utiliser la pallet asset-tx-payment à la place de la pallet transaction-payment. Donc on aurait effectivement une monnaie poids non transférable régie par la règle des quotas.

C’est un peu “départ à la case zéro” en matière de conception pour les frais, et ça implique d’assez gros changements dans le code. Mais j’ai peur qu’on se prenne les pieds dans le tapis si on ne le fait pas. Dans tous les cas il faut ajouter plein de tests pour les différents cas de figure de frais pour bien comprendre le fonctionnement (cf Comportement actuel des frais pour un exemple mystérieux).

<transaction_payment::CurrencyAdapter as OnChargeTransaction>::correct_and_deposit_fee prend already_withdrawn en argument donc il gère déjà le remboursement en fonction de ce qui a réellement été payé.

Il suffit de ne pas payer avec les quotas, mais de réduire les frais directement, de sorte à retourner ce qui a été payé hors quota dans withdraw_fee.

Si on ne wrappe pas les OnChargeTransaction on risque d’avoir plus de bugs (pas d’isolation entre les comportements des différentes palettes puisque tout est mélangé dans une fonction commune). Du coup ça peut valoir le coup de créer une macro qui génère cette imbrication mais avec une syntaxe en liste ordonnée (par ordre d’exécution) plutôt que d’indiquer sur chaque palette celle qui vient après.

1 Like

On peut avoir une fonction qui est juste un match et qui appelle les OnChargeTransaction des pallets.

// comme ça, mais pour toutes les pallets qui s'occupent de onchargetransaction
if let Some(
    Call::consume_oneshot_account { .. }
     | Call::consume_oneshot_account_with_remaining { .. },
) = call.is_sub_type()
1 Like

La difficulté que j’identifie est que l’on doit décider de PaysFee (Pays::Yes / Pays::No) en ayant connaissance des frais d’extrinsics, et donc on ne peut pas le faire au niveau du retour DispatchResultWithPostInfo du Call (un call n’a connaissance que de son poids maximal, pas de la conversion de ce poids en frais ni du tip associé). Donc on doit de toute façon payer des frais (générer un imbalance) et le prendre en charge de différentes manières.

Donc si je résume la manière dont on va insérer les quotas dans le paiement des frais :

  • on configure la pallet pallet_transaction_payment
  • on définit son type OnChargeTransaction
  • avec quelque chose qui implémente le trait OnChargeTransaction

c’est-à-dire

pub trait OnChargeTransaction<T: Config> {
	type Balance: frame_support::traits::tokens::Balance;
	type LiquidityInfo: Default;
	fn withdraw_fee(
		who: &T::AccountId,
		call: &T::RuntimeCall,
		dispatch_info: &DispatchInfoOf<T::RuntimeCall>,
		fee: Self::Balance,
		tip: Self::Balance,
	) -> Result<Self::LiquidityInfo, TransactionValidityError>;
	fn correct_and_deposit_fee(
		who: &T::AccountId,
		dispatch_info: &DispatchInfoOf<T::RuntimeCall>,
		post_info: &PostDispatchInfoOf<T::RuntimeCall>,
		corrected_fee: Self::Balance,
		tip: Self::Balance,
		already_withdrawn: Self::LiquidityInfo,
	) -> Result<(), TransactionValidityError>;
}
  • la fonction withdraw_fee distingue plusieurs cas particuliers
  • le cas des calls consume_oneshot_account (déjà traité hormis le TODO dans le code)
  • le cas des calls pour lesquels on ne veut pas pouvoir appliquer de quotas, dans ce cas on utilise l’implémentation par défaut du CurrencyAdapter
  • le cas des calls pour lesquels on veut pouvoir appliquer des quotas
  • dans ce cas withdraw_fees applique le comportement discuté dans les posts au dessus, ce qui peut aboutir à deux cas
  • le cas où les frais sont payés en Ğ1, withdraw est appelé sur le type Currency
  • le cas où les frais sont payés en Ğ1, on met en oeuvre la mécanique des quotas

Une implémentation possible serait de définir un autre token (fongible) mais non transférable pour les quotas. À voir si c’est moins d’efforts que “à la main” vu que toute la logique est déjà implémentée.

Dans tous les cas, le résultat de withdraw_fees est collecté dans le pre_dispatch. S’il s’agit de InitialPayment::Native(already_withdrawn) (non nul), c’est au niveau du post_dispatch que correct_and_deposit_fee est appelé et rembourse ou dépose en trésorerie.


Note : de cette manière, il ne peut pas y avoir de remboursement de quotas et le montant maximal est toujours prélevé, il vaudrait donc mieux utiliser une implémentation à base de tokens si on veut que les quotas soient remboursés au même titre que des frais une fois le poids réel du call connu.

Je pense que je me trompe d’endroit pour insérer cette gestion des quotas. Ce serait plutôt au niveau de correct_and_deposit_fee. En très résumé :

  • les frais maximum sont prélevés avec withdraw_fee
  • le call est exécuté, le poids d’exécution mesuré
  • si des quotas sont disponibles, ils sont décrémentés et les frais sont remboursés entièrement (sauf tip)
  • sinon les frais sont remboursés partiellement en fonction du poids réel et le reste est viré à la trésorerie

Problème : ça ma paraît être une mauvaise idée d’exécuter des traitements potentiellement compliqués (comme une lecture en db) dans un post_dispatch

Peut-être qu’il faudrait plutôt un call “reimburse_fees_using_quotas” appelé comme hook d’un paiement de frais. Ce serait une implémentation plus fidèle à ce que c’est vraiment : un “patch grossier pour exonérer les frais” plutôt que un “mécanisme essentiel profond”.

1 Like

Encore une autre idée pour découpler la question des quotas de la question des frais :

  • le comportement des frais est laissé tel quel
  • dans correct_and_deposit_fee on ajoute les frais payés par le compte à une queue de remboursement limitée en taille
    refund_queue: BoundedVecDeque<(AccountId, Fee), MAX_QUEUED_REFUNDS>
  • dans on_idle on traite les remboursements

De cette manière :

  • on est sûr de ne pas toucher un aspect structurant de substrate que sont les frais d’extrinsic
  • on découple le paiement des frais de leur remboursement
  • en cas de saturation de blockchain, la queue se sature et les remboursements sont différés
  • on n’a pas à trop se soucier du surcoût associé aux quotas parce qu’il “recycle” une ressource blockchain non utilisée
  • on fournit à l’utilisateur notre engagement de toujours “utiliser gratuitement une monnaie libre gérée comme un commun numérique”

(ok le dernier point c’est de la déco, mais il faut garder en tête la raison pour laquelle on ajoute ces quotas)

4 Likes

Solution pour le moins élégante !

Ça me plaît mieux que les propositions précédentes qui semblaient très complexes.

Par contre, si plusieurs blocs d’affilée sont saturés (même par des transactions hors-quota), la queue sera saturée et n’aura pas l’occasion d’être traitée. Les transactions admissibles au quota émises pendant cette période ne seront jamais remboursées. (certes c’est très improbable)
Pour partiellement corriger ça, peut ajouter une préférence (déterministe ou probabiliste) pour les transactions éligibles au quota dans le client.

Les quotas sont une manière de ne pas faire payer des frais aux utilisateurs quand la ressource est abondante. Mais quand elle devient rare, c’est une solution systémique qu’il faut trouver comme :

  • augmenter la puissance totale de la blockchain en changeant de machine de référence
  • développer des solutions de type “layer 2” pour limiter les actions en blockchain principale
  • optimiser le coût de base des opérations (on a un peu de marge de ce côté aussi, il ne faut pas l’oublier)

En fait c’est un autre sujet donc je n’essaye pas de le traiter ici.