diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8f92a74dd07b02da17f3f6d3104bbca04c362cba..d8337e427bd63831ca791893300ea6839ed9dee3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -2,6 +2,7 @@ stages:
   - build
   - test
   - publish
+  - release
 
 build:
   stage: build
@@ -59,10 +60,14 @@ pages:
   stage: test
   image: node:latest
   coverage: '/Statements[^0-9]+(\d+\.\d+)%/'
+  variables:
+    VERSION: "$CI_COMMIT_TAG || 'latest'"
   script:
     - npm run test:production
     - mkdir -p public/dist
     - cp -rf generated/npm/* public/dist/
+    - 'cp generated/npm/browser/all.mjs public/dist/browser/$VERSION.mjs'
+    - 'cp generated/npm/nodejs/all.mjs public/dist/nodejs/$VERSION.mjs'
     - mv generated/coverage public/
     - mv generated/jscpd/html public/jscpd
     - mv generated/maintainability public/
@@ -85,5 +90,38 @@ npm:
     - cd ./generated/npm
     - echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}'>.npmrc
     - npm publish
+  artifacts:
+    untracked: true
+    name: '$CI_COMMIT_TAG_for_nodejs'
+    paths:
+      - generated/npm/nodejs/
+  only:
+    - tags
+
+##--------------------------------RELEASE-------------------------------------------------------
+release:
+  stage: release
+  image: registry.gitlab.com/gitlab-org/release-cli:latest
+  script:
+    - cd ./generated/npm
+    - echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}'>.npmrc
+    - npm publish
+  release:
+    tag_name: '$CI_COMMIT_TAG'
+    name: '$CI_COMMIT_TAG'
+    description: '$CI_COMMIT_MESSAGE'
+    assets:
+      links:
+        - name: 'g1lib_for_browser'
+          url: 'https://libs.duniter.io/g1lib.js/public/dist/browser/$CI_COMMIT_TAG.mjs'
+          link_type: 'package'
+        - name: 'g1lib_for_nodejs'
+          url: 'https://libs.duniter.io/g1lib.js/public/dist/nodejs/$CI_COMMIT_TAG.mjs'
+          link_type: 'package'
+  artifacts:
+    untracked: true
+    name: '$CI_COMMIT_TAG_for_browser'
+    paths:
+      - generated/npm/browser/
   only:
     - tags
diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index 8c18354ed5d6c3450ea339d8f4a2a7ac3727ee08..6336d72f8af334b76cc6d890548280650270a901 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -7,12 +7,24 @@ Le format est basé sur [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
 et ce projet adhère au [versionnage sémantique](https://semver.org/spec/v2.0.0.html).
 
 ## Evolutions probable / Roadmap :
-- GraphQL stuff
-- @@@@ comme séparateur entre identifiant secret et mdp pour la génération de combinaison à tester (usage principal Gsper)
-
+- HD wallet (dérivation)
+- sharding
+- udid / civil_id_hash
+- GVA && indexer v2s GraphQL stuff (gestion des paiements, détection des paiements...)
 
 ## [Non-publié/Non-Stabilisé] (par [1000i100])
 
+## [Version 3.5.0] - 2022-11-27 (par [1000i100])
+### Ajouté
+- dictionary intègre un mécanisme de cache pour détecter les doublons. Il est désactivable pour éviter les crashs par saturation de la mémoire.
+- dictionary-parser gère toutes les syntaxes d'expression régulières qu'utilisaient gsper v2 + des situations plus complexes
+- dictionary-parser permet de distinguer identifiant secret et mot de passe via le séparateur `@@`
+- dictionary-parser permet plusieurs types de déclinaisons : sans accents, accents optionnels, sans majuscule, majuscule optionnelle, tout en majuscule, et des déclinaison type leetSpeak.
+- dictionary-tree permet de savoir combien de combinaisons sont possibles et d'itérer dessus sans avoir besoin de les pré-générer.
+### Corrections
+- Vérification à chaque build (et donc dans la CI) que les packets destinés à tourner dans le navigateur n'ont aucunes dépendances.
+- Suppression des dépendances résiduelles.
+
 ## [Version 3.4.2] - 2022-11-20 (par [1000i100])
 ### Corrections
 - checkKey ne complète plus automatiquement les clef trop courtes et envoi donc l'erreur attendue pour les clefs trops courtes.
@@ -109,8 +121,9 @@ et ce projet adhère au [versionnage sémantique](https://semver.org/spec/v2.0.0
 - intégration des librairies de crypto nécessaires
 - calcul de la clef publique correspondant à chaque combinaison de secrets saisie, et comparaison à la clef publique de référence.
 
-[Non-publié/Non-Stabilisé]: https://git.duniter.org/libs/g1lib.js/-/compare/v3.4.2...main
+[Non-publié/Non-Stabilisé]: https://git.duniter.org/libs/g1lib.js/-/compare/v3.5.0...main
 
+[Version 3.5.0]: https://git.duniter.org/libs/g1lib.js/-/compare/v3.4.2...v3.5.0
 [Version 3.4.2]: https://git.duniter.org/libs/g1lib.js/-/compare/v3.4.1...v3.4.2
 [Version 3.4.1]: https://git.duniter.org/libs/g1lib.js/-/compare/v3.4.0...v3.4.1
 [Version 3.4.0]: https://git.duniter.org/libs/g1lib.js/-/compare/v3.3.3...v3.4.0
diff --git a/npm/package.json b/npm/package.json
index 4b0c1277f74f11de8e93e582722ad7afbc4381a3..0206c04916871aa13552f3a82b7266af9a58773b 100644
--- a/npm/package.json
+++ b/npm/package.json
@@ -1,6 +1,6 @@
 {
   "name": "g1lib",
-  "version": "3.4.2",
+  "version": "3.5.0",
   "description": "An ubiquitous static javascript toolbox lib for Ǧ1 / Duniter ecosystem with reliability in mind.",
   "main": "nodejs/all.mjs",
 	"browser": "browser/all.mjs",
diff --git a/src/dictionary-parser.mjs b/src/dictionary-parser.mjs
index ff172a77c4ad056c24966ad97bee0663201d72fe..7e04e64c51c9d587f34d38a937b0864f96384369 100644
--- a/src/dictionary-parser.mjs
+++ b/src/dictionary-parser.mjs
@@ -128,7 +128,7 @@ function toLeet(str){
 		'R': '2',
 		'Z': '2',
 		'E': '3',
-		'A': '(4|@)',
+		'A': '4', // '(4|@)' conflit avec la syntaxe @@
 		'S': '(5|$)',
 		'G': '(6|9)',
 		'T': '(7|1)',
@@ -138,7 +138,7 @@ function toLeet(str){
 }
 function fromLeet(str){
 	return replaceList(str,{
-		'@': '(a|A)',
+		//'@': '(a|A)', conflit avec la syntaxe @@
 		'$': '(s|S)',
 		'0': '(o|O)',
 		'1': '(i|l|I|L|t|T)',
diff --git a/src/dictionary-tree.mjs b/src/dictionary-tree.mjs
index 4778b1d31c40d2d25ab8661022cf1d9dbaf9be51..b219137a5ed8c9f986be338bc66836aabdf8558e 100644
--- a/src/dictionary-tree.mjs
+++ b/src/dictionary-tree.mjs
@@ -1,6 +1,3 @@
-export function build(dictionaryString) {
-}
-
 export function buildTreeStruct(monoLineString) {
 	const stringAsArray = monoLineString.split('');
 	const rawTree = leftParser(stringAsArray);
diff --git a/src/dictionary.mjs b/src/dictionary.mjs
index 7ec9ad867ad9934328bbabba6d29983ae5c48d1c..d3aa4f207d8aa27ba57d32330535877587a6e2c6 100644
--- a/src/dictionary.mjs
+++ b/src/dictionary.mjs
@@ -1,23 +1,86 @@
+import {buildTreeStruct, getAlternative, serialize} from "./dictionary-tree.mjs";
+import {parse} from "./dictionary-parser.mjs";
+
 export class Dictionary {
-	constructor(dictionaryString) {
-		this.properties = initProperties(dictionaryString);
-		incorporate(this, this.properties);
+	constructor(dictionaryString, options= {}) {
+		this.originalConfig = options;
+		this.originalConfig.dictionaryString = dictionaryString;
+		this.config = JSON.parse(JSON.stringify(this.originalConfig));
+		if(typeof this.config.cache === 'undefined') this.config.cache = true;
+		if(typeof this.config.idSecPwd === 'undefined') this.config.idSecPwd = true;
+		this.tree = buildTreeStruct(parse(dictionaryString,this.config.idSecPwd));
+		this.estimateDuration = ()=>this.tree.altCount/(this.config.speed || 1)
+		this.estimateRemaining = ()=>(this.tree.altCount-this.tried)/(this.config.speed || 1)
+		if(this.config.speed) adjustVariant(this);
+		this.length = this.tree.altCount;
+		this.tried = 0;
+		this.cache = [];
+		this.duplicatedCount = ()=> this.tried - Object.keys(this.cache).length;
+		this.dryGet = index => getAlternative(index,this.tree);
+		this.get = index => {
+			if(typeof this.startTime === "undefined") this.startTime = Date.now();
+			this.tried++;
+			const alt = getAlternative(index,this.tree);
+			if(this.config.cache) {
+				if(typeof this.cache[alt] === "undefined") this.cache[alt] = [];
+				this.cache[alt].push(index);
+				if(this.cache[alt].length>1) return `§duplicate§${alt}`;
+			}
+			return alt;
+		}
+		this.timeSpent = ()=>(Date.now()-this.startTime)/1000;
+		this.duplicatedFound = ()=>{
+			const duplicated = [];
+			for(let key of Object.keys(this.cache)) if(this.cache[key].length>1) duplicated.push({alt:key,index:this.cache[key]});
+			const sortedDuplicate = duplicated.sort((a,b)=>b.index.length-a.index.length);
+			return sortedDuplicate;
+		}
+		this.dryRunDedup = ()=>dryRunDedup(this);
 	}
 }
-
-export function initProperties(dictionaryString) {
-	if (!dictionaryString) return {};
-	const properties = {
-		length: 81,
-		extraLength: 90
-	};
-	return properties;
-}
-
-export default Dictionary;
-
-function incorporate(target, toAdd) {
-	for (const key in toAdd) {
-		target[key] = toAdd[key];
+function adjustVariant(self){
+	const durationGoal = 3_600;
+	if (self.estimateDuration() >= durationGoal) return;
+	function rebuildTree(self){
+		self.tree = buildTreeStruct(parse(self.originalConfig.dictionaryString,self.config.idSecPwd,self.config.accent,self.config.lowerCase,self.config.leetSpeak));
+	}
+	let lastLess1hConfig;
+	while (self.estimateDuration() < durationGoal){
+		lastLess1hConfig = JSON.parse(JSON.stringify(self.config));
+		if(self.config.accent !== 2 && (typeof self.originalConfig.accent === 'undefined' || self.originalConfig.accent === "auto")) {
+			if(!self.config.accent) self.config.accent = 1;
+			else self.config.accent++;
+			rebuildTree(self);
+			continue;
+		}
+		if(self.config.lowerCase !== 4 && (typeof self.originalConfig.lowerCase === 'undefined' || self.originalConfig.lowerCase === "auto")) {
+			if(!self.config.lowerCase) self.config.lowerCase = 1;
+			else self.config.lowerCase++;
+			rebuildTree(self);
+			continue;
+		}
+		if(self.config.leetSpeak !== 3 && (typeof self.originalConfig.leetSpeak === 'undefined' || self.originalConfig.leetSpeak === "auto")) {
+			if(!self.config.leetSpeak) self.config.leetSpeak = 1;
+			else self.config.leetSpeak++;
+			rebuildTree(self);
+			continue;
+		}
+		console.log('strange, not an hour yet with all variants ?');
+		break;
 	}
+	self.config = lastLess1hConfig;
+	rebuildTree(self);
+	dryRunDedup(self);
+}
+function dryRunDedup(self){
+	const dry = new Dictionary(serialize(self.tree),{cache:true,idSecPwd:false});
+	for(let i = 0; i < dry.length;i++) dry.get(i);
+	const duplicate = dry.duplicatedFound();
+	self.lengthBeforeDedup = dry.length;
+	//TODO: Factorize dry.cache keys
+	const dedupAsString = `(${Object.keys(dry.cache).join('|')})`;
+	self.tree = buildTreeStruct(dedupAsString,false);
+	self.config.cache = false;
+	self.length = self.tree.altCount;
+	return duplicate;
 }
diff --git a/src/dictionary.test.mjs b/src/dictionary.test.mjs
index 7565f2922bb32f98bf854cf806259896a17d079e..8e09e3ba2d6686d1fcfd2da193755d6bed34afa3 100644
--- a/src/dictionary.test.mjs
+++ b/src/dictionary.test.mjs
@@ -2,12 +2,75 @@ import test from 'ava';
 import * as app from './dictionary.mjs';
 
 test('get dictionary length', t => {
+	const dictionaryString = '(a|b|c)d(e|f|g)';
+	const dico = new app.Dictionary(dictionaryString,{idSecPwd:false});
+	t.is(dico.length, 9);
+});
+test('get dictionary iteration', t => {
+	const dictionaryString = '(a|b|c)d(e|f|g)';
+	const dico = new app.Dictionary(dictionaryString);
+	t.is(dico.get(5), "ade@@bdg");
+});
+test('get past iteration count in this dictionary', t => {
 	const dictionaryString = '(a|b|c)d(e|f|g)';
 	const dico = new app.Dictionary(dictionaryString);
-	t.is(dico.length, 81);
+	t.is(dico.tried, 0);
+	dico.get(1);
+	dico.get(2);
+	t.is(dico.tried, 2);
 });
-test('get dictionary extraLength', t => {
+test('get duplicated found count (from dictionary)', t => {
 	const dictionaryString = '(a|b|c)d(e|f|g)';
 	const dico = new app.Dictionary(dictionaryString);
-	t.true(dico.extraLength >= 81);
+	dico.get(0);
+	dico.get(1);
+	t.is(dico.duplicatedCount(), 0);
+});
+test('get duplicated found in past iteration (from dictionary)', t => {
+	const dictionaryString = '((a|b)cd|(a|b)c(d|e)|bc(d|e))';
+	const dico = new app.Dictionary(dictionaryString, {idSecPwd:false});
+	for(let i = 0; i < dico.length;i++) dico.get(i);
+	t.deepEqual(dico.duplicatedFound(), [ {alt:"bcd",index: [ 1, 4, 6 ]}, {alt:"acd",index: [ 0, 2 ]}, {alt:"bce",index: [ 5, 7 ]} ]);
+});
+test('dictionary can dryRun to find all duplicate', t => {
+	const dictionaryString = '((a|b)cd|(a|b)c(d|e)|bc(d|e))';
+	const dico = new app.Dictionary(dictionaryString, {idSecPwd:false});
+	const duplicate = dico.dryRunDedup();
+	t.deepEqual(duplicate, [ {alt:"bcd",index: [ 1, 4, 6 ]}, {alt:"acd",index: [ 0, 2 ]}, {alt:"bce",index: [ 5, 7 ]} ]);
+	t.is(dico.lengthBeforeDedup,8);
+	t.is(dico.length,4);
+});
+
+test('dictionary can run with cache disabled', t => {
+	const dictionaryString = '(a|b|c)d(e|f|g)';
+	const dico = new app.Dictionary(dictionaryString,{cache:false});
+	dico.get(0);
+	t.is(dico.duplicatedCount(), dico.tried);
+});
+test('dictionary can run with cache on', t => {
+	const dictionaryString = 'a';
+	const dico = new app.Dictionary(dictionaryString,{cache:true});
+	dico.get(0);
+	dico.get(0);
+	t.is(dico.duplicatedCount(), 1);
+});
+test('duplicate match §duplicate§ pattern', t => {
+	const dictionaryString = 'a';
+	const dico = new app.Dictionary(dictionaryString);
+	const first = dico.get(0);
+	const second = dico.get(0);
+	t.is(first, 'a@@a');
+	t.is(second, '§duplicate§a@@a');
+});
+test.skip('dictionary called with speed option try to activate variante accent, caps and leetSpeak option to reach 1h of compute estimated time', t => {
+	const dictionaryString = 'Ǧ1Ǧ1';
+	const dico = new app.Dictionary(dictionaryString, {speed:30, leetSpeak:1});
+	t.is(dico.lengthBeforeDedup,2);
+	t.is(dico.length,2);
+});
+test.skip('dico should not crash', t=>{
+	const v8CrashStringButFirefoxWork = '(((Ǧ|ǧ)|(G|g))(1|i|l|I)((Ǧ|ǧ)|(G|g))(1|i|l|I)@@((Ǧ|ǧ)|(G|g))(1|i|l|I)((Ǧ|ǧ)|(G|g))(1|i|l|I)|(((Ǧ|ǧ)|(G|g))(1|i|l|I)((Ǧ|ǧ)|(G|g))(1|i|l|I)@@((Ǧ|ǧ)|(G|g))(1|i|l|I)((Ǧ|ǧ)|(G|g))(1|i|l|I)|(ǧ|g)(1|i|l|I)(ǧ|g)(1|i|l|I)@@(ǧ|g)(1|i|l|I)(ǧ|g)(1|i|l|I)))';
+	const dico = new app.Dictionary(v8CrashStringButFirefoxWork,{speed:30})
+	//t.is(dico.lengthBeforeDedup,2);
+	t.is(dico.length,2);
 });