feat(WalletConnect): Handle sign request expiration

Implementing the user-story for sign request expiry and add qml tests
+ other minor fixes

## Acceptance Criteria

```
//Always show the expiration
Given the sign/transaction request dialog is shown
When request has an expiration date
Then the user sees a 1 minute countdown in the dialog
```

```
// Show 1 minute timer
Given the sign/transaction request dialog is shown
When the request has 1 minute or less before expiring
Then the user sees a 1 second countdown in the dialog
```

```
Given the sign/transaction dialog is open
When the request expires
Then the Accept button is removed
And the only option for the user is to close the dialog
```

```
Given the sign/transaction request dialog is open
When the request expired
Then the `Sign` and `Reject` buttons are removed
And the `Close` button is visible
```

```
Given the sign/transaction request expired
Then a toast message is showing
And it contains the "<dapp domain> sign request timed out" message
```

```
Given the sign/transaction request dialog is open
When the request expired
Then the sign/transaction request dialog is still visible
```

```
Given the sign/transaction request expires
Then a console message is shown
And it contains 'WC WalletConnectSDK.onSessionRequestExpire; id: ${id}`'
```
This commit is contained in:
Alex Jbanca 2024-10-04 15:49:16 +03:00 committed by Alex Jbanca
parent f536c3447f
commit fd99b96cb5
15 changed files with 341 additions and 50 deletions

View File

@ -74,6 +74,12 @@ SplitView {
pill.expirationSeconds = 6
}
}
Button {
text: "Set expired now"
onClicked: {
pill.expirationSeconds = 0
}
}
Button {
text: "Set 5 minutes (10 minutes ago) -> expired"
onClicked: {

View File

@ -156,13 +156,14 @@ function formatApproveSessionResponse(networksArray, accountsArray, custom) {
function formatSessionRequest(chainId, method, params, topic, requestId) {
const reqId = requestId || 1717149885151715
const expiry = Date.now() / 1000 + 6000
let paramsStr = params.map(param => `${param}`).join(',')
return `{
"id": ${reqId},
"params": {
"chainId": "eip155:${chainId}",
"request": {
"expiryTimestamp": 1717150185,
"expiryTimestamp": ${expiry},
"method": "${method}",
"params": [${paramsStr}]
}

View File

@ -58,7 +58,7 @@ Item {
chainId: network,
data: "hello world",
preparedData: "hello world",
expirationTimestamp: Date.now() + 1000,
expirationTimestamp: (Date.now() + 10000) / 1000,
sourceId: Constants.DAppConnectors.WalletConnect
})
@ -114,8 +114,8 @@ Item {
}
property var onDisplayToastMessageTriggers: []
onDisplayToastMessage: function(message, error) {
onDisplayToastMessageTriggers.push({message, error})
onDisplayToastMessage: function(message, type) {
onDisplayToastMessageTriggers.push({message, type})
}
property var onPairingValidatedTriggers: []
@ -444,6 +444,150 @@ Item {
compare(request.haveEnoughFees, data.expect.haveEnoughForFees, "expected haveEnoughForFees to be set")
verify(!!request.feesInfo, "expected feesInfo to be set")
}
function test_sessionRequestExpiryInTheFuture() {
const sdk = handler.sdk
const testAddressUpper = "0x3A"
const chainId = 2
const method = "personal_sign"
const message = "hello world"
const params = [`"${DAppsHelpers.strToHex(message)}"`, `"${testAddressUpper}"`]
const topic = "b536a"
const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
verify(session.params.request.expiryTimestamp > Date.now() / 1000, "expected expiryTimestamp to be in the future")
// Expect to have calls to getActiveSessions from service initialization
const prevRequests = sdk.getActiveSessionsCallbacks.length
sdk.sessionRequestEvent(session)
verify(handler.requestsModel.count === 1, "expected a request to be added")
const request = handler.requestsModel.findRequest(topic, session.id)
verify(!!request, "expected request to be found")
verify(!request.isExpired(), "expected request to not be expired")
}
function test_sessionRequestExpiryInThePast()
{
const sdk = handler.sdk
const testAddressUpper = "0x3A"
const chainId = 2
const method = "personal_sign"
const message = "hello world"
const params = [`"${DAppsHelpers.strToHex(message)}"`, `"${testAddressUpper}"`]
const topic = "b536a"
const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
session.params.request.expiryTimestamp = (Date.now() - 10000) / 1000
verify(session.params.request.expiryTimestamp < Date.now() / 1000, "expected expiryTimestamp to be in the past")
sdk.sessionRequestEvent(session)
verify(handler.requestsModel.count === 1, "expected a request to be added")
const request = handler.requestsModel.findRequest(topic, session.id)
verify(!!request, "expected request to be found")
verify(request.isExpired(), "expected request to be expired")
verify(displayToastMessageSpy.count === 0, "no toast message should be displayed")
}
function test_wcSignalsSessionRequestExpiry()
{
const sdk = handler.sdk
const testAddressUpper = "0x3A"
const chainId = 2
const method = "personal_sign"
const message = "hello world"
const params = [`"${DAppsHelpers.strToHex(message)}"`, `"${testAddressUpper}"`]
const topic = "b536a"
const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
verify(session.params.request.expiryTimestamp > Date.now() / 1000, "expected expiryTimestamp to be in the future")
sdk.sessionRequestEvent(session)
const request = handler.requestsModel.findRequest(topic, session.id)
verify(!!request, "expected request to be found")
verify(!request.isExpired(), "expected request to not be expired")
sdk.sessionRequestExpired(session.id)
verify(request.isExpired(), "expected request to be expired")
verify(displayToastMessageSpy.count === 0, "no toast message should be displayed")
}
function test_acceptExpiredSessionRequest()
{
const sdk = handler.sdk
const testAddressUpper = "0x3A"
const chainId = 2
const method = "personal_sign"
const message = "hello world"
const params = [`"${DAppsHelpers.strToHex(message)}"`, `"${testAddressUpper}"`]
const topic = "b536a"
const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
session.params.request.expiryTimestamp = (Date.now() - 10000) / 1000
verify(session.params.request.expiryTimestamp < Date.now() / 1000, "expected expiryTimestamp to be in the past")
sdk.sessionRequestEvent(session)
verify(handler.requestsModel.count === 1, "expected a request to be added")
const request = handler.requestsModel.findRequest(topic, session.id)
request.resolveDappInfoFromSession({peer: {metadata: {name: "Test DApp", url: "https://test.dapp", icons:[]}}})
verify(!!request, "expected request to be found")
verify(request.isExpired(), "expected request to be expired")
verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest")
ignoreWarning("Error: request expired")
handler.store.userAuthenticated(topic, session.id, "1234", "", message)
verify(sdk.rejectSessionRequestCalls.length === 1, "expected a call to sdk.rejectSessionRequest")
sdk.sessionRequestUserAnswerResult(topic, session.id, false, "")
verify(displayToastMessageSpy.count === 1, "expected a toast message to be displayed")
compare(displayToastMessageSpy.signalArguments[0][0], "test.dapp sign request timed out")
}
function test_rejectExpiredSessionRequest()
{
const sdk = handler.sdk
const testAddressUpper = "0x3A"
const chainId = 2
const method = "personal_sign"
const message = "hello world"
const params = [`"${DAppsHelpers.strToHex(message)}"`, `"${testAddressUpper}"`]
const topic = "b536a"
const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
session.params.request.expiryTimestamp = (Date.now() - 10000) / 1000
verify(session.params.request.expiryTimestamp < Date.now() / 1000, "expected expiryTimestamp to be in the past")
sdk.sessionRequestEvent(session)
verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest")
ignoreWarning("Error: request expired")
handler.store.userAuthenticationFailed(topic, session.id)
verify(sdk.rejectSessionRequestCalls.length === 1, "expected a call to sdk.rejectSessionRequest")
}
function test_signFailedAuthOnExpiredRequest()
{
const sdk = handler.sdk
const testAddressUpper = "0x3A"
const chainId = 2
const method = "personal_sign"
const message = "hello world"
const params = [`"${DAppsHelpers.strToHex(message)}"`, `"${testAddressUpper}"`]
const topic = "b536a"
const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic))
session.params.request.expiryTimestamp = (Date.now() - 10000) / 1000
verify(session.params.request.expiryTimestamp < Date.now() / 1000, "expected expiryTimestamp to be in the past")
sdk.sessionRequestEvent(session)
verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest")
ignoreWarning("Error: request expired")
handler.store.userAuthenticationFailed(topic, session.id)
verify(sdk.rejectSessionRequestCalls.length === 1, "expected a call to sdk.rejectSessionRequest")
}
}
TestCase {
@ -561,7 +705,7 @@ Item {
verify(service.onApproveSessionResultTriggers[0].session, "expected session to be set")
compare(service.onDisplayToastMessageTriggers.length, 1, "expected a success message to be displayed")
verify(!service.onDisplayToastMessageTriggers[0].error, "expected no error")
verify(service.onDisplayToastMessageTriggers[0].type !== Constants.ephemeralNotificationType.danger, "expected no error")
verify(service.onDisplayToastMessageTriggers[0].message, "expected message to be set")
}
@ -1027,5 +1171,77 @@ Item {
verify(!popup.opened)
verify(!popup.visible)
}
function test_SignRequestExpired() {
const topic = "abcd"
const requestId = "12345"
let popup = showRequestModal(topic, requestId)
const request = controlUnderTest.sessionRequestsModel.findRequest(topic, requestId)
verify(!!request)
const countDownPill = findChild(popup, "countdownPill")
verify(!!countDownPill)
tryVerify(() => countDownPill.remainingSeconds > 0)
// Hackish -> countdownPill internals ask for a refresh before going to expired state
const remainingSeconds = countDownPill.remainingSeconds
tryVerify(() => countDownPill.visible)
tryVerify(() => countDownPill.remainingSeconds !== remainingSeconds)
request.setExpired()
tryVerify(() => countDownPill.isExpired)
verify(countDownPill.visible)
const signButton = findChild(popup, "signButton")
const rejectButton = findChild(popup, "rejectButton")
const closeButton = findChild(popup, "closeButton")
tryVerify(() => !signButton.visible)
verify(!rejectButton.visible)
verify(closeButton.visible)
}
function test_SignRequestDoesWithoutExpiry()
{
const topic = "abcd"
const requestId = "12345"
let popup = showRequestModal(topic, requestId)
const request = controlUnderTest.sessionRequestsModel.findRequest(topic, requestId)
verify(!!request)
request.expirationTimestamp = undefined
const countDownPill = findChild(popup, "countdownPill")
verify(!!countDownPill)
tryVerify(() => !countDownPill.visible)
request.setExpired()
tryVerify(() => countDownPill.visible)
const signButton = findChild(popup, "signButton")
const rejectButton = findChild(popup, "rejectButton")
const closeButton = findChild(popup, "closeButton")
verify(signButton.visible)
verify(rejectButton.visible)
verify(!closeButton.visible)
}
function test_SignRequestModalAfterModelRemove()
{
const topic = "abcd"
const requestId = "12345"
let popup = showRequestModal(topic, requestId)
const request = controlUnderTest.sessionRequestsModel.findRequest(topic, requestId)
verify(!!request)
controlUnderTest.sessionRequestsModel.removeRequest(topic, requestId)
verify(!controlUnderTest.sessionRequestsModel.findRequest(topic, requestId))
waitForRendering(controlUnderTest)
popup = findChild(controlUnderTest, "dappsRequestModal")
verify(!popup)
}
}
}

View File

@ -328,6 +328,9 @@ DappsComboBox {
signingTransaction: !!request.method && (request.method === SessionRequest.methods.signTransaction.name
|| request.method === SessionRequest.methods.sendTransaction.name)
requestPayload: request.preparedData
expirationSeconds: request.expirationTimestamp ? request.expirationTimestamp - requestTimestamp.getTime() / 1000
: 0
hasExpiryDate: !!request.expirationTimestamp
onClosed: {
Qt.callLater(rejectRequest)

View File

@ -40,6 +40,7 @@ StatusDialog {
property date requestTimestamp: new Date()
property int expirationSeconds
property bool hasExpiryDate: false
property ObjectModel leftFooterContents
property ObjectModel rightFooterContents: ObjectModel {
@ -49,7 +50,7 @@ StatusDialog {
StatusFlatButton {
objectName: "rejectButton"
Layout.preferredHeight: signButton.height
visible: !countdownPill.isExpired
visible: !root.hasExpiryDate || !countdownPill.isExpired
text: qsTr("Reject")
onClicked: root.reject() // close and emit rejected() signal
}
@ -57,7 +58,7 @@ StatusDialog {
objectName: "signButton"
id: signButton
interactive: !root.feesLoading && root.signButtonEnabled
visible: !countdownPill.isExpired
visible: !root.hasExpiryDate || !countdownPill.isExpired
icon.name: Constants.authenticationIconByType[root.loginType]
disabledColor: Theme.palette.directColor8
text: qsTr("Sign")
@ -66,7 +67,7 @@ StatusDialog {
StatusButton {
objectName: "closeButton"
id: closeButton
visible: countdownPill.isExpired
visible: root.hasExpiryDate && countdownPill.isExpired
text: qsTr("Close")
onClicked: root.close()
}
@ -225,12 +226,13 @@ StatusDialog {
CountdownPill {
id: countdownPill
objectName: "countdownPill"
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.current.padding
timestamp: root.requestTimestamp
expirationSeconds: root.expirationSeconds
visible: !!expirationSeconds
visible: !!root.hasExpiryDate
}
}

View File

@ -33,7 +33,8 @@ SQUtils.QObject {
}
signal sessionRequest(string id)
signal displayToastMessage(string message, bool error)
/*type - maps to Constants.ephemeralNotificationType*/
signal displayToastMessage(string message, int type)
Connections {
target: sdk
@ -61,29 +62,49 @@ SQUtils.QObject {
console.error("Error finding event for topic", topic, "id", id)
return
}
let methodStr = SessionRequest.methodToUserString(request.method)
if (!methodStr) {
console.error("Error finding user string for method", request.method)
return
}
d.lookupSession(topic, function(session) {
if (session === null)
return
const appUrl = session.peer.metadata.url
const appUrl = request.dappUrl
const appDomain = SQUtils.StringUtils.extractDomainFromLink(appUrl)
const requestExpired = request.isExpired()
requests.removeRequest(topic, id)
if (error) {
root.displayToastMessage(qsTr("Fail to %1 from %2").arg(methodStr).arg(appDomain), true)
root.displayToastMessage(qsTr("Fail to %1 from %2").arg(methodStr).arg(appDomain), Constants.ephemeralNotificationType.danger)
root.rejectSessionRequest(topic, id, true /*hasError*/)
console.error(`Error accepting session request for topic: ${topic}, id: ${id}, accept: ${accept}, error: ${error}`)
return
}
if (!requestExpired) {
let actionStr = accept ? qsTr("accepted") : qsTr("rejected")
root.displayToastMessage("%1 %2 %3".arg(appDomain).arg(methodStr).arg(actionStr), false)
})
root.displayToastMessage("%1 %2 %3".arg(appDomain).arg(methodStr).arg(actionStr), Constants.ephemeralNotificationType.success)
return
}
root.displayToastMessage("%1 sign request timed out".arg(appDomain), Constants.ephemeralNotificationType.normal)
}
function onSessionRequestExpired(sessionId) {
// Expired event coming from WC
// Handling as a failsafe in case the event is not processed by the SDK
let request = requests.findById(sessionId)
if (request === null) {
console.error("Error finding event for session id", sessionId)
return
}
if (request.isExpired()) {
return //nothing to do. The request is already expired
}
request.setExpired()
}
}
@ -96,6 +117,12 @@ SQUtils.QObject {
console.error("Error finding event for topic", topic, "id", id)
return
}
if (request.isExpired()) {
console.warn("Error: request expired")
root.rejectSessionRequest(topic, id, true /*hasError*/)
return
}
d.executeSessionRequest(request, password, pin, payload)
}
@ -105,13 +132,16 @@ SQUtils.QObject {
if (request === null || !methodStr) {
return
}
d.lookupSession(topic, function(session) {
if (session === null)
if (request.isExpired()) {
console.warn("Error: request expired")
root.rejectSessionRequest(topic, id, true /*hasError*/)
return
const appDomain = SQUtils.StringUtils.extractDomainFromLink(session.peer.metadata.url)
root.displayToastMessage(qsTr("Failed to authenticate %1 from %2").arg(methodStr).arg(appDomain), true)
root.rejectSessionRequest(topic, id, false /*hasErrors*/)
})
}
const appDomain = SQUtils.StringUtils.extractDomainFromLink(request.dappUrl)
root.displayToastMessage(qsTr("Failed to authenticate %1 from %2").arg(methodStr).arg(appDomain), Constants.ephemeralNotificationType.danger)
root.rejectSessionRequest(topic, id, true /*hasError*/)
}
function onSigningResult(topic, id, data) {
@ -166,6 +196,7 @@ SQUtils.QObject {
const interpreted = d.prepareData(method, data)
const enoughFunds = !d.isTransactionMethod(method)
const requestExpiry = event.params.request.expiryTimestamp
let obj = sessionRequestComponent.createObject(null, {
event,
@ -179,6 +210,7 @@ SQUtils.QObject {
maxFeesText: "?",
maxFeesEthText: "?",
enoughFunds: enoughFunds,
expirationTimestamp: requestExpiry
})
if (obj === null) {
console.error("Error creating SessionRequestResolved for event")
@ -213,6 +245,7 @@ SQUtils.QObject {
} else {
console.error("Error finding mainnet network")
}
let st = getEstimatedFeesStatus(data, method, obj.chainId, mainChainId)
let fundsStatus = checkFundsStatus(st.feesInfo.maxFees, st.feesInfo.l1GasFee, obj.accountAddress, obj.chainId, mainNet.chainId, interpreted.value)
obj.fiatMaxFees = st.fiatMaxFees

View File

@ -93,7 +93,7 @@ WalletConnectSDKBase {
preparedData: interpreted.preparedData,
maxFeesText: "?",
maxFeesEthText: "?",
enoughFunds: enoughFunds,
enoughFunds: enoughFunds
})
if (obj === null) {

View File

@ -198,7 +198,7 @@ WalletConnectSDKBase {
console.debug(`WC WalletConnectSDK.wcCall.rejectSessionRequest; topic: "${topic}", id: ${id}, error: "${error}"`)
d.engine.runJavaScript(`
wc.rejectSessionRequest("${topic}", ${id}, "${error}")
wc.rejectSessionRequest("${topic}", ${id}, ${error})
.then((value) => {
wc.statusObject.onRejectSessionRequestResponse("${topic}", ${id}, "")
})
@ -366,6 +366,11 @@ WalletConnectSDKBase {
console.debug(`WC WalletConnectSDK.onProposalExpire; details: ${JSON.stringify(details)}`)
root.sessionProposalExpired()
}
function onSessionRequestExpire(id) {
console.debug(`WC WalletConnectSDK.onSessionRequestExpire; id: ${id}`)
root.sessionRequestExpired(id)
}
}
WebEngineLoader {

View File

@ -112,7 +112,10 @@ QObject {
signal approveSessionResult(var key, var error, var topic)
// Emitted when a new session is requested by a dApp
signal sessionRequest(string id)
signal displayToastMessage(string message, bool error)
// Emitted when the services requests to display a toast message
// @param message The message to display
// @param type The type of the message. Maps to Constants.ephemeralNotificationType
signal displayToastMessage(string message, int type)
// Emitted as a response to WalletConnectService.validatePairingUri or other WalletConnectService.pair
// and WalletConnectService.approvePair errors
signal pairingValidated(int validationState)
@ -298,9 +301,9 @@ QObject {
function notifyDappDisconnect(dappUrl, err) {
const appDomain = StringUtils.extractDomainFromLink(dappUrl)
if(err) {
root.displayToastMessage(qsTr("Failed to disconnect from %1").arg(appDomain), true)
root.displayToastMessage(qsTr("Failed to disconnect from %1").arg(appDomain), Constants.ephemeralNotificationType.danger)
} else {
root.displayToastMessage(qsTr("Disconnected from %1").arg(appDomain), false)
root.displayToastMessage(qsTr("Disconnected from %1").arg(appDomain), Constants.ephemeralNotificationType.success)
}
}
@ -399,7 +402,7 @@ QObject {
// TODO #14754: implement custom dApp notification
const app_url = proposal.params.proposer.metadata.url ?? "-"
const app_domain = StringUtils.extractDomainFromLink(app_url)
root.displayToastMessage(qsTr("Connected to %1 via WalletConnect").arg(app_domain), false)
root.displayToastMessage(qsTr("Connected to %1 via WalletConnect").arg(app_domain), Constants.ephemeralNotificationType.success)
// Persist session
if(!store.addWalletConnectSession(JSON.stringify(session))) {
@ -425,9 +428,9 @@ QObject {
const app_domain = StringUtils.extractDomainFromLink(app_url)
if(err) {
d.reportPairErrorState(Pairing.errors.unknownError)
root.displayToastMessage(qsTr("Failed to reject connection request for %1").arg(app_domain), true)
root.displayToastMessage(qsTr("Failed to reject connection request for %1").arg(app_domain), Constants.ephemeralNotificationType.danger)
} else {
root.displayToastMessage(qsTr("Connection request for %1 was rejected").arg(app_domain), false)
root.displayToastMessage(qsTr("Connection request for %1 was rejected").arg(app_domain), Constants.ephemeralNotificationType.success)
}
}
@ -454,8 +457,8 @@ QObject {
timeoutTimer.stop()
root.sessionRequest(id)
}
onDisplayToastMessage: (message, error) => {
root.displayToastMessage(message, error)
onDisplayToastMessage: (message, type) => {
root.displayToastMessage(message, type)
}
}

File diff suppressed because one or more lines are too long

View File

@ -93,8 +93,8 @@ window.wc = {
// const { topic } = event;
});
window.wc.web3wallet.on("session_request_expire", (event) => {
wc.statusObject.echo("debug", `WC unhandled event: "session_request_expire" ${JSON.stringify(event)}`);
// const { id } = event
const { id } = event
wc.statusObject.onSessionRequestExpire(id)
});
window.wc.core.relayer.on("relayer_connect", () => {
wc.statusObject.echo("debug", `WC unhandled event: "relayer_connect" connection to the relay server is established`);

View File

@ -21,6 +21,9 @@ QObject {
required property string method
required property string accountAddress
required property string chainId
// optional expiry date in ms
property var expirationTimestamp
// Maps to Constants.DAppConnectors values
required property int sourceId
@ -54,6 +57,14 @@ QObject {
}
}
function isExpired() {
return !!expirationTimestamp && expirationTimestamp > 0 && Math.floor(Date.now() / 1000) >= expirationTimestamp
}
function setExpired() {
expirationTimestamp = Math.floor(Date.now() / 1000)
}
// dApp info
QtObject {
id: d
@ -61,5 +72,6 @@ QObject {
property string dappName
property string dappUrl
property url dappIcon
property bool hasExpiry
}
}

View File

@ -17,6 +17,16 @@ ListModel {
return null;
}
function removeRequest(topic, id) {
for (var i = 0; i < root.count; i++) {
let entry = root.get(i).requestItem
if (entry.topic == topic && entry.id == id) {
root.remove(i, 1);
return;
}
}
}
/// returns null if not found
function findRequest(topic, id) {
for (var i = 0; i < root.count; i++) {

View File

@ -2188,12 +2188,10 @@ Item {
Global.walletConnectService = walletConnectService
}
onDisplayToastMessage: (message, isErr) => {
Global.displayToastMessage(message, "",
isErr ? "warning" : "checkmark-circle", false,
isErr ? Constants.ephemeralNotificationType.danger
: Constants.ephemeralNotificationType.success,
"")
onDisplayToastMessage: (message, type) => {
const icon = type === Constants.ephemeralNotificationType.danger ? "warning" :
type === Constants.ephemeralNotificationType.success ? "checkmark-circle" : "info"
Global.displayToastMessage(message, "", icon, false, type, "")
}
}
}

View File

@ -17,7 +17,7 @@ IssuePill {
required property int expirationSeconds
onExpirationSecondsChanged: Qt.callLater(reset)
readonly property bool isExpired: expirationSeconds > 0 && d.secsDiff <= 0
readonly property bool isExpired: remainingSeconds <= 0
readonly property int remainingSeconds: d.secsDiff
signal expired
@ -44,6 +44,8 @@ IssuePill {
function reset() {
if (expirationSeconds === 0) {
timer.stop()
d.secsDiff = -1
root.expired()
return
}