diff --git a/src/app_service/service/community_tokens/async_tasks.nim b/src/app_service/service/community_tokens/async_tasks.nim index 4ec24f7b27..1e2752c457 100644 --- a/src/app_service/service/community_tokens/async_tasks.nim +++ b/src/app_service/service/community_tokens/async_tasks.nim @@ -100,8 +100,9 @@ const asyncGetRemoteBurnFeesTask: Task = proc(argEncoded: string) {.gcsafe, nimc "gasTable": tableToJsonArray(gasTable), "chainId": arg.chainId, "addressFrom": arg.addressFrom, + "error": "", "requestId": arg.requestId, - "error": "" }) + }) except Exception as e: arg.finish(%* { "error": e.msg, @@ -130,8 +131,9 @@ const asyncGetBurnFeesTask: Task = proc(argEncoded: string) {.gcsafe, nimcall.} "gasTable": tableToJsonArray(gasTable), "chainId": arg.chainId, "addressFrom": arg.addressFrom, - "requestId": arg.requestId, - "error": "" }) + "error": "", + "requestId": arg.requestId + }) except Exception as e: arg.finish(%* { "error": e.msg, @@ -163,15 +165,16 @@ const asyncGetMintFeesTask: Task = proc(argEncoded: string) {.gcsafe, nimcall.} arg.walletAddresses, collectibleAndAmount.amount).result.getInt gasTable[(chainId, collectibleAndAmount.communityToken.address)] = gas arg.finish(%* { - "requestId": arg.requestId, "feeTable": tableToJsonArray(feeTable), "gasTable": tableToJsonArray(gasTable), "addressFrom": arg.addressFrom, - "error": "" }) + "error": "", + "requestId": arg.requestId + }) except Exception as e: let output = %* { - "requestId": arg.requestId, - "error": e.msg + "error": e.msg, + "requestId": arg.requestId } arg.finish(output) diff --git a/src/app_service/service/community_tokens/service.nim b/src/app_service/service/community_tokens/service.nim index 771fd5d8ac..3734e236dd 100644 --- a/src/app_service/service/community_tokens/service.nim +++ b/src/app_service/service/community_tokens/service.nim @@ -656,8 +656,10 @@ QtObject: ) self.threadpool.start(arg) except Exception as e: - #TODO: handle error - emit error signal error "Error loading airdrop fees", msg = e.msg + var dataToEmit = AirdropFeesArgs() + dataToEmit.errorCode = ComputeFeeErrorCode.Other + self.events.emit(SIGNAL_COMPUTE_AIRDROP_FEE, dataToEmit) proc getFiatValue*(self: Service, cryptoBalance: float, cryptoSymbol: string): float = if (cryptoSymbol == ""): diff --git a/storybook/pages/BurnTokensPopupPage.qml b/storybook/pages/BurnTokensPopupPage.qml index 60c3c88cdb..1ce51b878f 100644 --- a/storybook/pages/BurnTokensPopupPage.qml +++ b/storybook/pages/BurnTokensPopupPage.qml @@ -68,24 +68,27 @@ SplitView { onBurnClicked: logs.logEvent("BurnTokensPopup::onBurnClicked --> Burn amount: " + burnAmount) onCancelClicked: logs.logEvent("BurnTokensPopup::onCancelClicked") + feeText: "0.0015 ETH ($75.43)" + feeErrorText: "" + isFeeLoading: false - onBurnFeesRequested: { - feeText = "" - feeErrorText = "" - isFeeLoading = true - - feeCalculationTimer.restart() + onSelectedAccountAddressChanged: { + burnTokensPopup.isFeeLoading = true + timer.delay(2000, () => burnTokensPopup.isFeeLoading = false) + } + onAmountToBurnChanged: { + burnTokensPopup.isFeeLoading = true + timer.delay(2000, () => burnTokensPopup.isFeeLoading = false) } } Timer { - id: feeCalculationTimer - - interval: 1000 - - onTriggered: { - burnTokensPopup.feeText = "0.0015 ETH ($75.43)" - burnTokensPopup.isFeeLoading = false + id: timer + function delay(ms, callback) { + timer.interval = ms + timer.repeat = false + timer.triggered.connect(callback) + timer.start() } } } diff --git a/storybook/pages/CommunityTokenViewPage.qml b/storybook/pages/CommunityTokenViewPage.qml index dae6e6f16a..b8da235a1a 100644 --- a/storybook/pages/CommunityTokenViewPage.qml +++ b/storybook/pages/CommunityTokenViewPage.qml @@ -62,6 +62,9 @@ SplitView { token: tokenObject tokenOwnersModel: TokenHoldersModel {} + feeText: "0.01" + feeErrorText: "" + isFeeLoading: false accounts: WalletAccountsModel {} diff --git a/storybook/pages/EditOwnerTokenViewPage.qml b/storybook/pages/EditOwnerTokenViewPage.qml index 8775283f9f..7adf16759e 100644 --- a/storybook/pages/EditOwnerTokenViewPage.qml +++ b/storybook/pages/EditOwnerTokenViewPage.qml @@ -53,7 +53,7 @@ SplitView { onMintClicked: logs.logEvent("EditOwnerTokenView::onMintClicked") - onDeployFeesRequested: { + Component.onCompleted: { feeText = "" feeErrorText = "" isFeeLoading = true diff --git a/storybook/pages/MintTokensSettingsPanelPage.qml b/storybook/pages/MintTokensSettingsPanelPage.qml index 7162d1fc31..1a08bd8f01 100644 --- a/storybook/pages/MintTokensSettingsPanelPage.qml +++ b/storybook/pages/MintTokensSettingsPanelPage.qml @@ -30,13 +30,12 @@ SplitView { } Timer { - id: feesTimer - - interval: 1000 - - onTriggered: { - panel.isFeeLoading = false - panel.feeText = "0,0002 ETH (123,15 USD)" + id: timer + function delay(delayTime, cb) { + timer.interval = delayTime; + timer.repeat = false; + timer.triggered.connect(cb); + timer.start(); } } @@ -48,6 +47,24 @@ SplitView { MintTokensSettingsPanel { id: panel + readonly property var singleTransactionFee: { + "ethCurrency": { + "objectName":"", + "amount":0.000007900500349933282, + "symbol":"ETH", + "displayDecimals":4, + "stripTrailingZeroes":false + }, + "fiatCurrency": { + "objectName":"", + "amount":0.012852533720433712, + "symbol":"USD", + "displayDecimals":2, + "stripTrailingZeroes":false + }, + "errorCode":0 + } + MintedTokensModel { id: mintedTokensModel } @@ -105,14 +122,9 @@ SplitView { onMintCollectible: logs.logEvent("CommunityMintTokensSettingsPanel::mintCollectible") onMintAsset: logs.logEvent("CommunityMintTokensSettingsPanel::mintAssets") onDeleteToken: logs.logEvent("CommunityMintTokensSettingsPanel::deleteToken: " + tokenKey) - - onDeployFeesRequested: { - feeText = "" - feeErrorText = "" - isFeeLoading = true - - feesTimer.restart() - } + onRegisterDeployFeesSubscriber: timer.delay(2000, () => feeSubscriber.feesResponse = panel.singleTransactionFee) + onRegisterSelfDestructFeesSubscriber: timer.delay(2000, () => feeSubscriber.feesResponse = panel.singleTransactionFee) + onRegisterBurnTokenFeesSubscriber: timer.delay(2000, () => feeSubscriber.feesResponse = panel.singleTransactionFee) } } diff --git a/storybook/pages/RemotelyDestructPopupPage.qml b/storybook/pages/RemotelyDestructPopupPage.qml index 34de2010ad..38910ec3d1 100644 --- a/storybook/pages/RemotelyDestructPopupPage.qml +++ b/storybook/pages/RemotelyDestructPopupPage.qml @@ -74,12 +74,6 @@ SplitView { ]) close() } - onRemotelyDestructFeesRequested: { - logs.logEvent("RemoteSelfDestructPopup::onRemotelyDestructFeesRequested", - ["walletsAndAmounts", "accountAddress"], [ - JSON.stringify(walletsAndAmounts), accountAddress - ]) - } Component.onCompleted: { open() diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/Subscription.qml b/ui/StatusQ/src/StatusQ/Core/Utils/Subscription.qml new file mode 100644 index 0000000000..0144ef7c90 --- /dev/null +++ b/ui/StatusQ/src/StatusQ/Core/Utils/Subscription.qml @@ -0,0 +1,13 @@ +import QtQuick 2.15 + +QtObject { + id: root + + readonly property string subscriptionId: Utils.uuid() + + property bool isReady: false + property int notificationInterval: 3000 // 1 notification every 3 seconds + //The topic to subscribe to + property string topic: "" + property var response: {} +} diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/SubscriptionBroker.qml b/ui/StatusQ/src/StatusQ/Core/Utils/SubscriptionBroker.qml new file mode 100644 index 0000000000..fb7c246560 --- /dev/null +++ b/ui/StatusQ/src/StatusQ/Core/Utils/SubscriptionBroker.qml @@ -0,0 +1,228 @@ +import QtQuick 2.15 + +//This is a helper component that is used to batch requests and send them periodically +//It is used to reduce the number of requests sent to the server and notify different subscribers of the same request +//It is used by the Subscription component +QtObject { + id: root + + signal request(string topic) + + signal subscribed(string subscriptionId) + signal unsubscribed(string subscriptionId) + + function response(topic, responseObj) { + d.onResponse(topic, responseObj) + } + function subscribe(subscription) { + d.subscribe(subscription) + } + function unsubscribe(subscription) { + d.unsubscribe(subscription) + } + + property bool active: true + + readonly property QtObject d: QtObject { + //Mapping subscriptionId to subscription object + //subscriptionId is a string and represents the id of the subscription + //E.g. "subscriptionId": {subscription: subscriptionObject, topic: "topic"} + //The purpose of this mapping is to keep track of the subscriptions and their topics + readonly property var managedSubscriptions: ({}) + + //Mapping topic to subscriptionIds and request data + //topic is a string and represents the topic of the subscription + //E.g. "topic": {nextRequestTimestamp: 0, requestInterval: 1000, subscriptions: ["subscriptionId1", "subscriptionId2"], response: null} + readonly property var topics: ({}) + property int topicsCount: 0 //helper property to track change events + + property bool requestIntervalTriggered: false + readonly property int requestInterval: { + //dependency: + d.topicsCount + d.requestIntervalTriggered + + const topicInfos = Object.values(d.topics) + if(!topicsCount || topicInfos.length === 0) + return 0 + + const now = Date.now() + + const interval = topicInfos.reduce((minInterval, topicInfo) => Math.max(0, Math.min(minInterval, topicInfo.nextRequestTimestamp - now)), Number.MAX_SAFE_INTEGER) + + return interval > 0 ? interval : requestInterval + } + + readonly property Timer requestTimer: Timer { + interval: d.requestInterval + repeat: true + running: interval > 0 && root.active + onTriggered: d.periodicRequest() + triggeredOnStart: true + } + + function subscribe(subscription) { + if(!(subscription instanceof Subscription)) + return + if(d.managedSubscriptions.hasOwnProperty(subscription.subscriptionId)) + return + + registerToManagedSubscriptions(subscription) + connectToSubscriptionEvents(subscription) + if(subscription.isReady && subscription.topic) + registerToTopic(subscription.topic, subscription.subscriptionId) + root.subscribed(subscription.subscriptionId) + } + + function unsubscribe(subscriptionId) { + if(!subscriptionId || !d.managedSubscriptions.hasOwnProperty(subscriptionId)) + return + + releaseManagedSubscription(subscriptionId) + root.unsubscribed(subscriptionId) + } + + function registerToManagedSubscriptions(subscriptionObject) { + d.managedSubscriptions[subscriptionObject.subscriptionId] = { + subscription: subscriptionObject, + topic: subscriptionObject.topic, + } + } + + function releaseManagedSubscription(subscriptionId) { + if(!subscriptionId || !d.managedSubscriptions.hasOwnProperty(subscriptionId)) return + + const subscriptionInfo = d.managedSubscriptions[subscriptionId] + + unregisterFromTopic(subscriptionInfo.topic, subscriptionId) + delete d.managedSubscriptions[subscriptionId] + } + + function connectToSubscriptionEvents(subscription) { + const subscriptionId = subscription.subscriptionId + const topic = subscription.topic + + const onTopicChangeHandler = () => { + if(!subscription.isReady || !d.managedSubscriptions.hasOwnProperty(subscriptionId)) return + + const newTopic = subscription.topic + const oldTopic = d.managedSubscriptions[subscriptionId].topic + + if(newTopic === oldTopic) return + + d.unregisterFromTopic(oldTopic, subscriptionId) + d.registerToTopic(newTopic, subscriptionId) + d.managedSubscriptions[subscriptionId].topic = newTopic + } + + const onReadyChangeHandler = () => { + if(!d.managedSubscriptions.hasOwnProperty(subscriptionId)) return + + if(subscription.isReady) { + d.registerToTopic(subscription.topic, subscription.subscriptionId) + } else { + const subscriptionTopic = d.managedSubscriptions[subscriptionId].topic + d.unregisterFromTopic(subscriptionTopic, subscriptionId) + } + } + + const onUnsubscribedHandler = (subscriptionId) => { + if(subscriptionId !== subscription.subscriptionId) + return + + subscription.Component.onDestruction.disconnect(onDestructionHandler) + subscription.isReadyChanged.disconnect(onReadyChangeHandler) + subscription.topicChanged.disconnect(onTopicChangeHandler) + } + + const onDestructionHandler = () => { + if(!d.managedSubscriptions.hasOwnProperty(subscriptionId)) + return + + root.unsubscribed.disconnect(onUnsubscribedHandler) //object is destroyed, no need to listen to the signal anymore + unsubscribe(subscriptionId) + } + + subscription.Component.onDestruction.connect(onDestructionHandler) + subscription.isReadyChanged.connect(onReadyChangeHandler) + subscription.topicChanged.connect(onTopicChangeHandler) + root.unsubscribed.connect(onUnsubscribedHandler) + } + + function registerToTopic(topic, subscriptionId) { + if(!d.topics.hasOwnProperty(topic)) { + d.topics[topic] = { + requestInterval: d.managedSubscriptions[subscriptionId].subscription.notificationInterval, + nextRequestTimestamp: Date.now() + d.managedSubscriptions[subscriptionId].subscription.notificationInterval, + subscriptions: [], + response: null, + requestPending: false + } + d.topicsCount++ + } + + const index = d.topics[topic].subscriptions.indexOf(subscriptionId) + if(index !== -1) { + console.assert("Duplicate subscription: " + subscriptionId + " " + topic) + return + } + + const subscriptionsCount = d.topics[topic].subscriptions.push(subscriptionId) + if(subscriptionsCount === 1 && root.active) { + d.request(topic) + } + d.managedSubscriptions[subscriptionId].subscription.response = d.topics[topic].response + } + + function unregisterFromTopic(topic, subscriptionId) { + if(!d.topics.hasOwnProperty(topic)) return + + const index = d.topics[topic].subscriptions.indexOf(subscriptionId) + if(index === -1) return + + d.topics[topic].subscriptions.splice(index, 1) + if(d.topics[topic].subscriptions.length === 0) { + delete d.topics[topic] + d.topicsCount-- + } + } + + function periodicRequest() { + if(!d.topics || !d.topicsCount) return + + Object.entries(d.topics).forEach(function(entry) { + const topic = entry[0] + const topicInfo = entry[1] + + if(!topicInfo || + !topicInfo.subscriptions || + !topicInfo.subscriptions.length || + topicInfo.requestPending || + topicInfo.nextRequestTimestamp > Date.now()) + return + + d.request(topic) + }) + d.requestIntervalTriggered = !d.requestIntervalTriggered + } + + function request(topic) { + if(!d.topics.hasOwnProperty(topic)) return + + d.topics[topic].requestPending = true + d.topics[topic].nextRequestTimestamp = Date.now() + d.topics[topic].requestInterval + + root.request(topic) + } + + function onResponse(topic, responseObj) { + if(!d.topics.hasOwnProperty(topic)) return + + d.topics[topic].response = responseObj + d.topics[topic].subscriptions.forEach(function(subscriptionId) { + d.managedSubscriptions[subscriptionId].subscription.response = responseObj + }) + d.topics[topic].requestPending = false + } + } +} diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/qmldir b/ui/StatusQ/src/StatusQ/Core/Utils/qmldir index a20bf6089a..ede0e58c2a 100644 --- a/ui/StatusQ/src/StatusQ/Core/Utils/qmldir +++ b/ui/StatusQ/src/StatusQ/Core/Utils/qmldir @@ -8,6 +8,8 @@ ModelChangeTracker 0.1 ModelChangeTracker.qml ModelsComparator 0.1 ModelsComparator.qml StackViewStates 0.1 StackViewStates.qml StatesStack 0.1 StatesStack.qml +Subscription 0.1 Subscription.qml +SubscriptionBroker 0.1 SubscriptionBroker.qml XSS 1.0 xss.js singleton AmountsArithmetic 0.1 AmountsArithmetic.qml singleton Emoji 0.1 Emoji.qml diff --git a/ui/StatusQ/src/statusq.qrc b/ui/StatusQ/src/statusq.qrc index d8f066aa30..079e87408e 100644 --- a/ui/StatusQ/src/statusq.qrc +++ b/ui/StatusQ/src/statusq.qrc @@ -226,5 +226,7 @@ StatusQ/Controls/StatusBlockProgressBar.qml StatusQ/Components/StatusInfoBoxPanel.qml StatusQ/Controls/StatusWarningBox.qml + StatusQ/Core/Utils/Subscription.qml + StatusQ/Core/Utils/SubscriptionBroker.qml diff --git a/ui/StatusQ/tests/TestCore/TestUtils/tst_SubscriptionBroker.qml b/ui/StatusQ/tests/TestCore/TestUtils/tst_SubscriptionBroker.qml new file mode 100644 index 0000000000..deca79c528 --- /dev/null +++ b/ui/StatusQ/tests/TestCore/TestUtils/tst_SubscriptionBroker.qml @@ -0,0 +1,277 @@ +import QtQuick 2.15 +import QtTest 1.0 + +import StatusQ.Core.Utils 0.1 + +import StatusQ.TestHelpers 0.1 + +TestCase { + id: testCase + name: "SubscriptionBroker" + + Component { + id: subscriptionBrokerComponent + SubscriptionBroker { + id: subscriptionBroker + + //Signal spies + readonly property SignalSpy requestSignalSpy: SignalSpy { + target: subscriptionBroker + signalName: "request" + } + readonly property SignalSpy subscribedSignalSpy: SignalSpy { + target: subscriptionBroker + signalName: "subscribed" + } + readonly property SignalSpy unsubscribedSignalSpy: SignalSpy { + target: subscriptionBroker + signalName: "unsubscribed" + } + } + } + + Component { + id: subscriptionComponent + Subscription { + id: subscription + } + } + + MonitorQtOutput { + id: qtOuput + } + + function init() { + qtOuput.restartCapturing() + } + + function test_new_instance() { + const subscriptionBroker = createTemporaryObject(subscriptionBrokerComponent, testCase) + verify(qtOuput.qtOuput().length === 0, `No output expected. Found:\n"${qtOuput.qtOuput()}"\n`) + verify(subscriptionBroker.active, "SubscriptionBroker should be active by default") + verify(subscriptionBroker.requestSignalSpy.valid == true, "request signal should be defined") + verify(subscriptionBroker.subscribedSignalSpy.valid == true, "subscribed signal should be defined") + verify(subscriptionBroker.unsubscribedSignalSpy.valid == true, "unsubscribed signal should be defined") + verify(subscriptionBroker.response != undefined, "response function should be defined") + verify(subscriptionBroker.subscribe != undefined, "subscribe function should be defined") + verify(subscriptionBroker.unsubscribe != undefined, "unsubscribe function should be defined") + } + + function test_subscribe_invalid_subscription() { + const subscriptionBroker = createTemporaryObject(subscriptionBrokerComponent, testCase) + subscriptionBroker.subscribe(undefined) + compare(qtOuput.qtOuput().length, 0, `No output expected. Found:\n"${qtOuput.qtOuput()}"\n`) + compare(subscriptionBroker.requestSignalSpy.count, 0, "request signal should not be emitted") + compare(subscriptionBroker.subscribedSignalSpy.count, 0, "subscribed signal should not be emitted") + compare(subscriptionBroker.unsubscribedSignalSpy.count, 0, "unsubscribed signal should not be emitted") + + const subscriptionAsEmptyObject = {} + subscriptionBroker.subscribe(subscriptionAsEmptyObject) + + compare(qtOuput.qtOuput().length, 0, `No output expected. Found:\n"${qtOuput.qtOuput()}"\n`) + compare(subscriptionBroker.requestSignalSpy.count, 0, "request signal should not be emitted") + compare(subscriptionBroker.subscribedSignalSpy.count, 0, "subscribed signal should not be emitted") + compare(subscriptionBroker.unsubscribedSignalSpy.count, 0, "unsubscribed signal should not be emitted") + } + + function test_subscribe_valid_subscription_object() { + const subscriptionBroker = createTemporaryObject(subscriptionBrokerComponent, testCase) + const subscription = createTemporaryObject(subscriptionComponent, testCase) + verify(subscription.subscriptionId != "", "subscription should have an id") + + subscriptionBroker.subscribe(subscription) + compare(qtOuput.qtOuput().length, 0, `No output expected. Found:\n"${qtOuput.qtOuput()}"\n`) + compare(subscriptionBroker.subscribedSignalSpy.count, 1, "subscribed signal should be emitted") + compare(subscriptionBroker.subscribedSignalSpy.signalArguments[0][0], subscription.subscriptionId, "subscribed signal should be emitted with the subscription id") + compare(subscriptionBroker.unsubscribedSignalSpy.count, 0, "unsubscribed signal should not be emitted") + compare(subscriptionBroker.requestSignalSpy.count, 0, "request signal should not be emitted. Subscription is inactive by default. Broker is inactive by default.") + + subscriptionBroker.unsubscribe(subscription.subscriptionId) + compare(subscriptionBroker.subscribedSignalSpy.count, 1, "subscribed signal should not be emitted for unsunbscribe") + compare(subscriptionBroker.unsubscribedSignalSpy.count, 1, "unsubscribed signal should be emitted") + compare(subscriptionBroker.unsubscribedSignalSpy.signalArguments[0][0], subscription.subscriptionId, "unsubscribed signal should be emitted with the subscription id") + compare(subscriptionBroker.requestSignalSpy.count, 0, "request signal should not be emitted") + } + + function test_periodic_request_one_subscription() { + const subscriptionBroker = createTemporaryObject(subscriptionBrokerComponent, testCase) + const subscription = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic1", isReady: true, notificationInterval: 50}) + + //Enable broker and subscription + //Verify that request signal is emitted after subscription + subscriptionBroker.active = true + subscriptionBroker.subscribe(subscription) + compare(subscriptionBroker.subscribedSignalSpy.count, 1, "subscribed signal should be emitted") + compare(subscriptionBroker.requestSignalSpy.count, 1, "request signal should be emitted") + compare(subscriptionBroker.requestSignalSpy.signalArguments[0][0], subscription.topic, "request signal should be emitted with the subscription topic") + + //Verify that request signal is emitted after notificationInterval + //The broker expects a response before sending another request + subscriptionBroker.response(subscription.topic, "responseAsString") + compare(subscription.response, "responseAsString", "subscription response should be updated") + + //first interval - check for one request every 50ms: + compare(subscriptionBroker.requestSignalSpy.count, 1, "request signal should not be emitted") + tryCompare(subscriptionBroker.requestSignalSpy, "count", 2, 90 /*40ms error margin*/, "request signal should be emitted after 50ms. Actual signal count: " + subscriptionBroker.requestSignalSpy.count) + compare(subscriptionBroker.requestSignalSpy.signalArguments[1][0], subscription.topic, "request signal should be emitted with the subscription topic") + + subscriptionBroker.response(subscription.topic, "responseAsString2") + compare(subscription.response, "responseAsString2", "subscription response should be updated") + + //second interval - check for one request every 50ms: + compare(subscriptionBroker.requestSignalSpy.count, 2, "request was emitted before 50ms interval") + tryCompare(subscriptionBroker.requestSignalSpy, "count", 3, 90 /*40ms error margin*/, "request signal should be emitted after 50ms") + compare(subscriptionBroker.requestSignalSpy.signalArguments[2][0], subscription.topic, "request signal should be emitted with the subscription topic") + subscriptionBroker.response(subscription.topic, "responseAsString3") + + //Verify the request is not sent again after unsubscribe + subscriptionBroker.unsubscribe(subscription.subscriptionId) + compare(subscriptionBroker.subscribedSignalSpy.count, 1, "subscribed signal should not be emitted for unsunbscribe") + compare(subscriptionBroker.unsubscribedSignalSpy.count, 1, "unsubscribed signal should be emitted") + compare(subscriptionBroker.unsubscribedSignalSpy.signalArguments[0][0], subscription.subscriptionId, "unsubscribed signal should be emitted with the subscription id") + compare(subscriptionBroker.requestSignalSpy.count, 3, "request signal should not be emitted on unsubscribe") + wait(90)/*40ms error margin*/ + compare(subscriptionBroker.requestSignalSpy.count, 3, "request signal should not be emitted again after unsubscribe") + + //Verify the request is not sent again after disabling the broker + subscriptionBroker.subscribe(subscription) + compare(subscriptionBroker.subscribedSignalSpy.count, 2, "subscribed signal should be emitted") + compare(subscriptionBroker.requestSignalSpy.count, 4, "request signal should be emitted on subscribe") + subscriptionBroker.response(subscription.topic, "responseAsString4") + tryCompare(subscriptionBroker.requestSignalSpy, "count", 5, 90 /*40ms error margin*/, "request signal should be emitted") + subscriptionBroker.response(subscription.topic, "responseAsString5") + + subscriptionBroker.active = false + compare(subscriptionBroker.requestSignalSpy.count, 5, "request signal should not be emitted after disabling the broker") + wait(90)/*40ms error margin*/ + compare(subscriptionBroker.requestSignalSpy.count, 5, "request signal should not be emitted again after disabling the broker") + + //Verify the request can be unsubsribed with a disabled broker + subscriptionBroker.unsubscribe(subscription.subscriptionId) + compare(subscriptionBroker.subscribedSignalSpy.count, 2, "subscribed signal should not be emitted for unsunbscribe") + compare(subscriptionBroker.unsubscribedSignalSpy.count, 2, "unsubscribed signal should be emitted") + compare(subscriptionBroker.unsubscribedSignalSpy.signalArguments[1][0], subscription.subscriptionId, "unsubscribed signal should be emitted with the subscription id") + compare(subscriptionBroker.requestSignalSpy.count, 5, "request signal should not be emitted on unsubscribe") + + //Verify the request can be subscribed with a disabled broker + subscriptionBroker.subscribe(subscription) + compare(subscriptionBroker.subscribedSignalSpy.count, 3, "subscribed signal should be emitted") + compare(subscriptionBroker.requestSignalSpy.count, 5, "request signal should not be emitted on subscribe") + wait(90)/*40ms error margin*/ + compare(subscriptionBroker.requestSignalSpy.count, 5, "request signal should not be emitted with a disabled broker") + + //verify the request is sent after enabling the broker + subscriptionBroker.active = true + tryCompare(subscriptionBroker.requestSignalSpy, "count", 6, 1/*allow the event loop to be processed and the subscriptionBroker.active = true to be processed */, "request signal should be emitted after enabling the broker") + } + + function test_periodic_request_multiple_subscriptions() { + const subscriptionBroker = createTemporaryObject(subscriptionBrokerComponent, testCase) + const subscription1 = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic1", isReady: true, notificationInterval: 50}) //10 requests in 500ms + const subscription2 = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic2", isReady: true, notificationInterval: 90}) //5 requests in 500ms + const subscription3 = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic3", isReady: true, notificationInterval: 130}) //3 requests in 500ms + const subscription4 = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic4", isReady: true, notificationInterval: 170}) //2 requests in 500ms + const subscription5 = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic5", isReady: true, notificationInterval: 210}) //2 requests in 500ms + //TOTAL: 22 requests in 500ms + var subscription1RequestTimestamp = 0 + var subscription2RequestTimestamp = 0 + var subscription3RequestTimestamp = 0 + var subscription4RequestTimestamp = 0 + var subscription5RequestTimestamp = 0 + + var requestCount = 0 + subscriptionBroker.request.connect(function(topic) { + //sending a unique response for each request + subscriptionBroker.response(topic, "responseAsString" + Date.now()) + }) + + //Enable broker and subscription + subscriptionBroker.active = true + subscriptionBroker.subscribe(subscription1) + subscriptionBroker.subscribe(subscription2) + subscriptionBroker.subscribe(subscription3) + subscriptionBroker.subscribe(subscription4) + subscriptionBroker.subscribe(subscription5) + + //Make sure the interval difference between the subscriptions is at least 30ms + //This is to make sure interval computation deffects are not hidden by the error margin + //The usual error margin on Timer is < 10 ms. Setting it to 30 ms should be enough + const requestIntervalErrorMargin = 30 + + subscription1.responseChanged.connect(function() { + if(subscription1RequestTimestamp !== 0) + fuzzyCompare(Date.now() - subscription1RequestTimestamp, subscription1.notificationInterval, requestIntervalErrorMargin, "subscription1 request should be sent after notificationInterval") + + subscription1RequestTimestamp = Date.now() + }) + subscription2.responseChanged.connect(function() { + if(subscription2RequestTimestamp !== 0) + fuzzyCompare(Date.now() - subscription2RequestTimestamp, subscription2.notificationInterval, requestIntervalErrorMargin, "subscription2 request should be sent after notificationInterval") + + subscription2RequestTimestamp = Date.now() + }) + subscription3.responseChanged.connect(function() { + if(subscription3RequestTimestamp !== 0) + fuzzyCompare(Date.now() - subscription3RequestTimestamp, subscription3.notificationInterval, requestIntervalErrorMargin, "subscription3 request should be sent after notificationInterval") + + subscription3RequestTimestamp = Date.now() + }) + subscription4.responseChanged.connect(function() { + if(subscription4RequestTimestamp !== 0) + fuzzyCompare(Date.now() - subscription4RequestTimestamp, subscription4.notificationInterval, requestIntervalErrorMargin, "subscription4 request should be sent after notificationInterval") + + subscription4RequestTimestamp = Date.now() + }) + subscription5.responseChanged.connect(function() { + if(subscription5RequestTimestamp !== 0) + fuzzyCompare(Date.now() - subscription5RequestTimestamp, subscription5.notificationInterval, requestIntervalErrorMargin, "subscription5 request should be sent after notificationInterval") + + subscription5RequestTimestamp = Date.now() + }) + + + ///Verify the request is sent periodically for 500 ms + ///The test is fuzzy because the timer is not precise + ///After each wait() the test error margin increases + + //We should have 27 requests in 500ms. Adding an error margin of 100ms => 600ms total + tryVerify(() => subscriptionBroker.requestSignalSpy.count > 26, 600, "request signal should be emitted more than 27 times. Actual: " + subscriptionBroker.requestSignalSpy.count) + + //Disable one subscription and verify the request count is reduced + subscription5.isReady = false + subscription4.isReady = false + + let previousRequestCount = subscriptionBroker.requestSignalSpy.count + + //We should have 18 requests in 500ms. Adding an error margin of 100ms => 600ms total + tryVerify(() => subscriptionBroker.requestSignalSpy.count > previousRequestCount + 17/*fuzzy compare. Exact number should be 18*/, 600, "request signal should be emitted more than 14 times. Actual: " + subscriptionBroker.requestSignalSpy.count) + + previousRequestCount = subscriptionBroker.requestSignalSpy.count + + //Leave just one subscription and verify the request count is reduced + subscription3.isReady = false + subscription2.isReady = false + + //We should have 10 requests in 500ms. Adding an error margin of 100ms => 600ms total + tryVerify(() => subscriptionBroker.requestSignalSpy.count > previousRequestCount + 9 /*fuzzy compare. Exact number should be 10*/, 600, "request signal should be emitted more than 8 times. Actual: " + subscriptionBroker.requestSignalSpy.count) + } + + //Testing how the subscription broker handles the topic changes + function test_topic_changes() { + const subscriptionBroker = createTemporaryObject(subscriptionBrokerComponent, testCase) + const subscription1 = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic1", isReady: true, notificationInterval: 50}) //10 requests in 500ms + const subscription2 = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic2", isReady: true, notificationInterval: 90}) //5 requests in 500ms + const subscription3 = createTemporaryObject(subscriptionComponent, testCase, {topic: "topic3", isReady: true, notificationInterval: 130}) //3 requests in 500ms + + subscriptionBroker.active = true + subscriptionBroker.subscribe(subscription1) + subscriptionBroker.subscribe(subscription2) + subscriptionBroker.subscribe(subscription3) + + compare(subscriptionBroker.subscribedSignalSpy.count, 3, "subscribed signal should be emitted") + + subscription1.topic = "topic1Changed" + compare(subscriptionBroker.requestSignalSpy.count, 4, "request signal should be emitted after changing the topic") + } +} + diff --git a/ui/app/AppLayouts/Communities/helpers/AirdropFeesSubscriber.qml b/ui/app/AppLayouts/Communities/helpers/AirdropFeesSubscriber.qml index fc801a5f73..9426e22b46 100644 --- a/ui/app/AppLayouts/Communities/helpers/AirdropFeesSubscriber.qml +++ b/ui/app/AppLayouts/Communities/helpers/AirdropFeesSubscriber.qml @@ -67,4 +67,4 @@ QtObject { } }) } -} \ No newline at end of file +} diff --git a/ui/app/AppLayouts/Communities/helpers/BurnTokenFeesSubscriber.qml b/ui/app/AppLayouts/Communities/helpers/BurnTokenFeesSubscriber.qml new file mode 100644 index 0000000000..30aff018df --- /dev/null +++ b/ui/app/AppLayouts/Communities/helpers/BurnTokenFeesSubscriber.qml @@ -0,0 +1,15 @@ +import QtQuick 2.15 +/*! + \qmltype BurnTokenFeesSubscriber + \inherits QtObject + \brief Helper object that holds the subscriber properties and the published properties for the fee computation. +*/ + +SingleFeeSubscriber { + id: root + + required property string tokenKey + required property string amount + required property string accountAddress + required property bool enabled +} diff --git a/ui/app/AppLayouts/Communities/helpers/DeployFeesSubscriber.qml b/ui/app/AppLayouts/Communities/helpers/DeployFeesSubscriber.qml new file mode 100644 index 0000000000..4c01998818 --- /dev/null +++ b/ui/app/AppLayouts/Communities/helpers/DeployFeesSubscriber.qml @@ -0,0 +1,16 @@ +import QtQuick 2.15 +/*! + \qmltype DeployFeesSubscriber + \inherits QtObject + \brief Helper object that holds the subscriber properties and the published properties for the fee computation. +*/ + +SingleFeeSubscriber { + id: root + + required property int chainId + required property int tokenType + required property bool isOwnerDeployment + required property string accountAddress + required property bool enabled +} diff --git a/ui/app/AppLayouts/Communities/helpers/SelfDestructFeesSubscriber.qml b/ui/app/AppLayouts/Communities/helpers/SelfDestructFeesSubscriber.qml new file mode 100644 index 0000000000..00f9ec4230 --- /dev/null +++ b/ui/app/AppLayouts/Communities/helpers/SelfDestructFeesSubscriber.qml @@ -0,0 +1,24 @@ +import QtQuick 2.15 +/*! + \qmltype SelfDestructFeesSubscriber + \inherits QtObject + \brief Helper object that holds the subscriber properties and the published properties for the fee computation. +*/ + +SingleFeeSubscriber { + id: root + + required property string tokenKey + /** + * walletsAndAmounts - array of following structure is expected: + * [ + * { + * walletAddress: string + * amount: int + * } + * ] + */ + required property var walletsAndAmounts + required property string accountAddress + required property bool enabled +} diff --git a/ui/app/AppLayouts/Communities/helpers/SingleFeeSubscriber.qml b/ui/app/AppLayouts/Communities/helpers/SingleFeeSubscriber.qml new file mode 100644 index 0000000000..1544534784 --- /dev/null +++ b/ui/app/AppLayouts/Communities/helpers/SingleFeeSubscriber.qml @@ -0,0 +1,39 @@ +import QtQuick 2.15 + +import StatusQ.Core 0.1 +import utils 1.0 + +/*! + \qmltype SingleFeeSubscriber + \inherits QtObject + \brief Helper object that parses fees response and provides fee text and error text for single fee respnse +*/ + + QtObject { + id: root + // Published properties + property var feesResponse + + // Internal properties based on response + readonly property string feeText: { + if (!feesResponse || !Object.values(feesResponse.ethCurrency).length || !Object.values(feesResponse.fiatCurrency).length) return "" + + if (feesResponse.errorCode !== Constants.ComputeFeeErrorCode.Success && feesResponse.errorCode !== Constants.ComputeFeeErrorCode.Balance) + return "" + + return LocaleUtils.currencyAmountToLocaleString(feesResponse.ethCurrency) + + " (" + LocaleUtils.currencyAmountToLocaleString(feesResponse.fiatCurrency) + ")" + } + readonly property string feeErrorText: { + if (!feesResponse) return "" + if (feesResponse.errorCode === Constants.ComputeFeeErrorCode.Success) return "" + + if (feesResponse.errorCode === Constants.ComputeFeeErrorCode.Balance) + return qsTr("Not enough funds to make transaction") + + if (feesResponse.errorCode === Constants.ComputeFeeErrorCode.Infura) + return qsTr("Infura error") + + return qsTr("Unknown error") + } +} diff --git a/ui/app/AppLayouts/Communities/helpers/TransactionFeesBroker.qml b/ui/app/AppLayouts/Communities/helpers/TransactionFeesBroker.qml new file mode 100644 index 0000000000..ef8bab7e03 --- /dev/null +++ b/ui/app/AppLayouts/Communities/helpers/TransactionFeesBroker.qml @@ -0,0 +1,190 @@ +import QtQuick 2.15 + +import shared.stores 1.0 +import utils 1.0 + +import StatusQ.Core.Utils 0.1 + +QtObject { + id: root + + enum FeeType { + Airdrop, + Deploy, + SelfDestruct, + Burn + } + + property CommunityTokensStore communityTokensStore + + property QtObject d: QtObject { + id: internal + + component AirdropFeeSubscription: Subscription { + required property AirdropFeesSubscriber subscriber + readonly property var requestArgs: ({ + type: TransactionFeesBroker.FeeType.Airdrop, + communityId: subscriber.communityId, + contractKeysAndAmounts: subscriber.contractKeysAndAmounts, + addressesToAirdrop: subscriber.addressesToAirdrop, + feeAccountAddress: subscriber.feeAccountAddress + }) + + isReady: !!subscriber.communityId && + !!subscriber.contractKeysAndAmounts && + !!subscriber.addressesToAirdrop && + !!subscriber.feeAccountAddress && + subscriber.contractKeysAndAmounts.length && + subscriber.addressesToAirdrop.length && + subscriber.enabled + + topic: isReady ? JSON.stringify(requestArgs) : "" + onResponseChanged: subscriber.airdropFeesResponse = response + } + + component DeployFeeSubscription: Subscription { + required property DeployFeesSubscriber subscriber + readonly property var requestArgs: ({ + type: TransactionFeesBroker.FeeType.Deploy, + chainId: subscriber.chainId, + accountAddress: subscriber.accountAddress, + tokenType: subscriber.tokenType, + isOwnerDeployment: subscriber.isOwnerDeployment + }) + + isReady: !!subscriber.chainId && + !!subscriber.accountAddress && + !!subscriber.tokenType && + subscriber.enabled + + topic: isReady ? JSON.stringify(requestArgs) : "" + onResponseChanged: subscriber.feesResponse = response + } + + component SelfDestructFeeSubscription: Subscription { + required property SelfDestructFeesSubscriber subscriber + readonly property var requestArgs: ({ + type: TransactionFeesBroker.FeeType.SelfDestruct, + walletsAndAmounts:subscriber.walletsAndAmounts, + tokenKey: subscriber.tokenKey, + accountAddress: subscriber.accountAddress, + }) + isReady: !!subscriber.walletsAndAmounts && + !!subscriber.tokenKey && + !!subscriber.accountAddress && + subscriber.walletsAndAmounts.length && + subscriber.enabled + + topic: isReady ? JSON.stringify(requestArgs) : "" + onResponseChanged: subscriber.feesResponse = response + } + + component BurnTokenFeeSubscription: Subscription { + required property BurnTokenFeesSubscriber subscriber + readonly property var requestArgs: ({ + type: TransactionFeesBroker.FeeType.Burn, + tokenKey: subscriber.tokenKey, + amount: subscriber.amount, + accountAddress: subscriber.accountAddress + }) + isReady: !!subscriber.tokenKey && + !!subscriber.amount && + !!subscriber.accountAddress && + subscriber.enabled + + topic: isReady ? JSON.stringify(requestArgs) : "" + onResponseChanged: subscriber.feesResponse = response + } + + readonly property Component airdropFeeSubscriptionComponent: AirdropFeeSubscription {} + readonly property Component deployFeeSubscriptionComponent: DeployFeeSubscription {} + readonly property Component selfDestructFeeSubscriptionComponent: SelfDestructFeeSubscription {} + readonly property Component burnFeeSubscriptionComponent: BurnTokenFeeSubscription {} + + readonly property SubscriptionBroker feesBroker: SubscriptionBroker { + active: Global.applicationWindow.active + onRequest: internal.computeFee(topic) + } + + property Connections communityTokensStoreConnections: Connections { + target: communityTokensStore + + function onDeployFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) { + d.feesBroker.response(responseId, { ethCurrency: ethCurrency, fiatCurrency: fiatCurrency, errorCode: errorCode }) + } + function onAirdropFeeUpdated(response) { + d.feesBroker.response(response.requestId, response) + } + + function onSelfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) { + d.feesBroker.response(responseId, { ethCurrency: ethCurrency, fiatCurrency: fiatCurrency, errorCode: errorCode }) + } + + function onBurnFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) { + d.feesBroker.response(responseId, { ethCurrency: ethCurrency, fiatCurrency: fiatCurrency, errorCode: errorCode }) + } + } + + function computeFee(topic) { + const args = JSON.parse(topic) + switch (args.type) { + case TransactionFeesBroker.FeeType.Airdrop: + computeAirdropFee(args, topic) + break + case TransactionFeesBroker.FeeType.Deploy: + computeDeployFee(args, topic) + break + case TransactionFeesBroker.FeeType.SelfDestruct: + computeSelfDestructFee(args, topic) + break + case TransactionFeesBroker.FeeType.Burn: + computeBurnFee(args, topic) + break + default: + console.error("Unknown fee type: " + args.type) + } + } + + function computeAirdropFee(args, topic) { + communityTokensStore.computeAirdropFee( + args.communityId, + args.contractKeysAndAmounts, + args.addressesToAirdrop, + args.feeAccountAddress, + topic) + } + + function computeDeployFee(args, topic) { + communityTokensStore.computeDeployFee(args.chainId, args.accountAddress, args.tokenType, args.isOwnerDeployment, topic) + } + + function computeSelfDestructFee(args, topic) { + communityTokensStore.computeSelfDestructFee(args.walletsAndAmounts, args.tokenKey, args.accountAddress, topic) + } + + function computeBurnFee(args, topic) { + console.assert(typeof args.amount === "string") + communityTokensStore.computeBurnFee(args.tokenKey, args.amount, args.accountAddress, topic) + } + } + + function registerAirdropFeesSubscriber(subscriberObj) { + const subscription = d.airdropFeeSubscriptionComponent.createObject(subscriberObj, { subscriber: subscriberObj }) + d.feesBroker.subscribe(subscription) + } + + function registerDeployFeesSubscriber(subscriberObj) { + const subscription = d.deployFeeSubscriptionComponent.createObject(subscriberObj, { subscriber: subscriberObj }) + d.feesBroker.subscribe(subscription) + } + + function registerSelfDestructFeesSubscriber(subscriberObj) { + const subscription = d.selfDestructFeeSubscriptionComponent.createObject(subscriberObj, { subscriber: subscriberObj }) + d.feesBroker.subscribe(subscription) + } + + function registerBurnFeesSubscriber(subscriberObj) { + const subscription = d.burnFeeSubscriptionComponent.createObject(subscriberObj, { subscriber: subscriberObj }) + d.feesBroker.subscribe(subscription) + } +} diff --git a/ui/app/AppLayouts/Communities/helpers/qmldir b/ui/app/AppLayouts/Communities/helpers/qmldir index 676d10b094..0ecc5bdebd 100644 --- a/ui/app/AppLayouts/Communities/helpers/qmldir +++ b/ui/app/AppLayouts/Communities/helpers/qmldir @@ -1,2 +1,8 @@ singleton PermissionsHelpers 1.0 PermissionsHelpers.qml +AirdropFeesSubscriber 1.0 AirdropFeesSubscriber.qml +BurnTokenFeesSubscriber 1.0 BurnTokenFeesSubscriber.qml +DeployFeesSubscriber 1.0 DeployFeesSubscriber.qml +SelfDestructFeesSubscriber 1.0 SelfDestructFeesSubscriber.qml +SingleFeeSubscriber 1.0 SingleFeeSubscriber.qml TokenObject 1.0 TokenObject.qml +TransactionFeesBroker 1.0 TransactionFeesBroker.qml diff --git a/ui/app/AppLayouts/Communities/panels/AirdropsSettingsPanel.qml b/ui/app/AppLayouts/Communities/panels/AirdropsSettingsPanel.qml index 7bd83868c8..0713b59595 100644 --- a/ui/app/AppLayouts/Communities/panels/AirdropsSettingsPanel.qml +++ b/ui/app/AppLayouts/Communities/panels/AirdropsSettingsPanel.qml @@ -20,7 +20,6 @@ StackView { required property bool isTokenMasterOwner required property bool isAdmin readonly property bool isPrivilegedTokenOwnerProfile: root.isOwner || root.isTokenMasterOwner - readonly property alias airdropFeesSubscriber: d.aidropFeeSubscriber // Owner and TMaster token related properties: readonly property bool arePrivilegedTokensDeployed: root.isOwnerTokenDeployed && root.isTMasterTokenDeployed @@ -38,8 +37,8 @@ StackView { property string previousPageName: depth > 1 ? qsTr("Airdrops") : "" signal airdropClicked(var airdropTokens, var addresses, string feeAccountAddress) - signal airdropFeesRequested(var contractKeysAndAmounts, var addresses, string feeAccountAddress) signal navigateToMintTokenSettings(bool isAssetType) + signal registerAirdropFeeSubscriber(var feeSubscriber) function navigateBack() { pop(StackView.Immediate) @@ -132,7 +131,6 @@ StackView { Component.onCompleted: { d.selectToken.connect(view.selectToken) d.addAddresses.connect(view.addAddresses) - d.aidropFeeSubscriber = feesSubscriber } AirdropFeesSubscriber { @@ -142,6 +140,7 @@ StackView { contractKeysAndAmounts: view.selectedContractKeysAndAmounts addressesToAirdrop: view.selectedAddressesToAirdrop feeAccountAddress: view.selectedFeeAccount + Component.onCompleted: root.registerAirdropFeeSubscriber(feesSubscriber) } } } diff --git a/ui/app/AppLayouts/Communities/panels/MintTokensSettingsPanel.qml b/ui/app/AppLayouts/Communities/panels/MintTokensSettingsPanel.qml index 8c7a597637..5d9b82a0d5 100644 --- a/ui/app/AppLayouts/Communities/panels/MintTokensSettingsPanel.qml +++ b/ui/app/AppLayouts/Communities/panels/MintTokensSettingsPanel.qml @@ -50,11 +50,6 @@ StackView { property var tokensModelWallet property var accounts // Expected roles: address, name, color, emoji, walletType - // Transaction related properties: - property string feeText - property string feeErrorText - property bool isFeeLoading: true - // Network related properties: property var layer1Networks property var layer2Networks @@ -67,25 +62,15 @@ StackView { signal kickUserRequested(string contactId) signal banUserRequested(string contactId) - - signal deployFeesRequested(int chainId, string accountAddress, int tokenType) - signal burnFeesRequested(string tokenKey, string amount, string accountAddress) - signal remotelyDestructFeesRequest(var walletsAndAmounts, // { [walletAddress (string), amount (int)] } - string tokenKey, - string accountAddress) signal remotelyDestructCollectibles(var walletsAndAmounts, // { [walletAddress (string), amount (int)] } string tokenKey, string accountAddress) - signal signBurnTransactionOpened(string tokenKey, string amount, string accountAddress) signal burnToken(string tokenKey, string amount, string accountAddress) signal airdropToken(string tokenKey, string amount, int type, var addresses) signal deleteToken(string tokenKey) - - function setFeeLoading() { - root.isFeeLoading = true - root.feeText = "" - root.feeErrorText = "" - } + signal registerDeployFeesSubscriber(var feeSubscriber) + signal registerSelfDestructFeesSubscriber(var feeSubscriber) + signal registerBurnTokenFeesSubscriber(var feeSubscriber) function navigateBack() { pop(StackView.Immediate) @@ -138,7 +123,6 @@ StackView { root.push(ownerTokenEditViewComponent, properties, StackView.Immediate) } - } initialItem: SettingsPage { @@ -243,31 +227,36 @@ StackView { allNetworks: root.allNetworks accounts: root.accounts + feeText: feeSubscriber.feeText + feeErrorText: feeSubscriber.feeErrorText + isFeeLoading: !feeSubscriber.feesResponse + onMintClicked: signMintPopup.open() - - onDeployFeesRequested: root.deployFeesRequested( - ownerToken.chainId, - ownerToken.accountAddress, - Constants.TokenType.ERC721) - - feeText: root.feeText - feeErrorText: root.feeErrorText - isFeeLoading: root.isFeeLoading + + DeployFeesSubscriber { + id: feeSubscriber + chainId: editOwnerTokenView.ownerToken.chainId + tokenType: editOwnerTokenView.ownerToken.type + isOwnerDeployment: editOwnerTokenView.ownerToken.isPrivilegedToken + accountAddress: editOwnerTokenView.ownerToken.accountAddress + enabled: editOwnerTokenView.visible || signMintPopup.visible + Component.onCompleted: root.registerDeployFeesSubscriber(feeSubscriber) + } SignMultiTokenTransactionsPopup { id: signMintPopup title: qsTr("Sign transaction - Mint %1 tokens").arg( editOwnerTokenView.communityName) - totalFeeText: root.isFeeLoading ? - "" : root.feeText - errorText: root.feeErrorText + totalFeeText: editOwnerTokenView.isFeeLoading ? + "" : editOwnerTokenView.feeText + errorText: editOwnerTokenView.feeErrorText accountName: editOwnerTokenView.ownerToken.accountName model: QtObject { readonly property string title: editOwnerTokenView.feeLabel readonly property string feeText: signMintPopup.totalFeeText - readonly property bool error: root.feeErrorText !== "" + readonly property bool error: editOwnerTokenView.feeErrorText !== "" } onSignTransactionClicked: editOwnerTokenView.signMintTransaction() @@ -335,7 +324,7 @@ StackView { validationMode: !newTokenPage.isAssetView ? newTokenPage.validationMode : StatusInput.ValidationMode.OnlyWhenDirty - collectible: newTokenPage.collectible + token: newTokenPage.collectible } CustomEditCommunityTokenView { @@ -345,10 +334,12 @@ StackView { validationMode: newTokenPage.isAssetView ? newTokenPage.validationMode : StatusInput.ValidationMode.OnlyWhenDirty - asset: newTokenPage.asset + token: newTokenPage.asset } component CustomEditCommunityTokenView: EditCommunityTokenView { + id: editView + viewWidth: root.viewWidth layer1Networks: root.layer1Networks layer2Networks: root.layer2Networks @@ -361,28 +352,27 @@ StackView { referenceName: newTokenPage.referenceName referenceSymbol: newTokenPage.referenceSymbol - feeText: root.feeText - feeErrorText: root.feeErrorText - isFeeLoading: root.isFeeLoading + feeText: deployFeeSubscriber.feeText + feeErrorText: deployFeeSubscriber.feeErrorText + isFeeLoading: !deployFeeSubscriber.feesResponse onPreviewClicked: { const properties = { - token: isAssetView ? asset : collectible + token: token } root.push(previewTokenViewComponent, properties, StackView.Immediate) } - onDeployFeesRequested: { - if (isAssetView) - root.deployFeesRequested(asset.chainId, - asset.accountAddress, - Constants.TokenType.ERC20) - else - root.deployFeesRequested(collectible.chainId, - collectible.accountAddress, - Constants.TokenType.ERC721) + DeployFeesSubscriber { + id: deployFeeSubscriber + chainId: editView.token.chainId + tokenType: editView.token.type + isOwnerDeployment: editView.token.isPrivilegedToken + accountAddress: editView.token.accountAddress + enabled: editView.visible + Component.onCompleted: root.registerDeployFeesSubscriber(deployFeeSubscriber) } } } @@ -407,20 +397,14 @@ StackView { viewWidth: root.viewWidth preview: true - feeText: root.feeText - feeErrorText: root.feeErrorText - isFeeLoading: root.isFeeLoading accounts: root.accounts - - onDeployFeesRequested: root.deployFeesRequested( - token.chainId, token.accountAddress, - token.type) + feeText: feeSubscriber.feeText + feeErrorText: feeSubscriber.feeErrorText + isFeeLoading: !feeSubscriber.feesResponse onMintClicked: signMintPopup.open() function signMintTransaction() { - root.setFeeLoading() - if(preview.isAssetView) root.mintAsset(token) else @@ -429,18 +413,28 @@ StackView { root.resetNavigation() } + DeployFeesSubscriber { + id: feeSubscriber + chainId: preview.token.chainId + tokenType: preview.token.type + isOwnerDeployment: preview.token.isPrivilegedToken + accountAddress: preview.token.accountAddress + enabled: preview.visible || signMintPopup.visible + Component.onCompleted: root.registerDeployFeesSubscriber(feeSubscriber) + } + SignMultiTokenTransactionsPopup { id: signMintPopup title: qsTr("Sign transaction - Mint %1 token").arg( preview.token.name) - totalFeeText: root.isFeeLoading ? "" : root.feeText + totalFeeText: preview.isFeeLoading ? "" : preview.feeText accountName: preview.token.accountName model: QtObject { readonly property string title: preview.feeLabel readonly property string feeText: signMintPopup.totalFeeText - readonly property bool error: root.feeErrorText !== "" + readonly property bool error: preview.feeErrorText !== "" } onSignTransactionClicked: preview.signMintTransaction() @@ -452,12 +446,11 @@ StackView { component TokenViewPage: SettingsPage { id: tokenViewPage - readonly property alias token: view.token + property TokenObject token: TokenObject {} readonly property bool deploymentFailed: view.deployState === Constants.ContractTransactionStatus.Failed - property alias tokenOwnersModel: view.tokenOwnersModel - property alias airdropKey: view.airdropKey - + property var tokenOwnersModel + property string airdropKey // Owner and TMaster related props readonly property bool isPrivilegedTokenItem: isOwnerTokenItem || isTMasterTokenItem readonly property bool isOwnerTokenItem: token.privilegesLevel === Constants.TokenPrivilegesLevel.Owner @@ -517,11 +510,12 @@ StackView { contentItem: CommunityTokenView { id: view - property string airdropKey // TO REMOVE: Temporal property until airdrop backend is not ready to use token key instead of symbol + property string airdropKey: tokenViewPage.airdropKey // TO REMOVE: Temporal property until airdrop backend is not ready to use token key instead of symbol viewWidth: root.viewWidth - token: TokenObject {} + token: tokenViewPage.token + tokenOwnersModel: tokenViewPage.tokenOwnersModel onGeneralAirdropRequested: { root.airdropToken(view.airdropKey, @@ -538,7 +532,7 @@ StackView { onRemoteDestructRequested: { if (token.isPrivilegedToken) { tokenMasterActionPopup.openPopup( - TokenMasterActionPopup.ActionType.RemotelyDestruct, name) + TokenMasterActionPopup.ActionType.RemotelyDestruct, name, address) } else { remotelyDestructPopup.open() // TODO: set the address selected in the popup's list @@ -572,16 +566,34 @@ StackView { TokenMasterActionPopup { id: tokenMasterActionPopup + property string address: "" + communityName: root.communityName networkName: view.token.chainName accountsModel: root.accounts + feeText: selfDestructFeesSubscriber.feeText + feeErrorText: selfDestructFeesSubscriber.feeErrorText + isFeeLoading: !selfDestructFeesSubscriber.feesResponse - function openPopup(type, userName) { + function openPopup(type, userName, address) { tokenMasterActionPopup.actionType = type tokenMasterActionPopup.userName = userName + tokenMasterActionPopup.address = address open() } + + SelfDestructFeesSubscriber { + id: selfDestructFeesSubscriber + walletsAndAmounts: [{ + walletAddress: tokenMasterActionPopup.address, + amount: 1 + }] + accountAddress: tokenMasterActionPopup.selectedAccount + tokenKey: view.token.key + enabled: tokenMasterActionPopup.opened + Component.onCompleted: root.registerSelfDestructFeesSubscriber(selfDestructFeesSubscriber) + } } KickBanPopup { @@ -661,27 +673,32 @@ StackView { RemotelyDestructPopup { id: remotelyDestructPopup + property alias feeSubscriber: remotelyDestructFeeSubscriber + collectibleName: view.token.name model: view.tokenOwnersModel || null accounts: root.accounts - chainName: view.token.chainName - - feeText: root.feeText - isFeeLoading: root.isFeeLoading - feeErrorText: root.feeErrorText - - onRemotelyDestructFeesRequested: { - root.remotelyDestructFeesRequest(walletsAndAmounts, - view.token.key, - accountAddress) - } + chainName: view.token.chainName + feeText: remotelyDestructFeeSubscriber.feeText + feeErrorText: remotelyDestructFeeSubscriber.feeErrorText + isFeeLoading: !remotelyDestructFeeSubscriber.feesResponse + onRemotelyDestructClicked: { remotelyDestructPopup.close() footer.accountAddress = accountAddress footer.walletsAndAmounts = walletsAndAmounts alertPopup.open() } + + SelfDestructFeesSubscriber { + id: remotelyDestructFeeSubscriber + walletsAndAmounts: remotelyDestructPopup.selectedWalletsAndAmounts + accountAddress: remotelyDestructPopup.selectedAccount + tokenKey: view.token.key + enabled: remotelyDestructPopup.tokenCount > 0 && accountAddress !== "" && (remotelyDestructPopup.opened || signTransactionPopup.opened) + Component.onCompleted: root.registerSelfDestructFeesSubscriber(remotelyDestructFeeSubscriber) + } } AlertPopup { @@ -702,11 +719,13 @@ StackView { id: signTransactionPopup property bool isRemotelyDestructTransaction + property var feeSubscriber: isRemotelyDestructTransaction + ? remotelyDestructPopup.feeSubscriber + : burnTokensPopup.feeSubscriber + readonly property string tokenKey: tokenViewPage.token.key function signTransaction() { - root.setFeeLoading() - if(signTransactionPopup.isRemotelyDestructTransaction) root.remotelyDestructCollectibles(footer.walletsAndAmounts, tokenKey, footer.accountAddress) @@ -723,37 +742,31 @@ StackView { tokenName: footer.token.name accountName: footer.token.accountName networkName: footer.token.chainName - feeText: root.feeText - isFeeLoading: root.isFeeLoading - errorText: root.feeErrorText + feeText: feeSubscriber.feeText + isFeeLoading: feeSubscriber.feeText === "" && feeSubscriber.feeErrorText === "" + errorText: feeSubscriber.feeErrorText - onOpened: { - root.setFeeLoading() - - signTransactionPopup.isRemotelyDestructTransaction - ? root.remotelyDestructFeesRequest(footer.walletsAndAmounts, tokenKey, footer.accountAddress) - : root.signBurnTransactionOpened(tokenKey, footer.burnAmount, footer.accountAddress) - } onSignTransactionClicked: signTransaction() } BurnTokensPopup { id: burnTokensPopup - + + property alias feeSubscriber: burnTokensFeeSubscriber communityName: root.communityName tokenName: footer.token.name remainingTokens: footer.token.remainingTokens multiplierIndex: footer.token.multiplierIndex tokenSource: footer.token.artworkSource chainName: footer.token.chainName + + onAmountToBurnChanged: burnTokensFeeSubscriber.updateAmount() - feeText: root.feeText - feeErrorText: root.feeErrorText - isFeeLoading: root.isFeeLoading + feeText: burnTokensFeeSubscriber.feeText + feeErrorText: burnTokensFeeSubscriber.feeErrorText + isFeeLoading: burnTokensFeeSubscriber.feeText === "" && burnTokensFeeSubscriber.feeErrorText === "" accounts: root.accounts - onBurnFeesRequested: root.burnFeesRequested(footer.token.key, burnAmount, accountAddress) - onBurnClicked: { burnTokensPopup.close() footer.burnAmount = burnAmount @@ -761,13 +774,25 @@ StackView { signTransactionPopup.isRemotelyDestructTransaction = false signTransactionPopup.open() } + + BurnTokenFeesSubscriber { + id: burnTokensFeeSubscriber + readonly property var updateAmount: Backpressure.debounce(burnTokensFeeSubscriber, 500, () => { + burnTokensFeeSubscriber.amount = burnTokensPopup.amountToBurn + }) + amount: "" + tokenKey: tokenViewPage.token.key + accountAddress: burnTokensPopup.selectedAccountAddress + enabled: burnTokensPopup.visible || signTransactionPopup.visible + Component.onCompleted: root.registerBurnTokenFeesSubscriber(burnTokensFeeSubscriber) + } } } AlertPopup { id: deleteTokenAlertPopup - readonly property alias tokenName: view.token.name + readonly property string tokenName: view.token.name width: 521 title: qsTr("Delete %1").arg(tokenName) @@ -799,6 +824,7 @@ StackView { } delegate: TokenViewPage { + required property var model implicitWidth: 0 anchors.fill: parent diff --git a/ui/app/AppLayouts/Communities/popups/BurnTokensPopup.qml b/ui/app/AppLayouts/Communities/popups/BurnTokensPopup.qml index 1e27c73a51..b888a3f478 100644 --- a/ui/app/AppLayouts/Communities/popups/BurnTokensPopup.qml +++ b/ui/app/AppLayouts/Communities/popups/BurnTokensPopup.qml @@ -27,6 +27,9 @@ StatusDialog { property url tokenSource property string chainName + readonly property alias amountToBurn: d.amountToBurn + readonly property alias selectedAccountAddress: d.accountAddress + // Fees related properties: property string feeText property string feeErrorText: "" @@ -38,7 +41,6 @@ StatusDialog { signal burnClicked(string burnAmount, string accountAddress) signal cancelClicked - signal burnFeesRequested(string burnAmount, string accountAddress) QtObject { id: d @@ -51,6 +53,8 @@ StatusDialog { LocaleUtils.numberToLocaleString(remainingTokensFloat) property string accountAddress + property string amountToBurn: !isFormValid ? "" : + specificAmountButton.checked ? amountInput.amount : root.remainingTokens readonly property bool isFeeError: root.feeErrorText !== "" @@ -119,7 +123,7 @@ StatusDialog { font.pixelSize: Style.current.primaryTextFontSize ButtonGroup.group: radioGroup - onToggled: if(checked) amountToBurnInput.forceActiveFocus() + onToggled: if(checked) amountInput.forceActiveFocus() } AmountInput { @@ -164,18 +168,6 @@ StatusDialog { FeesBox { id: feesBox - - readonly property bool triggerFeeReevaluation: { - specificAmountButton.checked - amountInput.amount - feesBox.accountsSelector.currentIndex - - if (root.opened) - requestFeeDelayTimer.restart() - - return true - } - Layout.fillWidth: true placeholderText: qsTr("Choose number of tokens to burn to see gas fees") @@ -195,24 +187,6 @@ StatusDialog { d.accountAddress = item.address } - Timer { - id: requestFeeDelayTimer - - interval: 500 - onTriggered: { - if (specificAmountButton.checked) { - if (!amountInput.valid) - return - - root.burnFeesRequested(amountInput.amount, - d.accountAddress) - } else { - root.burnFeesRequested(root.remainingTokens, - d.accountAddress) - } - } - } - QtObject { id: singleFeeModel diff --git a/ui/app/AppLayouts/Communities/popups/RemotelyDestructPopup.qml b/ui/app/AppLayouts/Communities/popups/RemotelyDestructPopup.qml index aa6ee8cd38..1000a54e26 100644 --- a/ui/app/AppLayouts/Communities/popups/RemotelyDestructPopup.qml +++ b/ui/app/AppLayouts/Communities/popups/RemotelyDestructPopup.qml @@ -27,11 +27,17 @@ StatusDialog { property string feeLabel: qsTr("Remotely destruct %1 token on %2").arg(root.collectibleName).arg(root.chainName) readonly property alias tokenCount: d.tokenCount + readonly property string selectedAccount: d.accountAddress + readonly property var selectedWalletsAndAmounts: { + //depedency + d.tokenCount + return ModelUtils.modelToArray(d.walletsAndAmountsList) + } // Account expected roles: address, name, color, emoji, walletType property var accounts + signal remotelyDestructClicked(var walletsAndAmounts, string accountAddress) - signal remotelyDestructFeesRequested(var walletsAndAmounts, string accountAddress) QtObject { id: d @@ -74,17 +80,6 @@ StatusDialog { d.walletsAndAmountsList, "amount") const sum = amounts.reduce((a, b) => a + b, 0) d.tokenCount = sum - - if (sum > 0) - sendFeeRequest() - } - - function sendFeeRequest() { - const walletsAndAmounts = ModelUtils.modelToArray( - d.walletsAndAmountsList) - - root.remotelyDestructFeesRequested(walletsAndAmounts, - d.accountAddress) } } @@ -126,10 +121,6 @@ StatusDialog { const item = ModelUtils.get(accountsSelector.model, accountsSelector.currentIndex) d.accountAddress = item.address - - // Whenever a change in the form happens, new fee calculation: - if (d.tokenCount > 0) - d.sendFeeRequest() } QtObject { diff --git a/ui/app/AppLayouts/Communities/popups/TokenMasterActionPopup.qml b/ui/app/AppLayouts/Communities/popups/TokenMasterActionPopup.qml index ddf66b912a..ca299800ba 100644 --- a/ui/app/AppLayouts/Communities/popups/TokenMasterActionPopup.qml +++ b/ui/app/AppLayouts/Communities/popups/TokenMasterActionPopup.qml @@ -7,6 +7,7 @@ import StatusQ.Core 0.1 import StatusQ.Controls 0.1 import StatusQ.Popups.Dialog 0.1 import StatusQ.Core.Theme 0.1 +import StatusQ.Core.Utils 0.1 import AppLayouts.Communities.panels 1.0 @@ -36,11 +37,14 @@ StatusDialog { property string communityName property string userName property string networkName - + property string feeText property string feeErrorText property bool isFeeLoading + + property var accountsModel + readonly property alias selectedAccount: d.accountAddress readonly property string feeLabel: qsTr("Remotely destruct 1 TokenMaster token on %1").arg( root.networkName) @@ -140,9 +144,18 @@ StatusDialog { readonly property string title: root.feeLabel readonly property string feeText: root.isFeeLoading ? - "" : root.feeText + "" : root.feeText readonly property bool error: root.feeErrorText !== "" } + + accountsSelector.onCurrentIndexChanged: { + if (accountsSelector.currentIndex < 0) + return + + const item = ModelUtils.get(accountsSelector.model, + accountsSelector.currentIndex) + d.accountAddress = item.address + } } } @@ -156,7 +169,6 @@ StatusDialog { } StatusButton { enabled: !root.isFeeLoading && root.feeErrorText === "" - && root.feeText !== "" text: { if (root.actionType === TokenMasterActionPopup.ActionType.Ban) return qsTr("Ban %1 and remotely destruct 1 token").arg(root.userName) @@ -179,4 +191,10 @@ StatusDialog { } } } + + QtObject { + id: d + + property string accountAddress: "" + } } diff --git a/ui/app/AppLayouts/Communities/views/CommunitySettingsView.qml b/ui/app/AppLayouts/Communities/views/CommunitySettingsView.qml index 7f3026a0e4..f5b08dc322 100644 --- a/ui/app/AppLayouts/Communities/views/CommunitySettingsView.qml +++ b/ui/app/AppLayouts/Communities/views/CommunitySettingsView.qml @@ -309,29 +309,6 @@ StatusSectionLayout { readonly property CommunityTokensStore communityTokensStore: rootStore.communityTokensStore - function setFeesInfo(ethCurrency, fiatCurrency, errorCode) { - if (errorCode === Constants.ComputeFeeErrorCode.Success - || errorCode === Constants.ComputeFeeErrorCode.Balance) { - - const valueStr = LocaleUtils.currencyAmountToLocaleString(ethCurrency) - + "(" + LocaleUtils.currencyAmountToLocaleString(fiatCurrency) + ")" - mintPanel.feeText = valueStr - - if (errorCode === Constants.ComputeFeeErrorCode.Balance) - mintPanel.feeErrorText = qsTr("Not enough funds to make transaction") - - mintPanel.isFeeLoading = false - - return - } else if (errorCode === Constants.ComputeFeeErrorCode.Infura) { - mintPanel.feeErrorText = qsTr("Infura error") - mintPanel.isFeeLoading = true - return - } - mintPanel.feeErrorText = qsTr("Unknown error") - mintPanel.isFeeLoading = true - } - // General community props communityName: root.community.name communityLogo: root.community.image @@ -356,22 +333,11 @@ StatusSectionLayout { allNetworks: communityTokensStore.allNetworks accounts: root.walletAccountsModel - onDeployFeesRequested: { - feeText = "" - feeErrorText = "" - isFeeLoading = true + onRegisterDeployFeesSubscriber: d.feesBroker.registerDeployFeesSubscriber(feeSubscriber) - communityTokensStore.computeDeployFee( - chainId, accountAddress, tokenType, !(mintPanel.isOwnerTokenDeployed && mintPanel.isTMasterTokenDeployed)) - } + onRegisterSelfDestructFeesSubscriber: d.feesBroker.registerSelfDestructFeesSubscriber(feeSubscriber) - onBurnFeesRequested: { - feeText = "" - feeErrorText = "" - isFeeLoading = true - - communityTokensStore.computeBurnFee(tokenKey, amount, accountAddress) - } + onRegisterBurnTokenFeesSubscriber: d.feesBroker.registerBurnFeesSubscriber(feeSubscriber) onMintCollectible: communityTokensStore.deployCollectible( @@ -384,17 +350,10 @@ StatusSectionLayout { communityTokensStore.deployOwnerToken( root.community.id, ownerToken, tMasterToken) - onRemotelyDestructFeesRequest: - communityTokensStore.computeSelfDestructFee( - walletsAndAmounts, tokenKey, accountAddress) - onRemotelyDestructCollectibles: communityTokensStore.remoteSelfDestructCollectibles( root.community.id, walletsAndAmounts, tokenKey, accountAddress) - onSignBurnTransactionOpened: - communityTokensStore.computeBurnFee(tokenKey, amount, accountAddress) - onBurnToken: communityTokensStore.burnToken(root.community.id, tokenKey, amount, accountAddress) @@ -524,20 +483,16 @@ StatusSectionLayout { membersModel: community.members accountsModel: root.walletAccountsModel - onAirdropClicked: communityTokensStore.airdrop( - root.community.id, airdropTokens, addresses, - feeAccountAddress) - + root.community.id, airdropTokens, addresses, + feeAccountAddress) + onNavigateToMintTokenSettings: { root.goTo(Constants.CommunitySettingsSections.MintTokens) mintPanel.openNewTokenForm(isAssetType) } - onAirdropFeesRequested: - communityTokensStore.computeAirdropFee( - root.community.id, contractKeysAndAmounts, addresses, - feeAccountAddress) + onRegisterAirdropFeeSubscriber: d.feesBroker.registerAirdropFeesSubscriber(feeSubscriber) } } @@ -556,6 +511,10 @@ StatusSectionLayout { readonly property bool tokenMaster: root.community.memberRole === Constants.memberRole.tokenMaster } + readonly property TransactionFeesBroker feesBroker: TransactionFeesBroker { + communityTokensStore: root.rootStore.communityTokensStore + } + function goTo(section: int, subSection: int) { const stackContent = stackLayout.children @@ -705,22 +664,6 @@ StatusSectionLayout { Connections { target: rootStore.communityTokensStore - function onDeployFeeUpdated(ethCurrency, fiatCurrency, errorCode) { - mintPanel.setFeesInfo(ethCurrency, fiatCurrency, errorCode) - } - - function onSelfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode) { - mintPanel.setFeesInfo(ethCurrency, fiatCurrency, errorCode) - } - - function onBurnFeeUpdated(ethCurrency, fiatCurrency, errorCode) { - mintPanel.setFeesInfo(ethCurrency, fiatCurrency, errorCode) - } - - function onAirdropFeeUpdated(airdropFees) { - airdropPanel.airdropFees = airdropFees - } - function onRemoteDestructStateChanged(communityId, tokenName, status, url) { if (root.community.id !== communityId) return diff --git a/ui/app/AppLayouts/Communities/views/CommunityTokenView.qml b/ui/app/AppLayouts/Communities/views/CommunityTokenView.qml index 56c3fe89f4..d635c40c70 100644 --- a/ui/app/AppLayouts/Communities/views/CommunityTokenView.qml +++ b/ui/app/AppLayouts/Communities/views/CommunityTokenView.qml @@ -24,6 +24,10 @@ StatusScrollView { // https://bugreports.qt.io/browse/QTBUG-84269 /* required */ property TokenObject token + /* required */ property string feeText + /* required */ property string feeErrorText + /* required */ property bool isFeeLoading + readonly property bool isAssetView: token.type === Constants.TokenType.ERC20 @@ -52,16 +56,12 @@ StatusScrollView { readonly property string feeLabel: isAssetView ? qsTr("Mint asset on %1").arg(token.chainName) : qsTr("Mint collectible on %1").arg(token.chainName) - + // Models: property var tokenOwnersModel // Required for preview mode: property var accounts - property string feeText - property string feeErrorText - property bool isFeeLoading - signal mintClicked() signal airdropRequested(string address) @@ -74,8 +74,6 @@ StatusScrollView { signal kickRequested(string name, string contactId) signal banRequested(string name, string contactId) - signal deployFeesRequested - QtObject { id: d @@ -179,8 +177,6 @@ StatusScrollView { accountsSelector.currentIndex) token.accountAddress = item.address token.accountName = item.name - - root.deployFeesRequested() }) } } diff --git a/ui/app/AppLayouts/Communities/views/EditAirdropView.qml b/ui/app/AppLayouts/Communities/views/EditAirdropView.qml index 4492f9d3c1..d1b973a688 100644 --- a/ui/app/AppLayouts/Communities/views/EditAirdropView.qml +++ b/ui/app/AppLayouts/Communities/views/EditAirdropView.qml @@ -59,13 +59,18 @@ StatusScrollView { onFeesPerSelectedContractChanged: { feesModel.clear() - feesPerSelectedContract.forEach(entry => { + + let feeSource = feesPerSelectedContract + if(!feeSource || feeSource.length === 0) // if no fees are available, show the placeholder text based on selection + feeSource = ModelUtils.modelToArray(root.selectedHoldingsModel, ["contractUniqueKey"]) + + feeSource.forEach(entry => { feesModel.append({ contractUniqueKey: entry.contractUniqueKey, title: qsTr("Airdrop %1 on %2") .arg(ModelUtils.getByKey(root.selectedHoldingsModel, "contractUniqueKey", entry.contractUniqueKey, "symbol")) .arg(ModelUtils.getByKey(root.selectedHoldingsModel, "contractUniqueKey", entry.contractUniqueKey, "networkText")), - feeText: entry.feeText + feeText: entry.feeText ?? "" }) }) } diff --git a/ui/app/AppLayouts/Communities/views/EditCommunityTokenView.qml b/ui/app/AppLayouts/Communities/views/EditCommunityTokenView.qml index 05b065aaa4..5152e4fd77 100644 --- a/ui/app/AppLayouts/Communities/views/EditCommunityTokenView.qml +++ b/ui/app/AppLayouts/Communities/views/EditCommunityTokenView.qml @@ -24,17 +24,13 @@ StatusScrollView { property int viewWidth: 560 // by design property bool isAssetView: false property int validationMode: StatusInput.ValidationMode.OnlyWhenDirty + property var tokensModel property var tokensModelWallet - - property TokenObject collectible: TokenObject { - type: Constants.TokenType.ERC721 + property TokenObject token: TokenObject { + type: root.isAssetView ? Constants.TokenType.ERC20 : Constants.TokenType.ERC721 } - - property TokenObject asset: TokenObject{ - type: Constants.TokenType.ERC20 - } - + // Used for reference validation when editing a failed deployment property string referenceName: "" property string referenceSymbol: "" @@ -53,12 +49,11 @@ StatusScrollView { property bool isFeeLoading readonly property string feeLabel: - isAssetView ? qsTr("Mint asset on %1").arg(asset.chainName) - : qsTr("Mint collectible on %1").arg(collectible.chainName) + isAssetView ? qsTr("Mint asset on %1").arg(root.token.chainName) + : qsTr("Mint collectible on %1").arg(root.token.chainName) signal chooseArtWork signal previewClicked - signal deployFeesRequested QtObject { id: d @@ -69,7 +64,7 @@ StatusScrollView { && symbolInput.valid && (unlimitedSupplyChecker.checked || (!unlimitedSupplyChecker.checked && parseInt(supplyInput.text) > 0)) && (!root.isAssetView || (root.isAssetView && assetDecimalsInput.valid)) - && !root.isFeeLoading && root.feeErrorText === "" && !requestFeeDelayTimer.running + && deployFeeSubscriber.feeText !== "" && deployFeeSubscriber.feeErrorText === "" readonly property int imageSelectorRectWidth: root.isAssetView ? 128 : 290 @@ -83,10 +78,7 @@ StatusScrollView { contentHeight: mainLayout.height Component.onCompleted: { - if(root.isAssetView) - networkSelector.setChain(asset.chainId) - else - networkSelector.setChain(collectible.chainId) + networkSelector.setChain(root.token.chainId) } ColumnLayout { @@ -106,8 +98,8 @@ StatusScrollView { Layout.fillWidth: true Layout.preferredHeight: d.imageSelectorRectWidth - dataImage: root.isAssetView ? asset.artworkSource : collectible.artworkSource - artworkSource: root.isAssetView ? asset.artworkSource : collectible.artworkSource + dataImage: root.token.artworkSource + artworkSource: root.token.artworkSource editorAnchorLeft: false editorRoundedImage: root.isAssetView uploadTextLabel.uploadText: root.isAssetView ? qsTr("Upload") : qsTr("Drag and Drop or Upload Artwork") @@ -116,25 +108,15 @@ StatusScrollView { editorTitle: root.isAssetView ? qsTr("Asset icon") : qsTr("Collectible artwork") acceptButtonText: root.isAssetView ? qsTr("Upload asset icon") : qsTr("Upload collectible artwork") - onArtworkSourceChanged: { - if(root.isAssetView) - asset.artworkSource = artworkSource - else - collectible.artworkSource = artworkSource - } - onArtworkCropRectChanged: { - if(root.isAssetView) - asset.artworkCropRect = artworkCropRect - else - collectible.artworkCropRect = artworkCropRect - } + onArtworkSourceChanged: root.token.artworkSource = artworkSource + onArtworkCropRectChanged: root.token.artworkCropRect = artworkCropRect } CustomStatusInput { id: nameInput label: qsTr("Name") - text: root.isAssetView ? asset.name : collectible.name + text: root.token.name charLimit: 15 placeholderText: qsTr("Name") validationMode: root.validationMode @@ -144,7 +126,7 @@ StatusScrollView { qsTr("Your token name contains invalid characters (use A-Z and 0-9, hyphens and underscores only)") extraValidator.validate: function (value) { // If minting failed, we can retry same deployment, so same name allowed - const allowRepeatedName = (root.isAssetView ? asset.deployState : collectible.deployState) === Constants.ContractTransactionStatus.Failed + const allowRepeatedName = root.token.deployState === Constants.ContractTransactionStatus.Failed if(allowRepeatedName) if(nameInput.text === root.referenceName) return true @@ -154,19 +136,14 @@ StatusScrollView { } extraValidator.errorMessage: qsTr("You have used this token name before") - onTextChanged: { - if(root.isAssetView) - asset.name = text - else - collectible.name = text - } + onTextChanged: root.token.name = text } CustomStatusInput { id: descriptionInput label: qsTr("Description") - text: root.isAssetView ? asset.description : collectible.description + text: root.token.description charLimit: 280 placeholderText: root.isAssetView ? qsTr("Describe your asset") : qsTr("Describe your collectible") input.multiline: true @@ -179,19 +156,14 @@ StatusScrollView { regexValidator.regularExpression: Constants.regularExpressions.ascii regexValidator.errorMessage: qsTr("Only A-Z, 0-9 and standard punctuation allowed") - onTextChanged: { - if(root.isAssetView) - asset.description = text - else - collectible.description = text - } + onTextChanged: root.token.description } CustomStatusInput { id: symbolInput label: qsTr("Symbol") - text: root.isAssetView ? asset.symbol : collectible.symbol + text: root.token.symbol charLimit: 6 placeholderText: root.isAssetView ? qsTr("e.g. ETH"): qsTr("e.g. DOODLE") validationMode: root.validationMode @@ -201,7 +173,7 @@ StatusScrollView { regexValidator.regularExpression: Constants.regularExpressions.capitalOnly extraValidator.validate: function (value) { // If minting failed, we can retry same deployment, so same symbol allowed - const allowRepeatedName = (root.isAssetView ? asset.deployState : collectible.deployState) === Constants.ContractTransactionStatus.Failed + const allowRepeatedName = root.token.deployState === Constants.ContractTransactionStatus.Failed if(allowRepeatedName) if(symbolInput.text.toUpperCase() === root.referenceSymbol.toUpperCase()) return true @@ -216,10 +188,7 @@ StatusScrollView { onTextChanged: { const cursorPos = input.edit.cursorPosition const upperSymbol = text.toUpperCase() - if(root.isAssetView) - asset.symbol = upperSymbol - else - collectible.symbol = upperSymbol + root.token.symbol = upperSymbol text = upperSymbol // breaking the binding on purpose but so does validate() and onTextChanged() internal handler input.edit.cursorPosition = cursorPos } @@ -237,15 +206,12 @@ StatusScrollView { label: qsTr("Unlimited supply") description: qsTr("Enable to allow the minting of additional tokens in the future. Disable to specify a finite supply") - checked: root.isAssetView ? asset.infiniteSupply : collectible.infiniteSupply + checked: root.token.infiniteSupply onCheckedChanged: { if(!checked) supplyInput.forceActiveFocus() - if(root.isAssetView) - asset.infiniteSupply = checked - else - collectible.infiniteSupply = checked + root.token.infiniteSupply = checked } } @@ -254,12 +220,8 @@ StatusScrollView { visible: !unlimitedSupplyChecker.checked label: qsTr("Total finite supply") - text: { - const token = root.isAssetView ? root.asset : root.collectible - - return SQUtils.AmountsArithmetic.toNumber(token.supply, - token.multiplierIndex) - } + text: SQUtils.AmountsArithmetic.toNumber(root.token.supply, + root.token.multiplierIndex) placeholderText: qsTr("e.g. 300") minLengthValidator.errorMessage: qsTr("Please enter a total finite supply") @@ -274,9 +236,8 @@ StatusScrollView { if (Number.isNaN(supplyNumber) || Object.values(errors).length) return - const token = root.isAssetView ? root.asset : root.collectible token.supply = SQUtils.AmountsArithmetic.fromNumber( - supplyNumber, token.multiplierIndex).toFixed(0) + supplyNumber, root.token.multiplierIndex).toFixed(0) } } @@ -286,9 +247,9 @@ StatusScrollView { visible: !root.isAssetView label: checked ? qsTr("Not transferable (Soulbound)") : qsTr("Transferable") description: qsTr("If enabled, the token is locked to the first address it is sent to and can never be transferred to another address. Useful for tokens that represent Admin permissions") - checked: !collectible.transferable + checked: !root.token.transferable - onCheckedChanged: collectible.transferable = !checked + onCheckedChanged: root.token.transferable = !checked } CustomSwitchRowComponent { @@ -297,8 +258,8 @@ StatusScrollView { visible: !root.isAssetView label: qsTr("Remotely destructible") description: qsTr("Enable to allow you to destroy tokens remotely. Useful for revoking permissions from individuals") - checked: !!collectible ? collectible.remotelyDestruct : true - onCheckedChanged: collectible.remotelyDestruct = checked + checked: !!root.token ? root.token.remotelyDestruct : true + onCheckedChanged: root.token.remotelyDestruct = checked } CustomStatusInput { @@ -309,7 +270,7 @@ StatusScrollView { charLimit: 2 charLimitLabel: qsTr("Max 10") placeholderText: "2" - text: !!asset ? asset.decimals : "" + text: root.token.decimals validationMode: StatusInput.ValidationMode.Always minLengthValidator.errorMessage: qsTr("Please enter how many decimals your token should have") regexValidator.errorMessage: d.hasEmoji(text) ? qsTr("Your decimal amount is too cool (use 0-9 only)") : @@ -317,7 +278,7 @@ StatusScrollView { regexValidator.regularExpression: Constants.regularExpressions.numerical extraValidator.validate: function (value) { return parseInt(value) > 0 && parseInt(value) <= 10 } extraValidator.errorMessage: qsTr("Enter a number between 1 and 10") - onTextChanged: asset.decimals = parseInt(text) + onTextChanged: root.token.decimals = parseInt(text) } FeesBox { @@ -338,44 +299,17 @@ StatusScrollView { readonly property bool error: root.feeErrorText !== "" } - Timer { - id: requestFeeDelayTimer - - interval: 500 - onTriggered: root.deployFeesRequested() - } - - readonly property bool triggerFeeReevaluation: { - dropAreaItem.artworkSource - nameInput.text - descriptionInput.text - symbolInput.text - supplyInput.text - unlimitedSupplyChecker.checked - transferableChecker.checked - remotelyDestructChecker.checked - feesBox.accountsSelector.currentIndex - asset.chainId - collectible.chainId - - requestFeeDelayTimer.restart() - return true - } - accountsSelector.model: root.accounts - readonly property TokenObject token: root.isAssetView ? root.asset - : root.collectible - // account can be changed also on preview page and it should be // reflected in the form after navigating back Connections { - target: feesBox.token + target: root.token function onAccountAddressChanged() { const idx = SQUtils.ModelUtils.indexOf( feesBox.accountsSelector.model, "address", - feesBox.token.accountAddress) + root.token.accountAddress) feesBox.accountsSelector.currentIndex = idx } @@ -387,8 +321,8 @@ StatusScrollView { const item = SQUtils.ModelUtils.get( accountsSelector.model, accountsSelector.currentIndex) - token.accountAddress = item.address - token.accountName = item.name + root.token.accountAddress = item.address + root.token.accountName = item.name } } @@ -511,15 +445,9 @@ StatusScrollView { multiSelection: false onToggleNetwork: (network) => { - if(root.isAssetView) { - asset.chainId = network.chainId - asset.chainName = network.chainName - asset.chainIcon = network.iconUrl - } else { - collectible.chainId = network.chainId - collectible.chainName = network.chainName - collectible.chainIcon = network.iconUrl - } + root.token.chainId = network.chainId + root.token.chainName = network.chainName + root.token.chainIcon = network.iconUrl } } } diff --git a/ui/app/AppLayouts/Communities/views/EditOwnerTokenView.qml b/ui/app/AppLayouts/Communities/views/EditOwnerTokenView.qml index 3cd754a628..be20767f84 100644 --- a/ui/app/AppLayouts/Communities/views/EditOwnerTokenView.qml +++ b/ui/app/AppLayouts/Communities/views/EditOwnerTokenView.qml @@ -69,7 +69,6 @@ StatusScrollView { .arg(communityName).arg(ownerToken.chainName) signal mintClicked - signal deployFeesRequested QtObject { id: d @@ -175,8 +174,6 @@ StatusScrollView { onAddressChanged: { ownerToken.accountAddress = address tMasterToken.accountAddress = address - - requestFeeDelayTimer.restart() } control.onDisplayTextChanged: { ownerToken.accountName = control.displayText @@ -324,16 +321,7 @@ StatusScrollView { tMasterToken.chainId = network.chainId tMasterToken.chainName = network.chainName tMasterToken.chainIcon = network.iconUrl - - requestFeeDelayTimer.restart() } } } - - Timer { - id: requestFeeDelayTimer - - interval: 500 - onTriggered: root.deployFeesRequested() - } } diff --git a/ui/imports/shared/stores/CommunityTokensStore.qml b/ui/imports/shared/stores/CommunityTokensStore.qml index 9b45a0d43f..fa3cefabf4 100644 --- a/ui/imports/shared/stores/CommunityTokensStore.qml +++ b/ui/imports/shared/stores/CommunityTokensStore.qml @@ -12,10 +12,10 @@ QtObject { property var enabledNetworks: networksModule.enabled property var allNetworks: networksModule.all - signal deployFeeUpdated(var ethCurrency, var fiatCurrency, int error) - signal selfDestructFeeUpdated(var ethCurrency, var fiatCurrency, int error) + signal deployFeeUpdated(var ethCurrency, var fiatCurrency, int error, string responseId) + signal selfDestructFeeUpdated(var ethCurrency, var fiatCurrency, int error, string responseId) signal airdropFeeUpdated(var airdropFees) - signal burnFeeUpdated(var ethCurrency, var fiatCurrency, int error) + signal burnFeeUpdated(var ethCurrency, var fiatCurrency, int error, string responseId) signal deploymentStateChanged(string communityId, int status, string url) signal ownerTokenDeploymentStateChanged(string communityId, int status, string url) @@ -63,18 +63,22 @@ QtObject { readonly property Connections connections: Connections { target: communityTokensModuleInst - function onDeployFeeUpdated(ethCurrency, fiatCurrency, errorCode) { - root.deployFeeUpdated(ethCurrency, fiatCurrency, errorCode) + function onDeployFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) { + root.deployFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) } - function onSelfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode) { - root.selfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode) + function onSelfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) { + root.selfDestructFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) } function onAirdropFeesUpdated(jsonFees) { root.airdropFeeUpdated(JSON.parse(jsonFees)) } + function onBurnFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) { + root.burnFeeUpdated(ethCurrency, fiatCurrency, errorCode, responseId) + } + function onDeploymentStateChanged(communityId, status, url) { root.deploymentStateChanged(communityId, status, url) } @@ -98,14 +102,22 @@ QtObject { function onBurnStateChanged(communityId, tokenName, status, url) { root.burnStateChanged(communityId, tokenName, status, url) } - - function onBurnFeeUpdated(ethCurrency, fiatCurrency, errorCode) { - root.burnFeeUpdated(ethCurrency, fiatCurrency, errorCode) - } } - function computeDeployFee(chainId, accountAddress, tokenType, isOwnerDeployment) { - communityTokensModuleInst.computeDeployFee(chainId, accountAddress, tokenType, isOwnerDeployment) + // Burn: + function computeBurnFee(tokenKey, amount, accountAddress, requestId) { + console.assert(typeof amount === "string") + communityTokensModuleInst.computeBurnFee(tokenKey, amount, accountAddress, requestId) + } + + function computeAirdropFee(communityId, contractKeysAndAmounts, addresses, feeAccountAddress, requestId) { + communityTokensModuleInst.computeAirdropFee( + communityId, JSON.stringify(contractKeysAndAmounts), + JSON.stringify(addresses), feeAccountAddress, requestId) + } + + function computeDeployFee(chainId, accountAddress, tokenType, isOwnerDeployment, requestId) { + communityTokensModuleInst.computeDeployFee(chainId, accountAddress, tokenType, isOwnerDeployment, requestId) } /** @@ -117,20 +129,14 @@ QtObject { * } * ] */ - function computeSelfDestructFee(walletsAndAmounts, tokenKey, accountAddress) { - communityTokensModuleInst.computeSelfDestructFee(JSON.stringify(walletsAndAmounts), tokenKey, accountAddress) + function computeSelfDestructFee(walletsAndAmounts, tokenKey, accountAddress, requestId) { + communityTokensModuleInst.computeSelfDestructFee(JSON.stringify(walletsAndAmounts), tokenKey, accountAddress, requestId) } function remoteSelfDestructCollectibles(communityId, walletsAndAmounts, tokenKey, accountAddress) { communityTokensModuleInst.selfDestructCollectibles(communityId, JSON.stringify(walletsAndAmounts), tokenKey, accountAddress) } - // Burn: - function computeBurnFee(tokenKey, amount, accountAddress) { - console.assert(typeof amount === "string") - communityTokensModuleInst.computeBurnFee(tokenKey, amount, accountAddress) - } - function burnToken(communityId, tokenKey, burnAmount, accountAddress) { console.assert(typeof burnAmount === "string") communityTokensModuleInst.burnTokens(communityId, tokenKey, burnAmount, accountAddress) @@ -140,10 +146,4 @@ QtObject { function airdrop(communityId, airdropTokens, addresses, feeAccountAddress) { communityTokensModuleInst.airdropTokens(communityId, JSON.stringify(airdropTokens), JSON.stringify(addresses), feeAccountAddress) } - - function computeAirdropFee(communityId, contractKeysAndAmounts, addresses, feeAccountAddress) { - communityTokensModuleInst.computeAirdropFee( - communityId, JSON.stringify(contractKeysAndAmounts), - JSON.stringify(addresses), feeAccountAddress) - } }