Skip to main content

durt2 2.2.0 copy "durt2: ^2.2.0" to clipboard
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-chain tool can be used. A pod imposed on the local network 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 named local, while the local chain announces gtest_local. A pod on localhost, a loopback address literal (127.x, ::1) or the Android emulator's 10.0.2.2 is now reached over http/ws; every other pod stays on https/wss, private addresses included, and so does any name that only starts like a local address (127.0.0.1.example.com). Networks.podChainId names the chain a pod of each network announces: gtest_local for local, the network name otherwise.

2.1.1 #

  • A payer account that does not exist cannot pay a revocation. canPayRevocation answered 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 takes exists). 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 the g1://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) and validateRevocationManualCode(manualCode) check it against the connected chain and answer a ValidatedRevocation; 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 a Stream<TransactionStatus> like revokeIdentity. Every refusal is a RevocationCertificateException whose failure says 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. RevocationCertificateCodec holds the network-free part: payload, codes, signature check, the rules of who may certify and revoke. revokeIdentity is 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_edwards and sr25519 are 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. clearWallets removed 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. deleteSafe does 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, DunipodEventHub now 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: status was sent with in, sorted by INDEX_DESC, and a name with includesInsensitive. getNetworkIdentitiesFiltered now 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 new genesisTime argument, 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 of getCertsReceived and the identity's createdOn, 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. getCertsReceived is unchanged.

  • An address is looked up whole, and only then (gecko#252). searchByAddress sent startsWith and endsWith on 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 in IdentitySearchException instead of in no result. It now sends nothing unless the text is a whole valid address, then looks it up with equalTo, 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.checkAndHandleSchemaError to checkAndHandleQueryError; handleSchemaError and DunipodSchemaValidator are gone.

  • An account's activity can include universal dividends (gecko#252). subscribeAccountActivity(address, withDividends: true) also asks the pod for UNIVERSAL_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, behind getCertsReceived and getCertsSent): 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 sees GetCertifications and AccountHistory (transfers and udHistory together) on a first page.

  • An exact comment, and the 70 cost vectors (dunipod!34, gecko#274). TransactionFilters.exactMatchComment is 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 overloaded retry 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 answer overloaded is 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 second overloaded is the caller's. Both waits are bounded, the GraphQL client never cancelling what it stopped waiting for: 12 s in line (then DunipodQueueTimeout, 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 a TimeoutException); 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.overloaded tells a full pod from a spent quota: the former was already retried and must not be again. Behaviour changes: AccountNameBatcher no 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. Throws DistanceEvaluationsException when the pod does not answer.

  • A name or address search that got no answer says so (gecko#274). Breaking: searchAddressByName and searchByAddress throw IdentitySearchException when 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). initialAccountHistoryScanBudget and initialNetworkActivityScanBudget (on dunipodInitialScanBudget) 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. isDunipodAccountSearch and isDunipodNetworkSearch tell 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 with DunipodQuotaRefusal in OperationException.linkException, which a search reads as AccountHistoryThrottled or AccountHistoryBudgetTooLarge. 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 in Retry-After-Ms before Retry-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). getCertsReceived selects createdOn, the block the certification was created in, next to updatedIn: 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: getNetworkActivityFiltered and getNetworkActivityFilteredRaw are replaced by searchNetworkActivity, which returns an AccountHistorySearch like searchAccountHistory. Without an account it reads the whole network; fromAddress and toAddress are 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 back AccountHistoryUnsupported: a pod refuses them on the whole network. An amount, a comment or both accounts make it a budgeted search, up to maxNetworkActivityScanBudget. exactMatchDirection is 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: getAccountHistoryFiltered and getAccountHistoryFilteredRaw are replaced by searchAccountHistory, which returns a sealed AccountHistorySearch: AccountHistoryResults with a complete page, or why there is none (AccountHistoryBudgetExceeded, AccountHistoryBudgetTooLarge, AccountHistoryThrottled with 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 new TransactionFilters.counterparty (one exact account, both directions) make it a search, sent with the scanBudget given, up to maxAccountHistoryScanBudget; a period alone is sent as before, without the argument, which pods older than !29 refuse. The account filter no longer reads addresses, fromAddress, toAddress nor 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; dunipodCostOf now prices with v2, dunipodGraphqlCostV1 still gives v1.

  • Names are searched as pods serve them (dunipod!26). Behaviour change: searchAddressByName returns 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: SquidService is DunipodService, SquidClientManager is DunipodClientManager (and parseSquidEndpoint is parseDunipodEndpoint), the query extensions are DunipodAccountQueries, DunipodNetworkQueries and DunipodSubscriptions. On Durt: squid is dunipod, squidEndpoint is dunipodEndpoint, isSquidConnected is isDunipodConnected, squidConnectionStatus and squidConnectionStatusStream are dunipodConnectionStatus and dunipodConnectionStatusStream, squidEndpointChangeStream is dunipodEndpointChangeStream, setFixedSquidEndpoint, clearFixedSquidEndpoint, reconnectSquid, testSquidEndpoint and getWorkingSquidEndpoints say Dunipod instead of Squid, and the initSquid parameter of connect is initDunipod. Networks.squidEndpoint and Networks.listSquidEndpoints are dunipodEndpoint and listDunipodEndpoints. The generated bindings moved from lib/src/models/graphql/squid/ to lib/src/models/graphql/dunipod/, with squid-schema.graphql.dart now dunipod-schema.graphql.dart. Removed, as nothing called them any more: EndpointType.squid, DuniterPeerings.getSquidEndpoints, RemoteNetworkConfig.squid, SquidClientManager.testEndpoint, isIndexerSynced, isVersionSupported and minimumSquidVersion, the Squid sync validator, the Squid probes of the endpoint tester and config/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.imposedDuniterEndpoint is 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. getIdentityName throws IdentityNameUnavailable in 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). getIdentityName now groups the lookups made within 30 ms into one identities(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; an overloaded 503 is left to the client link. Breaking: getIdentityName throws instead of returning null when the pod does not answer, and the one-account GetIdentityName document and its export are gone.

  • The certifications of one person are read through their identity (dunipod#18). Pods refuse certs filtered on the issuer or the receiver. One name is now looked up exactly, case included (dunipod#17), and its certIssued or certReceived read, latest change first. Both names give the one certification between the two identities. A person over a period is refused before sending: that belongs to certEvents. Breaking: CertificationFilters loses exactMatchIssuer, exactMatchReceiver, issuerAccountId and receiverAccountId. 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 them Identity.name, which pods leave empty for an identity not yet confirmed (68 of the 500 newest on g1pod.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 account or at the root, and each bound in its own element of an and. 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 order CREATED_ON_DESC gave; pods refuse CREATED_ON_DESC and LAST_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 by CREATED_ON_DESC, since pods serve UPDATED_ON_DESC only with it: a renewed certification no longer moves up in that view until they do (dunipod#18). The amount filter is a BigIntFilter.

    The Squid combined history reader is gone with its dividend helpers and CombinedAccountHistoryResult: readAccountHistory replaced 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, networkEvents and finalizedBlocks. subscribeAccountActivity, subscribeCertActivity, subscribeIdentityName, subscribeNetworkActivity, subscribeNetworkIdentities and subscribeNetworkCertifications keep their signatures and now read them; subAccountHistory and subscribeHistoryIssued, 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, one accountEvents for every watched account and one networkEvents, 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 after works but charges every replayed event: about fifteen exhausted the quota on g1pod.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_channel becomes 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.connect chooses a pod through DunipodConnector and 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.

    setFixedDunipodEndpoint imposes 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. testDunipodEndpoint asks the pod its own dunipodStatus. getWorkingDunipodEndpoints lists pods, as bare hosts. The user's former Squid address (customIndexer) is no longer read: a Squid cannot serve as a pod. connectDunipod is 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). readAccountHistory replaces 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 against g1pod.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.name is 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 its failure says 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 (EventFilter has no name).

    test/performance/history_pagination_real_test.dart measures both readers against a real pod (tag manual, accounts passed through HISTORY_ACCOUNTS). Run back to back, its own reads emptied the pod's per-source bucket, which answered GraphQL request quota exceeded with code rate_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. DunipodDiscoveryService starts from the seeds of config/dunipod_endpoints.json, asks each for otherDunipods, 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 own dunipodStatus, and kept only when chainId matches, initialized and genesisComplete are true and projectionStatus is complete, 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.publicReady is 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 are gtpod.elo.tf, gtest.pod.elo.tf and dunipod.gtest.pagu.re, all listed in nodes/networks and all accepting a WebSocket on 2026-10-30.

    Choosing and keeping one. EndpointType.dunipod is a kind of its own next to rpc, 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; sharing customIndexer with 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.

    DunipodConnector picks the pod. A pod answers dunipodStatus, 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 its Retry-After asks, 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. dunipodGraphqlCostV1 implements the normative dunipod-graphql-cost-v1: min(30, 1 + max(F div 5000, W div 800) + 9 * E), where F counts response fields, W weighted connection rows and E exhaustive 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 into test/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.

    dunipodOperationViolations checks, 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, events and extrinsics once 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-global is the server's own capacity, filled by every client, and read-source our share of it. Both belong to that pod alone, so either one running dry is a reason to try another pod. DunipodBudget reads RateLimit-Policy, RateLimit and Retry-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-After is 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.

    planDunipodRead chooses 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.tf on 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 build could 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. analyzer is held at 9.x because analyzer >=13.1.0 wants meta ^1.18.3 while the SDK's flutter_test pins meta 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) and source_gen below 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 __typename selections the current generator emits, and follow the schema more closely: a certification issuer's status is no longer nullable, as the schema of both Squid and Dunipod says. flutter analyze clean and the 312 offline tests pass.

1.10.2 #

  • No master key is generated while a safe depends on one (gecko#293). CryptoUtils.masterKey generated 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. WalletService now hands CryptoUtils its safes (CryptoUtils.guardMasterKeyWith), and while one exists a missing key throws SecureStorageException (operation generate) 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.getCertState checked the issuer's delay before the target's status: an issuer whose next certification was not due yet got mustWaitBeforeCert for 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 handle mustConfirmIdentity.

1.10.0 #

  • A certification can be sent without the newcomer's distance evaluation (gecko#276). DuniterService.certify bundled requestDistanceEvaluationFor whenever 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 parameter requestDistanceEvaluation (default true, the behaviour so far): an app that has computed the newcomer's distance below the rule, or could not compute it, passes false and the certification is sent alone. New DuniterService.certificationRequestsEvaluation(...) says, before certifying, whether the evaluation would be bundled: the very test certify applies.
  • 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 certify counted 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.wallets no longer throws under DurtTestMode: 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) makes Durt.i.isConnected true, for code that only runs once connected. Everything WalletService reads or writes now goes through a WalletStore (ObjectBoxWalletStore in the app, InMemoryWalletStore in tests); behaviour is unchanged. New on WalletService: getAllSafes, getAllWallets, hasSafes, storeSafe and findWallet, for callers that reach safeBox and walletBox directly 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_storage on 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 (default durt2), 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 .tmp siblings included), reads each one back, destroys the files lowest priority first, and returns a LegacySecretFileReport; on a store failure it throws SecureStorageException and 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, filesConflicting names it and report.unresolved is true, as it is for an unreadable file; read that flag and tell the user, the secrets are still on disk;
    • CryptoUtils.setMacosSecureStoragePath and CryptoUtils.secureStorage are 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.
  • WalletService.checkCode() answers false for 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. A SecureStorageException now goes through; the check itself is WalletService.verifyPin(safe, pin), static, testable without a store. Callers of checkCode catch SecureStorageException and count nothing. Only the cipher's own refusal answers false; 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.decrypt then throws SecureStorageException instead of "Invalid secret code", and CryptoUtils.legacySecretFilesUnresolved tells 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 SecureStorageException instead of reading as empty. secureRead, secureWrite, secureDelete, secureContainsKey and secureReadAll logged the failure and answered null or nothing, which masterKey() 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 key masterKey() generates is read back before it is used. A caller that read null as a broken store gets the exception instead.

1.7.1 #

  • A pod outage is no longer reported as an empty document. getContacts and getCertificationQueue caught every error and answered null, 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 introduced CesiumPlusUnavailableException for exactly this distinction and fixed getProfileByAddress, but these two readers flattened it back to null one 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. null is 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 of g1.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. getBalances sent one state_call per address in a single Future.wait, so scanning 30 derivations meant 30 concurrent runtime API calls. Duniter nodes serve only 4 at a time and answer 4003 too many concurrent runtime API calls to the rest: measured on the public Ğ1 nodes, a burst of 30 lost 12 to 26 calls on g1.gyroi.de, g1.pini.fr and g1.1000i100.fr, while rpc.duniter.org and g1.axiom-team.fr answered all of them. A single rejection made the whole Future.wait fail, 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.disconnect closes 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 a TimeoutException the 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 against gtpod.elo.tf: first=500 answers, first=501 is refused) and connectionPageSize() 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 answer null for 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 that null as "this user has no description, no city, no socials" and published those absences, which uploadProfile turns 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, null on a 404 (the only case where "absent" is an answer from the pod), and the new CesiumPlusUnavailableException for 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 existing catch makes them return false without 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.testEndpoint takes an enforceMinimumVersion flag (default true, so automatic selection keeps the minimumSquidVersion floor). Callers testing an endpoint the user named themselves pass false: 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 carried indexer.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 by squid.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 to wss://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 as test/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.raceWithMinWait gains a real early exit: it returns at first 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, and SquidClientManager.init now 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.
    • SquidConnector is split into prepare() (probe race, needs nothing from Duniter) and finalize() (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: objectbox and objectbox_flutter_libs 5.3.2, flutter_secure_storage 10.3.1, multiformats 1.4.0, logger 2.7.0, graphql 5.2.4, collection 1.19.1, unorm_dart 0.3.2. Dev dependencies follow: test 1.31.0, build_runner 2.15.3, lints 6.1.0, graphql_codegen 3.0.2, meta 1.18.0.
  • The analyzer: ^9.0.0 override is kept and now states its reason: analyzer 13.1.0 and later require meta ^1.18.3, while flutter_test from 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 IdentitiesNames map: DuniterStorageService.getIdentityIndexByName(name) for an exact match, and getIdentityIndexByNameMulti(names) for a batched one (single multiIdentitiesNames RPC, order preserved, result padded to the request length). Both return null for a free name and degrade to null on a WebSocket drop rather than throwing. Lets a client treat the on-chain identity name as authoritative over a self-declared profile name. MockDuniterStorageService gains setReservedName and a nameMultiLookupCount counter so batching can be asserted in tests.
  • DuniterStorageService.accountSurvivesAtZero(address): whether an account keeps existing once emptied. True when sufficients > 0, which on Duniter v2s means the account backs an identity (pallet_identity::create_identity calls inc_sufficients on 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.pay takes a keepAlive flag (default true, unchanged behaviour for Ğ1 amounts). false selects the chain's Expendable preservation: transferAllowDeath instead of transferKeepAlive, and transferUd instead of transferUdKeepAlive. This also aligns the two units: UD amounts previously always used transferUd (Expendable) while Ğ1 amounts used transferKeepAlive (Preserve), so the existential-deposit rule silently depended on the display unit.

1.2.1 #

  • Fix macOS biometrics: BiometricService now 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 via isContact, dead-entry GC, forward-compatible unknown fields) and encrypted-blob pod I/O getContacts / 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