Reputation: 21181
I have a very simple React app that calls a Solidity smart contract's method using Web3.js (4.3.2
) with some data coming from a form. I'm trying to catch errors while calling the contract method or sending the transaction, such as the user closing the wallet modal, in order to display an error message.
This is the setup code (simplified):
window.ethereum.request({ method: "eth_requestAccounts" });
const web3 = new Web3(window.ethereum);
const factoryCampaignContract = new web3.eth.Contract(CAMPAIGN_ABI, CAMPAIGN_ADDRESS);
In the form's submit handler I've tried 3 different options to catch the error.
try-catch
try {
const receipt = await factoryCampaignContract.methods
.createCampaign(minimumContribution)
.send({ from: accounts[0] });
} catch(err) {
console.log(err);
}
.then()
+ .catch()
factoryCampaignContract.methods
.createCampaign(minimumContribution)
.send({ from: accounts[0] })
.then(receipt => console.log(receipt))
.catch(err => console.log(err));
factoryCampaignContract.methods
.createCampaign(minimumContribution)
.send({ from: accounts[0] })
.on('transactionHash', (hash) => console.log(hash))
.on('confirmation', (confirmationNumber) => console.log(confirmationNumber))
.on('receipt', (receipt) => console.log(receipt))
.on('error', (error) => console.log(error));
As soon as I close the wallet modal, I see the following error in the console, but none of the blocks above where the error should have been caught are actually invoked:
Uncaught (in promise) Error: User rejected the request.
at ap.<anonymous> (<anonymous>:7:4040)
at Generator.next (<anonymous>)
at u (<anonymous>:1:1048)
Upvotes: 0
Views: 517
Reputation: 21181
Nevermind, mystery solved:
Short answer:
There's a bug in Phantom's wallet that prevents the dApp from catching the error. After trying again with MetaMask, everything was working as expected:
The issue was on the wallet side, particularly on the one I was using,
The console would still show an error logged by MetaMask extension's content script:
VM35:1 MetaMask - RPC Error: MetaMask Tx Signature: User denied transaction
signature. {code: 4001, message: 'MetaMask Tx Signature: User denied transaction
signature.'}
However, the try-catch
in my React app would successfully catch that, allowing me to handle it properly, showing an error message to the user.
So, the code I shared in the question is correct, and there's nothing wrong on the Solidity side or with Web3.js.
If you are having the same issue and wondering why none of the solutions you find online or even the examples in the official Web3.js' docs work, simply try using a different wallet.
Long answer:
Digging a bit to find out more about Phantom's bug, you'll find a block like this in their contentScript.js
:
switch (i = a ? "USE_METAMASK" : i, i) {
case "ALWAYS_ASK":
X_('\nObject.defineProperty(window, "ethereum", {\n value: undefined,\n writable: true,\n});\ndelete window.ethereum;\n '),
X_('<VERY LONG STRING>');
break;
case "USE_PHANTOM":
X_('\nObject.defineProperty(window, "ethereum", {\n value: undefined,\n writable: true,\n});\ndelete window.ethereum;\n '),
X_('<VERY LONG STRING>');
break;
case "USE_METAMASK":
X_('\nObject.defineProperty(window, "ethereum", {\n value: undefined,\n writable: true,\n});\ndelete window.ethereum;\n '),
X_('<VERY LONG STRING>');
break;
}
The error I was seeing in the console when using Phantom but was unable to catch comes from these eval-ed expressions, that's why the Console will say it comes from a file called something like VM1813
.
If you navigate to it in the Sources tab, you'll find something like this:
this.request = r => ct(this, null, function* () {
var n, u;
let o;
try {
let { method: h } = r
, E = "params" in r ? (n = r.params) != null ? n : [] : []
, x = vn[h];
if (!x) throw new Error("MethodNotFound");
let g = x.request.safeParse({ ... });
if (!g.success) { ... }
let P = g.data;
if (o = x.response.parse(yield lt(this, Mn).send(P)), "error" in o) {
// ⚠️ Here's the error we see on the console:
throw new gr(o.error);
}
try {
...
} catch (M) {
console.error("event emitter error", M)
}
return o.result
} catch (h) {
// ⚠️ Which is caught and re-thrown here, as `h instanceof gr` is true:
throw h instanceof gr ? h : h instanceof gt ? new gr({
code: -32e3,
message: "Missing or invalid parameters."
}, {
method: r.method
}) : h instanceof Error && h.message === "MethodNotFound" ? new gr({
code: -32601,
message: "The method does not exist / is not available."
}, {
method: r.method
}) : new gr({
code: -32603,
message: "Internal JSON-RPC error."
}, {
method: r.method
})
}
});
Note that they use generator functions heavily and wrap them in another function (ct
) to return a Promise
. Do you smell that? Most likely, somewhere hidden in their code, they are resolving a Promise
and throwing an error afterwards.
Just like in this question, but as their code is more complex, mostly due to the use of the generator functions and the conversion to Promise
, it is not so obvious where, especially when looking at the minified code.
Let's try to reproduce the error, to some extent:
/**
* Straight out of their bundle, just renamed or removed some params to make it a bit
* easier to understand.
*/
function ct(generatorFn) {
return new Promise((resolve, reject)=>{
var u = x=>{
try {
E(generatorFn.next(x))
} catch (err) {
reject(err)
}
}
, h = x=>{
try {
E(generatorFn.throw(x))
} catch (err) {
reject(err)
}
}
, E = x=>x.done ? resolve(x.value) : Promise.resolve(x.value).then(u, h);
E((generatorFn = generatorFn()).next())
})
}
/**
* This is trying to simulate `lt(this, Mn).send(P)`, which opens the wallet modal and waits
* for the user to confirm/reject the transaction. Note that while this allows us to reproduce
* the error I was experiencing, it's not a perfect mock of what's happening in Phantom's send()
* function, as explained below.
*/
function ltDotSend() {
return ct(function*() {
// This mimics (more or less, as this is blocking code) opening the wallet and
// clicking Confirm (number >= 50) or Close (number < 50):
const n = parseInt(prompt('Enter a number')) || 0;
if (n < 50) {
// Somewhere in their minified code for the send() function, they are throwing
// an error AFTER the return for this function has already been called.
setTimeout(() => {
throw new Error('Inner error');
});
}
return yield n;
});
}
let request = () => ct(function*() {
try {
const n = yield ltDotSend();
console.log('n =', n);
if (n === 'ERROR') {
throw new Error('Outter error');
}
return n;
} catch (err) {
console.log('request catch', err.message);
throw err;
}
});
async function submitHandler() {
try {
const requestResult = await request();
console.log('requestResult =', requestResult);
} catch (err) {
// This will never be invoked:
console.log('submitHandler catch', err.message);
}
}
document.getElementById('requestBtn').addEventListener('click', submitHandler);
<button id="requestBtn">request()</button>
However, there are some differences between this attempt at simulating the same behavior I was experiencing, and Phantom's implementation:
In my example, even when the error is thrown, the Promise
returned by request()
resolves, but not in the original code. I tried playing around with ltDotSend()
's implementation, but then the error is actually caught in submitHandler
's try-catch
, which is again not the case in the original code.
In their original code, if I add this to the Console and test my dApp again, the submit handler can catch the error, so just wrapping their request
function in an async function
resolves the issue:
const originalRequest = window.ethereum.request;
window.ethereum.request = async (...args) => {
return originalRequest(...args)
}
Their implementation of send()
is much more complex than the fake one I wrote as it's actually communicating with the extensions background script, so the error might be further down the rabbit hole.
Upvotes: 0