1139 Commits
Author SHA1 Message Date
Jules 838d20c153 fix(wallet): recover from stuck transactions instead of breaking the session (#1316)
* fix(wallet): poll receipts on the node that took the broadcast

Chain 1 now proxies to the same `/alchemy` path `nodes.broadcastTransaction`
and `nodes.getNonce` use. A node that does not hold the transaction answers
receipt lookups with null forever.

* fix(wallet): only commit the nonce on a validated broadcast

The result was read unvalidated, so a broadcast that produced no transaction
still burned its nonce and gapped every later one. Failures now carry the
node's own reason instead of the status code.

* fix(wallet): reconcile the nonce down against an empty pool

Nothing lowered the local counter, so one dropped transaction left every
later one signed at a gapped nonce until the browser restarted. When the
node's pending and latest counts agree it holds nothing for the account, so
a counter above that is lowered once it has stayed ahead for 5 blocks.

* feat(wallet): detect and notify dropped transactions

A never-mined transaction kept its 30s alarm forever, since only receipt
`0x1` and `0x0` settled it. `eth_getTransactionByHash` is now the probe: a
null receipt means nothing on its own, but three consecutive polls where the
node has no record of the hash settle it as dropped. RPC failures do not
count towards that.

* fix(wallet): sync pending transactions from the background monitor

The page read the list once at mount and wrote its own copy back over the
monitor's removals, leaving settled transactions pending. It now watches the
key it shares with the monitor.

* fix(wallet): keep the earliest mark for a pending count

The post-send mark overwrote the block a held one carried, so every send
from the address restarted the 5-block window and the counter was never
lowered -- each retry only added another gapped transaction. `resolveNonce`
now reports when a mark for the pool count is already held.
2026-08-27 13:21:36 +00:00
Jules f7fc111115 fix(wallet): stop underpricing transactions in the fee estimator (#1315)
* fix(wallet): read gasUsedRatio as a float

`eth_feeHistory` returns it as a float, so `parseInt(hex)` stopped at the
decimal point and `averageGasUsedRatio` was always 0, pinning the ETA to
the idle branch. Also guard the empty-history average against NaN.

* fix(wallet): floor the tx tip and raise the fee ceiling

Tip is now max(eth_maxPriorityFeePerGas, feeHistory p50, 1 gwei); the
suggestion alone ran ~270x under the median tip paid and `0x0` was
signable. Ceiling goes 2x -> 3x base fee, ~14 -> ~23 blocks of headroom
before the tx becomes unincludable.

* fix(wallet): lower the tip floor to 0.1 gwei

1 gwei sat ~16x over the median tip paid at a ~0.11 gwei base fee, so it
set the quoted price rather than backstopping it. Only quiet markets
change: above 1 gwei the tip is max(suggestion, p50) either way, leaving
congested sends and LiFi swaps untouched.
2026-08-27 13:21:36 +00:00
jinhojang6 1010bab46d fix(status.app): build og:url from the canonical URL on inheriting routes 2026-08-27 01:12:37 +09:00
jinhojang6 42e8d68cf8 fix(status.app): declare canonical URLs as absolute on every route 2026-08-27 01:12:37 +09:00
jinhojang6 379a8f38c1 chore: remove changesets from the repo 2026-08-25 22:46:58 +09:00
jinhojang6 a8bd7cdf4c fix(status.app): merge incoming className in CodeBlock 2026-08-25 21:13:31 +09:00
jinhojang6 a37a6d5fec fix(status.app): restore code block background on content pages 2026-08-25 21:13:31 +09:00
Jules 4666683c51 fix(release): drop stale yarn workspaces field from root package.json
@manypkg/find-root checks `workspaces` in package.json before
pnpm-workspace.yaml, so changesets was reading a drifted list that omitted
packages/ethereum-provider and packages/sitemap-utils, failing `changeset
version` with "package ... not in the workspace". pnpm-workspace.yaml is now
the single source of truth.
2026-08-25 01:23:25 +09:00
Jules 477954866f fix(community-dapp): unbreak vercel build broken by foundryup attestation check
* fix(community-dapp): skip foundryup attestation check on vercel

foundryup parses the release attestation manifest through bash process
substitution. Vercel's build container has no /dev/fd, so the redirection
fails and `set -e` aborts the build:

  /vercel/.foundry/bin/foundryup: line 58: /dev/fd/63: No such file or directory

foundryup is fetched unpinned, so this started failing with no repo change.

* chore(community-dapp): pin foundry to v1.5.1 for vercel builds

Vercel installed the latest release (v1.7.1) while ci.yml and release.yml
pin v1.5.1, so contract ABIs were built with a different compiler there.
2026-08-25 01:19:04 +09:00
jinhojang6 9bd0e45384 fix(status.app): fetch the specs submodule at build time 2026-08-25 01:08:16 +09:00
jinhojang6 5fcfd013d9 refactor(status.app): route the remaining same-origin URLs through SITE_URL 2026-08-25 01:08:16 +09:00
jinhojang6 3be39e4aa1 docs(status.app): document submodule and edge-cache requirements for self-hosting 2026-08-25 01:08:16 +09:00
jinhojang6 13f445117a perf(components): lazy-load avatar images 2026-08-25 01:08:16 +09:00
jinhojang6 86e3659cfc fix(status.app): demote the prefooter CTA heading to h2 2026-08-25 01:08:16 +09:00
jinhojang6 ddbf70df91 fix(status.app): fail the build when the specs submodule is missing 2026-08-25 01:08:16 +09:00
jinhojang6 b7696b682b refactor(status.app): consolidate the canonical site origin into one constant 2026-08-25 01:08:16 +09:00
jinhojang6 db869e8d32 fix(status.app): stop emitting localhost URLs from production builds 2026-08-25 01:08:16 +09:00
Jules 630fe059e5 feat(wallet): route dApp reads to the chain the dApp is on (#1303)
* feat(wallet): add a chain registry

One source for chain id, name, icon, viem chain, proxy route and whether
sends are possible, replacing three lists that disagreed: the switchable
set in rpc/chain.ts, the mainnet pin in public-client.ts and the
name/icon maps in the approval popup.

No upstream proxy path is known for Status Network Sepolia, so it ships
with proxyChainId null.

* refactor(wallet): resolve chains through the registry

Drops the duplicate SUPPORTED_CHAIN_IDS set, the SIGNABLE_CHAIN_ID
constant and the CHAIN_NAMES/CHAIN_ICONS maps.

Chain ids are matched by value rather than by string, so 0x6300B5EA and
0x6300b5ea stop being two chains, and the registry's spelling is what
gets stored.

* feat(wallet): route dApp reads to the origin's chain

Forwarded reads went to a module-level mainnet client whatever chain the
dApp had switched to. getPublicClient memoizes one client per chain over
that chain's proxy route.

A chain with no route is refused with 4901, not 4902: it is recognised
and switchable, so 4902 would invite an add-and-retry that succeeds and
then fails the same way.

* test(wallet): cover multi-chain read routing

* chore(wallet): add changeset

* chore(wallet): drop redundant comments

* refactor(wallet): convert the origin chain id through the registry

The typed-data chain check used parseInt, which toChainId exists to
replace: it reads a bare '1' or a trailing-garbage '0x1zzz' as mainnet,
so a malformed id would compare equal to a chain it is not. Nothing
reaches the check with a non-canonical id today, so this is the
invariant landing in one place rather than a behaviour change.

The mismatch message now names the chain, falling back to the raw hex
so an unrecognised id does not print as NaN.
2026-08-21 19:53:23 +09:00
Jules c389fd9465 feat(wallet): support eth_sendTransaction for dApps (#1302)
* refactor(wallet): extract the transaction send policy

Moves the fee backfill, the LiFi quoted-priority rule, the EIP-1559 clamp
and the ERC20/contract/native branch out of signer-context so the service
worker can reach the same policy. Transports are injected: the page holds a
tRPC proxy client, the worker a caller.

requestFeeRate moves to lib/gas-fees.ts rather than being exported from
use-gas-fees.ts, which would drag React, react-query and ethers into the
background bundle.

No behavioural change.

* refactor(wallet): share the dApp signer guards

requireOriginAddress, requireWalletFor and assertNoPendingApproval were
private to sign.ts; eth_sendTransaction needs the same three.

* feat(wallet): send transactions for dApps

Replaces the 4200 stub. Fenced to mainnet, since nodes.getFeeRate,
broadcastTransaction and getNonce all pin z.enum(['ethereum']).

The fee is estimated once, before the popup, and the resolved values are
fed back into the send, so what the user approves is what gets broadcast.
Estimation always runs: it is also where a reverting call is caught.

'from' is checked against the origin's pinned account, not the wallet's
selection. Omitted 'value' defaults to 0; contract deployment is refused
by name rather than failing zod server-side.

* test(wallet): cover dApp transaction sending

* chore(wallet): add changeset

* fix(wallet): route only re-encodable transfers through sendErc20

`sendErc20` does not sign the calldata it is handed: it reads the
recipient and amount back out and re-encodes an `erc20Transfer`, a shape
carrying no ETH value. Dispatching on the 4-byte selector alone therefore
dropped every byte past the amount word, and the value entirely, after
the popup had already displayed both -- the signed transaction was not
the approved one.

Take that route only for a transfer that survives the round trip
byte-for-byte: canonical length, zero value, zero-padded recipient word.
Everything else goes through `sendContractCall`, which signs the calldata
verbatim and carries the value.

No delta on the paths that reach this through the wallet itself: the
in-wallet token send calls `sendErc20` directly, and the LiFi widget only
ever emits `approve` and router calldata.

* fix(wallet): refuse dApp transaction fields that cannot be honoured

The handler read a fixed set of fields and dropped the rest, so a request
could be signed as something other than what it asked for while the popup
still showed the original.

`nonce` is the sharp one: `nonceTracker` assigns its own, so a resubmit
meant to replace or cancel a pending transaction would have broadcast as
an additional spend at the next nonce. `gasPrice` has no route to the
backend, which takes only the EIP-1559 pair, and was being swapped for
our own estimate. A non-empty `accessList` changes gas semantics and was
not carried into the signing input.

`type` stays accepted -- every transaction we sign is Enveloped, so a
dApp asking for `0x2` is describing what it already gets.

* fix(wallet): accept input as an alias for data, as geth does

Some libraries send calldata only as `input`. Reading `data` alone left
it undefined, turning a contract call into a bare ETH transfer to the
contract address -- the dApp's call silently did nothing it asked for.

Two values that disagree is geth's error case rather than a precedence
rule: there is no way to tell which one the dApp meant.
2026-08-21 19:53:23 +09:00
Jules fd96d31c95 feat(wallet): sign EIP-712 typed data for dApps (#1301)
* refactor(wallet): drive the approval popup from the request type

A single `isSign` boolean set the title, the button label and the content
branch, so adding an approval type would have rendered it as "Connect dApp".
An exhaustive switch makes that a compile error instead.

* feat(wallet): sign EIP-712 typed data for dApps

`eth_signTypedData_v4` threw 4200. It now signs through the same per-origin
pinning and approval path as `personal_sign`.

Parameters are swapped relative to `personal_sign` -- address first, payload
second, per status-go `commands/sign.go`. The payload is parsed and validated
before the popup opens, `domain.chainId` included, so malformed input cannot
reach viem as an opaque -32603 after the user has already approved. The popup
renders it as hostile input, capped by depth, row count and value length.

* test(wallet): cover typed data signing

The swapped parameter order, malformed payloads refused before the popup, the
domain chain check across the three spellings dApps use, and the display caps.

* chore(wallet): add changeset

* fix(status.app): lint with eslint instead of next lint

`next lint` injects eslint-config-next, whose absolute-path `import/resolver`
entries replace the repo's own. Under pnpm's non-hoisted layout they resolve
from the config rather than the linted file, so an app-local dependency such as
`entities` reports as unresolved while root-hoisted ones do not.

The flat config already registers @next/next, so the Next rules still run.
`next lint` is deprecated and removed in Next 16.

* fix(wallet): render typed data rows from the signed type

Only the fields declared in types[primaryType] reach the EIP-712 hash.
Walking the raw message let a dApp pad it with unsigned keys until the
fields that are actually signed fell past the row cap, unseen. The
domain header had the same hole: a dApp-declared EIP712Domain decides
what the separator covers, so a name left out of it was displayed as
the dApp's identity without being signed.

* fix(wallet): bind the typed-data chain check to the signed domain

A dApp declaring its own EIP712Domain decides which domain fields reach
the separator, so a chainId present in domain but absent from that type
satisfied the chain check while binding the signature to no chain.

* test(wallet): cover the derived EIP712Domain chain check
2026-08-21 19:53:22 +09:00
Jules ef29a2a336 fix(wallet): gate dApp RPC on granted permission (#1299)
* feat(wallet): add status-go remote method allowlist

The 35 methods status-go forwards to the node, plus the local ones it
answers before a dApp connects. Transcribed from remote.go at 384a179.

* fix(wallet): gate dApp RPC on granted permission

The handler ended in a default: branch forwarding any unrecognised method
to the node for any origin, connected or not. Dispatch now mirrors
status-go's CallRPC: local registry, remote allowlist, then -32601.

Method bodies move to lib/rpc/ and rpc-handler.ts keeps a handler map, so
the gated set cannot drift from the implemented one.

* test(wallet): port status-go connector permission table

* chore(wallet): add changeset

* fix(wallet): notify the origin when a dApp revokes over RPC

`wallet_revokePermissions` dropped the grant without telling anyone, so
the calling page -- and every other tab on the origin -- kept showing the
account until a reload. It now goes through `disconnectDapp`, the same
revoke-and-notify pair the wallet's own Disconnect action uses, so there
is one revoke path rather than two that can drift.

`broadcast` swallows a failing `tabs.query`. The notification happens
after the permission is already deleted, so a push failure must not
surface to the dApp as a failed revoke.

* fix(wallet): keep the caveats a dApp asked for in wallet_getPermissions

The `eth_accounts` branch replaced the whole caveat list with the derived
`restrictReturnedAccounts`, so anything a dApp had stored through
`wallet_requestPermissions` was persisted but never reported back.

Only that one caveat is derived now; the rest are passed through. A
stored `restrictReturnedAccounts` is discarded rather than merged -- a
dApp can write one, and it must not shadow the account the wallet will
actually return.
2026-08-21 19:53:22 +09:00
Jules 3051af7796 fix(wallet): persist dApp connections per account (#1298)
* fix(wallet): keep dApp connections until explicitly revoked

Move permissions from chrome.storage.session to local, keyed per origin and
per account. A browser restart, a page reload or an account switch no longer
drops a connection -- only wallet_revokePermissions or the wallet's own
Disconnect action does.

Each origin is pinned to the account it was connected with, so switching
accounts exposes the new account only to dApps already connected to it and
pushes accountsChanged to their tabs. eth_accounts, eth_requestAccounts and
personal_sign all resolve through that pinned account.

* fix(provider): stop tearing down the dApp session on reload and errors

A fresh provider restores its session from eth_accounts rather than reporting
itself disconnected, close() no longer revokes the stored permission, and an
RPC error whose message reads "dApp is not permitted by user" no longer
disconnects. isConnected() reports transport readiness per EIP-1193 instead
of account state.

Adds a window listener for accountsChanged pushed by the wallet, so an
account switch reaches the page without a reload.

* feat(wallet): add dApp connections menu

Lists connected dApps behind their own trigger beside the wallet selector.
Each entry shows the account that dApp sees, offers Connect for the selected
account when it is not connected there, and Disconnect.

* fix(wallet): stop the approval popup rendering an empty window

The mount effect awaited the pending approval and the session status with no
rejection handling, so either one failing left the popup at `return null` --
a white window with no way out. A cold service worker rejecting the first
port message is enough to trigger it. Both reads are now guarded, and a
missing request renders an explicit "no longer available" state.

Claim the single approval slot synchronously as well. The stored record is
read asynchronously, so two requests in the same tick both saw it empty and
each opened a popup, then overwrote each other's record.

* fix(wallet): count approval popups in the mock instead of casting

The spy cast an async wrapper to the overloaded chrome.windows.create type,
which tsc rejects. The mock already owns that call, so it counts there.

* chore(wallet): add changeset
2026-08-21 19:53:21 +09:00
JulesFILIOT eaf5301c66 fix(ci): build job timeout 2026-08-18 16:04:12 +02:00
jinhojang6 de1916d802 fix(status.app): improve mobile core web vitals 2026-08-07 00:17:07 +09:00
Jules ccef237691 feat(status.app): serve the reworked news feeds under /v2 (#1297)
* refactor(status.app): restore the shipped news feed rendering as v1

The clients in the wild parse what production serves today, so that build
is kept verbatim in its own module: unnamespaced `newsLink`, markup
stripped down to text, Ghost's escaping passed through. It imports nothing
but the XML parser, so a change to the reworked build cannot reach it.

`handleRssFeed` picks the build from a version that defaults to `v1`,
leaving the existing routes untouched.

* feat(status.app): serve the reworked news feeds under /v2

`/desktop-news/rss/v2` and `/mobile-news/rss/v2` carry the lists, the body
links, the escaping fixes and the namespaced call-to-action. The clients
move over once status-go reads the new elements; until then
`/desktop-news/rss` and `/mobile-news/rss` keep serving what they serve
today.
2026-08-07 00:16:49 +09:00
Jules 92f61f2f1e feat(status.app): keep body links in the desktop news feed (#1295)
Anchors were flattened to their label. The parser now carries them as
inline nodes, so a link in a paragraph or a list item reaches the desktop
client as `<a href>` with the href escaped, quotes included.

The mobile feed keeps the label alone -- plain text cannot carry a link --
and the call-to-action is still lifted out of the body. Validity is
unaffected: the whole body is escaped on its way into `description`.
2026-08-07 00:16:48 +09:00
Jules bb1e5f8f0d feat(status.app): render lists in the mobile and desktop news RSS feeds (#1293)
* chore(status.app): run unit tests with vitest

The workspace had test files but no test script, so turbo run test skipped them.

* feat(status.app): render lists in the news RSS feeds

Ghost HTML was split on </p> and stripped of every other tag, so list items
were concatenated into one run of text. Parse the markup into blocks instead
and emit <ul>/<ol>/<li>, keeping <br> as a single break.

Mobile now uses the same HTML formatting as desktop. The call-to-action link
is extracted structurally, so a link inside a list item can no longer replace
it and its label no longer leaks into the body.

* fix(status.app): escape Ghost values in the news RSS feeds

Ghost wraps titles and other text in CDATA, which the parser drops and the
builder rebuilds with entity processing off, so a bare & upstream was
re-served as invalid XML. Escape the values Ghost supplies, leaving existing
entities and the fields processItem generates alone.

* fix(status.app): keep the mobile news feed as plain text

The mobile client renders the description as plain text, so the HTML the
desktop feed emits would show up as literal markup. Render per feed: <ul>/<ol>
for desktop, text bullets for mobile.

* refactor(status.app): extract the news feed build from the RSS handler

Makes the pipeline a pure string transform, testable without the env the
handler pulls in, and drops the main/news ternaries from both paths.

* fix(status.app): escape the news feed channel description

`description` names both an item and a channel element, so skipping the
generated item fields channel-wide left Ghost's publication description
with a bare `&`, which invalidates the whole feed.

* fix(status.app): resolve Ghost's HTML entities in the news feeds

XML defines five entity names, so passing `&nbsp;` or `&copy;` through
cost the whole feed. Decoding is strict: the legacy HTML rules resolve
`&copy` without a semicolon and would corrupt a `?copy=1` query string.

* fix(status.app): escape the desktop news feed's markup

RSS 2.0 defines `description` as character data, so the markup is escaped
on its way into the XML tree rather than written as child elements of it.
status-go decodes the element with gofeed before handing it to the client,
which therefore receives the same markup as before. The mobile feed carries
no markup and stays untouched.

* fix(status.app): namespace the news feeds' call-to-action elements

RSS 2.0 rejects an extension element that carries no namespace, so
`newsLink` and `newsLinkLabel` move to `status:newsLink` and
`status:newsLinkLabel` under a namespace declared on `<rss>`.

gofeed routes a namespaced element to `item.Extensions` instead of
`item.Custom`, so status-go must read them from there before this ships.
2026-08-07 00:16:48 +09:00
jinhojang6 c265d36ca8 chore(status.app): add canonical metadata changeset 2026-07-31 19:59:54 +09:00
jinhojang6 b73f5fdbc9 fix(status.app): keep metadata in document head 2026-07-31 19:59:54 +09:00
jinhojang6 fa3b484151 fix(status.app): render affected help article 2026-07-31 01:52:34 +09:00
jinhojang6 b5f937e21d fix(status.app): link remaining help articles 2026-07-31 01:52:34 +09:00
jinhojang6 7034c7fec9 fix(status.app): cache generated blog search index 2026-07-31 01:42:50 +09:00
jinhojang6 bb7c373714 fix(status.app): collapse legacy help redirects 2026-07-31 01:42:50 +09:00
jinhojang6 4b0b4e6039 fix(status.app): handle empty legacy job ids 2026-07-31 01:42:34 +09:00
jinhojang6 a9affff2b9 fix(status.app): avoid legacy job redirect chains 2026-07-31 01:42:34 +09:00
jinhojang6 6a58c042bb fix(wallet): fit onboarding layout to shell height to avoid clipping 2026-07-28 22:24:10 +09:00
jinhojang6 2d1ade5e35 fix(wallet): make onboarding card scrollable so long word grids stay reachable 2026-07-28 22:24:10 +09:00
jinhojang6 de9116d0c1 fix(wallet): clamp paste focus to new word count, disable submit while loading 2026-07-28 22:24:10 +09:00
jinhojang6 ac47d665a4 test(wallet): precise e2e coverage for recovery phrase grid 2026-07-28 22:24:10 +09:00
jinhojang6 98061c1336 fix(wallet): autofocus first word, alert role, enter submit on last word 2026-07-28 22:24:10 +09:00
jinhojang6 5ca91e8d40 fix(wallet): add use client directive to recovery phrase components 2026-07-28 22:24:10 +09:00
jinhojang6 816bfcde18 feat(wallet): recovery phrase word grid input 2026-07-28 22:24:10 +09:00
jinhojang6 929a99ad2d fix(api): throttle repeated portfolio refreshes 2026-07-25 00:17:38 +09:00
jinhojang6 689c1449ec fix(api): preserve tRPC response headers 2026-07-25 00:17:38 +09:00
jinhojang6 b534922c8a fix(wallet): propagate asset balance rate limits 2026-07-25 00:17:38 +09:00
jinhojang6 f5272ff9da fix(api): avoid throttling normal wallet traffic 2026-07-25 00:17:38 +09:00
jinhojang6 fd01290b85 fix(api): preserve nested rate limit errors 2026-07-25 00:17:38 +09:00
jinhojang6 f8f6090f0c fix(api): harden proxy rate limiting 2026-07-25 00:17:38 +09:00
Jules 3dcc27e805 fix(status.app): prevent desktop navbar overflow
* fix(status.app): prevent desktop navbar overflow

The top bar renders from 1150px but its full-text CTAs push the row past
the viewport until ~1315px, causing horizontal page overflow. Collapse the
download CTAs to icon-only below 1320px; full labels return above it.

* chore: add changeset

* fix(status.app): collapse floating navbar CTAs to icon-only

Mirror the top navbar: icon-only download CTAs below 1320px so the
floating scroll-up navbar can't overflow the viewport.

* fix(status.app): use native scroll listener for floating navbar

react-spring useScroll's rAF frameloop doesn't fire reliably under
Turbopack dev, so the overlay never appeared locally (worked in the
webpack prod build). Read window.scrollY via a plain scroll listener;
same detection logic.
2026-07-23 19:18:29 +09:00
jinhojang6 5f63ec0c77 Align blog search with content width 2026-07-23 00:58:52 +09:00