feat(frontend): trader leaderboard and profiles use 4/6 address + blockie PFP #656

Closed
opened 2026-08-26 03:07:38 +00:00 by PlasticDigits · 9 comments
PlasticDigits commented 2026-08-26 03:07:38 +00:00 (Migrated from gitlab.com)

Summary

Retail trader surfaces still print long terra1… bech32 as the only identity: Charts Trader leaderboard shows shortenAddress(addr, 10, 6) with no avatar, and /trader/:addr / /portfolio headers use AddressRow at 12/6 with no PFP. Bundle one shared trader identity: visible label is first 4 + last 6 of the bech32, plus a deterministic blockie from the existing react-blockies library. The Trader Profile header (and any other trader-as-person surface) must show the same blockie.

Do not split this into separate “truncate” vs “avatar” issues. They are one identity treatment.

Related (do not regress): #188 AddressRow, #541 token identity (tokens ≠ traders), #553 leaderboard Volume USD, #378 logo allowlist / blockie fallback, #430 explorer href safety, #126 trader payload parse, #186 wallet chip shorten, #489 retail copy, #653 one chrome layer.


Current codebase

Charts leaderboard (primary retail list)

ChartsPage.tsx Trader leaderboard (aria-label="Trader leaderboard") maps GET /api/v1/traders/leaderboard rows (IndexerTrader) and renders the Trader cell as a Link to /trader/${trader.address} whose only child is:

shortenAddress(trader.address, 10, 6)

That is first 10 + last 6 (example: terra16wtm…vhpax3). There is no identicon, no title with the full bech32 on the link, and no shared identity component. Volume / P&L / trade-count columns are #553 and stay out of this change except that the Trader cell must keep linking to the full address.

Leaderboard tests in ChartsPage.test.tsx cover USD volume (charts-leaderboard-volume) only. They do not assert address shape or a PFP.

Docs: docs/frontend.md § Charts trader leaderboard. Playbook: skills/AGENTS_FRONTEND_TRADER_VOLUME_USD.md.

Trader profile + portfolio (shared header)

TraderPage.tsx /trader/:address and PortfolioPage.tsx both render TraderSummaryStats.tsx. The header identity is an AddressRow with startChars={12} / endChars={6} (data-testid="trader-profile-address-row"). Copy + explorer already use the full bech32 (#188 / #184). There is no blockie.

AddressRow defaults remain 8/6 for contracts / LP / protocol rows. Trader header is the only consumer that currently overrides to 12/6 (AGENTS_FRONTEND_ADDRESS_ROW.md rule 4).

TraderPage.test.tsx and TraderSummaryStats.test.tsx do not assert a PFP. Playwright e2e/trader-page.spec.ts (#422) is a positions smoke — it does not check identity chrome.

Shorten helper (already exists)

tokenDisplay.ts shortenAddress(addr, startChars = 8, endChars = 6) returns ${addr.slice(0, start)}…${addr.slice(-end)} (unicode ellipsis) when the string is longer than start + end + 2. Tests: tokenDisplay.test.ts.

Blockies already in the dApp — for tokens, not traders

package.json already depends on react-blockies ^1.4.1. TokenLogo.tsx seeds with addressForBlockie ?? blockieSeed, .toLowerCase(), size={6}, scale = max(2, ceil(px/6)), circular clip. Remote logoURI is allowlisted (#378 tokenLogoAllowlist.ts); untrusted hosts fall back to a blockie.

TokenLogo is the token mark (CW20 / native denom). It must not grow a trader logo_url or be reused as a trader PFP — traders have no allowlisted remote avatar.

Module shim: vite-env.d.ts declare module 'react-blockies'. Many Vitest files already vi.mock('react-blockies', …).

Other address surfaces (scope boundary)

Surface Today This issue
Charts leaderboard Trader cell 10/6 text link, no PFP In — 4/6 + blockie
/trader/:addr header AddressRow 12/6, no PFP In — 4/6 + blockie
/portfolio header same TraderSummaryStats In (inherits)
Tape TradesTable no trader column Out unless a trader column is added; if added later, reuse the same primitive
Order book owner tooltip owner.slice(0, 12) only Out (maker row chrome, not trader identity)
Wallet chip WalletButton 6/6 desktop, 4/4 phone (#186); menu AddressRow showFull Out — do not retune the connected-wallet chip
Token / pair / protocol AddressRow 8/6 defaults Out — not traders
Protocol hook / tx hash shorten different helpers Out

If a new trader-as-person row is added (leaderboard elsewhere, “top traders”, etc.), it must use the same primitive — that is the “anywhere else traders are displayed” rule.


Why this is needed

A full or near-full terra1… string is the dominant visual in the leaderboard and profile header. Retail users cannot scan or remember it; two adjacent rows look identical until the last few characters. A 4/6 chip plus a stable blockie gives each wallet a recognizable face without ENS, uploads, or indexer avatar URLs.

The profile page currently has copy + explorer but still no visual identity, so a user who clicked a leaderboard row lands on a wall of stats with the same long bech32. The PFP must match the leaderboard so the click-through feels like the same person.

This is display-only. Ranking, volume USD, P&L, and indexer JSON stay #553 / #560 / #126.


Constraints / guardrails

  1. Display-only. No indexer schema, no traders.avatar_url, no new API field, no on-chain profile contract.
  2. No remote trader avatars. Do not load logo_url, ENS, NFTs, Gravatar, or user-uploaded images for wallets. Seed is the bech32 only. Do not extend tokenLogoAllowlist.ts for traders.
  3. Reuse react-blockies. Do not add a second identicon library. Seed with lowercase bech32 (same as TokenLogo) so terra1ABC… (if it ever appeared) and terra1abc… paint the same grid. Do not invent a custom color hash.
  4. Do not reuse TokenLogo as the trader PFP. Tokens may show an allowlisted bitmap; traders must always be the generated blockie. A dedicated TraderBlockie / TraderIdentity keeps the trust boundary (#378).
  5. Visible label is 4 + 6. shortenAddress(addr, 4, 6) → terr…vhpax3 for a typical Classic bech32. The HRP is six characters (terra1); the visible prefix is intentionally terr, not another terra1… essay. Use the existing unicode …. Do not change shortenAddress defaults (8/6) used by tokens / contracts.
  6. Full address stays the source of truth. Link to, copy payload, explorer href, React key, and /trader/:address route param are the full validated bech32. Never navigate to a truncated string.
  7. Validate before paint / navigate. Leaderboard rows and profile headers only render a blockie when isValidTerraAddress (constants.ts ^terra1[a-z0-9]{38,}$) passes. Invalid indexer strings: no blockie, no /trader/ link with junk, no javascript: href (#430). Explorer still only via getExplorerAddressUrl.
  8. Collision honesty. 4+6 is not unique. title / accessible name on the chip must include the full address. Do not imply the short form is a unique handle. Do not add a “username” layer.
  9. A11y. Blockie is decorative (alt="" / aria-hidden). The accessible name of the leaderboard link is the short label (or “Trader {short}”) with the full address in title. Do not hide the text and leave only a canvas. Leaderboard table stays a real <table> with # / Trader / metric / Trades.
  10. Do not nest chrome. No card-glass around each leaderboard row or around the profile blockie (#653 C653-1). Circular clip + existing mint link color is enough.
  11. Sizes. Leaderboard / compact rows: ~16–20px (match token logos). Profile / portfolio header: ~32–40px, left of the AddressRow. Light + dark; 375px must not wrap the short label under the rank or clip the blockie.
  12. Copy / explorer on profile stay. Do not remove AddressRow copy + explorer from the trader header. Leaderboard may stay link-only (click → profile) — do not dump copy/explorer on every row (#489).
  13. Wallet chip out of scope. Do not change #186 6/6 / 4/4 trigger or the full-address wallet menu.
  14. No dangerouslySetInnerHTML. Address is text / title / href via helpers only.
  15. Docs / playbook. Update docs/frontend.md (leaderboard + trader profile + AddressRow note) and add a short agent playbook (or a section on the AddressRow skill) so the next agent does not hand-roll a second PFP.

Relevant files

Path Role
frontend-dapp/src/pages/ChartsPage.tsx Leaderboard Trader cell
frontend-dapp/src/pages/ChartsPage.test.tsx Leaderboard Vitest (extend)
frontend-dapp/src/components/trader/TraderSummaryStats.tsx Profile + portfolio header
frontend-dapp/src/components/trader/TraderSummaryStats.test.tsx Header Vitest (extend)
frontend-dapp/src/pages/TraderPage.tsx /trader/:addr (consumes summary)
frontend-dapp/src/pages/PortfolioPage.tsx Shared summary
frontend-dapp/src/components/ui/AddressRow.tsx Short label + copy + explorer; trader start/end → 4/6
frontend-dapp/src/utils/tokenDisplay.ts shortenAddress (keep defaults; add trader helper if needed)
frontend-dapp/src/components/ui/TokenLogo.tsx Do not reuse; seed/scale reference only
frontend-dapp/src/utils/constants.ts isValidTerraAddress
frontend-dapp/src/utils/terraExplorer.ts getExplorerAddressUrl
frontend-dapp/src/services/indexer/traderProfilePayload.ts Address parse (#126) — do not loosen
docs/frontend.md Leaderboard + trader profile + AddressRow
skills/AGENTS_FRONTEND_ADDRESS_ROW.md Trader 4/6 + blockie rule
frontend-dapp/e2e/trader-page.spec.ts Optional identity assertion
frontend-dapp/package.json react-blockies already present

  1. Add TraderBlockie (seed = address.toLowerCase(), react-blockies, circular clip, aria-hidden) and TraderIdentity: [blockie] [short 4/6] with optional Link to /trader/${full} (leaderboard) or compose with AddressRow (profile).
  2. Named helper, e.g. shortenTraderAddress(addr) => shortenAddress(addr, 4, 6), plus exported constants TRADER_ADDR_START_CHARS = 4 / TRADER_ADDR_END_CHARS = 6 so Charts and AddressRow cannot drift.
  3. Charts leaderboard Trader cell: TraderIdentity as the Link child; title={full address}; to={/trader/${trader.address}} only when isValidTerraAddress.
  4. TraderSummaryStats: blockie (larger) + AddressRow at 4/6. Portfolio inherits.
  5. Keep vi.mock('react-blockies') in page tests; add a small unit test that the real component receives seed={addr.toLowerCase()}.
  6. make verify-issue-<iid> script: Vitest for helper + Charts leaderboard + TraderSummaryStats (no LocalTerra / Postgres required).

Acceptance criteria

  • T-ID-1. Charts leaderboard Trader column shows shortenAddress(addr, 4, 6) (e.g. terr…0abcd) — not 10/6, not the full bech32 as the visible label.
  • T-ID-2. Each valid leaderboard row shows a circular blockie immediately left of the short address. Same address → same pixels; different addresses → different grids (same library + lowercase seed).
  • T-ID-3. Leaderboard link href / React Router to is /trader/{full bech32}. Click still opens that profile.
  • T-ID-4. /trader/:addr header shows the same-style blockie (larger) next to the address row. /portfolio header matches when TraderSummaryStats is used.
  • T-ID-5. Profile AddressRow visible label is 4/6. Copy still copies the full address. Explorer still uses getExplorerAddressUrl(full) and is omitted when the helper returns null.
  • T-ID-6. Invalid / non-terra1 indexer strings do not render a blockie and do not become a /trader/ or explorer link.
  • T-ID-7. No new npm identicon package. No trader logo_url. No change to GET /api/v1/traders/* JSON.
  • T-ID-8. Token logos, pair identity (#541), wallet chip (#186), tape, and order-book owner tooltip are unchanged.
  • T-ID-9. Light + dark; 375px and 1280px: leaderboard Trader cell stays one line (blockie + 4/6); profile header does not overflow or nest a second card.
  • T-ID-10. Docs + AddressRow playbook updated. Shared primitive — no second hand-rolled blockie in Charts vs profile.

Test plan (functional paths)

Unit / Vitest

  1. shortenTraderAddress / 4+6: known Classic bech32 → exact terr… + last 6; string shorter than threshold unchanged; empty → empty / no throw.
  2. TraderBlockie: react-blockies seed is addr.toLowerCase(); no <img src="http…">; aria-hidden.
  3. Determinism: same address rendered twice → same seed; mixed-case input (if passed) still lowercased.
  4. Charts leaderboard: mock getLeaderboard with a valid address — row text is 4/6, not 10/6 / not full bech32; Link href contains the full address; blockie present (data-testid e.g. trader-identity-blockie / charts-leaderboard-trader).
  5. Leaderboard empty / error / loading: unchanged (#553 volume tests still pass).
  6. Leaderboard P&L tabs: identity cell unchanged when sort is not total_volume_usd.
  7. TraderSummaryStats: blockie + 4/6 label; copy button still receives full address; You badge still works for isOwnProfile.
  8. AddressRow: trader call site 4/6; default 8/6 consumers still pass existing tests.
  9. Invalid address: { address: 'javascript:alert(1)' }, { address: 'not-terra' }, { address: 'TERRA1…' } — no blockie, no explorer//trader/ link with that string.
  10. #126: parseIndexerTraderPayload still rejects non-objects / bad addresses; do not render identity from a thrown parse.

Integration / page

  1. Charts overview with ≥1 leaderboard row: identity + volume column (charts-leaderboard-volume) together.
  2. Navigate leaderboard → /trader/{full}: profile heading + matching blockie seed.
  3. Portfolio connected: header blockie matches /trader/{wallet} for the same wallet.
  4. Disconnected /trader search box: still requires isValidTerraAddress before navigate; no blockie on the empty-state search panel.

Visual / a11y

  1. 375px and 1280px, light and dark: no overflow, no nested card-glass on the row.
  2. Keyboard: leaderboard Trader Link is tabbable; profile copy / explorer still reachable.
  3. aria-label on the table stays “Trader leaderboard”.

Playwright (optional, no new tx)

  1. Extend e2e/trader-page.spec.ts to assert a blockie node + 4/6 text on /trader/{dev wallet} when the indexer has that trader. Do not require a new e2e-tx flow.

Test plan (attack, spoof, and abuse)

Treat the short label + blockie as cosmetic. The full bech32 remains the only identifier for navigation, copy, and explorer.

ID Vector Expected
A1 Truncation collision. Two valid wallets share the same first 4 and last 6 (vanity / brute). Both rows render; title / copy / to stay full distinct addresses. Tests use two fixtures that collide on 4/6 and assert different hrefs and seeds.
A2 Homograph / case. TERRA1… or mixed-case bech32 in indexer JSON. isValidTerraAddress is lowercase-only today — reject; no blockie; no link. Do not “fix” by uppercasing in the URL.
A3 XSS / protocol in address. javascript:…, data:, <img>, "><script>. No href with that string; no dangerouslySetInnerHTML; text content escaped by React. getExplorerAddressUrl still returns null for non-bech32 (#430).
A4 Path injection. address = "terra1…/../admin" or ?x= / # in the field. Fail isValidTerraAddress (charset [a-z0-9]). Router must not see a relative path.
A5 Remote PFP injection. Agent or indexer adds avatar_url / logo_url on IndexerTrader. UI ignores it. No <img src={trader.logo_url}>. Regression: even https://evil.example/p.png is not requested.
A6 Reuse TokenLogo + evil logoURI. Trader identity must not call TokenLogo with a trader field. Token allowlist tests (#378) unchanged.
A7 Explorer open-redirect. Crafted address that slips into getExplorerAddressUrl. Helper remains the only href builder; invalid → omit icon. Do not concatenate Finder hosts with raw strings.
A8 Clipboard swap. Visible 4/6 vs copy payload. Copy / CopyButton still gets the full address (existing AddressRow + Clipboard tests). Leaderboard does not add a copy control that copies the short form.
A9 Fake “official” PFP. User expects a CL8Y/brand avatar for a known wallet. Blockie is not the product mark. Do not overlay /logo.png on trader rows. No “verified” badge from a blockie.
A10 DoS / layout. Leaderboard limit 20 (current); malicious huge address string. Invalid long strings fail validation. Valid bech32 length is bounded. Do not render unbounded canvas scales.
A11 Click the blockie vs the label. Entire identity is one Link (leaderboard) so the hit target cannot send the user to a different wallet than the label.
A12 Seed confusion with token blockies. Same library, CW20 address vs wallet. Acceptable (deterministic). Do not reuse a token’s logoURI for a trader even if the strings match a listed CW20.
A13 Profile search XSS. Paste HTML into “Paste a trader wallet address”. Existing isValidTerraAddress gate; no identity render until navigate to a valid /trader/:addr.
A14 Stale identity on address change. /trader/A → /trader/B. Blockie seed updates with trader.address / route param. Route error reset already keyed on address (#126).
A15 Phishing copy. Short form used in a toast / share text as if it were the full wallet. Do not put the 4/6 string into share/QR/deep-link payloads. Full address only.

Verification criteria

Done when all of the following are true:

  1. On /charts, every leaderboard trader row shows a blockie + shortenAddress(addr, 4, 6) and links to /trader/{full}.
  2. On /trader/:addr (and /portfolio via TraderSummaryStats), the header shows the same blockie family (larger) and a 4/6 AddressRow with full-address copy + safe explorer.
  3. make test-frontend (or the scoped Vitest list in make verify-issue-<iid>) covers T-ID-1–T-ID-7 and A1, A3, A5, A8.
  4. #553 leaderboard volume tests, #188 AddressRow defaults, #378 TokenLogo allowlist, and #186 wallet chip tests still pass.
  5. docs/frontend.md and the AddressRow (or new trader-identity) playbook describe 4/6 + blockie and the token/wallet exclusions.
  6. Manual or Playwright: 375px light/dark — leaderboard and profile identity readable; no second chrome card; no remote image request for a trader.

Out of scope / do not fold in: wallet-chip avatars, tape trader column, order-book owner PFP, ENS/nameservice, indexer-hosted avatars, changing total_volume_usd ranking.

## Summary Retail trader surfaces still print long `terra1…` bech32 as the only identity: Charts **Trader leaderboard** shows `shortenAddress(addr, 10, 6)` with no avatar, and `/trader/:addr` / `/portfolio` headers use `AddressRow` at **12/6** with no PFP. Bundle one shared **trader identity**: visible label is **first 4 + last 6** of the bech32, plus a **deterministic blockie** from the existing `react-blockies` library. The Trader Profile header (and any other trader-as-person surface) must show the same blockie. Do **not** split this into separate “truncate” vs “avatar” issues. They are one identity treatment. Related (do not regress): [#188](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/188) `AddressRow`, [#541](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/541) token identity (tokens ≠ traders), [#553](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/553) leaderboard Volume USD, [#378](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/378) logo allowlist / blockie fallback, [#430](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/430) explorer href safety, [#126](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/126) trader payload parse, [#186](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/186) wallet chip shorten, [#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489) retail copy, [#653](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/653) one chrome layer. --- ## Current codebase ### Charts leaderboard (primary retail list) [`ChartsPage.tsx`](frontend-dapp/src/pages/ChartsPage.tsx) **Trader leaderboard** (`aria-label="Trader leaderboard"`) maps `GET /api/v1/traders/leaderboard` rows (`IndexerTrader`) and renders the Trader cell as a `Link` to `/trader/${trader.address}` whose **only** child is: ```ts shortenAddress(trader.address, 10, 6) ``` That is first **10** + last **6** (example: `terra16wtm…vhpax3`). There is **no** identicon, no `title` with the full bech32 on the link, and no shared identity component. Volume / P&L / trade-count columns are [#553](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/553) and stay out of this change except that the Trader cell must keep linking to the full address. Leaderboard tests in [`ChartsPage.test.tsx`](frontend-dapp/src/pages/ChartsPage.test.tsx) cover USD volume (`charts-leaderboard-volume`) only. They do not assert address shape or a PFP. Docs: [`docs/frontend.md` § Charts trader leaderboard](docs/frontend.md#charts-trader-leaderboard). Playbook: [`skills/AGENTS_FRONTEND_TRADER_VOLUME_USD.md`](skills/AGENTS_FRONTEND_TRADER_VOLUME_USD.md). ### Trader profile + portfolio (shared header) [`TraderPage.tsx`](frontend-dapp/src/pages/TraderPage.tsx) `/trader/:address` and [`PortfolioPage.tsx`](frontend-dapp/src/pages/PortfolioPage.tsx) both render [`TraderSummaryStats.tsx`](frontend-dapp/src/components/trader/TraderSummaryStats.tsx). The header identity is an [`AddressRow`](frontend-dapp/src/components/ui/AddressRow.tsx) with `startChars={12}` / `endChars={6}` (`data-testid="trader-profile-address-row"`). Copy + explorer already use the **full** bech32 ([#188](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/188) / [#184](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/184)). There is **no** blockie. `AddressRow` defaults remain **8/6** for contracts / LP / protocol rows. Trader header is the only consumer that currently overrides to 12/6 ([`AGENTS_FRONTEND_ADDRESS_ROW.md`](skills/AGENTS_FRONTEND_ADDRESS_ROW.md) rule 4). [`TraderPage.test.tsx`](frontend-dapp/src/pages/TraderPage.test.tsx) and [`TraderSummaryStats.test.tsx`](frontend-dapp/src/components/trader/TraderSummaryStats.test.tsx) do not assert a PFP. Playwright [`e2e/trader-page.spec.ts`](frontend-dapp/e2e/trader-page.spec.ts) (#422) is a positions smoke — it does not check identity chrome. ### Shorten helper (already exists) [`tokenDisplay.ts`](frontend-dapp/src/utils/tokenDisplay.ts) `shortenAddress(addr, startChars = 8, endChars = 6)` returns `${addr.slice(0, start)}…${addr.slice(-end)}` (unicode ellipsis) when the string is longer than `start + end + 2`. Tests: [`tokenDisplay.test.ts`](frontend-dapp/src/utils/__tests__/tokenDisplay.test.ts). ### Blockies already in the dApp — for **tokens**, not traders [`package.json`](frontend-dapp/package.json) already depends on **`react-blockies` ^1.4.1**. [`TokenLogo.tsx`](frontend-dapp/src/components/ui/TokenLogo.tsx) seeds with `addressForBlockie ?? blockieSeed`, **`.toLowerCase()`**, `size={6}`, `scale = max(2, ceil(px/6))`, circular clip. Remote `logoURI` is allowlisted ([#378](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/378) [`tokenLogoAllowlist.ts`](frontend-dapp/src/utils/tokenLogoAllowlist.ts)); untrusted hosts fall back to a blockie. `TokenLogo` is the **token** mark (CW20 / native denom). It must **not** grow a trader `logo_url` or be reused as a trader PFP — traders have no allowlisted remote avatar. Module shim: [`vite-env.d.ts`](frontend-dapp/src/vite-env.d.ts) `declare module 'react-blockies'`. Many Vitest files already `vi.mock('react-blockies', …)`. ### Other address surfaces (scope boundary) | Surface | Today | This issue | |---------|--------|------------| | Charts leaderboard Trader cell | 10/6 text link, no PFP | **In** — 4/6 + blockie | | `/trader/:addr` header | `AddressRow` 12/6, no PFP | **In** — 4/6 + blockie | | `/portfolio` header | same `TraderSummaryStats` | **In** (inherits) | | Tape [`TradesTable`](frontend-dapp/src/components/ui/TradesTable.tsx) | no trader column | **Out** unless a trader column is added; if added later, reuse the same primitive | | Order book `owner` | tooltip `owner.slice(0, 12)` only | **Out** (maker row chrome, not trader identity) | | Wallet chip [`WalletButton`](frontend-dapp/src/components/wallet/WalletButton.tsx) | 6/6 desktop, 4/4 phone ([#186](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/186)); menu `AddressRow showFull` | **Out** — do not retune the connected-wallet chip | | Token / pair / protocol `AddressRow` | 8/6 defaults | **Out** — not traders | | Protocol hook / tx hash shorten | different helpers | **Out** | If a **new** trader-as-person row is added (leaderboard elsewhere, “top traders”, etc.), it must use the same primitive — that is the “anywhere else traders are displayed” rule. --- ## Why this is needed A full or near-full `terra1…` string is the dominant visual in the leaderboard and profile header. Retail users cannot scan or remember it; two adjacent rows look identical until the last few characters. A **4/6** chip plus a **stable blockie** gives each wallet a recognizable face without ENS, uploads, or indexer avatar URLs. The profile page currently has copy + explorer but still no visual identity, so a user who clicked a leaderboard row lands on a wall of stats with the same long bech32. The PFP must match the leaderboard so the click-through feels like the same person. This is display-only. Ranking, volume USD, P&L, and indexer JSON stay [#553](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/553) / [#560](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/560) / [#126](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/126). --- ## Constraints / guardrails 1. **Display-only.** No indexer schema, no `traders.avatar_url`, no new API field, no on-chain profile contract. 2. **No remote trader avatars.** Do not load `logo_url`, ENS, NFTs, Gravatar, or user-uploaded images for wallets. Seed is the bech32 only. Do **not** extend [`tokenLogoAllowlist.ts`](frontend-dapp/src/utils/tokenLogoAllowlist.ts) for traders. 3. **Reuse `react-blockies`.** Do not add a second identicon library. Seed with **lowercase** bech32 (same as `TokenLogo`) so `terra1ABC…` (if it ever appeared) and `terra1abc…` paint the same grid. Do not invent a custom color hash. 4. **Do not reuse `TokenLogo` as the trader PFP.** Tokens may show an allowlisted bitmap; traders must always be the generated blockie. A dedicated `TraderBlockie` / `TraderIdentity` keeps the trust boundary (#378). 5. **Visible label is 4 + 6.** `shortenAddress(addr, 4, 6)` → `terr…vhpax3` for a typical Classic bech32. The HRP is six characters (`terra1`); the visible prefix is intentionally `terr`, not another `terra1…` essay. Use the existing unicode `…`. Do not change `shortenAddress` defaults (8/6) used by tokens / contracts. 6. **Full address stays the source of truth.** `Link` `to`, copy payload, explorer `href`, React `key`, and `/trader/:address` route param are the **full** validated bech32. Never navigate to a truncated string. 7. **Validate before paint / navigate.** Leaderboard rows and profile headers only render a blockie when `isValidTerraAddress` ([`constants.ts`](frontend-dapp/src/utils/constants.ts) `^terra1[a-z0-9]{38,}$`) passes. Invalid indexer strings: no blockie, no `/trader/` link with junk, no `javascript:` href ([#430](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/430)). Explorer still only via `getExplorerAddressUrl`. 8. **Collision honesty.** 4+6 is not unique. `title` / accessible name on the chip must include the **full** address. Do not imply the short form is a unique handle. Do not add a “username” layer. 9. **A11y.** Blockie is decorative (`alt=""` / `aria-hidden`). The accessible name of the leaderboard link is the short label (or “Trader {short}”) with the full address in `title`. Do not hide the text and leave only a canvas. Leaderboard table stays a real `<table>` with `#` / Trader / metric / Trades. 10. **Do not nest chrome.** No `card-glass` around each leaderboard row or around the profile blockie ([#653](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/653) **C653-1**). Circular clip + existing mint link color is enough. 11. **Sizes.** Leaderboard / compact rows: ~16–20px (match token logos). Profile / portfolio header: ~32–40px, left of the `AddressRow`. Light + dark; 375px must not wrap the short label under the rank or clip the blockie. 12. **Copy / explorer on profile stay.** Do not remove `AddressRow` copy + explorer from the trader header. Leaderboard may stay link-only (click → profile) — do not dump copy/explorer on every row ([#489](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/489)). 13. **Wallet chip out of scope.** Do not change `#186` 6/6 / 4/4 trigger or the full-address wallet menu. 14. **No `dangerouslySetInnerHTML`.** Address is text / `title` / `href` via helpers only. 15. **Docs / playbook.** Update `docs/frontend.md` (leaderboard + trader profile + AddressRow note) and add a short agent playbook (or a section on the AddressRow skill) so the next agent does not hand-roll a second PFP. --- ## Relevant files | Path | Role | |------|------| | `frontend-dapp/src/pages/ChartsPage.tsx` | Leaderboard Trader cell | | `frontend-dapp/src/pages/ChartsPage.test.tsx` | Leaderboard Vitest (extend) | | `frontend-dapp/src/components/trader/TraderSummaryStats.tsx` | Profile + portfolio header | | `frontend-dapp/src/components/trader/TraderSummaryStats.test.tsx` | Header Vitest (extend) | | `frontend-dapp/src/pages/TraderPage.tsx` | `/trader/:addr` (consumes summary) | | `frontend-dapp/src/pages/PortfolioPage.tsx` | Shared summary | | `frontend-dapp/src/components/ui/AddressRow.tsx` | Short label + copy + explorer; trader start/end → 4/6 | | `frontend-dapp/src/utils/tokenDisplay.ts` | `shortenAddress` (keep defaults; add trader helper if needed) | | `frontend-dapp/src/components/ui/TokenLogo.tsx` | **Do not** reuse; seed/scale reference only | | `frontend-dapp/src/utils/constants.ts` | `isValidTerraAddress` | | `frontend-dapp/src/utils/terraExplorer.ts` | `getExplorerAddressUrl` | | `frontend-dapp/src/services/indexer/traderProfilePayload.ts` | Address parse (#126) — do not loosen | | `docs/frontend.md` | Leaderboard + trader profile + AddressRow | | `skills/AGENTS_FRONTEND_ADDRESS_ROW.md` | Trader 4/6 + blockie rule | | `frontend-dapp/e2e/trader-page.spec.ts` | Optional identity assertion | | `frontend-dapp/package.json` | `react-blockies` already present | --- ## Recommended direction 1. Add **`TraderBlockie`** (`seed = address.toLowerCase()`, `react-blockies`, circular clip, `aria-hidden`) and **`TraderIdentity`**: `[blockie] [short 4/6]` with optional `Link` to `/trader/${full}` (leaderboard) or compose with `AddressRow` (profile). 2. Named helper, e.g. `shortenTraderAddress(addr) => shortenAddress(addr, 4, 6)`, plus exported constants `TRADER_ADDR_START_CHARS = 4` / `TRADER_ADDR_END_CHARS = 6` so Charts and `AddressRow` cannot drift. 3. Charts leaderboard Trader cell: `TraderIdentity` as the `Link` child; `title={full address}`; `to={`/trader/${trader.address}`}` only when `isValidTerraAddress`. 4. `TraderSummaryStats`: blockie (larger) + `AddressRow` at 4/6. Portfolio inherits. 5. Keep `vi.mock('react-blockies')` in page tests; add a small unit test that the real component receives `seed={addr.toLowerCase()}`. 6. `make verify-issue-<iid>` script: Vitest for helper + Charts leaderboard + `TraderSummaryStats` (no LocalTerra / Postgres required). --- ## Acceptance criteria - [ ] **T-ID-1.** Charts leaderboard Trader column shows `shortenAddress(addr, 4, 6)` (e.g. `terr…0abcd`) — not 10/6, not the full bech32 as the visible label. - [ ] **T-ID-2.** Each valid leaderboard row shows a circular blockie immediately left of the short address. Same address → same pixels; different addresses → different grids (same library + lowercase seed). - [ ] **T-ID-3.** Leaderboard link `href` / React Router `to` is `/trader/{full bech32}`. Click still opens that profile. - [ ] **T-ID-4.** `/trader/:addr` header shows the same-style blockie (larger) next to the address row. `/portfolio` header matches when `TraderSummaryStats` is used. - [ ] **T-ID-5.** Profile `AddressRow` visible label is 4/6. Copy still copies the **full** address. Explorer still uses `getExplorerAddressUrl(full)` and is omitted when the helper returns `null`. - [ ] **T-ID-6.** Invalid / non-`terra1` indexer strings do not render a blockie and do not become a `/trader/` or explorer link. - [ ] **T-ID-7.** No new npm identicon package. No trader `logo_url`. No change to `GET /api/v1/traders/*` JSON. - [ ] **T-ID-8.** Token logos, pair identity (#541), wallet chip (#186), tape, and order-book owner tooltip are unchanged. - [ ] **T-ID-9.** Light + dark; 375px and 1280px: leaderboard Trader cell stays one line (blockie + 4/6); profile header does not overflow or nest a second card. - [ ] **T-ID-10.** Docs + AddressRow playbook updated. Shared primitive — no second hand-rolled blockie in Charts vs profile. --- ## Test plan (functional paths) ### Unit / Vitest 1. **`shortenTraderAddress` / 4+6:** known Classic bech32 → exact `terr…` + last 6; string shorter than threshold unchanged; empty → empty / no throw. 2. **`TraderBlockie`:** `react-blockies` `seed` is `addr.toLowerCase()`; no `<img src="http…">`; `aria-hidden`. 3. **Determinism:** same address rendered twice → same `seed`; mixed-case input (if passed) still lowercased. 4. **Charts leaderboard:** mock `getLeaderboard` with a valid address — row text is 4/6, **not** 10/6 / not full bech32; `Link` `href` contains the full address; blockie present (`data-testid` e.g. `trader-identity-blockie` / `charts-leaderboard-trader`). 5. **Leaderboard empty / error / loading:** unchanged (#553 volume tests still pass). 6. **Leaderboard P&L tabs:** identity cell unchanged when sort is not `total_volume_usd`. 7. **`TraderSummaryStats`:** blockie + 4/6 label; copy button still receives full address; `You` badge still works for `isOwnProfile`. 8. **`AddressRow`:** trader call site 4/6; default 8/6 consumers still pass existing tests. 9. **Invalid address:** `{ address: 'javascript:alert(1)' }`, `{ address: 'not-terra' }`, `{ address: 'TERRA1…' }` — no blockie, no explorer/`/trader/` link with that string. 10. **#126:** `parseIndexerTraderPayload` still rejects non-objects / bad addresses; do not render identity from a thrown parse. ### Integration / page 11. **Charts overview** with ≥1 leaderboard row: identity + volume column (`charts-leaderboard-volume`) together. 12. **Navigate** leaderboard → `/trader/{full}`: profile heading + matching blockie seed. 13. **Portfolio** connected: header blockie matches `/trader/{wallet}` for the same wallet. 14. **Disconnected** `/trader` search box: still requires `isValidTerraAddress` before navigate; no blockie on the empty-state search panel. ### Visual / a11y 15. 375px and 1280px, light and dark: no overflow, no nested `card-glass` on the row. 16. Keyboard: leaderboard Trader `Link` is tabbable; profile copy / explorer still reachable. 17. `aria-label` on the table stays “Trader leaderboard”. ### Playwright (optional, no new tx) 18. Extend [`e2e/trader-page.spec.ts`](frontend-dapp/e2e/trader-page.spec.ts) to assert a blockie node + 4/6 text on `/trader/{dev wallet}` when the indexer has that trader. Do not require a new e2e-tx flow. --- ## Test plan (attack, spoof, and abuse) Treat the short label + blockie as **cosmetic**. The full bech32 remains the only identifier for navigation, copy, and explorer. | ID | Vector | Expected | |----|--------|----------| | **A1** | **Truncation collision.** Two valid wallets share the same first 4 and last 6 (vanity / brute). | Both rows render; `title` / copy / `to` stay **full** distinct addresses. Tests use two fixtures that collide on 4/6 and assert different `href`s and seeds. | | **A2** | **Homograph / case.** `TERRA1…` or mixed-case bech32 in indexer JSON. | `isValidTerraAddress` is lowercase-only today — reject; no blockie; no link. Do not “fix” by uppercasing in the URL. | | **A3** | **XSS / protocol in address.** `javascript:…`, `data:`, `<img>`, `"><script>`. | No `href` with that string; no `dangerouslySetInnerHTML`; text content escaped by React. `getExplorerAddressUrl` still returns `null` for non-bech32 ([#430](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/430)). | | **A4** | **Path injection.** `address = "terra1…/../admin"` or `?x=` / `#` in the field. | Fail `isValidTerraAddress` (charset `[a-z0-9]`). Router must not see a relative path. | | **A5** | **Remote PFP injection.** Agent or indexer adds `avatar_url` / `logo_url` on `IndexerTrader`. | UI **ignores** it. No `<img src={trader.logo_url}>`. Regression: even `https://evil.example/p.png` is not requested. | | **A6** | **Reuse TokenLogo + evil logoURI.** | Trader identity must not call `TokenLogo` with a trader field. Token allowlist tests (#378) unchanged. | | **A7** | **Explorer open-redirect.** Crafted address that slips into `getExplorerAddressUrl`. | Helper remains the only `href` builder; invalid → omit icon. Do not concatenate Finder hosts with raw strings. | | **A8** | **Clipboard swap.** Visible 4/6 vs copy payload. | Copy / `CopyButton` still gets the **full** address (existing `AddressRow` + Clipboard tests). Leaderboard does not add a copy control that copies the short form. | | **A9** | **Fake “official” PFP.** User expects a CL8Y/brand avatar for a known wallet. | Blockie is not the product mark. Do not overlay `/logo.png` on trader rows. No “verified” badge from a blockie. | | **A10** | **DoS / layout.** Leaderboard limit 20 (current); malicious huge `address` string. | Invalid long strings fail validation. Valid bech32 length is bounded. Do not render unbounded canvas scales. | | **A11** | **Click the blockie vs the label.** | Entire identity is one `Link` (leaderboard) so the hit target cannot send the user to a different wallet than the label. | | **A12** | **Seed confusion with token blockies.** Same library, CW20 address vs wallet. | Acceptable (deterministic). Do not reuse a token’s `logoURI` for a trader even if the strings match a listed CW20. | | **A13** | **Profile search XSS.** Paste HTML into “Paste a trader wallet address”. | Existing `isValidTerraAddress` gate; no identity render until navigate to a valid `/trader/:addr`. | | **A14** | **Stale identity on address change.** `/trader/A` → `/trader/B`. | Blockie seed updates with `trader.address` / route param. Route error reset already keyed on address (#126). | | **A15** | **Phishing copy.** Short form used in a toast / share text as if it were the full wallet. | Do not put the 4/6 string into share/QR/deep-link payloads. Full address only. | --- ## Verification criteria Done when all of the following are true: 1. On `/charts`, every leaderboard trader row shows a blockie + `shortenAddress(addr, 4, 6)` and links to `/trader/{full}`. 2. On `/trader/:addr` (and `/portfolio` via `TraderSummaryStats`), the header shows the same blockie family (larger) and a 4/6 `AddressRow` with full-address copy + safe explorer. 3. `make test-frontend` (or the scoped Vitest list in `make verify-issue-<iid>`) covers T-ID-1–T-ID-7 and A1, A3, A5, A8. 4. `#553` leaderboard volume tests, `#188` `AddressRow` defaults, `#378` `TokenLogo` allowlist, and `#186` wallet chip tests still pass. 5. `docs/frontend.md` and the AddressRow (or new trader-identity) playbook describe 4/6 + blockie and the token/wallet exclusions. 6. Manual or Playwright: 375px light/dark — leaderboard and profile identity readable; no second chrome card; no remote image request for a trader. **Out of scope / do not fold in:** wallet-chip avatars, tape trader column, order-book owner PFP, ENS/nameservice, indexer-hosted avatars, changing `total_volume_usd` ranking.
PlasticDigits commented 2026-08-26 04:16:13 +00:00 (Migrated from gitlab.com)

mentioned in issue #665

mentioned in issue #665
PlasticDigits commented 2026-08-26 04:16:15 +00:00 (Migrated from gitlab.com)

marked as related to #665

marked as related to #665
PlasticDigits commented 2026-08-26 07:17:45 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1170

mentioned in merge request !1170
PlasticDigits commented 2026-08-26 07:17:59 +00:00 (Migrated from gitlab.com)

Implemented on !1170 (feat/656-trader-identity). make verify-issue-656 passed twice.

Acceptance (this MR): T-ID-1–T-ID-8, T-ID-10, A1/A3/A5/A8 covered in Vitest.

Not in this MR (optional / visual): Playwright identity assertion on e2e/trader-page.spec.ts; live 375px/1280px light+dark pass (T-ID-9) — CSS uses whitespace-nowrap + no card-glass on the row, but needs a human/browser check with indexer data.

Out of scope unchanged: wallet chip, tape, order-book owner, ENS/indexer avatars, volume ranking.

Implemented on !1170 (`feat/656-trader-identity`). `make verify-issue-656` passed twice. **Acceptance (this MR):** T-ID-1–T-ID-8, T-ID-10, A1/A3/A5/A8 covered in Vitest. **Not in this MR (optional / visual):** Playwright identity assertion on `e2e/trader-page.spec.ts`; live 375px/1280px light+dark pass (T-ID-9) — CSS uses `whitespace-nowrap` + no `card-glass` on the row, but needs a human/browser check with indexer data. Out of scope unchanged: wallet chip, tape, order-book owner, ENS/indexer avatars, volume ranking.
PlasticDigits commented 2026-08-26 08:46:28 +00:00 (Migrated from gitlab.com)

mentioned in commit 5d1647c208

mentioned in commit 5d1647c208c2f7a12e276fc54fc4a7303e8218f4
PlasticDigits commented 2026-08-26 08:46:35 +00:00 (Migrated from gitlab.com)

mentioned in commit 183a53de1b

mentioned in commit 183a53de1be93fa077a5393cf218334632e83831
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-26 08:46:36 +00:00
PlasticDigits commented 2026-08-26 08:51:55 +00:00 (Migrated from gitlab.com)

mentioned in commit 9a82f40580

mentioned in commit 9a82f40580ec38509feefce19e1b1fd63fda61b7
PlasticDigits commented 2026-08-26 09:31:03 +00:00 (Migrated from gitlab.com)

Merged to main via !1170. Identity is inside the shared TraderLeaderboard (4/6 + blockie), including pair-scoped Charts ranks after !1176.

Leftover: /trader and /charts leaderboard chips after frontend rebuild. make verify-issue-656.

Merged to `main` via !1170. Identity is inside the shared `TraderLeaderboard` (4/6 + blockie), including pair-scoped Charts ranks after !1176. Leftover: `/trader` and `/charts` leaderboard chips after frontend rebuild. `make verify-issue-656`.
PlasticDigits commented 2026-08-26 09:31:35 +00:00 (Migrated from gitlab.com)

mentioned in issue #673

mentioned in issue #673
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#656
No description provided.