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 @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’à paiement des frais ou exonération des frais
si linked_idty est à None, le compte paie les frais
si linked_idty est à Some(index), on regarde s’il reste des quotas gratuits à l’identité associée
si l’identité n’est plus membre, le compte paie les frais et linked_idty redevient None
si fees est inférieur à gift, les frais sont exonérés, fees est incrémenté
on regarde ensuite la valeur de interval = current_block - since, la durée qu’il a fallu à l’identité pour épuiser ses quotas gratuits
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é
l’identité a dépensé son quota trop rapidement et le compte doit payer des frais ou attendre
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.
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.
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’à paiement des frais ou exonération des frais
si account_id n’est pas présent dans la map account_id → idty_index le compte paie les frais
pour l’identité reliée au compte, on incrémente amount de (current_block - last_use) × factor avec une valeur maximale égale à Q
on met à jour last_use à current_block
on décrémente amount d’une valeur x égale au minimum entre la valeur des frais et la valeur de amount
le compte paie les frais d’une valeur réduite de x ( / 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.
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.
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.
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.
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 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
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.
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.
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()
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
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”.
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)
Ç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.