Linking a wallet to an account

How a signed-in user attaches an address to their account, what row it writes, how unlinking promotes the next link, and the denormalised walletAddress mirror only one model hook may write.

11 min readUpdated 6 August 2026profile, provider-user, linking, walletaddress

Wallet sign-in only works for addresses the platform already knows. Linking is how an address becomes known, and it is the step every new user has to go through first.

Why linking comes first

POST /api/auth/login/wallet looks the signed address up in provider_user and refuses with "Wallet address not recognized" (401) when there is no row. It never creates one.

That is deliberate, and it has a consequence worth stating plainly to anyone evaluating this addon: you cannot register with a wallet. The onboarding order is fixed:

  1. Register conventionally — email and password, or Google.

  2. Verify the email. An INACTIVE account is refused at wallet sign-in the same way it is refused at password sign-in.

  3. Link the wallet from the profile, as below.

  4. From then on, either credential signs the user in.

There is no admin screen that links a wallet on a user's behalf, and no import path. The user has to do it themselves, because the whole point is a signature only they can produce.

Linking from the profile

The Wallet tab lives at /user/profile?tab=wallet. It is in the profile sidebar for every user — it is not hidden when the extension is disabled, which is the cause of one confusing failure described below.

Registers a wallet address for the signed-in user after verifying a SIWE signature

What happens when the user clicks Connect wallet:

  1. The Reown modal opens and the user picks a wallet. This is a connection only — no signature yet, and nothing has reached your server.

  2. The page notices the connection and immediately starts the linking sequence, without a second click. It first checks whether this address is already among the account's linked providers and stops if it is.

  3. A nonce is fetched from GET /api/auth/login/nonce — the same endpoint and the same five-minute single-use nonce that sign-in uses.

  4. A SIWE message is signed. The wallet prompts. This is the only prompt the user has to approve deliberately.

  5. The signature is posted to /api/user/profile/wallet/connect, which runs the same verification chain as sign-in: extension enabled and licensed, project ID present, address and nonce parsed, nonce consumed, chain on the allow-list, signature valid.

  6. One row is written into provider_user, and a wallet.connected activity row is recorded. If it is the account's first WALLET link it is marked primary, and a model hook mirrors it onto user.walletAddress — see the walletAddress mirror below.

Because the sequence starts on connection rather than on a second click, a user who connects a wallet merely to look at it will still be asked to sign. Rejecting that prompt leaves them connected in the browser but unlinked on the server, and the tab will show the address as connected. Reloading the page and reconnecting restarts the sequence.

The row it writes

-- provider_user
id             CHAR(36)     -- uuid
userId         CHAR(36)     -- the account
providerUserId VARCHAR(255) -- the wallet address, LOWERCASED by the column setter
provider       ENUM('GOOGLE','WALLET')
isPrimary      TINYINT(1)   -- TRUE or NULL, never FALSE
chainId        INT          -- the EIP-155 chain the signature was proven on
verifiedAt     DATETIME     -- when that signature was verified

Four properties of that table drive most of the behaviour operators ask about.

providerUserId is globally unique — across both provider types, not per user. One address can be linked to exactly one account. A second user trying to link an address someone else already holds gets a failure, not a shared login.

The address is stored lowercased. The model's column setter lowercases anything matching ^0x[0-9a-fA-F]{40}$ on write, so a Google sub survives byte for byte and an EIP-55 checksummed address does not. Lowercase your needle in anything you query by hand, and compare case-insensitively in anything you write.

A collision answers 409, not 500. The handler looks the address up on the indexed column alone: linked to this account is a 200 "Wallet already registered", linked to another is "This wallet address is already linked to a different account." (409), and a lost race on the unique index is caught and re-answered as the same 409. If your install still returns "Internal server error" here it predates that fix, and a 500 on connect means a duplicate address.

Multiple wallets per account are allowed by the schema — nothing stops a user linking a second address, and any of them will sign in. Only the first is isPrimary, and only the primary one is mirrored onto the user row. The Wallet tab displays whichever one the browser currently has connected, so a user with two linked wallets sees one at a time.

Unlinking

Removes a wallet link for the signed-in user

The Disconnect Wallet button disconnects the browser session and posts the address to be unlinked. The handler does two things in order, and the order is load-bearing: it rewrites providerUserId to deleted:<row id> and clears isPrimary, then soft-deletes the row. The providerUserId unique index does not honour deletedAt, so a row left with its address in place would lock that address out of the platform forever; blanking it first is what genuinely releases it, for this user or another one. The row itself survives as history.

Destroying the row fires the afterDestroy hook, which promotes the next-oldest surviving WALLET link to primary — rewriting user.walletAddress to that address — or clears the mirror when there is nothing left. That is covered in full below.

Two notes:

  • Disconnect is not gated on the extension. Unlike the nonce, sign-in and connect endpoints, it does not check whether wallet_connect is enabled or licensed. That is the escape hatch: if you disable the addon, users can still detach an address they no longer want on their account.
  • Unlinking the last wallet is not blocked. A user whose only working credential is a wallet can unlink it and lock themselves out of that path. Every account has an email address, so password reset remains the recovery route.

The walletAddress mirror

The user table has a walletAddress column and a walletProvider column beside it, and they are populated — but not by this handler and not by anything an operator can reach. The afterSave hook in backend/models/access/providerUser.ts writes the address of the account's primary WALLET link into user.walletAddress and the literal string WALLETCONNECT into user.walletProvider.

beforeUpdate and beforeBulkUpdate in backend/models/user.ts refuse everyone else with a thrown error:

user.walletAddress is a mirror of providerUser and may only be changed by the SIWE link flow. See backend/models/access/providerUser.ts.

The only call the guard lets through is one carrying context: { source: "providerUserMirror" }, which is what the mirror hook passes and nothing else does. beforeBulkUpdate is there because beforeUpdate fires only for instance saves — user.update({...}, { where }) is a bulk update and would have skipped the guard entirely, which is the exact call shape the mirror itself uses.

Two things in that code read narrower than they look. Both guards test walletAddress by name — row.changed("walletAddress") and fields.includes("walletAddress") — so a write touching only walletProvider is never challenged. And the mirror does not in fact depend on the context to get past them: it passes hooks: false as well, so neither guard runs for it at all. The context is the second lock, and the one that would still hold if the hooks: false were ever dropped.

Why it is guarded and not simply written

user.walletAddress is a payout target, not a display field. nft/auction/[id]/settle reads both sides of it to move the NFT and the funds, nft/listing/[id]/buy reads the seller's, nft/auction/deploy passes it as the auction contract's seller and royalty recipient, and nft/offer/[id]/confirm matches it against the transaction sender. A writer that could set the column could redirect somebody else's settlement to an address nobody proved they control — and a SIWE signature is the only proof of control this platform ever collects. Deriving the column from provider_user means every value in it was signed for.

A direct SQL UPDATE, a mysql console, an import job or anything else running outside Sequelize writes the column without a word of complaint. So does any Sequelize call that passes hooks: false — that option is precisely how the mirror gets past its own guard, and it disarms it for everyone else too. A repair script written against the models is not safer than one written in SQL unless it leaves hooks on.

If you repair rows by hand you own keeping the mirror true, and a mirror you leave stale points an NFT settlement at the wrong address. The re-derive statement is on Managing linked wallets.

isPrimary decides, and the connect handler sets it only on an account's first WALLET link. Later links get NULL — never FALSE, because UNIQUE(userId, provider, isPrimary) treats every FALSE as a collision while MySQL permits unlimited NULLs in a unique index. So:

  • Linking a first wallet writes the mirror.
  • Linking a second does not. The mirror keeps the first address, and the second is a working sign-in credential that no NFT flow will ever pay.
  • Unlinking the primary promotes the next-oldest surviving WALLET link: afterDestroy sets isPrimary on it, which re-enters afterSave and rewrites the mirror to that address. The customer never chooses which one, and there is no screen or endpoint that promotes a link deliberately.
  • Unlinking the last one sets both columns back to NULL, and the NFT gates start refusing again with "Connect a wallet address in your profile to …".

Both mirror writes pass hooks: false to get past the guard, which also skips the user model's own cache hooks — so each one deletes the user:<id>:profile Redis key by hand afterwards. That delete is best-effort by design: a Redis failure must not roll back a verified link, so a profile read in the moments after an unlink can still serve the old address.

What the column drives

NFT Marketplace lets its on-chain actions through. Its helper (nft/utils/nft-auth.ts) loads the user record, reads walletAddress and throws 400 "Connect a wallet address in your profile to …" when it is empty. That gate sits in front of buying a listing, transferring a token and approving a contract, and the same field is read directly by auction settlement, offer confirmation and contract deployment. A user with a linked primary wallet passes all of them; a user without one gets that message, and linking a wallet is the fix rather than a support ticket. See NFT Marketplace.

Profile completion can reach 100%. The score counts ten fields and walletAddress is one of them, so an account with no linked wallet caps at 90%.

The profile dashboard and the Wallet tab agree. The overview card reads user.walletAddress, the Wallet tab reads the providers array, and for the primary link both show the same address. They can still disagree for a second linked wallet: the tab shows whichever address the browser is connected to, the card always shows the primary.

No admin screen shows it, and no administrator can change it. walletAddress and walletProvider are in the admin CRM read schema in admin/crm/user/utils.ts, and the user-list route excludes only password and metadata, so both columns are in the JSON an admin's browser receives — but nothing draws them. There is no such column in the users grid, none on the user detail page, and neither field is in the CSV export. The one admin surface that renders an address at all, the Wallet address card on a KYC application, is fed by a route whose attributes list stops at profile, so it is empty for every applicant on every install.

Writing is closed twice over. Neither field is in userUpdateSchema, and the admin PUT destructures the fields it accepts, so an address never reaches the model — and if a future edit form sent one, the guard above would throw before it reached the column. That is the point of the guard: an admin edit to a payout target is an unsigned edit, and this column only holds addresses somebody proved they control.

If your install predates the mirror

Older installs have provider_user rows with no isPrimary, addresses stored in whatever casing the wallet produced, and possibly a user.walletAddress that no signature ever proved. backend/scripts/unify-wallet-address-stores.mjs reconciles all of it: it adds the isPrimary, chainId and verifiedAt columns if auto-sync has not, lowercases every stored address, marks the oldest WALLET link per user primary, mirrors that onto the user row, and quarantines the rest.

node scripts/unify-wallet-address-stores.mjs
node scripts/unify-wallet-address-stores.mjs --apply

A user.walletAddress with no provider_user row behind it was never proven by a signature — on this platform it can only have arrived by a direct database write — and it is a payout target. The script snapshots every one into dex_migration_quarantine and then nulls it, so those users must re-link through /user/profile?tab=wallet before an NFT settlement will pay them.

Read the dry-run list before you pass --apply, take a backup, and restart the backend afterwards so cached user rows are refreshed. The quarantine table is the only undo.

What linking is not

It is not a deposit address. Nothing is ever sent to a linked wallet by the platform, and the platform cannot spend from it. Customer balances live in Bicrypto wallets; on-chain custody, where it exists at all, is Ecosystem and uses addresses this install derives and holds keys for. A linked wallet is a credential, in the same category as a password.

It is not a KYC signal. Ownership of an address says nothing about identity, and no KYC level, feature gate or withdrawal policy consults it.

It is not a chain preference. The chain ID in the SIWE message is used only to route signature verification. Linking on Base and later signing in on Polygon works, as long as both are on the allow-list.