Connecting MetaMask to MyZubster: Building Wallet Ownership Without Turning Every Action Into a Blockchain Transaction
I’m currently adding MetaMask and EVM wallet ownership verification to MyZubster.
At first sight, this might sound like a simple “Connect Wallet” feature.
It isn’t.
The real problem I wanted to solve was:
How can a MyZubster user prove that they control an Ethereum wallet without giving MyZubster custody of that wallet, without exposing private keys, and without turning every Marketplace action into an on-chain transaction?
This distinction is becoming increasingly important as MyZubster grows.
The ecosystem already contains Marketplace flows, GitHub integrations, AI-assisted workflows through Zorgax, blockchain evidence experiments, Base Sepolia anchoring, internal MYZ accounting, and support for multiple crypto assets.
Adding MetaMask therefore cannot just mean placing a button in the UI.
It needs a clear security and product model.
The architecture I’m implementing is based on one central principle:
Connected wallet
!=
Verified wallet ownership
!=
Signed Marketplace intent
!=
Payment authorization
!=
Payment
!=
Settlement
Those states must remain separate.
Why add MetaMask to MyZubster?
Until now, a MyZubster account could interact with the Marketplace using the normal application identity.
That works.
But there are cases where it becomes useful to have an additional cryptographic proof tied to a user-controlled wallet.
For example, imagine a Marketplace request.
A buyer could say:
I want to request this item.
Today that intention is represented by the authenticated MyZubster account.
With a verified wallet, the same user could additionally sign a canonical request payload.
That would create cryptographic evidence that:
this wallet controlled by this user
signed this exact request
at this specific moment
without sending a transaction.
No gas.
No transfer.
No smart contract interaction.
Just an off-chain signature.
This is the direction I want MyZubster to take.
The first milestone: proving wallet ownership
The first feature is deliberately small.
Before thinking about payments, tokens or escrow, MyZubster needs to answer one question reliably:
Does the authenticated MyZubster user actually control the wallet address they want to connect?
Simply receiving an Ethereum address from the browser is not enough.
A frontend can obtain an address from MetaMask using:
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
const address = accounts[0];
But at this stage MyZubster should only consider the wallet:
CONNECTED
not:
VERIFIED
The proof comes from signing a server-generated challenge.
Current architecture
The flow I’m implementing looks like this:
MyZubster authenticated user
↓
Connect MetaMask
↓
eth_requestAccounts
↓
Read wallet address
↓
Read chain ID
↓
POST /api/wallet/challenge
↓
MyZubster generates a one-time challenge
↓
MetaMask signs the challenge
↓
personal_sign
↓
POST /api/wallet/verify
↓
Server recovers signer with ethers
↓
Recovered address == requested wallet?
↓
YES
↓
WALLET_VERIFIED
The important part is that verification happens on the server.
The browser never gets to say:
{
"walletVerified": true
}
and have the backend trust it.
That would defeat the entire purpose.
The API
The first implementation introduces four authenticated endpoints:
GET /api/wallet/me
POST /api/wallet/challenge
POST /api/wallet/verify
DELETE /api/wallet/disconnect
They intentionally represent a very small wallet lifecycle.
GET /api/wallet/me returns the wallet state associated with the authenticated MyZubster account.
POST /api/wallet/challenge starts ownership verification.
POST /api/wallet/verify validates the signature.
DELETE /api/wallet/disconnect removes the relationship between the MyZubster account and the EVM wallet.
Generating the challenge
The challenge is generated server-side.
It is bound to several pieces of information:
MyZubster user
wallet address
chain ID
MyZubster domain
application URI
cryptographically random nonce
issued time
expiration time
intended action
The intended action for this flow is:
LINK_WALLET
A simplified challenge looks conceptually like this:
www.myzubster.com wants you to link your Ethereum account to MyZubster:
0x1234...abcd
Sign this message to prove wallet control.
This is not a payment and does not spend ETH.
URI: https://www.myzubster.com
Version: 1
Chain ID: 1
Nonce: d70b4f...
Issued At: 2026-09-25T04:00:00.000Z
Expiration Time: 2026-09-25T04:05:00.000Z
Request ID: LINK_WALLET:user-123
That wording is intentional.
A wallet signature must never be presented to the user as if it were a payment.
Why the nonce matters
Without a nonce, a signature can potentially be replayed.
For example, imagine MyZubster accepted the same signature indefinitely.
Someone could capture the signed message and submit it again later.
Instead, every challenge gets a cryptographically random nonce:
crypto.randomBytes(16).toString('hex');
The challenge also expires after a short period.
For the first implementation, the default validity window is around five minutes.
So the verification logic checks:
challenge exists
↓
challenge has not expired
↓
requested address matches
↓
message matches the server challenge
↓
signature is valid
↓
recovered signer matches wallet address
Only after all of those conditions succeed does MyZubster persist the wallet as verified.
Signature verification with ethers
MyZubster already uses ethers, so the backend can recover the signer directly.
Conceptually:
const recovered = verifyMessage(message, signature);
Then:
if (recovered !== expectedAddress) {
throw new Error('Wallet signer mismatch');
}
Addresses are normalized before comparison.
That avoids accidental mismatches caused by formatting or checksum casing.
What gets stored?
MyZubster does not store:
private keys
seed phrases
mnemonics
wallet passwords
MetaMask secrets
The account only needs metadata about the wallet relationship.
Conceptually:
evmWallet: {
walletType: 'EVM',
provider: 'metamask',
address: '0x...',
chainId: 1,
status: 'WALLET_VERIFIED',
verifiedAt: Date,
lastVerifiedAt: Date
}
During verification there is also temporary challenge state containing things such as:
address
chainId
nonce hash
message hash
issuedAt
expiresAt
action
The challenge metadata is not part of the public wallet profile.
Wallet state matters
I’m explicitly modelling wallet state instead of using one boolean.
The first states are:
WALLET_NOT_CONNECTED
WALLET_CHALLENGE_PENDING
WALLET_VERIFIED
WALLET_DISCONNECTED
This becomes useful later because Zorgax and the UI can explain exactly what has happened.
For example:
MetaMask is connected.
is different from:
MetaMask ownership has been cryptographically verified.
And both are different from:
You paid for this order.
Connecting MetaMask in the frontend
The existing MyZubster Wallet Hub is being extended instead of creating a completely separate wallet system.
The user sees:
Connect MetaMask
The browser first asks MetaMask for the account:
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
Then MyZubster reads the chain:
const chainHex = await window.ethereum.request({
method: 'eth_chainId'
});
After receiving the challenge from the backend, MetaMask signs the exact message:
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, address]
});
That signature is then sent back to MyZubster.
The server decides whether the wallet becomes verified.
Preventing one wallet from silently belonging to multiple accounts
There is another interesting product question.
What happens if:
MyZubster account A
verifies:
0xABC...
and then account B attempts to verify the same address?
For now, the implementation treats an already verified relationship as a conflict.
Conceptually:
MyZubster Account A
↓
0xABC
↓
VERIFIED
MyZubster Account B
↓
attempts 0xABC
↓
WALLET_ALREADY_LINKED
This does not mean the Ethereum address is a person's legal identity.
It simply prevents MyZubster from claiming contradictory account-to-wallet relationships at the same time.
What changes in MyZubster?
This feature changes more than the wallet UI.
It introduces a new cryptographic identity layer into the ecosystem.
Today MyZubster can already know things such as:
MyZubster account identity
GitHub identity
community profile
Marketplace role
Zorgax profile context
Now it can additionally know:
this authenticated account proved control
of this EVM wallet
at this time
That becomes a reusable primitive.
The next step: signed Marketplace requests
This is where the feature starts becoming much more interesting.
Once a wallet is verified, a Marketplace request can be signed off-chain.
Imagine a listing:
Listing ID: garden-tools-103
Quantity: 2
Buyer: account-42
Wallet: 0xABC...
MyZubster could generate a canonical payload:
{
"schema": "myzubster.marketplace-request.v1",
"intent": "MARKETPLACE_REQUEST",
"listingId": "garden-tools-103",
"quantity": 2,
"buyerAccountReference": "account-42",
"walletAddress": "0xABC...",
"nonce": "6f4c...",
"issuedAt": "2026-09-25T10:00:00Z",
"expiresAt": "2026-09-25T10:05:00Z"
}
The buyer signs it.
The server verifies it.
Then the Marketplace order can be created as:
REQUESTED
with accompanying cryptographic evidence.
But still:
REQUEST_SIGNED != PAYMENT
That distinction is extremely important.
Why not put every Marketplace request on-chain?
Because I don’t think blockchain should be used simply because blockchain exists.
If every Marketplace request required an Ethereum transaction, the flow would become:
request item
↓
open wallet
↓
estimate gas
↓
approve transaction
↓
wait for blockchain
↓
continue
That adds cost and friction without necessarily adding proportional value.
Instead, the initial architecture is:
Marketplace request
↓
off-chain wallet signature
↓
seller acceptance
↓
exchange lifecycle
↓
structured evidence
↓
optional blockchain anchor
Blockchain is used when permanence adds value.
Not as mandatory friction.
MyZubster already has EVM infrastructure
This MetaMask work is not happening in isolation.
The codebase already contains Ethereum-related infrastructure.
There is currently support or experimental infrastructure around:
ethers
Ethereum Sepolia
Base Sepolia
Marketplace ETH payment verification
blockchain evidence anchoring
NFT experimentation
knowledge evidence commitments
For example, MyZubster already has server-side verification code capable of checking Ethereum Sepolia transactions.
It also has Base Sepolia evidence flows where a canonical payload can be anchored and independently verified.
The wallet-linking feature connects the user-controlled EVM side of that architecture.
User wallet vs server wallet
This distinction is also critical.
MyZubster already uses dedicated server-side wallets for certain blockchain evidence experiments.
Those wallets must remain completely separate from user wallets.
The architecture should always preserve:
User MetaMask wallet
!=
MyZubster server anchoring wallet
A user signs actions with their own wallet.
A server evidence wallet performs explicitly defined infrastructure operations.
Private keys for infrastructure wallets never go to the frontend.
User private keys never go to MyZubster.
What this could enable later
Once wallet verification and signed intents are reliable, several future features become technically possible.
For example:
signed Marketplace agreements
optional blockchain settlement
escrow experiments
NFT ownership interactions
portable proof of participation
token-gated experiences
signed contribution evidence
Metaverse ownership features
cross-application identity proofs
But I’m deliberately not implementing all of these at once.
The foundation has to be trustworthy first.
What it changes for Zorgax
Zorgax can also become more useful when wallet state is explicit.
Instead of guessing, the assistant can answer questions like:
User:
Is my wallet connected?
Zorgax:
Your MetaMask wallet is linked and ownership has been verified.
Or:
User:
Did I pay for this Marketplace request?
Zorgax:
No. Your wallet signed the request, but there is no confirmed payment evidence.
That may sound like a small distinction.
In financial or blockchain-related systems, it is not.
It prevents the AI layer from confusing:
signature
transaction
payment
settlement
Human approval remains part of the design
This also follows the architecture I recently implemented for Zorgax and GitHub:
AI proposes
↓
human reviews
↓
human approves
↓
system executes
↓
external result can be verified
Wallet interactions should follow the same principle.
MyZubster should never silently ask a wallet to perform economically meaningful actions.
If a future operation transfers ETH or another asset, the user should see clearly:
network
asset
amount
destination
action
estimated cost
before MetaMask asks for confirmation.
Security lessons
Building wallet integration makes one thing very clear:
The hardest part is not opening MetaMask.
This is easy:
window.ethereum.request({
method: 'eth_requestAccounts'
});
The harder problem is designing the meaning of everything around that call.
What does “connected” mean?
What does “verified” mean?
Can a challenge be replayed?
Does the signature expire?
Is the signature bound to the authenticated account?
Can the same signature be used in another environment?
Does the interface clearly explain that signing a message is not paying?
Can the same wallet become associated with multiple MyZubster users?
What happens when someone loses control of the wallet?
Those are the questions that turn a wallet button into an actual product feature.
Tests
I’m also adding automated tests around these boundaries.
The current implementation tests cases such as:
valid wallet signs challenge
→ accepted
different wallet signs challenge
→ rejected
expired challenge
→ rejected
message explicitly states that signing is not payment
→ required
There are also wiring tests checking that the API is authenticated and that the frontend uses the expected MetaMask provider methods.
Current status
The first implementation is now in a GitHub pull request:
PR #1385 — MetaMask wallet ownership linking
It contains the wallet model, challenge service, API endpoints, frontend integration and tests.
This should be considered the wallet ownership foundation, not the final Ethereum payment implementation.
The next milestone is:
Verified MetaMask wallet
↓
canonical MARKETPLACE_REQUEST
↓
off-chain signature
↓
server verification
↓
MarketplaceOrder REQUESTED
After that, payment flows can remain a completely separate layer.
The bigger architectural change
For me, the most interesting part is not “MyZubster supports MetaMask”.
It is that MyZubster is gradually gaining multiple independent layers of verifiable intent.
GitHub can prove public development activity.
MyZubster accounts represent application identity.
MetaMask can prove control of an EVM address.
Off-chain signatures can prove intent.
Marketplace state can represent agreement.
Payment verification can prove payment.
Blockchain anchors can preserve selected evidence.
Zorgax can help humans navigate all of those states without becoming the source of truth itself.
That produces an architecture that looks more like:
Identity
↓
Cryptographic control
↓
Signed intent
↓
Application state
↓
Payment evidence
↓
Optional permanent blockchain evidence
instead of:
Connect wallet
↓
everything is blockchain
And I think that distinction will matter a lot as MyZubster evolves.
Project: MyZubster
Stack: Node.js, Express, MongoDB, React, ethers, MetaMask
Current focus: non-custodial EVM wallet ownership verification
Next milestone: signed Marketplace requests without gas
GitHub:
https://github.com/MyZubster-Ecosystem/myzubster
MetaMask implementation:
Top comments (0)