durt2 2.2.0
durt2: ^2.2.0 copied to clipboard
Dart library for interacting with Duniter v2s blockchains. Provides wallet management, transaction signing, Dunipod GraphQL requests, and more
2.2.0 #
- The local chain of Dunipod's
local-chaintool can be used. A pod imposed on thelocalnetwork was refused twice: durt2 reached every pod over TLS, while a pod on this machine serves plain HTTP on a loopback port, and it expected the pod to index a chain namedlocal, while the local chain announcesgtest_local. A pod onlocalhost, a loopback address literal (127.x,::1) or the Android emulator's10.0.2.2is now reached overhttp/ws; every other pod stays onhttps/wss, private addresses included, and so does any name that only starts like a local address (127.0.0.1.example.com).Networks.podChainIdnames the chain a pod of each network announces:gtest_localforlocal, the network name otherwise.
2.1.1 #
- A payer account that does not exist cannot pay a revocation.
canPayRevocationanswered yes for an account never credited when the chain waived the fee, which it does while lightly loaded: the chain then refused the transaction, after the payer had typed their PIN. An account that does not exist on chain now answers no, whatever the fee (RevocationCertificateCodec.canPayFees, which takesexists). An identity account stays able to pay a waived fee below the existential deposit, since its identity keeps it alive.
2.1.0 #
- Revocation certificates, the format of Cesium² (gecko#311). An
identity owner can now sign, in advance, a certificate that lets anyone
revoke the identity later from any account paying the fees: a relative
trusted with it, or the owner who lost their recovery phrase. A
certificate made by Ğecko is read by Cesium² and the other way round,
checked by running Cesium²'s own code on both sides.
DuniterService.createRevocationCertificate(owner)answers theg1://rev:code (for a QR code or a file) and the Base58 manual code Cesium² prints; it refuses when the chain gives another owner key for the identity than the one signing.validateRevocationCertificate(code)andvalidateRevocationManualCode(manualCode)check it against the connected chain and answer aValidatedRevocation; a typed manual code may keep the spaces, hyphens and line breaks between its groups.canPayRevocation(revocation, payerAddress)says whether that account can pay the fees, without its key: the fee is priced on a blank signature, so nothing signed leaves the device before the payer confirms.revokeWithCertificate(revocation, payer)submits it, signed by the payer, as aStream<TransactionStatus>likerevokeIdentity. Every refusal is aRevocationCertificateExceptionwhosefailuresays why (malformed code, no or unknown identity, not the owner, already revoked, never a member, bad signature, validated on another chain); a network failure propagates as is.RevocationCertificateCodecholds the network-free part: payload, codes, signature check, the rules of who may certify and revoke.revokeIdentityis unchanged. - ECDSA certificates are not supported. An ECDSA account can own an identity, and Cesium² accepts its certificates; durt2 reads and writes their 65-byte codes but refuses them at validation as a bad signature, verifying ed25519 and sr25519 only.
ed25519_edwardsandsr25519are direct dependencies, at the versions polkadart_keyring already brought: checking a certificate needs a public key alone, which the keyring's pairs do not take.
2.0.3 #
- A master key read under way when it is forgotten is no longer kept. Forgetting every safe deletes the master key; a read of it already under way cached it when it came back, and a call made meanwhile joined that read. The next safe was then encrypted with a key the store no longer held, and would not open after a restart. Forgetting the key now moves a counter: a load started before may still answer its caller, but neither caches the key nor serves a later call. The window was narrow, found by the audit of 2.0.2.
2.0.2 #
- Forgetting safes no longer stops on a secret store that refuses to
delete (gecko#293). Since flutter_secure_storage_linux 3.0.2 a delete
the keyring refuses throws, where 3.0.1 reported success.
clearWalletsremoved the safes, then stopped: the app saw a failure for safes already gone, and a data migration that forgets them started over at every launch. The safes are now forgotten whatever the store answers; the master key or a biometric PIN the store keeps is only logged.deleteSafedoes the same for the safe's biometric PIN. - A new safe never inherits a biometric PIN. Biometric enrolment is read
from the secret store alone, so a PIN left by a former safe of the same
number would have been offered, and tried, for the new one, every try
booked as a wrong code. Creating, importing or restoring a safe now clears
the biometric PIN of the number it takes, once its secret is encrypted;
a store that refuses to clear a PIN it holds fails the creation with a
SecureStorageException.
2.0.1 #
- A pod that refuses the WebSocket is left for another one (durt#11). A
pod can answer every HTTP query and still reject the WebSocket handshake:
it passed the discovery checks, history loaded, and no live event ever
arrived, the subscriptions waiting forever on a socket that kept failing.
When a subscription waits more than
socketRefusalDelay(45 s) for a socket that never comes up,DunipodEventHubnow moves to another pod, as for one that stopped answering, unless the device is offline. A pod left this way and landed on again (an imposed one, or when every pod refuses) is kept for its HTTP answers rather than left in a loop.
2.0.0 #
-
Breaking: the indexer is Dunipod, Squid is gone (gecko#252). Every indexer read (history, certifications, identities, searches) and every indexer live feed now goes through a Dunipod pod, chosen and changed at runtime. Balances, identity state and blocks are still read from Duniter, and Cesium+ is untouched. No pod is listed yet for gdev and the local network, so there is no indexer there until one is added to
config/dunipod_endpoints.json. The entries below say what changed for callers; those marked Breaking need a change on their side. -
The identities of the network are filtered the way pods serve them (gecko#288). Every status filter was refused:
statuswas sent within, sorted byINDEX_DESC, and a name withincludesInsensitive.getNetworkIdentitiesFilterednow asks one criterion at a time, as settled in dunipod#17: a name by its start, ignoring case; the members, newest first, optionally created within a period; one other status. It sends nothing for a combination no pod serves (a name with a status, a period with anything but the members, several statuses,Removed, which is no protocol state) and returns null. A period needs the newgenesisTimeargument, to be turned into creation blocks. -
The certifications received come with the block the identity was created in (gecko#286).
getCertsReceivedWithCreation(address)answers the list ofgetCertsReceivedand the identity'screatedOn, read in the same grouped document at no extra unit. On Duniter v2, the certification issued in that block is the creator's. An identity migrated from Ğ1 v1 has a negative block, shared by every certification of its first membership.getCertsReceivedis unchanged. -
An address is looked up whole, and only then (gecko#252).
searchByAddresssentstartsWithandendsWithon the address, which no pod has a bounded plan for: every pod refused the whole request, and a search for a name nobody bears ended inIdentitySearchExceptioninstead of in no result. It now sends nothing unless the text is a whole valid address, then looks it up withequalTo, its identity's name included. Part of an address finds nothing: pods cannot search it. -
Breaking: an operation a pod refuses no longer moves to another pod (gecko#252). The schema-error check, kept from Squid, moved to another indexer on an "unknown field" answer; every pod runs the same engine and would refuse it alike, so trying them in turn only spent their quotas. Only an unreachable pod is left behind now. Callers rename
DunipodClientManager.checkAndHandleSchemaErrortocheckAndHandleQueryError;handleSchemaErrorandDunipodSchemaValidatorare gone. -
An account's activity can include universal dividends (gecko#252).
subscribeAccountActivity(address, withDividends: true)also asks the pod forUNIVERSAL_DIVIDEND_CREATED, so a history that shows dividends refreshes when a new one is created instead of at the next transfer. A dividend event names no account: every listener asking for it hears it, once a day, for one unit of quota. Without the flag nothing changes. -
Reads asked together leave together (gecko#274). As Dunipod's author advised, to stay further from a pod's quota: the certification lists asked within 30 ms go as one document per 10 accounts, both directions when both are asked (
CertificationBatcher, behindgetCertsReceivedandgetCertsSent): 10 accounts cost 7 units in one request, against 20 in twenty. A page of history reads transfers and dividends in one request while both are followed. A pod refuses a document whole: every read of a refused document gets what a refused read got before, and a quota wait longer than 4 s is not waited. A test that stubs the pod by operation name now seesGetCertificationsandAccountHistory(transfers andudHistorytogether) on a first page. -
An exact comment, and the 70 cost vectors (dunipod!34, gecko#274).
TransactionFilters.exactMatchCommentis read: the whole comment, case and spaces included (comment.remark.equalTo), served by pods since Dunipod 0.15. It costs what a contained comment costs. The cost model refuses, like a pod, a comment filter with two remark operators. Its tests check the 70 normative vectors of Dunipod v0.16.1. -
The
overloadedretry no longer waits for the refused request to be torn down (gecko#274). It waited for a cancellation the transport may take its time over, which delayed the retry, and under a fake clock kept it from ever starting. -
A request's answer frees the pod's line (gecko#274). The GraphQL client resolves a query on its answer and may send the next one before the answer's stream ends: that next one waited for a request already answered, and under a fake clock forever.
-
One request at a time per pod, and a single retry of
overloaded(gecko#274). As Dunipod's author advised: requests to a pod wait in line, one in flight at a time (DunipodQueueLink,DunipodSlots), before being priced against the quota; an answeroverloadedis asked once more after the delay the pod names plus up to 250 ms of jitter, through the line and the quota again, and not when nobody waits for it any more (DunipodOverloadRetryLink). A secondoverloadedis the caller's. Both waits are bounded, the GraphQL client never cancelling what it stopped waiting for: 12 s in line (thenDunipodQueueTimeout, never sent, not a network failure) and 15 s for an answer once sent (then the slot is given up and the request fails with aTimeoutException); a retry is not started more than 10 s after the first ask. The client's own timeout is raised to 45 s, a safety net above these. The liveness probe uses its own client, outside the line.AccountHistoryThrottled.overloadedtells a full pod from a spent quota: the former was already retried and must not be again. Behaviour changes:AccountNameBatcherno longer retries a 503 itself, the link being the one level that does; an HTML 503 from a proxy is no longer retried by anyone. -
The distance evaluations an account paid for (gecko#277).
getDistanceEvaluations(address)lists them, newest first, with the identity evaluated (its own on a renewal, a newcomer's when a certification requested it), the price reserved, and the outcome: refused (the price went to the treasury), accepted or closed without a verdict (it came back), or pending, with the distance and both blocks. Only the account's last 100 balance changes are read. A pod does not index evaluations: they are read from the account's balance changes and the events of the blocks involved. ThrowsDistanceEvaluationsExceptionwhen the pod does not answer. -
A name or address search that got no answer says so (gecko#274). Breaking:
searchAddressByNameandsearchByAddressthrowIdentitySearchExceptionwhen the pod refused them or did not reply, instead of returning an empty list that read as "nobody found". A refused prefix still returns the exact match alone. A caller that wants the old behaviour catches it. -
A search starts with a budget sized to it (gecko#274).
initialAccountHistoryScanBudgetandinitialNetworkActivityScanBudget(ondunipodInitialScanBudget) give 256 candidates for a period of about three months, 512 up to a year, the pod's default of 1024 beyond or without a start, halved when a comment is searched; an amount without a counterparty starts at 512 at least, the budget from which a pod may probe its amount index. Never above the largest budget a pod admits, 0 when none is. A pod charges the budget declared, not the work done.isDunipodAccountSearchandisDunipodNetworkSearchtell a wallet which of its reads make a budgeted search. -
Every Dunipod read is priced against the pod's quota before it is sent (gecko#274). Queries and mutations go through
DunipodBudgetLink, which prices them with the v2 cost model and checks the price against the buckets the pod announces (RateLimit-Policy,RateLimit), counting the units of requests still in flight. What fits goes at once; what fits after at most 3 s of refill waits for it; anything else is not sent and fails withDunipodQuotaRefusalinOperationException.linkException, which a search reads asAccountHistoryThrottledorAccountHistoryBudgetTooLarge. Each pod keeps its own budget across client recreations (DunipodClientManager.budget); such a refusal never moves the client to another pod, and an unanswered request gives its units up after 20 s. A throttled answer is read inRetry-After-MsbeforeRetry-After. The largest search budget stays within the model's 30 units, and within the capacity the pod announces when it is smaller. -
A received certification says when it was first issued (gecko#262).
getCertsReceivedselectscreatedOn, the block the certification was created in, next toupdatedIn: a renewal moves the latter, not the former, which is what tells who certified an identity first. -
The whole network's transfers are narrowed by an exact account (gecko#275). Breaking:
getNetworkActivityFilteredandgetNetworkActivityFilteredRaware replaced bysearchNetworkActivity, which returns anAccountHistorySearchlikesearchAccountHistory. Without an account it reads the whole network;fromAddressandtoAddressare now exact addresses, and a period, an amount or a comment only narrow what one of them sent or received. Asked without either, it is not sent and comes backAccountHistoryUnsupported: a pod refuses them on the whole network. An amount, a comment or both accounts make it a budgeted search, up tomaxNetworkActivityScanBudget.exactMatchDirectionis no longer read, and no identity name is searched. -
An account's history is searched within a pod's work budget (dunipod!29, gecko#274). Breaking:
getAccountHistoryFilteredandgetAccountHistoryFilteredRaware replaced bysearchAccountHistory, which returns a sealedAccountHistorySearch:AccountHistoryResultswith a complete page, or why there is none (AccountHistoryBudgetExceeded,AccountHistoryBudgetTooLarge,AccountHistoryThrottledwith the delay the pod asked for,AccountHistoryUnsupported,AccountHistoryUnavailable). A caller that read null as a failure switches on the result instead, and never asks again on its own: each attempt is charged. An amount, a comment or the newTransactionFilters.counterparty(one exact account, both directions) make it a search, sent with thescanBudgetgiven, up tomaxAccountHistoryScanBudget; a period alone is sent as before, without the argument, which pods older than !29 refuse. The account filter no longer readsaddresses,fromAddress,toAddressnor the exact-match flags: a pod searches an account's history by no identity name, no part of an address and no exact comment. Cost model v2 (dunipodGraphqlCostV2, checked against Dunipod's 66 vectors) prices the search surcharge;dunipodCostOfnow prices with v2,dunipodGraphqlCostV1still gives v1. -
Names are searched as pods serve them (dunipod!26). Behaviour change:
searchAddressByNamereturns the exact name, then the names starting with it, in identity order, and no longer names that merely contain the text, which pods refuse. The two go as separate requests, so the exact name still comes back when a prefix matching over 1024 names is refused. -
The code says Dunipod where it said Squid, and the Squid-only code is gone. Breaking, a rename with no change of behaviour:
SquidServiceisDunipodService,SquidClientManagerisDunipodClientManager(andparseSquidEndpointisparseDunipodEndpoint), the query extensions areDunipodAccountQueries,DunipodNetworkQueriesandDunipodSubscriptions. OnDurt:squidisdunipod,squidEndpointisdunipodEndpoint,isSquidConnectedisisDunipodConnected,squidConnectionStatusandsquidConnectionStatusStreamaredunipodConnectionStatusanddunipodConnectionStatusStream,squidEndpointChangeStreamisdunipodEndpointChangeStream,setFixedSquidEndpoint,clearFixedSquidEndpoint,reconnectSquid,testSquidEndpointandgetWorkingSquidEndpointssayDunipodinstead ofSquid, and theinitSquidparameter ofconnectisinitDunipod.Networks.squidEndpointandNetworks.listSquidEndpointsaredunipodEndpointandlistDunipodEndpoints. The generated bindings moved fromlib/src/models/graphql/squid/tolib/src/models/graphql/dunipod/, withsquid-schema.graphql.dartnowdunipod-schema.graphql.dart. Removed, as nothing called them any more:EndpointType.squid,DuniterPeerings.getSquidEndpoints,RemoteNetworkConfig.squid,SquidClientManager.testEndpoint,isIndexerSynced,isVersionSupportedandminimumSquidVersion, the Squid sync validator, the Squid probes of the endpoint tester andconfig/squid_endpoints.json. A Squid endpoint an older version imposed (customIndexer) or stored is no longer read. -
A survey of the network, for a screen that shows it (gecko#255).
Durt.surveyDuniterNodes()tests every known node of the network, those that stopped answering included, and gives for each whether it answered, its latency, its newest block and whether it is syncing.Durt.surveyDunipods()asks every known pod, and says whether it can be read from or why not (behind, not ready, another chain, busy, silent), with its latency, newest block and lag. Both mark the one in use and the one the user imposed (Durt.imposedDuniterEndpointis new). They run the tests the connection runs, feed the same statistics, and never ask again a pod that asked to be left alone. -
A refusal from the pod is no longer read as an absence. The filtered readers of the network tabs and of an account's history returned an empty list when the pod refused the filter or did not answer, so the app said "no certification" where it knew nothing. They now return null.
getIdentityNamethrowsIdentityNameUnavailablein that case instead of returning null, which the app read as "no identity" and used to erase the name it had cached. -
Identity names are read 50 at a time. A contact list asks for every name at once; sent one by one, 60 lookups got 24 to 46 "overloaded" refusals from two pods (2026-09-24).
getIdentityNamenow groups the lookups made within 30 ms into oneidentities(filter: {accountId: {in: ...}})request per 50 accounts (a pod refuses more), and asks a busy pod again after the delay it names when its quota is spent (429), up to four attempts, one request after another; anoverloaded503 is left to the client link. Breaking:getIdentityNamethrows instead of returning null when the pod does not answer, and the one-accountGetIdentityNamedocument and its export are gone. -
The certifications of one person are read through their identity (dunipod#18). Pods refuse
certsfiltered on the issuer or the receiver. One name is now looked up exactly, case included (dunipod#17), and itscertIssuedorcertReceivedread, latest change first. Both names give the one certification between the two identities. A person over a period is refused before sending: that belongs tocertEvents. Breaking:CertificationFilterslosesexactMatchIssuer,exactMatchReceiver,issuerAccountIdandreceiverAccountId. A name is always matched exactly, and pods refuse the account filters at the root. -
The GraphQL bindings are generated from Dunipod's schema, and the network tabs load on a pod (gecko#252).
dunipod-schema.graphql, which the generator reads, is a copy of the vendored Dunipod SDL (tool/dunipod/onchain-schema.graphql), and a test fails if the two differ. All 20 documents still generate against it. Breaking for callers: fields the Dunipod SDL declares optional become nullable, among themIdentity.name, which pods leave empty for an identity not yet confirmed (68 of the 500 newest ong1pod.elo.tf). Generated from the Squid schema, one such identity made the whole page unreadable. Several other fields are declared optional without ever being seen empty; the question is asked in dunipod#20.A single filter condition is sent as itself rather than inside a one-element
and, which pods refuse. A period on an account's history takes the shape pods serve for it (dunipod#13): the accountorat the root, and each bound in its own element of anand. Combinations pods do not serve are still sent and refused: filtering in the app would break pagination (dunipod#13), and what is missing is asked of Dunipod (dunipod#15 to #18).Identities are listed newest first by
INDEX_DESC, the orderCREATED_ON_DESCgave; pods refuseCREATED_ON_DESCandLAST_CHANGE_ON_DESC(dunipod#17). The filtered list used the latter, so a view filtered by status no longer shows the identities that changed last first. Certifications without the active filter are listed byCREATED_ON_DESC, since pods serveUPDATED_ON_DESConly with it: a renewed certification no longer moves up in that view until they do (dunipod#18). The amount filter is aBigIntFilter.The Squid combined history reader is gone with its dividend helpers and
CombinedAccountHistoryResult:readAccountHistoryreplaced it and nothing else used it. -
Live updates come from the pod's event feeds (gecko#252). The Squid live queries the app subscribed to do not exist on Dunipod, whose subscriptions are
accountEvents,networkEventsandfinalizedBlocks.subscribeAccountActivity,subscribeCertActivity,subscribeIdentityName,subscribeNetworkActivity,subscribeNetworkIdentitiesandsubscribeNetworkCertificationskeep their signatures and now read them;subAccountHistoryandsubscribeHistoryIssued, which nothing used, are gone, and so are the generated Squid subscription documents.They all go through
DunipodEventHub, which holds at most two pod subscriptions, oneaccountEventsfor every watched account and onenetworkEvents, and hands the events out. One subscription per screen does not fit a pod: it accepts 8 per WebSocket, and charges a unit for each subscription and each event delivered.Live only. After a refused subscription, a WebSocket drop or a change of pod, the feeds start again from the present and every listener gets a resync, once per outage, on which the screens refresh. A resync id is unique across runs, since the app persists the last one it saw. Replaying the gap with
afterworks but charges every replayed event: about fifteen exhausted the quota ong1pod.elo.tf(2026-09-23). A refused subscription is asked again after 5 s, doubling up to a minute, each delay spread by up to a fifth either way so the clients a pod cuts at once do not all come back in the same second (dunipod#33). Nothing is emitted on subscribe any more: the screens load their data when they open.The WebSocket now speaks graphql-transport-ws, the pods' protocol; the package defaulted to the older graphql-ws. It is opened with the client, so a drop is noticed from the start, and pinged every 20 s at the WebSocket level so one that died silently is closed and reconnected: graphql-transport-ws alone never notices.
web_socket_channelbecomes a direct dependency for that.Known gap: pods name no account in certification events (dunipod#19), so they are not asked for per account, and the certifications of one account are not updated live until pods name them; the certification screens also follow Duniter meanwhile.
-
The indexer is a Dunipod pod (gecko#252).
Durt.connectchooses a pod throughDunipodConnectorand points the app's single GraphQL client at it; Squid endpoints are no longer selected. The Squid names were renamed after Dunipod; see the entry above.The pod no longer waits for Duniter. A Squid had to be checked against the node's own blocks because it said nothing trustworthy about itself; a pod names the chain it indexes and how far it has got. A slow or unreachable node therefore no longer holds the history back, and the indexer is watched and recovered even when Duniter fails.
Choosing no longer waits for every pod: once one answers acceptably, the others get 400 ms, so a pod that hangs cannot hold each start to the probe timeout.
setFixedDunipodEndpointimposes a pod, and refuses one that does not answer as a pod of this network while keeping the indexer in use: a typo must not leave the app without history.testDunipodEndpointasks the pod its owndunipodStatus.getWorkingDunipodEndpointslists pods, as bare hosts. The user's former Squid address (customIndexer) is no longer read: a Squid cannot serve as a pod.connectDunipodis gone, since it chose a pod without installing it.A pod's refusal of one operation (HTTP 400, such as
unsupported_graphql_feature) and a busy answer (429, 503) no longer count as network errors toward switching pods: every pod runs the same engine and would refuse the same operation, and a busy pod asked to be left alone. When the pod in use does fail, it is set aside so the next choice prefers another.The imposed pod is kept per network: one imposed on ĞTest no longer follows the user to Ğ1. A pod answering for another chain is refused without being recorded as a pod of this one. An attempt overtaken by a newer decision (a network switch, a pod imposed meanwhile) installs nothing.
The app's live subscriptions were rewritten for the pod's event feeds; see the entry above.
-
An account's history is read whole, once, in order, from Dunipod (gecko#252).
readAccountHistoryreplaces the combined reader for the app's history. The old one asked, on every page, for the 60 newest transfers and the 500 newest dividends, merged them in the app and kept what was older than the last row shown. Measured on 2026-09-23 againstg1pod.elo.tf, on three member accounts:account before after 24 transfers, 199 UD all shown, 643 KB to scroll all shown, 126 KB 145 transfers, 199 UD no transfer shown all 344 rows, 218 KB 410 transfers, 199 UD 350 transfers never shown, 953 KB all 609 rows, 420 KB Each stream is now followed with its own server cursor, taken from the last entry of that stream actually shown, so the next page starts exactly where each one stopped. Nothing is downloaded twice, no window leaves the older transfers behind, and two operations at the same second both stay, where a time cursor dropped every row sharing the boundary. A page is two requests of 20 rows instead of 60 and 500: about four to five times less data. The cursor is stateless and versioned; one from the previous format is refused rather than read as a fresh start, which would append the first page again under the rows already shown.
The model is written by hand instead of generated from the Squid schema. Dunipod's
Identity.nameis optional where Squid's is not, and on the 145-transfer account two counterparts have no name: the generated model refused the whole page, and the failure was logged as "Address has no transactions".A failure is now an
AccountHistoryException, never an empty history, and itsfailuresays which side it is on (unreachable,rejected,unreadable) so a caller need not read the message. So is a server that announces more after an empty page, serves again the entry it was asked to resume after, or goes back to entries newer than the last one shown: dunipod#11 does the first two, and following it would loop. These are checked as a slice arrives, so nothing already on screen is shown twice.Written for Dunipod only, since Squid is replaced in one go rather than kept alongside: no indexer-type switch, and no fallback rebuilding dividends from raw events. That fallback made up for a Squid bug (an identity that changed owner key came back with no dividend), and its query does not even validate on Dunipod (
EventFilterhas noname).test/performance/history_pagination_real_test.dartmeasures both readers against a real pod (tagmanual, accounts passed throughHISTORY_ACCOUNTS). Run back to back, its own reads emptied the pod's per-source bucket, which answeredGraphQL request quota exceededwith coderate_limited, once with a body that was not JSON: that is where a 429 was first seen for real, and the harness now pauses between phases. -
Dunipod: finding a pod, choosing one, and spending its quota without overspending it (gecko#252). The groundwork the indexer switch above stands on.
Finding pods.
DunipodDiscoveryServicestarts from the seeds ofconfig/dunipod_endpoints.json, asks each forotherDunipods, then probes the hosts it heard of and keeps only the ones that can serve the wanted chain, freshest first. A peer list says where to look, and only the pod itself can say what it serves: an announcement is unsigned, and a pod that is buggy or hostile can name anything, including one indexing another chain that answers every query with plausible, unrelated data. So each candidate is asked its owndunipodStatus, and kept only whenchainIdmatches,initializedandgenesisCompleteare true andprojectionStatusiscomplete, since a pod that has not passed the genesis, or cannot decode the runtime in force, answers too, only with holes; after a runtime upgrade no pod knows yet, there is no indexer until the pods are updated.offchainAvailability.publicReadyis not asked: it describes the off-chain service, and is false on a pod that disables it on purpose, which still serves as an indexer (dunipod#33). Every field of that answer is type-checked: a pod answering a string where a number belongs is a pod not answering, not a crash.Because every client reads the same unsigned lists, what they can make a wallet do is bounded: at most eight hosts are taken from one pod's list and twenty probed in all, seeds first; an announced host is probed only under a public name on the HTTPS port, so IP addresses in any form, local names (
localhost,.local,.lan, a name without a dot) and other ports are left out; an answer above 64 KB is not read; a probe gives up after five seconds; and an answer left unread is cancelled so its connection is released. Seeds and a pod the user typed are not filtered, being chosen rather than announced. A host is normalised so one pod cannot enter the set three times because it was written three ways, and an address carrying credentials is refused rather than sent along.Deliberately separate from the Duniter peering: a Duniter node never announces a pod.
Measured against the live pods: on ĞTest, one seed leads to all three (
gtpod.elo.tf,dunipod.gtest.bulma.sleoconnect.fr,dunipod.gtest.pagu.re) on 2026-09-21, all at the same block, about two seconds behind, version 0.9.1-dunipod. On Ğ1, three pods seed the list (g1pod.elo.tf,g1pod2.elo.tf,dunipod.g1.gyroi.de), measured on 2026-09-23 at the same block, four to five seconds behind; each announces the other two. The ĞTest seeds aregtpod.elo.tf,gtest.pod.elo.tfanddunipod.gtest.pagu.re, all listed innodes/networksand all accepting a WebSocket on 2026-10-30.Choosing and keeping one.
EndpointType.dunipodis a kind of its own next torpc, not a flavour of it: the two are found and checked differently and neither can stand in for the other. It has its own row in the endpoint table and its own slot for an address the user imposes; sharingcustomIndexerwith Squid would have sent each query to the wrong server. The stored pod list is bounded to twenty, keeping bundled seeds and pods that answered before announced ones never tried. The bound applies to pods only: the RPC list is kept whole as before.DunipodConnectorpicks the pod. A pod answersdunipodStatus, so the choice needs nothing from the Duniter connection. Freshness comes before speed: a pod twenty minutes behind answers a balance promptly and wrongly, one more than half an hour behind is refused, and one that does not say how fresh it is is refused too. A stamp ahead of the device clock counts as up to date, not as a reason to prefer that pod. A pod that fails is set aside for thirty seconds, doubling at each further failure up to a quarter of an hour until it answers again, unless every pod is set aside at once, which says more about our own connection than about theirs. A pod answering 429 or 503 is busy, not broken: it is left alone for as long as itsRetry-Afterasks, longer if it keeps answering so, and its record is not marked failing. The pod set is refreshed in the background, at most every ten minutes and never twice at once.A pod the user imposed wins over a faster one, and is never forgotten because it was unreachable once. By default another pod takes over meanwhile; whoever imposed a pod on purpose can refuse any substitute, and then an unreachable pod is an error.
Knowing what an operation costs.
dunipodGraphqlCostV1implements the normativedunipod-graphql-cost-v1:min(30, 1 + max(F div 5000, W div 800) + 9 * E), whereFcounts response fields,Wweighted connection rows andEexhaustive name-safety roots. The charge depends only on the operation, its coerced variables and the schema. Checked against Dunipod's own 29 normative vectors, copied intotest/fixtures/dunipod_static_cost_v1.json, with all three quantities asserted, not only the final charge.Worth knowing before building on it: with quanta of 5000 fields and 800 rows, an ordinary page costs the floor of one unit whether it holds 50 rows or 500. Asking for less spares the server work and the user's data, not quota. What the budget limits is how many requests are sent.
dunipodOperationViolationschecks, before anything leaves, the limits the engine applies before it reads: 500 rows a page and 200 for the execution connections, twelve root fields, four repeats of one,blocks,calls,eventsandextrinsicsonce each, depth twelve, a quarter of a million estimated response fields. Pricing follows the norm's 1000-row ceiling and admission the engine's 500, so a query is not priced happily and refused on arrival. Both read the operation the same way, declared variable defaults included.The contract is compiled in rather than asked of a pod: standard introspection carries no directive applications and no root field announces a cost-model version. The schema is vendored in
tool/dunipod/with the revision it came from, the metadata is generated from it, and a test fails the build if the two drift. Noticing a pod that runs another contract is still open.Spending the quota. A pod publishes two budgets:
read-globalis the server's own capacity, filled by every client, andread-sourceour share of it. Both belong to that pod alone, so either one running dry is a reason to try another pod.DunipodBudgetreadsRateLimit-Policy,RateLimitandRetry-After, and the published figure always wins over our arithmetic, which only fills the gap between sending a request and reading its answer. A lost response is not a refund, because the pod charges before executing; an answer that overtook another does not put spent units back.Retry-Afteris obeyed on a 429 or a 503 only, and never beyond a quarter of an hour. Background work leaves a reserve for what the user is waiting on, and waits rather than being refused when it cannot keep that reserve. Units spent with no headers to read, such as on a WebSocket, can be debited by the caller.planDunipodReadchooses between sending, waiting, asking for less and asking elsewhere. Not cheapest-wins: the richest variant that can go now goes; failing that, the richest one whose wait the caller accepts waits; failing that, another pod when there is one. Variants are declared by the caller, never trimmed behind its back, since only the caller knows which fields the screen cannot do without.Headers measured against
gtpod.elo.tfon 2026-09-21, which runs the low-memory profile (3000/150 and 600/30), not the figures in the spec's table: they are read per pod, never assumed.Not here yet: prioritising visible screens and cancelling obsolete searches, which belong to the Ğecko providers that will read from Dunipod.
-
Code generation works again.
dart run build_runner buildcould not even compile its own script, so no GraphQL operation in this package could be changed: any fix that needed a query edit had to be worked around instead (the page cap of 1.7.0 rather than real cursor pagination, durt#1).The cause was a version stand-off around one pin.
analyzeris held at 9.x becauseanalyzer >=13.1.0wantsmeta ^1.18.3while the SDK'sflutter_testpinsmeta 1.18.0; but the generators kept resolving to releases built against a newer element model. Each is now held at the last version that accepts analyzer 9:dart_style 3.1.3(3.1.4 wants analyzer ^10),json_serializable 6.13.0(6.13.1 wants >=10) andsource_genbelow 4.3.0 (which wants >=14). They are consequences of the analyzer pin and are dropped together with it, which the pubspec now says.The generated bindings were stale, since nothing could regenerate them while the chain was down: 21 files come back with the
__typenameselections the current generator emits, and follow the schema more closely: a certification issuer'sstatusis no longer nullable, as the schema of both Squid and Dunipod says.flutter analyzeclean and the 312 offline tests pass.
1.10.2 #
- No master key is generated while a safe depends on one (gecko#293).
CryptoUtils.masterKeygenerated a key whenever the secret store answered "no master key". A store that loses track of its entry answers just like an empty one: on Linux, a keyring not loaded yet or a default collection gone. A key generated then would never open the safes encrypted with the real one.WalletServicenow handsCryptoUtilsits safes (CryptoUtils.guardMasterKeyWith), and while one exists a missing key throwsSecureStorageException(operationgenerate) instead. Deleting the safes, which never needs the key, lifts the refusal: the next safe gets a fresh key. Nothing to do for callers.
1.10.1 #
- An identity still to confirm is reported as such even while the issuer
waits (durt#6).
DuniterStorageService.getCertStatechecked the issuer's delay before the target's status: an issuer whose next certification was not due yet gotmustWaitBeforeCertfor an identity still to confirm, and an app offered to schedule a certification the chain refuses unless the identity is confirmed by then. What the target lacks (revoked, empty account, identity to confirm) now comes before the issuer's delay. Nothing to do for callers that already handlemustConfirmIdentity.
1.10.0 #
- A certification can be sent without the newcomer's distance evaluation
(gecko#276).
DuniterService.certifybundledrequestDistanceEvaluationForwhenever the certification brought a confirmed newcomer to the threshold, on the certifier's account, and a failed evaluation may cost the certifier what it reserved. New parameterrequestDistanceEvaluation(defaulttrue, the behaviour so far): an app that has computed the newcomer's distance below the rule, or could not compute it, passesfalseand the certification is sent alone. NewDuniterService.certificationRequestsEvaluation(...)says, before certifying, whether the evaluation would be bundled: the very testcertifyapplies. - Renewing a certification no longer requests the evaluation of a
newcomer still short of the threshold. A renewed certification adds
none to the newcomer's count, yet
certifycounted it as one: renewing at four certifications out of five requested an evaluation the runtime rejects (NotEnoughCerts). Nothing to do for callers.
1.9.0 #
- Test mode has a wallet service, in memory (durt#2).
Durt.i.walletsno longer throws underDurtTestMode: it keeps its safes and wallets in memory, so creating, restoring, deriving and deleting a safe run in a test as in the app, keys derived by the same keyring.DurtTestConfig(connected: true)makesDurt.i.isConnectedtrue, for code that only runs once connected. EverythingWalletServicereads or writes now goes through aWalletStore(ObjectBoxWalletStorein the app,InMemoryWalletStorein tests); behaviour is unchanged. New onWalletService:getAllSafes,getAllWallets,hasSafes,storeSafeandfindWallet, for callers that reachsafeBoxandwalletBoxdirectly today: those still work in the app, and throw in a test, which has no ObjectBox.
1.8.0 #
- Breaking: macOS secrets live in the login Keychain; the clear-text file
is gone.
Every durt2 secret on macOS (the master key that peppers the PIN
derivation, the biometric PIN blobs) was written to a JSON file under the
user's home, in clear. gecko#259 measured, from a copy of that file and of
the ObjectBox database, a 4-digit PIN recovered offline in 22 s and the
biometric PIN decrypted in milliseconds. The file backend is removed on
every platform; macOS now goes through
flutter_secure_storageon the login Keychain (usesDataProtectionKeychain: false, since a Developer ID build outside the App Store carries no application-identifier entitlement and the data-protection Keychain refuses it), under a per-app service name. What callers must do:- call
CryptoUtils.useMacosKeychainService('<bundle id>')before any secret is touched (defaultdurt2), and never rename it afterwards: the login Keychain finds an item by (service, account); - call
CryptoUtils.migrateLegacySecretFiles([...])once at startup on macOS, with the paths of the old files most recent location first. It moves every entry (the.tmpsiblings included), reads each one back, destroys the files lowest priority first, and returns aLegacySecretFileReport; on a store failure it throwsSecureStorageExceptionand leaves every file where it was, so the next launch tries again. A file naming another master key than the one in use (the Keychain's, or the most recent file's) is a conflict: nothing of it is imported or destroyed,filesConflictingnames it andreport.unresolvedis true, as it is for an unreadable file; read that flag and tell the user, the secrets are still on disk; CryptoUtils.setMacosSecureStoragePathandCryptoUtils.secureStorageare removed (the breaking part), and so is the biometric enrollment migration from the raw Keychain plugin to the file. A macOS install that enrolled biometrics with a build older than that migration re-enrols once with the PIN.CryptoUtils.masterKey()refuses to generate a key while a migration failed or left a file unresolved in the process (the files still hold the real one), and rejects a stored key that is not 32 bytes.
- call
WalletService.checkCode()answersfalsefor a wrong PIN only. It caught every exception, so a Keychain refusal or a pending migration came back as "wrong PIN", which the host app books towards the deletion of the safe. ASecureStorageExceptionnow goes through; the check itself isWalletService.verifyPin(safe, pin), static, testable without a store. Callers ofcheckCodecatchSecureStorageExceptionand count nothing. Only the cipher's own refusal answersfalse; any other fault propagates.- A failed decryption while a legacy file is unresolved is not a wrong
PIN. The key in use may not be the one the safe was encrypted with (a
restored backup, the file of an older build):
CryptoUtils.decryptthen throwsSecureStorageExceptioninstead of "Invalid secret code", andCryptoUtils.legacySecretFilesUnresolvedtells the host app which state it is in, so it can name the conflict to the user. A stored master key that does not decode is a store failure too. DurtTestMode.installInMemorySecretStore()replaces the native secret store with a map for a host app's tests, and returns it.- A failing secret store throws
SecureStorageExceptioninstead of reading as empty.secureRead,secureWrite,secureDelete,secureContainsKeyandsecureReadAlllogged the failure and answerednullor nothing, whichmasterKey()took for "no key yet": it generated a fresh key over the real one, and every safe became undecryptable. The failure now propagates, and a keymasterKey()generates is read back before it is used. A caller that readnullas a broken store gets the exception instead.
1.7.1 #
- A pod outage is no longer reported as an empty document.
getContactsandgetCertificationQueuecaught every error and answerednull, which their callers read as "there is nothing on the pod yet". Both write the result back, so a client that read the outage as an absence published its own partial list over the complete one: the user's contacts, and the certifications waiting in their queue. 1.6.0 introducedCesiumPlusUnavailableExceptionfor exactly this distinction and fixedgetProfileByAddress, but these two readers flattened it back tonullone frame later. They now let it through, and raise it themselves when the service holds no endpoint: not being initialised is not an answer either.nullis left to the one case where the pod did answer and there is no profile or no field. Measured during the 2026-09-20 outage ofg1.data.e-is.pro, where a reinstalled Ğecko showed no contact and an empty certification queue while both were sitting on the pod. See gecko#266 for the client side.
1.7.0 #
-
Runtime API calls are now capped and retried instead of being fired all at once.
getBalancessent onestate_callper address in a singleFuture.wait, so scanning 30 derivations meant 30 concurrent runtime API calls. Duniter nodes serve only 4 at a time and answer4003 too many concurrent runtime API callsto the rest: measured on the public Ğ1 nodes, a burst of 30 lost 12 to 26 calls ong1.gyroi.de,g1.pini.frandg1.1000i100.fr, whilerpc.duniter.organdg1.axiom-team.franswered all of them. A single rejection made the wholeFuture.waitfail, which is what broke Ğecko's derivation scan on restore. Every runtime API call now goes through one shared semaphore of 4, sized on that measurement, and a call rejected for overload is retried a couple of times with a short backoff. The semaphore is shared rather than per-call-site because the node's limit applies to the connection: a derivation scan and the balance streams of the open wallets hit the same node together. Errors other than overload, and an exhausted retry budget, still propagate.Each call also gets a 15 s deadline, which the sharing makes necessary. polkadart's
WsProvider.disconnectcloses the socket without ever completing the calls still in flight, and durt2 replaces the provider on a node switch or a network change; such a call used to hang forever, penalising only its own caller. Holding a shared slot, four of them would have wedged every balance query in the app behind a semaphore that never frees up again, with no error and no log. The deadline releases the slot and surfaces aTimeoutExceptionthe callers already handle. -
No query asks an indexer connection for more than it will serve. The combined history requested a thousand universal dividends at once. Dunipod refuses any connection page above 500 and rejects the whole query rather than serving part of it, so every account owning an identity got an HTTP 400 that callers read as "this account has nothing" (gecko#250). Squid was lenient about it, which is why the number went unnoticed until a second indexer implementation appeared.
maxConnectionPageSize(500, measured againstgtpod.elo.tf:first=500answers,first=501is refused) andconnectionPageSize()are now applied where a number enters a query, so no call site can bring the default back by forgetting it; both are exported. A caller asking for more than 500 gets 500 instead of an error. Past 500 dividends, roughly sixteen months, the oldest ones fall out of the merged history: the real answer is cursor pagination, which needs the GraphQL bindings regenerated.
1.6.0 #
-
Contract change on
CesiumPlusService.getProfileByAddress: it can now throw. It used to answernullfor three different things, a 404, an unexpected HTTP status, and a network failure, swallowing the last two and only logging them. Callers that re-publish a profile read thatnullas "this user has no description, no city, no socials" and published those absences, whichuploadProfileturns into an explicit erasure: a pod hiccup was enough to wipe a profile remotely, on every device at once. The read now answers three distinct things: the document,nullon a 404 (the only case where "absent" is an answer from the pod), and the newCesiumPlusUnavailableExceptionfor everything else, including a 200 with no_source, a pod we failed to understand is not an empty profile. Writes inherit the guarantee without changing a line: their existingcatchmakes them returnfalsewithout publishing anything. Callers that only want to display a profile must now handle the exception instead of showing "no profile" for an outage. -
Squid indexers:
SquidClientManager.testEndpointtakes anenforceMinimumVersionflag (defaulttrue, so automatic selection keeps theminimumSquidVersionfloor). Callers testing an endpoint the user named themselves passfalse: the floor no longer refuses a deliberate choice on a version number alone. The sync check (recent block hashes compared with the Duniter node) and the schema probe still run in both cases. -
config/squid_endpoints.json: the gtest list carriedindexer.duniter.org, which indexes the Ğ1 chain, so the hash check rightly refused it and the two remaining gtest entries answered 502. It is replaced bysquid.gtest.bulma.sleoconnect.fr, which follows the ĞTest chain.
1.5.1 #
- Version bump only, no code change since 1.5.0.
1.5.0 #
- Refreshed the bundled g1 bootstrap endpoints. The assets shipped a SINGLE
RPC seed (
g1.p2p.legal) and a SINGLE Squid seed (g1-squid.axiom-team.fr); both are permanently decommissioned, so every fresh install or cold cache burned seconds in timeouts before falling back to the remote network config. The seeds now carry the canonical g1 lists minus those two dead hosts (11 RPC, 8 Squid), currently-healthy endpoints first, and the g1 polkadart codegen source moves towss://rpc.duniter.org. Measured on the real g1 network the same day: time to a fully usable node + indexer went from 6.4s / 7.7-9.9s (legacy flow, old seeds) to 0.4-0.7s / 0.6-0.85s (new flow, new seeds). The measurement tool is committed astest/performance/startup_timing_real_test.dart. - Startup connection overhaul: the Duniter node and the Squid indexer now
come up in about a second on a healthy network instead of many seconds,
and a single dead or slow endpoint no longer delays (or sinks) the whole
connection. Four structural fixes, each covered by deterministic tests
that drive the real connection stack against fake local endpoints
(
test/services/connection/startup_latency_test.dart):RaceToFirstSuccess.raceWithMinWaitgains a real early exit: it returns atfirst success + minWait(or when every operation settled) instead of waiting for the slowest operation or the global timeout. A dead bootstrap endpoint used to hold the Duniter connection to its own 3s+ timeout. The Duniter race additionally requires a quorum of 2 successful results (minResults) before the window may close, so freshness selection stays comparative: a fast but stale node cannot win unopposed while healthier nodes are still answering. Deliberate trade-off versus the legacy exhaustive wait: the ranking rule is identical (not-syncing, then highest block, then peers, then latency) but the compared sample is the endpoints that answered within the window, no longer every endpoint that answered before the 8s timeout.- The winning RPC endpoint's test WebSocket is promoted to the real
connection (
TestResult.provider, kept alive on demand): the second handshake and its re-verification RPCs are gone from the critical path. Losing providers are always disconnected, including late finishers. - Squid endpoints are validated by ONE combined GraphQL request
(
EndpointTester.probeSquidEndpoint: reachability + indexer version + schema probe + 5 recent blocks with hashes) instead of up to five sequential queries, andSquidClientManager.initnow trusts a single pre-validated endpoint (skipSyncValidation+ one endpoint) instead of re-validating the entire endpoint list behind a 15s wait bounded by the slowest endpoint, which could throw away a connection already won. SquidConnectoris split intoprepare()(probe race, needs nothing from Duniter) andfinalize()(strict hash sync acceptance): the orchestrator runs the probes IN PARALLEL with the Duniter connection and accepts the best candidate against fresh Duniter blocks with zero extra requests in the nominal case. Strict sync validation, custom-endpoint precedence and the background peering discovery (the wide network view) are unchanged. Because prepare() can now run while the device is still offline, an unreachable user-configured endpoint is NOT cleared there: finalize(), which only runs once Duniter proved the network is up, re-tests it and either honors it or clears it (the decision the legacy flow made in the same network-proven context). Trusted endpoints (user-fixed, local dev) no longer overwrite the discovery fast cache with themselves, so returning to auto-discovery within the cache window cannot re-select the endpoint the user just removed.- Also fixed on the way: the post-connect storage subscriptions are now
awaited inside their guard, so a subscription error is logged instead of
surfacing as an unhandled zone error; the fast-endpoint cache stores only
endpoints that passed the test (winner first) instead of every tested
endpoint including dead ones; the dead legacy
ConnectionManager(pre-orchestrator, 1900 lines, imported by nothing) is removed.
1.4.0 #
- Toolchain and dependency refresh, no API change. Built and tested against
Flutter 3.44.8 (Dart 3.12.2). The declared floors now match what the package
resolves to:
objectboxandobjectbox_flutter_libs5.3.2,flutter_secure_storage10.3.1,multiformats1.4.0,logger2.7.0,graphql5.2.4,collection1.19.1,unorm_dart0.3.2. Dev dependencies follow:test1.31.0,build_runner2.15.3,lints6.1.0,graphql_codegen3.0.2,meta1.18.0. - The
analyzer: ^9.0.0override is kept and now states its reason: analyzer 13.1.0 and later requiremeta ^1.18.3, whileflutter_testfrom the Flutter 3.44.8 SDK pins meta 1.18.0, so version solving fails without it.
1.3.0 #
- Identity-name lookup over the on-chain
IdentitiesNamesmap:DuniterStorageService.getIdentityIndexByName(name)for an exact match, andgetIdentityIndexByNameMulti(names)for a batched one (singlemultiIdentitiesNamesRPC, order preserved, result padded to the request length). Both returnnullfor a free name and degrade tonullon a WebSocket drop rather than throwing. Lets a client treat the on-chain identity name as authoritative over a self-declared profile name.MockDuniterStorageServicegainssetReservedNameand anameMultiLookupCountcounter so batching can be asserted in tests. DuniterStorageService.accountSurvivesAtZero(address): whether an account keeps existing once emptied. True whensufficients > 0, which on Duniter v2s means the account backs an identity (pallet_identity::create_identitycallsinc_sufficientson the owner key). Such an account may go below the existential deposit without being reaped, so a client can offer the full balance instead of refusing the amount or burning the change.DuniterService.paytakes akeepAliveflag (defaulttrue, unchanged behaviour for Ğ1 amounts).falseselects the chain'sExpendablepreservation:transferAllowDeathinstead oftransferKeepAlive, andtransferUdinstead oftransferUdKeepAlive. This also aligns the two units: UD amounts previously always usedtransferUd(Expendable) while Ğ1 amounts usedtransferKeepAlive(Preserve), so the existential-deposit rule silently depended on the display unit.
1.2.1 #
- Fix macOS biometrics:
BiometricServicenow stores the encrypted PIN in the platform-aware secure storage (file-based on macOS, where the Keychain is not reliably available via Flutter plugins) instead of the raw Keychain plugin. Readable legacy Keychain enrollments are migrated on first use; broken ones simply require re-enrolling. New public API:CryptoUtils.secureRead/secureWrite/secureDelete/secureContainsKey/secureReadAll.
1.2.0 #
- Contacts sync foundation (Cesium+ pods): CRDT model
ContactsSyncState/ContactSyncEntry(per-field last-write-wins, commutative merge, non- destructive removal viaisContact, dead-entry GC, forward-compatible unknown fields) and encrypted-blob pod I/OgetContacts/saveContacts(cross-preserves the co-hosted certification queue).
0.6.0 #
- Bulk storage support
- Improved network scan
- Connection orchestrator improvements
0.3.0 #
- Implement all Gecko v2s requirements
0.1.0 #
- Migrate files from Ğazelle to split responsabilities