Skip to content

Release: develop -> main - #1201

Merged
TaprootFreak merged 3 commits into
mainfrom
develop
Jul 28, 2026
Merged

Release: develop -> main#1201
TaprootFreak merged 3 commits into
mainfrom
develop

Conversation

@github-actions

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

…rule in CI (#1197)

* fix(safe): label a foreign write mandate as view only, and guard the rule in CI

The picker labelled by access level alone. A write grant on someone else's
account therefore appeared exactly like one's own — fully usable — and then
dropped the transaction area without a word once selected. Label and behaviour
now share one predicate, so the list cannot promise what the screen refuses.

That predicate is also the only thing standing between a mandate holder and an
order booked against the wrong Safe, and it went wrong twice during review. Its
only coverage was a Playwright run, which is explicitly not a CI gate. It has a
unit test now: own plus write acts, own narrowed to read does not, foreign does
not at either level, legacy acts.

Moving it out of the hook is what makes that testable — it is plain logic with
no React in it, and importing the hook pulls in ESM that Jest will not parse.

* style(safe): remove a stray comment opener left by the extraction

Pulling canTransact apart left an empty /** above the replacement comment.
Still one valid block, so nothing complained — but every tooltip over
canTransact showed the leftover opener as part of the text.

* test(safe): show what a write mandate over a foreign Safe looks like

The picker fix is about exactly one state — a write grant on someone else's
account — and no baseline showed it. The only evidence was a sentence in the
pull request saying it had been checked by hand.

A third account carries that mandate now, and the fourth shot is that state:
labelled view only, holdings visible, no transaction area, despite the grant
saying write. The test insists on both before taking the picture.
* Add Bank Frick personal IBAN buy parameter

* Map Bank Frick URL selector to API enum

* Fix personal IBAN public parameter contract

* Clarify personal IBAN integration names

* Preserve empty personal IBAN web attribute

* Rerender personal IBAN web attribute changes

* Scope web attributes to initialization

* Harden personal IBAN selector handling

Review of the initial implementation surfaced several ways a customer could
request a personal IBAN and silently end up with the default bank instead.

The selector is no longer copied into component state. It is derived on every
read from its actual source - the URL search in the browser, the live widget
property when embedded - so browser back/forward, redirects, re-authentication
and widget attribute changes all take effect without a lifecycle to keep in
sync. This removes the session storage entry, the logout heuristic and the
explicit clearing, all of which could drift from the source.

The web component now applies attribute and property changes after the initial
connection again, including removal, and the app router is created once per
embedding so an attribute change no longer sends the customer back to the
start route.

Frick errors are mapped from the API tokens to translated, cause-specific
messages, kept separate for the buy flow and for stored payment detail
reconstruction. The feature specific mapping only applies when the customer
actually set the selector - it previously also caught ordinary EUR bank
transfers and replaced the KYC dialog with an unusable retry hint.

The extra bank row is limited to personal IBANs, relative URLs merge an
existing query instead of appending a second one, overlapping quote fetches
cannot show stale data, and failed invoice or receipt downloads surface a
visible per row error instead of being swallowed.

* Align personal IBAN error tokens with the API

Two stored-detail tokens were renamed on the API side and never followed here,
so the checks never matched and the customer saw the raw token instead of a
translated message. Both paths are reachable in normal operation: a personal
IBAN deactivated by an operator, and one reassigned by an account merge.

Two further mismatches came out of a full comparison against the API enum: a
check for a value the API never sends, and a value placed in the wrong mapper -
it can only occur while requesting a quote, never while reconstructing stored
payment details. One value the API does send was not covered at all.

Tests now pin every token explicitly, including negative cases for the retired
spellings, so a rename on either side fails here instead of silently degrading
to an untranslated message.

* Only promise a personal IBAN in the customer's name where that is true

CHF accounts at Yapeal are genuinely held by the customer. EUR accounts at
Bank Frick are not: the account stays with DFX and the customer gets an IBAN
assigned to them alone. The self-service screen promised the customer's own
name regardless, and it defaulted to EUR when no currency was given — reachable
today through the sitemap link, which passes none. Until this feature there was
no EUR provider, so the default was harmless.

A missing currency is now a visible error instead of a silent default, and the
confirmation text states what is actually true for the currency at hand. The
promotional block on the buy screen already excluded EUR and is unchanged.

* Consume the published SDK types for the personal IBAN selector

The SDK release that carries these types is out, so the local bridge types can
go. personalIbanProvider is now the SDK enum instead of a string, and the buy
response type no longer needs a local extension for the bank field - the
compiler enforces the contract with the API rather than a hand-kept copy.

This also moves the app from react 1.5.0 to 1.6.0, which carries two unrelated
SDK changes (#189, #193) that come along with the version.

* Verify the personal IBAN response and remove the dead ends

A review of the complete diff found that the app committed whatever the
quote returned. Against an older API the selector is stripped from the
request, so a customer who asked for a personal IBAN silently received
ordinary bank details — and the legacy path names the customer as the
account holder, although Bank Frick keeps the account with DFX AG. That
is the mismatch a sending bank flags during payee verification.

An explicit Frick request is now checked against the response: it must be
marked as a personal IBAN, come from Bank Frick and name DFX AG. If it
does not match, or if the offer cannot carry a personal IBAN at all, the
payment details stay hidden behind an explicit acknowledgement instead of
being substituted without a word. The acknowledgement resets whenever the
selector or the quote inputs change.

Two states dead-ended: a missing KYC level and an unrecognised selector
both offered only a Retry button that reproduced the same failure. The
KYC case now routes through the normal hint with its working action and
keeps the feature-specific wording; the unrecognised selector offers
continuing without it, or leaving.

Further, from the same review:

- the selector no longer survives a logout, in the URL or as a widget
  property, so it cannot leak into the next customer's purchase
- external login callbacks forward only the selector instead of copying
  the entire live query string
- changing a quote input invalidates the pending request immediately
  rather than after the debounce, so an obsolete response cannot win
- the bank row appears only for a verified personal IBAN, restoring the
  previous screen for customers who never use the selector
- a failed document download shows the real error instead of a
  substituted placeholder

* Close the gaps the previous round left behind

A fresh review of the reworked state found six defects, four of them
introduced by the previous round's own fixes.

Removing the dead ends created a new one: "Continue without personal
IBAN" sent the plain quote but left the error on screen, so the customer
stayed where they were. Invalidation now follows the effective selector,
which clears the error before the standard quote arrives — and the test
no longer stops at the outgoing request, it asserts that the payment
details are actually reached.

The logout suppression for embedded widgets could not be lifted. It
compared the selector value, and a host reasserting the same value for
the next customer changes nothing about that value, so the selector
stayed silently disabled. Every attribute and property write now bumps a
revision counter, and the suppression follows the counter rather than the
string.

The logout cleanup required an observed transition from logged in to
logged out, which never happens when the persisted token is already
expired at mount. An expired session is now distinguished from a first
visit; only the latter keeps the selector through the login redirect.

Also: clearing the amount discards the pending quote instead of letting
its response arrive for an empty form, and sitemap links carry the
selector along instead of dropping it.

* Tie the selector to an observed customer boundary

The last two rounds tried to recognise "a different person is using this
app now" from the session state, and got it wrong three times. The rule
is explicit in the code now, and the tests state it from the customer's
side.

The selector stops applying only when this app instance actually observed
a customer boundary: a logout, or a change of the authenticated identity.
An expired token found at mount is not a boundary — that is the same
person coming back and being asked to log in again, and the previous
round silently dropped their request. Neither is a first visit without a
token, nor credentials that a host supplies together with the selector.

After a genuine boundary the suppression now lasts for the lifetime of
the mounted app, standalone as well as embedded. Otherwise the browser
Back button reaches an older, selector-bearing entry, and because the
login flow deliberately carries the selector, the next customer inherits
it. It is lifted only by a new explicit intent: a navigation carrying the
parameter, or a host write, which the revision counter already models.

Two more from the same review:

- Retry on a transient issuance failure sent no second request at all —
  the debounce discarded the structurally identical value. It now carries
  a retry counter, like the info screen already did. The test clicks the
  button instead of only asserting it exists.
- Quote invalidation compares a canonical signature rather than raw form
  values, so retyping 300 as 300.0 no longer discards a valid response.

* Bind an incoming selector to the credentials it arrived with

A widget host that opens the app for a new customer supplies that
customer's session together with the selector. The app recorded both as
already seen before the wallet had installed the session, so the customer
still present in local storage was observed first and the incoming
selector was discarded as belonging to someone else — silently, with
ordinary bank details as the result.

Credentials and selector that arrive together now stay pending until
those credentials have established their identity, and are then taken
into account as one. Nothing is inferred from whichever identity happens
to initialise first.

The same reasoning fixes the React integration. Reasserting the selector
after a customer boundary worked only for the Web Component, because only
it produced the internal write counter. A React host expresses the same
intent by supplying the selector together with changed credentials, and
that now counts as fresh intent. The counter stays internal to the Web
Component bridge — it was exposed in the widget parameters and is
removed there, since a revision an integrator has to maintain by hand is
a contract nobody asked for.

Quote invalidation now signs the provider that the request actually
carries rather than the live selector, so toggling it on an offer that
cannot carry it no longer throws away a valid pending response.

* Make the fallback fetch what it promises

"Continue without personal IBAN" only revealed the response that had just
been rejected. Against an older API that response is the personal IBAN
with the customer named as recipient — the very payee mismatch this PR
exists to prevent, shown to a customer who explicitly chose the plain
alternative. Both purchase flows now discard the rejected response,
suppress the selector and wait for a new selector-free one; the tests
assert the standard IBAN and DFX AG as recipient, and assert the personal
IBAN is gone.

The previous round's pending state could never resolve for credentials
supplied after mount, because this app applies supplied session and
address props during initialisation only. The selector stayed hidden
forever. Pending now applies only where the credentials are actually
consumed, which is where it fixed a real problem; elsewhere a selector
supplied with new credentials counts immediately. The initialisation-only
contract is written down in the README instead of being implied.

The exact-price synchronisation compared against the raw input, so a
customer typing 300.0 invalidated the response the app had just received
and waited for another. It now uses the same canonical signature as the
request.

The Bank Frick branch on the self-service screen was unreachable: that
endpoint only knows Yapeal, and Yapeal only does CHF. It is removed along
with its translations and a test that asserted a state production cannot
enter. The README no longer claims the API rejects unknown providers —
they never reach it.

* Ask instead of guessing who the selector belongs to

Five rounds tried to recognise from events that a different person had
taken over the app: pending credential intent, observed transitions,
suppression scoped to the mount, restoration on navigation, a revision
counter in the Web Component bridge. Each round closed one edge case and
opened the next — most recently an invalid session that left the selector
pending forever, and a suppression that a reload simply forgot.

All five mechanisms are gone. The selector stays what it was, a value
read from the URL or the widget property. When it is applied to a quote,
the authenticated account it applied for is recorded, in storage that
survives a reload. Before applying it again the app compares: the same
account follows the stored decision, a first use binds it, and a
different account is asked to confirm or decline.

The case is genuinely undecidable from the address alone — the same URL
can mean a leftover from the previous customer or a deliberate request by
the current one. So it is now a question to the customer instead of a
guess, which is what the rest of this flow already does.

The four guarantees the earlier rounds established each have a test: an
embedded customer arriving over a still-valid previous session, an
expired token surviving the login redirect, logout followed by Back and a
reload, and a malformed session that can still be left.

Also: the failure state of the selector-free fallback offers Close beside
Retry, the error mapping compares SDK members instead of hand-written
strings, and the test that asserted README prose is removed — this
repository has no precedent for it, and the companion backend PR dropped
the same construct.

* Confirm the personal IBAN instead of storing who it belonged to

The identity binding from the previous round carried its own problems: a
single decline locked the customer out for good, one stored decision per
provider overwrote the previous customer's without a trace, and an
unreadable or unexpected stored value was treated as a valid empty
answer. All three follow from keeping a durable record of who a selector
belonged to.

That record is gone. A personal IBAN means a real account is opened at a
bank and cannot be revoked, so the app asks the customer to confirm it —
once per tab and authenticated customer, remembered in session storage so
a reload does not ask twice. A different customer in the same tab is
asked separately, a new tab asks again, and nothing outlives the tab.
Declining applies to this occurrence only; a selector arriving anew asks
again.

If session storage is unavailable or holds something unexpected, the app
asks. An answer it cannot read is not an answer.

Customers who never set the selector see no dialog, no storage access and
no change in quote timing — the previous round's authentication gate had
started delaying their quote too.

The tests now drive the scenarios instead of asserting a mocked end
state: the dialog is clicked and the outgoing request inspected, the
reload and the Back navigation are actually performed, and session
storage is made to throw. Where the test setup cannot drive a scenario —
the SDK's authentication is globally mocked in this repository — the
report says so rather than asserting a proxy. The error-token test now
reads the installed SDK enum instead of inventing values and asserting
the real tokens do not match them.

* Say who the account belongs to, and stop storing the answer

The confirmation told the customer an account is opened for them. For
Bank Frick that is untrue: the account is held by DFX AG and only the
IBAN is theirs. In a change that exists because of a payee name mismatch,
that sentence was the wrong one to get wrong. It now says what happens —
a transfer IBAN of their own, on an account belonging to DFX — in German,
French and Italian. The Yapeal wording stays as it is, because there the
account really is the customer's.

The answer is no longer written to session storage. That storage did not
deliver the lifetime the design assumed — a tab opened from another
same-origin tab inherits a copy of it — and a read that threw while
writes succeeded could leave a customer unable to get out of the
confirmation. It is now held in memory for the running instance: a reload
asks again, which is proportionate for an account that is created for
real and cannot be revoked. Nothing is serialised, so there is no shape
to validate and no failure to fall back from.

A decline applies to the occurrence in front of the customer. Any fresh
arrival of the selector asks again, including a changed React prop — the
React integration previously collapsed all its occurrences into one and
would have gone quietly to ordinary details after an earlier decline.

Two behaviours cannot be tested here: the selector surviving a login
redirect after an expired token, and a logged-out customer's selector not
reaching the next one after Back and a reload. This repository mocks the
SDK's authentication, so no honest automated test reaches that boundary;
both are written down as manual checks instead of being approximated by a
test that only looks like proof.

The Playwright visual now confirms the prompt before waiting for the bank
details it could no longer have reached.

* Check the selector before warning about a bank account

The confirmation about a real, non-revocable account appeared for any
non-empty selector, before the provider was recognised and before
applicability was checked. A customer who mistyped the parameter was
warned about a Bank Frick account that would never have been created, and
a valid request on an offer that cannot carry a personal IBAN got the
same prompt. Recognition and applicability now come first; only a valid,
applicable Bank Frick request reaches the confirmation, and an unknown
provider keeps the local rejection with its acknowledged fallback.

A decline outlived the purchase it was given for in a mounted widget, so
every later purchase quietly received ordinary details. The occurrence
now includes the purchase, and a new one asks again. The selector staying
in the address bar is deliberate and unchanged: an issued personal IBAN
is a durable property of the customer, so reusing it for a later eligible
EUR purchase is correct.

The currency message claimed personal IBANs are EUR-only. Yapeal issues
them in CHF; only the Bank Frick path is EUR.

The copy guard has the right shape now. It checked complete sentences in
three translation files, so better wording would have failed it while
wrong wording in the component would have passed. It now renders the
prompt per language and asserts the fact: the account is attributed to
DFX, and no text claims it belongs to the customer.

The README prose test is removed again — it was reintroduced under a new
name after being rejected, and this repository has no precedent for
asserting documentation in Jest.

* Stop making customers without the selector wait

Both quote screens waited for wallet initialisation before requesting a
quote at all, not only when a personal IBAN was involved. A customer who
never sets the selector had no reason to wait, and the info screen could
stay blank if initialisation stalled. Readiness is now checked only
inside the Bank Frick branch, and its settling no longer triggers a
second request. The test meant to cover this had set initialisation to
finished, so it never exercised its own claim; both screens now test it
with initialisation pending.

Declining the confirmation on the main buy screen asked for the same
decision a second time and explained it with a message about personal
IBANs being EUR-only — which is both wrong and unrelated. The two buy
flows had drifted apart; the main screen now uses the same condition as
the info screen, which was already correct.

The occurrence identity no longer falls back to an inferred counter when
a caller omits it. That fallback turned a caller's mistake into a silent
customer outcome: the inferred value does not change on a re-render, so a
previous decline stayed in force and ordinary details went out unasked.
It now fails loudly, and identity is owned by the two bridges that can
produce it correctly.

* Let the two integrations agree on what a new request is

The Web Component treated a property or attribute write with an unchanged
value as a fresh selector request, while an unchanged React prop did not.
So the same customer action meant different things depending on how the
app was embedded, and in the Web Component a declined confirmation could
reappear at once for the same purchase.

Both now take a new request from a changed value — including one
appearing or disappearing — or from a new navigation, and not from a
write that changes nothing. The same-value trigger existed to let a host
reassert the selector for a new customer back when suppression was
lifetime-scoped; the confirmation is bound to the authenticated customer
now, so a customer change asks again on its own. The mechanism outlived
its reason.

The exported `usePersonalIban` hook had no consumer and carried comments
describing an integration that does not exist. Both are gone; deriving
the selector stays private to the confirmation hook.

The payment-details screenshot cropped to amount, IBAN, BIC and bank, so
it did not cover the recipient — the field this change is about. The crop
now includes it and the test asserts DFX AG as text, which holds even
before the baselines are regenerated. Both baselines need regenerating on
a machine that can run the stack; the confirmation one does not exist yet.

* Restore the standalone entry point

The widget build swaps src/index.tsx for the web component entry and
restores it afterwards, keeping the original in src/index.bak.tsx while
it runs. The previous commit captured that swapped state: the standalone
entry contained the web component bootstrap, and the real one was
committed as the backup file.

A normal build would have compiled without an error and served an empty
page — the entry registered the custom element and never mounted the app
into #root. Nothing in lint, the unit tests or either build can catch
that, which is why it went out.

src/index.tsx is identical to develop again, the web component bootstrap
lives only in src/index-widget.tsx, and the backup file is gone.

* Make the quote tests wait for the condition, not the clock

One personal-IBAN test failed in a full run that took 244 seconds because
the machine was busy, and passed in 15 seconds when it was not. Flakiness
is a defect here, not an environmental quirk: what fails under load can
fail in CI.

The race-protection tests waited out a fixed 500 ms for a slow in-flight
request to settle and used arbitrary three-second ceilings. They now
resolve that request explicitly and wait for the condition itself, so the
outcome no longer depends on how fast the machine happens to be. Raising
the timeouts would have hidden the dependency instead of removing it.

The export-surface test is gone. It asserted that a module does not
export a symbol — implementation surface rather than behaviour, with no
precedent in this repository, and the third construct of its kind removed
from this change. What matters is covered by the confirmation and
integration tests.

* Keep the feature wording out of a declined quote

After the customer declines the confirmation on the info screen, the
request correctly goes out without the provider, but the error mapping
was still keyed on the raw selector. A declined customer who then hit
KycRequired read "Personal IBANs require KYC level 50" instead of the
generic KYC dialog with its working action.

It is now keyed on the provider the request actually carries, as the
interactive buy screen already did. That is the third time the two flows
drifted apart and the second time this particular scope was wrong — the
first time it took the KYC dialog away from customers who never used the
feature at all.

Both flows were compared end to end afterwards: request construction,
error scope, confirmation, decline, fallback and every resulting state.
The differences that remain follow from what the two routes are — an
editable form with a debounce and an exact-price cycle versus one fixed
request from route parameters, a back navigation versus a Close action —
and each is recorded with its reason.

* Show why the invoice could not be created

The PDF invoice button caught every failure and wrote it to the console.
The API now returns specific reasons for exactly this path — a personal
IBAN that was deactivated, reassigned, mismatched, or whose bank stopped
accepting payments between the quote and the click — and this app already
had translated messages for them. The customer saw the button stop
loading and nothing else.

The error is now rendered: known tokens through their translated message,
anything else through the API's own text, with no placeholder substituted
for a missing one. The other consumers of these endpoints were checked;
the transaction list already handled them correctly.

* Drop the guards that cannot fail and mark the false positive

The analysis bot flagged three conditions on isRunning that always hold.
They are synchronous statements inside the effect body, where the flag has
just been set and nothing can have reset it — leftovers from the race work.
The asynchronous guards in then, catch and finally stay: those can observe
a stale run and are the actual protection.

CodeQL reports clear-text storage of sensitive information at the selector
read. It is a false positive: the parameter carries a provider identifier —
frick — never an IBAN, the value is normalised to the SDK enum, and neither
the hook nor its context persists anything. The confirmation decisions live
in an in-memory map keyed by customer id and are gone on remount. The read
carries a scoped suppression with that reasoning; the alert itself is left
for a maintainer to judge.
TaprootFreak
TaprootFreak previously approved these changes Jul 28, 2026
* fix(pages): restore the cache semantics four assets lost

public/_headers promises to replicate what the previous CDN served, but four
assets fall through it. robots.txt, favicon.ico and logo.png have no rule at
all and take Cloudflare's default instead of the values the upload step used
to set. The WebAssembly chunk of the main build lands in the build root,
because config-overrides.js set webassemblyModuleFilename only in the branch
guarded by CUSTOM_CHUNK_PATH, which only the widget build sets; it is served
with max-age=0 today, where the previous step served it as immutable.

That chunk is content-hashed, so it belongs with the other fingerprinted
assets: the default output path now points at static/wasm/, which the
existing /static/* rule already covers, and the widget build keeps
overriding it with its own versioned path. A /*.module.wasm rule would work
as well, since Cloudflare does support splat patterns of that shape, but
letting one rule keep covering the whole fingerprinted bucket is the smaller
change.

robots.txt also gets its own comment, so the reason it must not be cached is
visible in the file itself rather than only in the pull request.

* docs(pages): say revalidate where the header means revalidate

The comment above the robots.txt rule said "do not cache", but the value it
describes is no-cache, must-revalidate. That does not forbid storing the
response; it forbids reusing it without a successful revalidation, and only
no-store forbids storing. The entry-point comment a few lines above already
words it that way, so the two now match.

The header value itself is unchanged.
@TaprootFreak
TaprootFreak merged commit 38d27cd into main Jul 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant