diff --git a/.github/workflows/contract-verify.yml b/.github/workflows/contract-verify.yml index 19b325637..33f9a173f 100644 --- a/.github/workflows/contract-verify.yml +++ b/.github/workflows/contract-verify.yml @@ -39,6 +39,6 @@ jobs: echo "contract-filepaths=$files" >> "$GITHUB_OUTPUT" - name: Contract verify action step id: verify - uses: Franziska-Mueller/qubic-contract-verify@v1.0.4 + uses: Franziska-Mueller/qubic-contract-verify@v1.0.5 with: filepaths: '${{ steps.filepaths.outputs.contract-filepaths }}' diff --git a/README.md b/README.md index 8d3a3bcaa..0a31460cd 100644 --- a/README.md +++ b/README.md @@ -150,5 +150,6 @@ We cannot support you in any case. You are welcome to provide updates, bug fixes - [Qubic protocol](doc/protocol.md) - [Custom mining](doc/custom_mining.md) - [Seamless epoch transition](SEAMLESS.md) +- [Querying oracles](doc/contracts_oracles.md) - [Proposals and voting](doc/contracts_proposals.md) diff --git a/doc/OracleEngine.png b/doc/OracleEngine.png new file mode 100644 index 000000000..f646ace4e Binary files /dev/null and b/doc/OracleEngine.png differ diff --git a/doc/OracleEngine.svg b/doc/OracleEngine.svg new file mode 100644 index 000000000..1add7c0ca --- /dev/null +++ b/doc/OracleEngine.svg @@ -0,0 +1,3 @@ + + +oe.startContractQuery(), oe.startUserQuery(), or oe.generateSubscriptionQueries()oe.startContractQuery(), oe.start...oe.processOracleMachineReply()oe.processOracleMachineReply()OM NodeOM Noderequest processorrequest pr...tick / contract processortick / contract proc...oe.getReplyCommitTransaction()oe.getReplyCommitTransaction()OracleMachineQuery messageOracleMachineQuery messageOracleMachineReply messageOracleMachineReply messagebroadcast reply commit transaction per computorbroadcast reply commit transaction per comput...Other Core NodesOther Core No...processTick()processTick()processTick()processTick()oe.processOracleReplyCommitTransaction():When a quorum of commits is reached, statusis changed from pending to committed and it is time to reveal the oracle reply.oe.processOracleReplyCommitTransaction():...oe.getReplyCommitTransaction()oe.getReplyCommitTransaction()broadcast transaction per computorbroadcast transaction per computorbroadcast reply reveal transactionbroadcast reply reveal transactionOther Core NodesOther Core No...oe.getReplyRevealTransaction()oe.getReplyRevealTransaction()Another node may broadcast a reveal transaction concurrently, but the first reveal received stops the node from sending an own reveal through oe.announceExpectedRevealTransaction()Another node may broadcast a reveal transaction...processTick()processTick()oe.processOracleReplyRevealTransaction():Knowledge proofs are checked and commits may be invalidated, oracle revenue points are granted, status is set to success (or unresolvable if too many commits were invalidated)oe.processOracleReplyRevealTransaction():...oe.getNotification()... returns contract notifications that are run in the contract processor one after another.oe.getNotification()...processTick()processTick()oe.processTimeouts():... checks for timeouts in every tick; updates state to timeout and queues notification if timeout is hitoe.processTimeouts():...oe.getNotification()... returns contract notifications that are run in the contract processor one after another.oe.getNotification()...Oracle Engine's most important functions and interactions for processing queries(oe = oracleEngine)Oracle Engine's most important functions and interactions for processing que... \ No newline at end of file diff --git a/doc/contracts.md b/doc/contracts.md index 52f1e21a8..318d1e932 100644 --- a/doc/contracts.md +++ b/doc/contracts.md @@ -478,6 +478,12 @@ QX never releases shares passively (following call of `qpi.acquireShares()` by a The callbacks `PRE_RELEASE_SHARES()` and `PRE_ACQUIRE_SHARES()` may also check that the `qpi.originator()` initiating the transfer is the owner/possessor. +## Querying off-chain data from Oracles + +Oracles enable Smart Contracts to actively query off-chain data sources called Oracles. +Read [Querying Oracles from Contracts](contracts_oracles.md) for more details. + + ## Other QPI features ### Container types diff --git a/doc/contracts_oracles.md b/doc/contracts_oracles.md new file mode 100644 index 000000000..c886f1b5c --- /dev/null +++ b/doc/contracts_oracles.md @@ -0,0 +1,484 @@ +# Querying oracles from contracts + +Oracles enable Smart Contracts to actively query off-chain data sources via the QPI. +When a query is initiated, the Core nodes, which run the contracts, communicate with the Oracle Machine (OM) nodes, which serve as a bridge between the Core nodes and the Oracles. +Oracles are the sources from which information can be retrieved. +They are usually independent from Qubic. +Oracles provide the queried information to the OM nodes, which send a reply back to the Core nodes. +Afterwards, the reply needs to be confirmed by the Quorum, before it is made available on-chain. + +Queries to Oracles are subject to fees to avoid spam. +Those fees are destroyed (not put into execution reserve as with regular burning of QU). + +The OM nodes are run by computor operators, as the Core nodes. +The participation in replying to Oracle Queries is reflected in an additional component of the computors revenue algorithm, similar to transaction counting. + + +## Query lifecycle and status + +When a query is initiated, the following happens: + +1. The fee is deduced and destroyed (if an error occurs during the start of the query, the fee is reimbursed), +2. A query ID is generated that allows to track the query, +3. The query status is set to `ORACLE_QUERY_STATUS_PENDING`, +4. The query is sent to the Oracle Machine nodes. + +Afterwards, the execution (of contract code and transactions) continues. +That is, the Oracle Machine nodes process the query asynchronously. +They usually request the information from the Oracle, an external information provider, and send the reply back to the core node. +They may also get the Oracle Reply from their cache if it is already available from a prior request. + +After receiving the reply, the core nodes create one reply commit transaction per computor (whereas each transaction may contain reply commits of multiple queries for increasing efficiency). +The commits consist of a digest and a knowledge proof of the Oracle Reply, but do not reveal the actual value of the Oracle Reply yet. +When 451 agreeing reply commits have been processed, the status of the query switches to `ORACLE_QUERY_STATUS_COMMITTED`. +After that, the fastest computors send a reply reveal transaction, which changes the status to `ORACLE_QUERY_STATUS_SUCCESS` and triggers the notification when executed. + +As an incentive for providing fast and correct Oracle Replies, there is an oracle revenue counter for each computor, which influences the overall revenue payed to the computor at the end of the epoch. +Revenue points are granted to the first 451 reply commits (+ the ones in the same tick) with agreeing digest and correct knowledge proof when processing the reply reveal transaction. + +If too many commits disagree in the digest or have wrong knowledge proof, a quorum of reply commits isn't possible anymore. +In this case, the status changes to `ORACLE_QUERY_STATUS_UNRESOLVABLE` and error notification without a valid reply is triggered immediately. + +Each query is associated with a timeout in order to handle the lack of a timely reply, a quorum of commits, and/or a reveal. +When the timeout hits, the status switches to `ORACLE_QUERY_STATUS_TIMEOUT` and error notification is triggered. + +All queries and replies stay available until the end of the epoch via their unique query ID. + + +## Query types + +For contracts, there are two ways of querying Oracles: + +1. One-time queries, the normal way of querying an oracle. Each of these queries is completely independent. +2. Subscriptions, a cheaper and more efficient way to query time-dependent information, such as prices, regularly. A subscription repeatedly sends the same query, only changing the query timestamp. The subscription queries may be shared by multiple contracts automatically. + +Further, a one-time query can also be started by a user through a special transaction. + +Here is an overview of the three types: + +| | One-time contract query | Subscription contract query | One-time user query | +|--|--|--|--| +| Start | QPI call `QUERY_ORACLE` | QPI call `SUBSCRIBE_ORACLE` / scheduler | Special transaction to zero address (`OracleUserQueryTransaction`) | +| Fee | Per query (`QUERY_ORACLE`) | Per subscription (`SUBSCRIBE_ORACLE`), cheaper than many equivalent one-time queries | Per query (`OracleUserQueryTransaction`) | +| Notification | User procedure | User procedure | - | +| Timing | Minimal latency | Potentially synced with other contracts for maximal efficiency | Minimal latency | + + +## Oracle interfaces + +An oracle interface defines the Oracle Machine input and output types: `OracleQuery` and `OracleReply`. +Multiple oracles (sources of information) may share the same input and output structs, making them accessible through the same interface. +Multiple oracle interfaces usually differ in their definition of `OracleQuery` and `OracleReply`, because they provide different types of information and require different parameter for querying the information. + +An example interface is `Price`, providing access to crypto prices of multiple exchanges (oracles). +Future interfaces, that have not been implemented yet may be: `SportEvent`, `YesNoResult`, `IntResult`. +The source code of the oracles interfaces resides in the directory `src/oracle_interfaces/` of the Qubic Core repository, with one header file per oracle interface. + +In the Qubic contracts, oracle interfaces are made available through the namespace `OI`. +For example, you can access the oracle query struct type of the `Price` interface via `OI::Price::OracleQuery`. + + +### Oracle query + +The `OracleQuery` struct of the interface defines the input of the Oracle Machine, specifying what exactly is queried. +For example, the `OracleQuery` struct of the `Price` interface has the following member variables: +- `oracle`: the Oracle to ask for (e.g., binance, mexc, gate.io), +- `timestamp`: the specific date and time to ask the price for, +- `currency1` and `currency2`: the currency pair to get the price for. + +In general, the `OracleQuery` struct must be designed in a way that consecutive queries with the same `OracleQuery` member values always lead the exact same `OracleReply`. +The rationale is that the oracle is queried individually by the computors and a quorum needs to agree on the exact same reply in order to confirm its correctness. +In order to satisfy this requirement, most oracle query structs will require: +- an oracle identifier, exactly specifying the source/provider, where to get the information, +- a timestamp about the exact time in case of information that varies with time, such as prices, +- additional specification which data is queried, such as a pair of currencies for prices or a + location for weather. + +If the oracle interface is supposed to support subscriptions, the `OracleQuery` struct must define a member named `timestamp` of the type `QPI::DateAndTime`. + +The size of `OracleQuery` is restricted by the transaction size. +The limit is available through the constant `MAX_ORACLE_QUERY_SIZE`. + + +### Oracle reply + +The `OracleReply` struct of the interface defines the output of the Oracle Machine, specifying which types of information are provided. + +For example, in the `Price` oracle, the `OracleReply` has the member variables `numerator` and `denominator` giving the exchange rate such that `currency1 = currency2 * numerator / denominator` at `timestamp` as given by the query. + +The size of both the `OracleReply` struct is restricted by the transaction size. +The limit is available through the constant `MAX_ORACLE_REPLY_SIZE`. + + +### Fees + +The interface defines the oracle fees through the static member functions `getQueryFee()` and, optionally, if subscriptions are supported, `getSubscriptionFee()`. +They must be declared as follows: + +```C++ +// mandatory: get fee for regular one-time query +static sint64 getQueryFee(const OracleQuery& query); + +// optional function: get one-time fee for subscription +static sint64 getSubscriptionFee(const OracleQuery& query, uint32 notifyPeriodInMilliseconds); +``` + +The one-time query fee needs to be paid for each call to `QUERY_ORACLE()` and as the amount of each user oracle query transaction. +It may depend on the specific query, for example, on the oracle (source providing the information). + +The subscription fee needs to be paid for each call to `SUBSCRIBE_ORACLE()`, which is usually once per epoch. +It may depend on the specific query and on the notification period, that is, how often the contract wants to get notified with a new value. + +For example, when writing these docs, the fee for a one-time `Price` query is 10 QU. +The fee for a `Price` subscription is 10000 QU for getting an update each minute, which means about 1 QU/query if the subscription is active in the full epoch. +A subscription querying each 2 or 3 minutes costs only 6500 QU. +The fee reduces again and again with a notification intervals of 4 minutes, 8 minutes, and following powers of 2. +While total cost of the subscription reduces, the cost per query increases (by design, because the potential efficiency gain by sharing queries between contracts reduces with the frequency). +See the comments in the source code in `src/oracle_interfaces/Price.h` for more details. + +The source code of the official Qubic Core repository and release should usually tell the current oracle fees. +However, the Quorum decides as usual in Qubic, because the computors running the Qubic Network may change the code, including the fee amounts. + + +### Adding new interfaces + +New oracle interfaces may be added by contract developers as required for their contracts with Pull Requests, see [How to Contribute](contributing.md). + +A new interface should be designed in a generic way, that allows to reuse it in other contracts and applications. +For example, even if only one oracle (information provider) is supported initially, there should be an oracle member in the `OracleQuery` struct for supporting others later. + +We recommend to start developing an interface by copying the file `src/oracle_interfaces/Price.h`, changing the name, and changing the content of the file. +The file must contain an oracle interface struct named the same as the filename stem, e.g., `Price` for `Price.h`. +The oracle interface struct is never instantiated, but its types and static members are available to contracts via `OI::[InterfaceName]`, for example, `OI::Price::OracleQuery` and `OI::Price::getQueryFee(query)`. + +Each oracle interface is internally identified through the `oracleInterfaceIndex`, a number identifying the interface. +It has to be defined as a static member of the interface struct as follows: + +```C++ +static constexpr uint32 oracleInterfaceIndex = ORACLE_INTERFACE_INDEX; +``` + +`ORACLE_INTERFACE_INDEX` is defined in the file `src\oracle_core\oracle_interfaces_def.h`, which includes all interface header files and is discussed later below. + +As mentioned in Sections above, the following structs and functions have to be defined as members of the interface struct: + +```C++ +struct OracleQuery +{ + // add input to OM +}; + +struct OracleReply +{ + // add output of OM +}; + +static sint64 getQueryFee(const OracleQuery& query) +{ + // Return query fee, which may depend on the specific query (for example on the oracle). +} +``` + +If the interface is supposed to support subscriptions, the `OracleQuery` struct must have a member `QPI::DateAndTime timestamp;`. +Further, the interface struct must define the following function: + +```C++ +static sint64 getSubscriptionFee(const OracleQuery& query, uint32 notifyPeriodInMilliseconds) +{ + // Return one-time fee for subscription +} +``` + +Additionally, the interface struct may contain other structs or convenience features for contracts using the oracle interface. + +All code in the interface header file must respect the same [C++ language feature restrictions as contracts](#restrictions-of-c-language-features). +These are checked with the [Qubic Contract Verification Tool](https://github.com/Franziska-Mueller/qubic-contract-verify). + +A new oracle interface has to be added file `src/oracle_core/oracle_interfaces_def.h`. +Search for "add new interface above this line" in this file to see, where to add references to new interface to make it available to contracts and the oracle engine. + +Another mandatory requirement for a new interface is adding a reference implementation of the oracle service counterpart in [Oracle Machine repository](https://github.com/qubic/oracle-machine/). +This repository references the original interface definition from the Qubic Core repository via a git submodule. +For testing before the new interface is merged to the official repository, we recommend to replace the submodule with a reference to your own fork of the Core. + +Please note that structs such as `OracleReply` may have some padding (gaps, unused bytes for better alignment in the memory). +Make sure that `OracleReply` is set to all-0 before setting the member data, so that alignment/padding bytes are initialized with 0 and that no memory content of the OM node is published on-chain. + + +## Notifications + +As mentioned above, `QUERY_ORACLE()` and `SUBSCRIBE_ORACLE()` just start the query and subscription asynchronously and return without a reply. +The contract is notified about success or error through an asynchronous call to a special notification user procedure, usually multiple ticks later (exception are a few error cases that trigger the notification immediately, such as not having enough QU to pay the fee). +Notifications are run by the system after execution of transactions (user procedures) but before `END_TICK` (with 0 invocation reward and originator/invocator `NULL_ID`). + + +### Defining notification procedures + +An oracle notification procedure must have the input type `OracleNotificationInput` and the output type `NoData`. +It may be defined with or without locals. + +For example, a notification procedure for the `Price` oracle may be defined like this: + +```C++ +typedef OracleNotificationInput NotifyPriceOracleReply_input; +typedef NoData NotifyPriceOracleReply_output; +struct NotifyPriceOracleReply_locals +{ + OI::Price::OracleQuery query; +}; + +PRIVATE_PROCEDURE_WITH_LOCALS(NotifyPriceOracleReply) +{ + if (input.status == ORACLE_QUERY_STATUS_SUCCESS) + { + // get and use query info if needed + if (!qpi.getOracleQuery(input.queryId, locals.query)) + return; + + // use example convenience function provided by oracle interface + if (!OI::Price::replyIsValid(input.reply)) + return; + + // process reply ... + } + else + { + // handle failure ... + } +} +``` + +The `OracleNotificationInput` input has the members: + +- `sint64 queryId`: ID of the oracle query that led to this notification (or -1 in case of an early error, before a query ID has been assigned), +- `sint32 subscriptionId`: ID of the oracle subscription or -1 in case of a one-time oracle query, +- `OracleInterface::OracleReply reply`: Oracle reply to query (type `OI::Price::OracleReply` in the example above), only valid if `status` is `ORACLE_QUERY_STATUS_SUCCESS`, +- `uint8 status`: Oracle query status as defined in `src/network_messages/common_def.h`; one of `ORACLE_QUERY_STATUS_SUCCESS`, `ORACLE_QUERY_STATUS_TIMEOUT`, `ORACLE_QUERY_STATUS_UNRESOLVABLE`, or `ORACLE_QUERY_STATUS_UNKNOWN` (in case of an early error, before the query has been started). + +Usually, the notification procedure should be a `PRIVATE_PROCEDURE` or `PRIVATE_PROCEDURE_WITH_LOCALS`. +If you define it as a `PUBLIC_PROCEDURE`, other contracts may invoke it, what you probably want to avoid. + + +### Registering notification procedures + +In order to make the procedure usable as a notification, it must be registered with `REGISTER_USER_PROCEDURE_NOTIFICATION()` in `REGISTER_USER_FUNCTIONS_AND_PROCEDURES`. + +However, it should **NOT** be registered for external access with `REGISTER_USER_PROCEDURE()` as most other user procedures. +Otherwise, if registered with the latter, any user can invoke your notification procedure through a transaction. + +In the example above, `REGISTER_USER_FUNCTIONS_AND_PROCEDURES` may look like this: + +```C++ +REGISTER_USER_FUNCTIONS_AND_PROCEDURES() +{ + REGISTER_USER_PROCEDURE_NOTIFICATION(NotifyPriceOracleReply); + + // REGISTER_USER_PROCEDURE calls, none for NotifyPriceOracleReply !!! + + // REGISTER_USER_FUNCTION calls +} +``` + + +## Query QPI + +### Sending out a query + +As mentioned above, oracles are queried asynchronously, because it takes at least 7 ticks until the reply is committed and revealed. + +A contract can initiate a query using the `QUERY_ORACLE()` macro, which takes the following parameters: + +- `OracleInterface`: Oracle interface struct of the interface to query, e.g., `OI::Price`, +- `query`: Instance of type `OracleInterface::OracleQuery` containing details about which oracle to query for which information, as defined by the specific oracle interface, +- `userProcNotification`: User notification procedure that shall be executed when the oracle reply is available or an error occurs (must be registered, see [Registering Notification Procedures](#registering-notification-procedures)), +- `timeoutMillisec`: Maximum number of milliseconds to wait for the reply. Reasonable values are 30000 or 60000 (30 or 60 seconds). Too low values may lead to always getting timeout errors. + +The macro returns the Oracle query ID that can be used to get the status of the query, or -1 on error. + +Here is an example of how to use this macro: + +```C++ +locals.queryId = QUERY_ORACLE(OI::Price, locals.priceOracleQuery, NotifyPriceOracleReply, 60000); +``` + +This call will automatically burn the oracle query fee as defined by the oracle interface. +More specifically, it will destroy the QUs without adding to the contract's execution fee reserve. +It will fail if the contract doesn't have enough QUs. + +The notification callback will be executed when the reply is available or on error. +The callback must be a user procedure of the contract calling `QUERY_ORACLE()` with the procedure input type `OracleNotificationInput` and `NoData` as output. +The procedure must be registered with `REGISTER_USER_PROCEDURE_NOTIFICATION()` in `REGISTER_USER_FUNCTIONS_AND_PROCEDURES`. + +In the notification callback, success is indicated by `input.status == ORACLE_QUERY_STATUS_SUCCESS`. +If an error happened before the query has been created and sent, `input.status` is `ORACLE_QUERY_STATUS_UNKNOWN` and `input.queryId` is -1 (invalid). +Other errors that may happen with valid `input.queryId` are `input.status == ORACLE_QUERY_STATUS_TIMEOUT` and `input.status == ORACLE_QUERY_STATUS_UNRESOLVABLE`. + +All queries, including pending queries are discarded at the end of the epoch. Contracts aren't notified in this case. + +An alternative way of initiating periodic queries is using subscriptions, see the [Subscription QPI](#subscription-qpi) + + +### Getting the query status by query ID + +A contract can get the status of any query by the query ID as in this example: + +```C++ +output.status = qpi.getOracleQueryStatus(input.queryId); +``` + +The returned status is one of the following: + +- `ORACLE_QUERY_STATUS_UNKNOWN`: Query not found / not valid. +- `ORACLE_QUERY_STATUS_PENDING`: Query is being processed. +- `ORACLE_QUERY_STATUS_COMMITTED`: The Quorum has committed to an oracle reply, but it has not been revealed yet. +- `ORACLE_QUERY_STATUS_SUCCESS`: The oracle reply has been confirmed and is available. +- `ORACLE_QUERY_STATUS_UNRESOLVABLE`: No valid oracle reply is available, because computors disagreed about the value. +- `ORACLE_QUERY_STATUS_TIMEOUT`: No valid oracle reply is available and timeout has hit. + + +### Getting the query by query ID + +A contract can get the query data of any query given it knows the query ID and the associated oracle interface. Example: + +```C++ +if (qpi.getOracleQuery(input.queryId, locals.priceQuery)) +{ + // use locals.priceQuery +} +``` + +This tries to get the query data associated with the query identified by `queryId`. +If `queryId` is valid and matches with the oracle interface given via the template parameter (`OI::Price` in this example), `locals.priceQuery` (which must be of type `OI::Price::OracleQuery` in this example) is set and the function returns true. Otherwise it returns false. + + +### Getting the reply by query ID + +A contract can get the reply data of any query given it knows the query ID and the associated oracle interface. Example: + +```C++ +if (qpi.getOracleReply(input.queryId, locals.priceReply)) +{ + // use locals.priceReply +} +``` + +This functions returns wether `queryId` is found, matches the oracle interface, and a valid reply is available (query status is `ORACLE_QUERY_STATUS_SUCCESS`). +If all this is true, the function copies the oracle reply into `locals.priceReply`, which must be of type `OI::Price::OracleReply` in this example. + + +## Subscription QPI + +Subscriptions are a cheaper and more efficient way to query time-dependent information, such as prices, regularly. +A subscription repeatedly sends the same query, only changing the query timestamp. +The subscription queries may be shared by multiple contracts automatically. + +After subscribing, updated information is pushed to the subscriber contract in defined intervals as requested. +Internally, a scheduler takes care for translating subscriptions into bundled one-time queries, whose reply may be delivered to multiple contracts. + +Subscriptions end with the epoch. That is, contracts must subscribe again in a new epoch, even if the epoch transition is seamless. + +A subscriptions usually costs a higher fee than a single one-time query, but a lower fee than replacing the subscription with recurring one-time queries (because resources are saved when multiple contracts subscribe to the same oracle “channel”). + + +### Subscribing + +A contract can subscribe for regularly querying an oracle using the macro `SUBSCRIBE_ORACLE()`, which expects the following parameters: + +- `OracleInterface`: Oracle interface struct of the interface to query that supports subscriptions, e.g., `OI::Price`, +- `query`: Instance of type `OracleInterface::OracleQuery` that specifies which information is requested, e.g., the oracle and currencies in `OI::Price`. + It must have a member `DateAndTime timestamp` that can be set by the scheduler. +- `notificationCallback`: User notification procedure that shall be executed when the oracle reply is available or an error occurs (must be registered, see [Registering Notification Procedures](#registering-notification-procedures)), +- `notificationPeriodInMilliseconds`: Number of milliseconds between consecutive queries/replies that the contract is notified about. Currently, only multiples of 60000 are supported and other values are rejected with an error. +- `notifyWithPreviousReply`: Whether to immediately notify this contract with the most up-to-date value if any is available. + +The macro returns the Oracle subscription ID or -1 on error. + +Here is an example of how to use this macro: + +```C++ +output.subscriptionId = SUBSCRIBE_ORACLE(OI::Price, input.priceOracleQuery, NotifyPriceOracleReply, input.subscriptionPeriodMilliseconds, input.notifyPreviousValue); +``` + +Subscriptions automatically expire at the end of each epoch. +So, a common pattern is to call `SUBSCRIBE_ORACLE()` in `BEGIN_EPOCH`. + +Subscriptions facilitate sharing common oracle queries among multiple contracts. +This saves network resources and allows to provide a fixed-price subscription for the whole epoch, which is usually much cheaper than the equivalent series of +individual `QUERY_ORACLE()` calls. + +The `SUBSCRIBE_ORACLE()` call will automatically burn the oracle subscription fee as defined by the oracle interface +(burning without adding to the contract's execution fee reserve). +It will fail if the contract doesn't have enough QUs. + +The notification callback will be executed when the reply is available or on error. +The callback must be a user procedure of the contract calling `SUBSCRIBE_ORACLE()` with the procedure input type `OracleNotificationInput` and `NoData` as output. +The procedure must be registered with `REGISTER_USER_PROCEDURE_NOTIFICATION()` in `REGISTER_USER_FUNCTIONS_AND_PROCEDURES`. + +In the notification callback, success is indicated by `input.status == ORACLE_QUERY_STATUS_SUCCESS`. +If an error happened before the query has been created and sent, `input.status` is `ORACLE_QUERY_STATUS_UNKNOWN` and `input.queryId` is -1 (invalid). +Other errors that may happen with valid `input.queryId` are `input.status == ORACLE_QUERY_STATUS_TIMEOUT` and `input.status == ORACLE_QUERY_STATUS_UNRESOLVABLE`. +The timeout of subscription queries is always 60000 milliseconds. + +A contract may subscribe to the same oracle interface with multiple different queries. +However, it cannot subscribe with the same query multiple times. +In order to change the notification period of an existing query, it needs to be unsubscribed first and subscribed again afterwards. + + +### Unsubscribing + +A contract can unsubscribe from an own subscription with the subscription ID returned by the `SUBSCRIBE_ORACLE()` call as illustrated in the following example: + +```C++ +output.success = qpi.unsubscribeOracle(input.subscriptionId); +``` + +The function returns true if the subscription given by the ID has been stopped (false means that the ID is invalid). + +If there is a pending query initiated for this subscription while unsubscribing, the contract will still be notified for that query even after unsubscribing. +However, no new queries for this contract will be generated. + + +## IDs + +### Query ID + +The query ID is a signed 64-bit integer, with values >= 0 being reserved for valid query IDs and values < 0 reserved for signalling errors. + +Query IDs are unique. +They are constructed from the tick, when the query was initiated and an index during the tick. + +For user queries, the index is given by the index of the query transaction in the tick data. +For contract one-time queries and subscription queries, the index is generated sequentially, starting at the maximum number of transactions per tick. + +With that index and the query tick, the ID is generated `queryId = (tick << 31) + index` (shift the tick by 31 bits and add the index). + + +### Subscription ID + +The subscription ID is a signed 32-bit integer. +Valid IDs are >= 0. +Negative values are reserved for signaling errors. + +A subscription ID is bound to an oracle interface and specific initial query passed to `SUBSCRIBE_ORACLE()`. +Multiple contracts (subscribers) may share the same subscription ID, but have individual notification periods. + +Subscription IDs are generated sequentially, that is, if there are N subscriptions they will have the IDs 0, 1, ..., N-1. + + +## Tools + +The command-line tool [qubic-cli](https://github.com/qubic/qubic-cli) provides useful commands for debugging and experimenting with oracles. +For example: + +- `qubic-cli [...] -queryoracle`: Command for generating a user query to an oracle. Run this to get help how to use it. +- `qubic-cli [...] -getoraclequery`: Command for getting information about oracle queries. Run this to get help how to use it. +- `qubic-cli [...] -getoraclesubscription`: Command for getting information about oracle subscriptions. Run this to get help how to use it. +- `qubic-cli [...] -querypriceviacontract`: Send price query via contract. Useful for testing contract queries and subscriptions. Run this to get help how to use it. + +Another useful tool for debugging your contract in the Core when running a testnet is [qlogging](https://github.com/qubic/qlogging/). +This prints the event log messages emitted by the core, including messages for oracle query state changes and subscribing/unsubscribing. + + +## Implementation details + + diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index c09ed1a0b..c7a4f2f45 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -26,7 +26,6 @@ - diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 6bff789b4..24b11e9e1 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -365,9 +365,6 @@ oracle_core - - contracts - @@ -418,4 +415,4 @@ platform - \ No newline at end of file + diff --git a/src/assets/assets.h b/src/assets/assets.h index 5a5adff22..f6f72e019 100644 --- a/src/assets/assets.h +++ b/src/assets/assets.h @@ -14,6 +14,7 @@ #include "contract_core/contract_def.h" #include "public_settings.h" +#include "private_settings.h" #include "logging/logging.h" #include "kangaroo_twelve.h" #include "four_q.h" @@ -771,7 +772,7 @@ static bool saveUniverse(const CHAR16* fileName = UNIVERSE_FILE_NAME, const CHAR return false; } -static bool loadUniverse(const CHAR16* fileName = UNIVERSE_FILE_NAME, CHAR16* directory = NULL) +static bool loadUniverse(const CHAR16* fileName = UNIVERSE_FILE_NAME, CHAR16* directory = NULL, bool rebuildIndexLists = true) { PROFILE_SCOPE(); @@ -782,9 +783,38 @@ static bool loadUniverse(const CHAR16* fileName = UNIVERSE_FILE_NAME, CHAR16* di return false; } - as.indexLists.rebuild(); + + if (rebuildIndexLists) + as.indexLists.rebuild(); + + return true; +} + +#if TICK_STORAGE_AUTOSAVE_MODE +static bool saveSnapshotUniverseIndex(const CHAR16* fileName, const CHAR16* directory = NULL) +{ + long long savedSize = save(fileName, sizeof(as.indexLists), (unsigned char*)&as.indexLists, directory); + logToConsole(L"Saving universe index"); + if (savedSize != sizeof(as.indexLists)) + { + logToConsole(L"Failed to save universe index"); + return false; + } + return true; +} + +static bool loadSnapshotUniverseIndex(const CHAR16* fileName, CHAR16* directory = NULL) +{ + long long loadedSize = load(fileName, sizeof(as.indexLists), (unsigned char*)&as.indexLists, directory); + logToConsole(L"Loading universe index"); + if (loadedSize != sizeof(as.indexLists)) + { + logToConsole(L"Failed to load universe index"); + return false; + } return true; } +#endif static void assetsEndEpoch() { diff --git a/src/assets/net_msg_impl.h b/src/assets/net_msg_impl.h index a8afafb40..2d417860d 100644 --- a/src/assets/net_msg_impl.h +++ b/src/assets/net_msg_impl.h @@ -135,14 +135,14 @@ static void processRequestAssetsSendRecord(Peer* peer, RequestResponseHeader* re static void processRequestAssets(Peer* peer, RequestResponseHeader* header) { - // check size of recieved message (request by universe index may be smaller than sizeof(RequestAssets)) + // check size of received message (request by universe index may be smaller than sizeof(RequestAssets)) if (!header->checkPayloadSizeMinMax(sizeof(RequestAssets::byUniverseIdx), sizeof(RequestAssets))) return; RequestAssets* request = header->getPayload(); if (request->assetReqType != RequestAssets::requestByUniverseIdx && !header->checkPayloadSize(sizeof(RequestAssets))) return; - // initalize output message (with siblings because the variant without siblings is just a subset) + // initialize output message (with siblings because the variant without siblings is just a subset) struct { RequestResponseHeader header; @@ -152,7 +152,7 @@ static void processRequestAssets(Peer* peer, RequestResponseHeader* header) response.header.setType(RespondAssets::type()); response.header.setDejavu(header->dejavu()); - // size of output message depends on whether sibilings are requested + // size of output message depends on whether siblings are requested if (request->byFilter.flags & RequestAssets::getSiblings) response.header.setSize(); else diff --git a/src/contract_core/contract_def.h b/src/contract_core/contract_def.h index 035e2b23f..095bf89a1 100644 --- a/src/contract_core/contract_def.h +++ b/src/contract_core/contract_def.h @@ -112,11 +112,7 @@ #define CONTRACT_INDEX QVAULT_CONTRACT_INDEX #define CONTRACT_STATE_TYPE QVAULT #define CONTRACT_STATE2_TYPE QVAULT2 -#ifdef OLD_QVAULT -#include "contracts/QVAULT_old.h" -#else #include "contracts/QVAULT.h" -#endif #undef CONTRACT_INDEX #undef CONTRACT_STATE_TYPE @@ -258,6 +254,20 @@ #define CONTRACT_STATE2_TYPE PULSE2 #include "contracts/Pulse.h" +#ifndef NO_VOTTUN + +#undef CONTRACT_INDEX +#undef CONTRACT_STATE_TYPE +#undef CONTRACT_STATE2_TYPE + +#define VOTTUNBRIDGE_CONTRACT_INDEX 25 +#define CONTRACT_INDEX VOTTUNBRIDGE_CONTRACT_INDEX +#define CONTRACT_STATE_TYPE VOTTUNBRIDGE +#define CONTRACT_STATE2_TYPE VOTTUNBRIDGE2 +#include "contracts/VottunBridge.h" + +#endif + // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES @@ -368,6 +378,9 @@ constexpr struct ContractDescription {"QTF", 199, 10000, sizeof(QTF::StateData)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"QDUEL", 199, 10000, sizeof(QDUEL::StateData)}, // proposal in epoch 197, IPO in 198, construction and first use in 199 {"PULSE", 204, 10000, sizeof(PULSE::StateData)}, // proposal in epoch 202, IPO in 203, construction and first use in 204 +#ifndef NO_VOTTUN + {"VOTTUN", 206, 10000, sizeof(VOTTUNBRIDGE::StateData)}, // proposal in epoch 204, IPO in 205, construction and first use in 206 +#endif // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES {"TESTEXA", 138, 10000, sizeof(TESTEXA::StateData)}, @@ -488,6 +501,9 @@ static void initializeContracts() REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QTF); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(QDUEL); REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(PULSE); +#ifndef NO_VOTTUN + REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(VOTTUNBRIDGE); +#endif // new contracts should be added above this line #ifdef INCLUDE_CONTRACT_TEST_EXAMPLES REGISTER_CONTRACT_FUNCTIONS_AND_PROCEDURES(TESTEXA); diff --git a/src/contracts/QVAULT_old.h b/src/contracts/QVAULT_old.h deleted file mode 100644 index 9142d275d..000000000 --- a/src/contracts/QVAULT_old.h +++ /dev/null @@ -1,677 +0,0 @@ -using namespace QPI; - -constexpr uint64 QVAULT_MAX_REINVEST_AMOUNT = 100000000000ULL; -constexpr uint64 QVAULT_QCAP_ASSETNAME = 1346454353; -constexpr uint64 QVAULT_QCAP_MAX_SUPPLY = 21000000; -constexpr uint32 QVAULT_MAX_NUMBER_OF_BANNED_ADDRESSES = 16; - -struct QVAULT2 -{ -}; - -struct QVAULT : public ContractBase -{ - struct StateData - { - id QCAP_ISSUER; - id authAddress1, authAddress2, authAddress3, newAuthAddress1, newAuthAddress2, newAuthAddress3; - id reinvestingAddress, newReinvestingAddress1, newReinvestingAddress2, newReinvestingAddress3; - id adminAddress, newAdminAddress1, newAdminAddress2, newAdminAddress3; - id bannedAddress1, bannedAddress2, bannedAddress3; - id unbannedAddress1, unbannedAddress2, unbannedAddress3; - Array bannedAddress; - uint32 numberOfBannedAddress; - uint32 shareholderDividend, QCAPHolderPermille, reinvestingPermille, devPermille, burnPermille; - uint32 newQCAPHolderPermille1, newReinvestingPermille1, newDevPermille1; - uint32 newQCAPHolderPermille2, newReinvestingPermille2, newDevPermille2; - uint32 newQCAPHolderPermille3, newReinvestingPermille3, newDevPermille3; - }; - -public: - - /* - submitAuthAddress PROCEDURE - multisig addresses can submit the new multisig address in this procedure. - */ - - struct submitAuthAddress_input - { - id newAddress; - }; - - struct submitAuthAddress_output - { - }; - - /* - changeAuthAddress PROCEDURE - the new multisig address can be changed by multisig address in this procedure. - the new multisig address should be submitted by the 2 multisig addresses in submitAuthAddress. if else, it will not be changed with new address - */ - - struct changeAuthAddress_input - { - uint32 numberOfChangedAddress; - }; - - struct changeAuthAddress_output - { - }; - - /* - submitDistributionPermille PROCEDURE - the new distribution Permilles can be submitted by the multisig addresses in this procedure. - */ - - struct submitDistributionPermille_input - { - uint32 newQCAPHolderPermille; - uint32 newReinvestingPermille; - uint32 newDevPermille; - }; - - struct submitDistributionPermille_output - { - }; - - /* - changeDistributionPermille PROCEDURE - the new distribution Permilles can be changed by multisig address in this procedure. - the new distribution Permilles should be submitted by multsig addresses in submitDistributionPermille PROCEDURE. if else, it will not be changed with new Permilles. - */ - - struct changeDistributionPermille_input - { - uint32 newQCAPHolderPermille; - uint32 newReinvestingPermille; - uint32 newDevPermille; - }; - - struct changeDistributionPermille_output - { - }; - - /* - submitReinvestingAddress PROCEDURE - the new reinvestingAddress can be submitted by the multisig addresses in this procedure. - */ - - struct submitReinvestingAddress_input - { - id newAddress; - }; - - struct submitReinvestingAddress_output - { - }; - - /* - changeReinvestingAddress PROCEDURE - the new reinvesting address can be changed by multisig address in this procedure. - the new reinvesting address should be submitted by multsig addresses in submitReinvestingAddress. if else, it will not be changed with new address. - */ - - struct changeReinvestingAddress_input - { - id newAddress; - }; - - struct changeReinvestingAddress_output - { - - }; - - struct getData_input - { - }; - - struct getData_output - { - uint64 numberOfBannedAddress; - uint32 shareholderDividend, QCAPHolderPermille, reinvestingPermille, devPermille; - id authAddress1, authAddress2, authAddress3, reinvestingAddress, adminAddress; - id newAuthAddress1, newAuthAddress2, newAuthAddress3; - id newReinvestingAddress1, newReinvestingAddress2, newReinvestingAddress3; - id newAdminAddress1, newAdminAddress2, newAdminAddress3; - id bannedAddress1, bannedAddress2, bannedAddress3; - id unbannedAddress1, unbannedAddress2, unbannedAddress3; - }; - - /* - submitAdminAddress PROCEDURE - the new adminAddress can be submitted by the multisig addresses in this procedure. - */ - - struct submitAdminAddress_input - { - id newAddress; - }; - - struct submitAdminAddress_output - { - }; - - /* - changeAdminAddress PROCEDURE - the new admin address can be changed by multisig address in this procedure. - the new admin address should be submitted by multsig addresses in submitAdminAddress PROCEDURE. if else, it will not be changed with new address. - */ - - struct changeAdminAddress_input - { - id newAddress; - }; - - struct changeAdminAddress_output - { - }; - - /* - submitBannedAddress PROCEDURE - the banned addresses can be submitted by multisig address in this procedure. - */ - struct submitBannedAddress_input - { - id bannedAddress; - }; - - struct submitBannedAddress_output - { - }; - - /* - saveBannedAddress PROCEDURE - the banned address can be changed by multisig address in this procedure. - the banned address should be submitted by multisig addresses in submitBannedAddress PROCEDURE. if else, it will not be saved. - */ - - struct saveBannedAddress_input - { - id bannedAddress; - }; - - struct saveBannedAddress_output - { - }; - - /* - submitUnbannedAddress PROCEDURE - the unbanned addresses can be submitted by multisig address in this procedure. - */ - - struct submitUnbannedAddress_input - { - id unbannedAddress; - }; - - struct submitUnbannedAddress_output - { - }; - - /* - unblockBannedAddress PROCEDURE - the banned address can be unblocked by multisig address in this procedure. - the unbanned address should be submitted by multisig addresses in submitUnbannedAddress PROCEDURE. if else, it will not be unblocked. - */ - - struct unblockBannedAddress_input - { - id unbannedAddress; - }; - - struct unblockBannedAddress_output - { - }; - -protected: - - PUBLIC_PROCEDURE(submitAuthAddress) - { - if(qpi.invocator() == state.get().authAddress1) - { - state.mut().newAuthAddress1 = input.newAddress; - } - if(qpi.invocator() == state.get().authAddress2) - { - state.mut().newAuthAddress2 = input.newAddress; - } - if(qpi.invocator() == state.get().authAddress3) - { - state.mut().newAuthAddress3 = input.newAddress; - } - - } - - struct changeAuthAddress_locals { - bit succeed; - }; - - PUBLIC_PROCEDURE_WITH_LOCALS(changeAuthAddress) - { - if(qpi.invocator() != state.get().authAddress1 && qpi.invocator() != state.get().authAddress2 && qpi.invocator() != state.get().authAddress3) - { - return ; - } - - locals.succeed = 0; - - if(qpi.invocator() != state.get().authAddress1 && input.numberOfChangedAddress == 1 && state.get().newAuthAddress2 != NULL_ID && state.get().newAuthAddress2 == state.get().newAuthAddress3) - { - state.mut().authAddress1 = state.get().newAuthAddress2; - locals.succeed = 1; - } - - if(qpi.invocator() != state.get().authAddress2 && input.numberOfChangedAddress == 2 && state.get().newAuthAddress1 != NULL_ID && state.get().newAuthAddress1 == state.get().newAuthAddress3) - { - state.mut().authAddress2 = state.get().newAuthAddress1; - locals.succeed = 1; - } - - if(qpi.invocator() != state.get().authAddress3 && input.numberOfChangedAddress == 3 && state.get().newAuthAddress1 != NULL_ID && state.get().newAuthAddress1 == state.get().newAuthAddress2) - { - state.mut().authAddress3 = state.get().newAuthAddress1; - locals.succeed = 1; - } - - if(locals.succeed == 1) - { - state.mut().newAuthAddress1 = NULL_ID; - state.mut().newAuthAddress2 = NULL_ID; - state.mut().newAuthAddress3 = NULL_ID; - } - } - - PUBLIC_PROCEDURE(submitDistributionPermille) - { - if(input.newDevPermille + input.newQCAPHolderPermille + input.newReinvestingPermille + state.get().shareholderDividend + state.get().burnPermille != 1000) - { - return ; - } - - if(qpi.invocator() == state.get().authAddress1) - { - state.mut().newDevPermille1 = input.newDevPermille; - state.mut().newQCAPHolderPermille1 = input.newQCAPHolderPermille; - state.mut().newReinvestingPermille1 = input.newReinvestingPermille; - } - - if(qpi.invocator() == state.get().authAddress2) - { - state.mut().newDevPermille2 = input.newDevPermille; - state.mut().newQCAPHolderPermille2 = input.newQCAPHolderPermille; - state.mut().newReinvestingPermille2 = input.newReinvestingPermille; - } - - if(qpi.invocator() == state.get().authAddress3) - { - state.mut().newDevPermille3 = input.newDevPermille; - state.mut().newQCAPHolderPermille3 = input.newQCAPHolderPermille; - state.mut().newReinvestingPermille3 = input.newReinvestingPermille; - } - - } - - PUBLIC_PROCEDURE(changeDistributionPermille) - { - if(qpi.invocator() != state.get().authAddress1 && qpi.invocator() != state.get().authAddress2 && qpi.invocator() != state.get().authAddress3) - { - return ; - } - - if(input.newDevPermille + input.newQCAPHolderPermille + input.newReinvestingPermille + state.get().shareholderDividend + state.get().burnPermille != 1000) - { - return ; - } - - if(input.newDevPermille == 0 || input.newDevPermille != state.get().newDevPermille1 || state.get().newDevPermille1 != state.get().newDevPermille2 || state.get().newDevPermille2 != state.get().newDevPermille3) - { - return ; - } - - if(input.newQCAPHolderPermille == 0 || input.newQCAPHolderPermille != state.get().newQCAPHolderPermille1 || state.get().newQCAPHolderPermille1 != state.get().newQCAPHolderPermille2 || state.get().newQCAPHolderPermille2 != state.get().newQCAPHolderPermille3) - { - return ; - } - - if(input.newReinvestingPermille == 0 || input.newReinvestingPermille != state.get().newReinvestingPermille1 || state.get().newReinvestingPermille1 != state.get().newReinvestingPermille2 || state.get().newReinvestingPermille2 != state.get().newReinvestingPermille3) - { - return ; - } - - state.mut().devPermille = state.get().newDevPermille1; - state.mut().QCAPHolderPermille = state.get().newQCAPHolderPermille1; - state.mut().reinvestingPermille = state.get().newReinvestingPermille1; - - state.mut().newDevPermille1 = 0; - state.mut().newDevPermille2 = 0; - state.mut().newDevPermille3 = 0; - - state.mut().newQCAPHolderPermille1 = 0; - state.mut().newQCAPHolderPermille2 = 0; - state.mut().newQCAPHolderPermille3 = 0; - - state.mut().newReinvestingPermille1 = 0; - state.mut().newReinvestingPermille2 = 0; - state.mut().newReinvestingPermille3 = 0; - } - - PUBLIC_PROCEDURE(submitReinvestingAddress) - { - if(qpi.invocator() == state.get().authAddress1) - { - state.mut().newReinvestingAddress1 = input.newAddress; - } - if(qpi.invocator() == state.get().authAddress2) - { - state.mut().newReinvestingAddress2 = input.newAddress; - } - if(qpi.invocator() == state.get().authAddress3) - { - state.mut().newReinvestingAddress3 = input.newAddress; - } - } - - PUBLIC_PROCEDURE(changeReinvestingAddress) - { - if(qpi.invocator() != state.get().authAddress1 && qpi.invocator() != state.get().authAddress2 && qpi.invocator() != state.get().authAddress3) - { - return ; - } - - if(input.newAddress == NULL_ID || input.newAddress != state.get().newReinvestingAddress1 || state.get().newReinvestingAddress1 != state.get().newReinvestingAddress2 || state.get().newReinvestingAddress2 != state.get().newReinvestingAddress3) - { - return ; - } - - state.mut().reinvestingAddress = state.get().newReinvestingAddress1; - - state.mut().newReinvestingAddress1 = NULL_ID; - state.mut().newReinvestingAddress2 = NULL_ID; - state.mut().newReinvestingAddress3 = NULL_ID; - } - - PUBLIC_FUNCTION(getData) - { - output.authAddress1 = state.get().authAddress1; - output.authAddress2 = state.get().authAddress2; - output.authAddress3 = state.get().authAddress3; - output.reinvestingAddress = state.get().reinvestingAddress; - output.shareholderDividend = state.get().shareholderDividend; - output.devPermille = state.get().devPermille; - output.QCAPHolderPermille = state.get().QCAPHolderPermille; - output.reinvestingPermille = state.get().reinvestingPermille; - output.adminAddress = state.get().adminAddress; - output.newAuthAddress1 = state.get().newAuthAddress1; - output.newAuthAddress2 = state.get().newAuthAddress2; - output.newAuthAddress3 = state.get().newAuthAddress3; - output.newAdminAddress1 = state.get().newAdminAddress1; - output.newAdminAddress2 = state.get().newAdminAddress2; - output.newAdminAddress3 = state.get().newAdminAddress3; - output.newReinvestingAddress1 = state.get().newReinvestingAddress1; - output.newReinvestingAddress2 = state.get().newReinvestingAddress2; - output.newReinvestingAddress3 = state.get().newReinvestingAddress3; - output.numberOfBannedAddress = state.get().numberOfBannedAddress; - output.bannedAddress1 = state.get().bannedAddress1; - output.bannedAddress2 = state.get().bannedAddress2; - output.bannedAddress3 = state.get().bannedAddress3; - output.unbannedAddress1 = state.get().unbannedAddress1; - output.unbannedAddress2 = state.get().unbannedAddress2; - output.unbannedAddress3 = state.get().unbannedAddress3; - - } - - PUBLIC_PROCEDURE(submitAdminAddress) - { - if(qpi.invocator() == state.get().authAddress1) - { - state.mut().newAdminAddress1 = input.newAddress; - } - if(qpi.invocator() == state.get().authAddress2) - { - state.mut().newAdminAddress2 = input.newAddress; - } - if(qpi.invocator() == state.get().authAddress3) - { - state.mut().newAdminAddress3 = input.newAddress; - } - - } - - PUBLIC_PROCEDURE(changeAdminAddress) - { - if(qpi.invocator() != state.get().authAddress1 && qpi.invocator() != state.get().authAddress2 && qpi.invocator() != state.get().authAddress3) - { - return ; - } - - if(input.newAddress == NULL_ID || input.newAddress != state.get().newAdminAddress1 || state.get().newAdminAddress1 != state.get().newAdminAddress2 || state.get().newAdminAddress2 != state.get().newAdminAddress3) - { - return ; - } - - state.mut().adminAddress = state.get().newAdminAddress1; - - state.mut().newAdminAddress1 = NULL_ID; - state.mut().newAdminAddress2 = NULL_ID; - state.mut().newAdminAddress3 = NULL_ID; - } - - - PUBLIC_PROCEDURE(submitBannedAddress) - { - if(qpi.invocator() == state.get().authAddress1) - { - state.mut().bannedAddress1 = input.bannedAddress; - } - if(qpi.invocator() == state.get().authAddress2) - { - state.mut().bannedAddress2 = input.bannedAddress; - } - if(qpi.invocator() == state.get().authAddress3) - { - state.mut().bannedAddress3 = input.bannedAddress; - } - - } - - PUBLIC_PROCEDURE(saveBannedAddress) - { - if(state.get().numberOfBannedAddress >= QVAULT_MAX_NUMBER_OF_BANNED_ADDRESSES) - { - return ; - } - - if(qpi.invocator() != state.get().authAddress1 && qpi.invocator() != state.get().authAddress2 && qpi.invocator() != state.get().authAddress3) - { - return ; - } - - if(input.bannedAddress == NULL_ID || input.bannedAddress != state.get().bannedAddress1 || state.get().bannedAddress1 != state.get().bannedAddress2 || state.get().bannedAddress2 != state.get().bannedAddress3) - { - return ; - } - - state.mut().bannedAddress.set(state.get().numberOfBannedAddress, input.bannedAddress); - - state.mut().numberOfBannedAddress++; - state.mut().newAdminAddress1 = NULL_ID; - state.mut().newAdminAddress2 = NULL_ID; - state.mut().newAdminAddress3 = NULL_ID; - - } - - PUBLIC_PROCEDURE(submitUnbannedAddress) - { - if(qpi.invocator() == state.get().authAddress1) - { - state.mut().unbannedAddress1 = input.unbannedAddress; - } - if(qpi.invocator() == state.get().authAddress2) - { - state.mut().unbannedAddress2 = input.unbannedAddress; - } - if(qpi.invocator() == state.get().authAddress3) - { - state.mut().unbannedAddress3 = input.unbannedAddress; - } - - } - - struct unblockBannedAddress_locals - { - uint32 _t, flag; - }; - - PUBLIC_PROCEDURE_WITH_LOCALS(unblockBannedAddress) - { - if(qpi.invocator() != state.get().authAddress1 && qpi.invocator() != state.get().authAddress2 && qpi.invocator() != state.get().authAddress3) - { - return ; - } - - if(input.unbannedAddress == NULL_ID || input.unbannedAddress != state.get().unbannedAddress1 || state.get().unbannedAddress1 != state.get().unbannedAddress2 || state.get().unbannedAddress2 != state.get().unbannedAddress3) - { - return ; - } - - locals.flag = 0; - - for(locals._t = 0; locals._t < state.get().numberOfBannedAddress; locals._t++) - { - if(locals.flag == 1 || input.unbannedAddress == state.get().bannedAddress.get(locals._t)) - { - if(locals._t == state.get().numberOfBannedAddress - 1) - { - state.mut().bannedAddress.set(locals._t, NULL_ID); - locals.flag = 1; - break; - } - state.mut().bannedAddress.set(locals._t, state.get().bannedAddress.get(locals._t + 1)); - locals.flag = 1; - } - } - - if(locals.flag == 1) - { - state.mut().numberOfBannedAddress--; - } - state.mut().unbannedAddress1 = NULL_ID; - state.mut().unbannedAddress2 = NULL_ID; - state.mut().unbannedAddress3 = NULL_ID; - - } - - REGISTER_USER_FUNCTIONS_AND_PROCEDURES() - { - REGISTER_USER_FUNCTION(getData, 1); - - REGISTER_USER_PROCEDURE(submitAuthAddress, 1); - REGISTER_USER_PROCEDURE(changeAuthAddress, 2); - REGISTER_USER_PROCEDURE(submitDistributionPermille, 3); - REGISTER_USER_PROCEDURE(changeDistributionPermille, 4); - REGISTER_USER_PROCEDURE(submitReinvestingAddress, 5); - REGISTER_USER_PROCEDURE(changeReinvestingAddress, 6); - REGISTER_USER_PROCEDURE(submitAdminAddress, 7); - REGISTER_USER_PROCEDURE(changeAdminAddress, 8); - REGISTER_USER_PROCEDURE(submitBannedAddress, 9); - REGISTER_USER_PROCEDURE(saveBannedAddress, 10); - REGISTER_USER_PROCEDURE(submitUnbannedAddress, 11); - REGISTER_USER_PROCEDURE(unblockBannedAddress, 12); - - } - - INITIALIZE() - { - state.mut().QCAP_ISSUER = ID(_Q, _C, _A, _P, _W, _M, _Y, _R, _S, _H, _L, _B, _J, _H, _S, _T, _T, _Z, _Q, _V, _C, _I, _B, _A, _R, _V, _O, _A, _S, _K, _D, _E, _N, _A, _S, _A, _K, _N, _O, _B, _R, _G, _P, _F, _W, _W, _K, _R, _C, _U, _V, _U, _A, _X, _Y, _E); - state.mut().authAddress1 = ID(_T, _K, _U, _W, _W, _S, _N, _B, _A, _E, _G, _W, _J, _H, _Q, _J, _D, _F, _L, _G, _Q, _H, _J, _J, _C, _J, _B, _A, _X, _B, _S, _Q, _M, _Q, _A, _Z, _J, _J, _D, _Y, _X, _E, _P, _B, _V, _B, _B, _L, _I, _Q, _A, _N, _J, _T, _I, _D); - state.mut().authAddress2 = ID(_F, _X, _J, _F, _B, _T, _J, _M, _Y, _F, _J, _H, _P, _B, _X, _C, _D, _Q, _T, _L, _Y, _U, _K, _G, _M, _H, _B, _B, _Z, _A, _A, _F, _T, _I, _C, _W, _U, _K, _R, _B, _M, _E, _K, _Y, _N, _U, _P, _M, _R, _M, _B, _D, _N, _D, _R, _G); - state.mut().authAddress3 = ID(_K, _E, _F, _D, _Z, _T, _Y, _L, _F, _E, _R, _A, _H, _D, _V, _L, _N, _Q, _O, _R, _D, _H, _F, _Q, _I, _B, _S, _B, _Z, _C, _W, _S, _Z, _X, _Z, _F, _F, _A, _N, _O, _T, _F, _A, _H, _W, _M, _O, _V, _G, _T, _R, _Q, _J, _P, _X, _D); - state.mut().reinvestingAddress = ID(_R, _U, _U, _Y, _R, _V, _N, _K, _J, _X, _M, _L, _R, _B, _B, _I, _R, _I, _P, _D, _I, _B, _M, _H, _D, _H, _U, _A, _Z, _B, _Q, _K, _N, _B, _J, _T, _R, _D, _S, _P, _G, _C, _L, _Z, _C, _Q, _W, _A, _K, _C, _F, _Q, _J, _K, _K, _E); - state.mut().adminAddress = ID(_H, _E, _C, _G, _U, _G, _H, _C, _J, _K, _Q, _O, _S, _D, _T, _M, _E, _H, _Q, _Y, _W, _D, _D, _T, _L, _F, _D, _A, _S, _Z, _K, _M, _G, _J, _L, _S, _R, _C, _S, _T, _H, _H, _A, _P, _P, _E, _D, _L, _G, _B, _L, _X, _J, _M, _N, _D); - - state.mut().shareholderDividend = 30; - state.mut().QCAPHolderPermille = 500; - state.mut().reinvestingPermille = 450; - state.mut().devPermille = 20; - state.mut().burnPermille = 0; - - /* - initial banned addresses - */ - state.mut().bannedAddress.set(0, ID(_K, _E, _F, _D, _Z, _T, _Y, _L, _F, _E, _R, _A, _H, _D, _V, _L, _N, _Q, _O, _R, _D, _H, _F, _Q, _I, _B, _S, _B, _Z, _C, _W, _S, _Z, _X, _Z, _F, _F, _A, _N, _O, _T, _F, _A, _H, _W, _M, _O, _V, _G, _T, _R, _Q, _J, _P, _X, _D)); - state.mut().bannedAddress.set(1, ID(_E, _S, _C, _R, _O, _W, _B, _O, _T, _F, _T, _F, _I, _C, _I, _F, _P, _U, _X, _O, _J, _K, _G, _Q, _P, _Y, _X, _C, _A, _B, _L, _Z, _V, _M, _M, _U, _C, _M, _J, _F, _S, _G, _S, _A, _I, _A, _T, _Y, _I, _N, _V, _T, _Y, _G, _O, _A)); - state.mut().numberOfBannedAddress = 2; - - } - - struct END_EPOCH_locals - { - Entity entity; - AssetPossessionIterator iter; - Asset QCAPId; - uint64 revenue; - uint64 paymentForShareholders; - uint64 paymentForQCAPHolders; - uint64 paymentForReinvest; - uint64 paymentForDevelopment; - uint64 amountOfBurn; - uint64 circulatedSupply; - uint32 _t; - id possessorPubkey; - }; - - END_EPOCH_WITH_LOCALS() - { - qpi.getEntity(SELF, locals.entity); - locals.revenue = locals.entity.incomingAmount - locals.entity.outgoingAmount; - - locals.paymentForShareholders = div(locals.revenue * state.get().shareholderDividend, 1000ULL); - locals.paymentForQCAPHolders = div(locals.revenue * state.get().QCAPHolderPermille, 1000ULL); - locals.paymentForReinvest = div(locals.revenue * state.get().reinvestingPermille, 1000ULL); - locals.amountOfBurn = div(locals.revenue * state.get().burnPermille, 1000ULL); - locals.paymentForDevelopment = locals.revenue - locals.paymentForShareholders - locals.paymentForQCAPHolders - locals.paymentForReinvest - locals.amountOfBurn; - - if(locals.paymentForReinvest > QVAULT_MAX_REINVEST_AMOUNT) - { - locals.paymentForQCAPHolders += locals.paymentForReinvest - QVAULT_MAX_REINVEST_AMOUNT; - locals.paymentForReinvest = QVAULT_MAX_REINVEST_AMOUNT; - } - - qpi.distributeDividends(div(locals.paymentForShareholders, 676ULL)); - qpi.transfer(state.get().adminAddress, locals.paymentForDevelopment); - qpi.transfer(state.get().reinvestingAddress, locals.paymentForReinvest); - qpi.burn(locals.amountOfBurn); - - locals.circulatedSupply = QVAULT_QCAP_MAX_SUPPLY; - - for(locals._t = 0 ; locals._t < state.get().numberOfBannedAddress; locals._t++) - { - locals.circulatedSupply -= qpi.numberOfPossessedShares(QVAULT_QCAP_ASSETNAME, state.get().QCAP_ISSUER, state.get().bannedAddress.get(locals._t), state.get().bannedAddress.get(locals._t), QX_CONTRACT_INDEX, QX_CONTRACT_INDEX); - } - - locals.QCAPId.assetName = QVAULT_QCAP_ASSETNAME; - locals.QCAPId.issuer = state.get().QCAP_ISSUER; - - locals.iter.begin(locals.QCAPId); - while (!locals.iter.reachedEnd()) - { - locals.possessorPubkey = locals.iter.possessor(); - - for(locals._t = 0 ; locals._t < state.get().numberOfBannedAddress; locals._t++) - { - if(locals.possessorPubkey == state.get().bannedAddress.get(locals._t)) - { - break; - } - } - - if(locals._t == state.get().numberOfBannedAddress) - { - qpi.transfer(locals.possessorPubkey, div(locals.paymentForQCAPHolders, locals.circulatedSupply) * qpi.numberOfPossessedShares(QVAULT_QCAP_ASSETNAME, state.get().QCAP_ISSUER, locals.possessorPubkey, locals.possessorPubkey, QX_CONTRACT_INDEX, QX_CONTRACT_INDEX)); - } - - locals.iter.next(); - } - - } -}; diff --git a/src/contracts/VottunBridge.h b/src/contracts/VottunBridge.h new file mode 100644 index 000000000..6f12d55d2 --- /dev/null +++ b/src/contracts/VottunBridge.h @@ -0,0 +1,2097 @@ +using namespace QPI; + +struct VOTTUNBRIDGE2 +{ +}; + +struct VOTTUNBRIDGE : public ContractBase +{ +public: + // Bridge Order Structure + struct BridgeOrder + { + id qubicSender; // Sender address on Qubic + id qubicDestination; // Destination address on Qubic + Array ethAddress; // Destination Ethereum address + uint64 orderId; // Unique ID for the order + uint64 amount; // Amount to transfer + uint8 orderType; // Type of order (e.g., mint, transfer) + uint8 status; // Order status (e.g., Created, Pending, Refunded) + bit fromQubicToEthereum; // Direction of transfer + bit tokensReceived; // Flag to indicate if tokens have been received + bit tokensLocked; // Flag to indicate if tokens are in locked state + }; + + // Input and Output Structs + struct createOrder_input + { + id qubicDestination; // Destination address on Qubic (for EVM to Qubic orders) + uint64 amount; + Array ethAddress; + bit fromQubicToEthereum; + }; + + struct createOrder_output + { + uint8 status; + uint64 orderId; + }; + + struct getTotalReceivedTokens_input + { + }; + + struct getTotalReceivedTokens_output + { + uint64 totalTokens; + }; + + struct completeOrder_input + { + uint64 orderId; + }; + + struct completeOrder_output + { + uint8 status; + }; + + struct refundOrder_input + { + uint64 orderId; + }; + + struct refundOrder_output + { + uint8 status; + }; + + struct transferToContract_input + { + uint64 amount; + uint64 orderId; + }; + + struct transferToContract_output + { + uint8 status; + }; + + // Get Available Fees structures + struct getAvailableFees_input + { + // No parameters + }; + + struct getAvailableFees_output + { + uint64 availableFees; + uint64 totalEarnedFees; + uint64 totalDistributedFees; + }; + + // Order Response Structure + struct OrderResponse + { + id originAccount; // Origin account + Array destinationAccount; // Destination account + uint64 orderId; // Order ID as uint64 + uint64 amount; // Amount as uint64 + Array memo; // Notes or metadata + uint32 sourceChain; // Source chain identifier + id qubicDestination; + uint8 status; // Order status (0=pending, 1=completed, 2=refunded) + }; + + struct getOrder_input + { + uint64 orderId; + }; + + struct getOrder_output + { + uint8 status; + OrderResponse order; // Updated response format + Array message; + }; + + struct getContractInfo_input + { + // No parameters + }; + + struct getContractInfo_output + { + Array managers; + uint64 nextOrderId; + uint64 lockedTokens; + uint64 totalReceivedTokens; + uint64 earnedFees; + uint32 tradeFeeBillionths; + uint32 sourceChain; + // Debug info + Array firstOrders; // First 16 orders + uint64 totalOrdersFound; // How many non-empty orders exist + uint64 emptySlots; + // Multisig info + Array multisigAdmins; // List of multisig admins + uint8 numberOfAdmins; // Number of active admins + uint8 requiredApprovals; // Required approvals threshold + uint64 totalProposals; // Total number of active proposals + }; + + // Logger structures + struct EthBridgeLogger + { + uint32 _contractIndex; // Index of the contract + uint32 _errorCode; // Error code + uint64 _orderId; // Order ID if applicable + uint64 _amount; // Amount involved in the operation + sint8 _terminator; // Marks the end of the logged data + }; + + struct AddressChangeLogger + { + id _newAdminAddress; + uint32 _contractIndex; + uint8 _eventCode; // Event code 'adminchanged' + sint8 _terminator; + }; + + struct TokensLogger + { + uint32 _contractIndex; + uint64 _lockedTokens; // Balance tokens locked + uint64 _totalReceivedTokens; // Balance total receivedTokens + sint8 _terminator; + }; + + struct getTotalLockedTokens_locals + { + }; + + struct getTotalLockedTokens_input + { + // No input parameters + }; + + struct getTotalLockedTokens_output + { + uint64 totalLockedTokens; + }; + + // Enum for error codes + enum EthBridgeError + { + onlyManagersCanCompleteOrders = 1, + invalidAmount = 2, + insufficientTransactionFee = 3, + orderNotFound = 4, + invalidOrderState = 5, + insufficientLockedTokens = 6, + transferFailed = 7, + maxManagersReached = 8, + notAuthorized = 9, + onlyManagersCanRefundOrders = 10, + proposalNotFound = 11, + proposalAlreadyExecuted = 12, + proposalAlreadyApproved = 13, + notOwner = 14, + maxProposalsReached = 15, + noAvailableSlots = 16, + belowMinimumAmount = 17 + }; + + // Enum for proposal types + enum ProposalType + { + PROPOSAL_SET_ADMIN = 1, + PROPOSAL_ADD_MANAGER = 2, + PROPOSAL_REMOVE_MANAGER = 3, + PROPOSAL_WITHDRAW_FEES = 4, + PROPOSAL_CHANGE_THRESHOLD = 5 + }; + + // Admin proposal structure for multisig + struct AdminProposal + { + uint64 proposalId; + uint8 proposalType; // Type from ProposalType enum + id targetAddress; // For setAdmin/addManager/removeManager (new admin address) + id oldAddress; // For setAdmin: which admin to replace + uint64 amount; // For withdrawFees or changeThreshold + Array approvals; // Array of owner IDs who approved + uint8 approvalsCount; // Count of approvals + bit executed; // Whether proposal was executed + bit active; // Whether proposal is active (not cancelled) + }; + +public: + // Contract State + struct StateData + { + Array orders; + id feeRecipient; // Specific wallet to receive fees + Array managers; // Managers list + uint64 nextOrderId; // Counter for order IDs + uint64 lockedTokens; // Total locked tokens in the contract (balance) + uint64 totalReceivedTokens; // Total tokens received + uint32 sourceChain; // Source chain identifier (e.g., Ethereum=1, Qubic=0) + uint32 _tradeFeeBillionths; // Trade fee in billionths (e.g., 0.5% = 5,000,000) + uint64 _earnedFees; // Accumulated fees from trades + uint64 _distributedFees; // Fees already distributed to shareholders + uint64 _earnedFeesQubic; // Accumulated fees from Qubic trades + uint64 _distributedFeesQubic; // Fees already distributed to Qubic shareholders + uint64 _reservedFees; // Fees reserved for pending orders (not distributed yet) + uint64 _reservedFeesQubic; // Qubic fees reserved for pending orders (not distributed yet) + uint64 minimumOrderAmount; // Minimum order amount to prevent zero-fee spam + + // Multisig state + Array admins; // List of multisig admins + uint8 numberOfAdmins; // Number of active admins + uint8 requiredApprovals; // Threshold: number of approvals needed (2 of 3) + Array proposals; // Pending admin proposals + uint64 nextProposalId; // Counter for proposal IDs + }; + + // Internal methods for admin/manager permissions + typedef id isManager_input; + typedef bit isManager_output; + + struct isManager_locals + { + uint64 i; + }; + + PRIVATE_FUNCTION_WITH_LOCALS(isManager) + { + for (locals.i = 0; locals.i < state.get().managers.capacity(); ++locals.i) + { + if (state.get().managers.get(locals.i) == input) + { + output = true; + return; + } + } + output = false; + } + + typedef id isMultisigAdmin_input; + typedef bit isMultisigAdmin_output; + + struct isMultisigAdmin_locals + { + uint64 i; + }; + + PRIVATE_FUNCTION_WITH_LOCALS(isMultisigAdmin) + { + for (locals.i = 0; locals.i < (uint64)state.get().numberOfAdmins; ++locals.i) + { + if (state.get().admins.get(locals.i) == input) + { + output = true; + return; + } + } + output = false; + } + +public: + // Create a new order and lock tokens + struct createOrder_locals + { + BridgeOrder newOrder; + EthBridgeLogger log; + uint64 i; + uint64 j; + bit slotFound; + bit recyclableFound; + uint64 requiredFeeEth; + uint64 requiredFeeQubic; + uint64 totalRequiredFee; + uint64 invReward; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(createOrder) + { + // [QVB-11] Capture invocationReward immediately + locals.invReward = qpi.invocationReward(); + + // [QVB-09] Validate minimum order amount (prevents zero-fee spam) + if (input.amount < state.get().minimumOrderAmount) + { + if (locals.invReward > 0) qpi.transfer(qpi.invocator(), locals.invReward); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::belowMinimumAmount, + 0, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::belowMinimumAmount; + return; + } + + // Calculate fees as percentage of amount (0.5% each, 1% total) + locals.requiredFeeEth = div(input.amount * state.get()._tradeFeeBillionths, 1000000000ULL); + locals.requiredFeeQubic = div(input.amount * state.get()._tradeFeeBillionths, 1000000000ULL); + locals.totalRequiredFee = locals.requiredFeeEth + locals.requiredFeeQubic; + + // [QVB-09] Safety check: reject if calculated fee rounds to zero + if (locals.totalRequiredFee == 0) + { + if (locals.invReward > 0) qpi.transfer(qpi.invocator(), locals.invReward); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::insufficientTransactionFee, + 0, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::insufficientTransactionFee; + return; + } + + // [QVB-11] Verify that the fee paid is sufficient + if (static_cast(locals.invReward) < static_cast(locals.totalRequiredFee)) + { + if (locals.invReward > 0) qpi.transfer(qpi.invocator(), locals.invReward); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::insufficientTransactionFee, + 0, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::insufficientTransactionFee; + return; + } + + // [QVB-18] Validate ethAddress is not all zeros (check first 20 bytes) + locals.slotFound = false; // Reuse temporarily as "has non-zero byte" flag + for (locals.i = 0; locals.i < 20; ++locals.i) + { + if (input.ethAddress.get(locals.i) != 0) + { + locals.slotFound = true; + break; + } + } + if (!locals.slotFound) + { + if (locals.invReward > 0) qpi.transfer(qpi.invocator(), locals.invReward); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidAmount, + 0, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidAmount; + return; + } + + // [QVB-17] Set order fields WITHOUT incrementing nextOrderId yet + locals.newOrder.orderId = state.get().nextOrderId; // No ++ here + locals.newOrder.qubicSender = qpi.invocator(); + + // Set qubicDestination according to the direction + if (!input.fromQubicToEthereum) + { + // EVM → Qubic + locals.newOrder.qubicDestination = input.qubicDestination; + + // [QVB-09] Check and RESERVE liquidity + if (state.get().lockedTokens < input.amount) + { + if (locals.invReward > 0) qpi.transfer(qpi.invocator(), locals.invReward); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::insufficientLockedTokens, + 0, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::insufficientLockedTokens; + return; + } + state.mut().lockedTokens -= input.amount; + } + else + { + // Qubic → EVM + locals.newOrder.qubicDestination = qpi.invocator(); + } + + for (locals.i = 0; locals.i < 42; ++locals.i) + { + locals.newOrder.ethAddress.set(locals.i, input.ethAddress.get(locals.i)); + } + locals.newOrder.amount = input.amount; + locals.newOrder.orderType = 0; // Default order type + locals.newOrder.status = 0; // Created + locals.newOrder.fromQubicToEthereum = input.fromQubicToEthereum; + locals.newOrder.tokensReceived = false; + locals.newOrder.tokensLocked = false; + + // [QVB-02] Single-pass slot allocation: find empty slot OR track first recyclable + locals.slotFound = false; + locals.recyclableFound = false; + locals.j = 0; + + for (locals.i = 0; locals.i < state.get().orders.capacity(); ++locals.i) + { + if (state.get().orders.get(locals.i).status == 255) + { + // Empty slot found - use directly + locals.newOrder.orderId = state.mut().nextOrderId++; // [QVB-17] Increment only on success + state.mut().orders.set(locals.i, locals.newOrder); + + state.mut()._earnedFees += locals.requiredFeeEth; + state.mut()._earnedFeesQubic += locals.requiredFeeQubic; + state.mut()._reservedFees += locals.requiredFeeEth; + state.mut()._reservedFeesQubic += locals.requiredFeeQubic; + + // [QVB-11] Refund excess invocationReward + if (locals.invReward > locals.totalRequiredFee) + { + qpi.transfer(qpi.invocator(), locals.invReward - locals.totalRequiredFee); + } + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, + locals.newOrder.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = 0; + output.orderId = locals.newOrder.orderId; + return; + } + else if (!locals.recyclableFound && + (state.get().orders.get(locals.i).status == 1 || state.get().orders.get(locals.i).status == 2)) + { + // Track first recyclable slot (completed or refunded) + locals.recyclableFound = true; + locals.j = locals.i; + } + } + + // No empty slot. Try recyclable slot. + if (locals.recyclableFound) + { + locals.newOrder.orderId = state.mut().nextOrderId++; // [QVB-17] Increment only on success + state.mut().orders.set(locals.j, locals.newOrder); + + state.mut()._earnedFees += locals.requiredFeeEth; + state.mut()._earnedFeesQubic += locals.requiredFeeQubic; + state.mut()._reservedFees += locals.requiredFeeEth; + state.mut()._reservedFeesQubic += locals.requiredFeeQubic; + + // [QVB-11] Refund excess invocationReward + if (locals.invReward > locals.totalRequiredFee) + { + qpi.transfer(qpi.invocator(), locals.invReward - locals.totalRequiredFee); + } + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, + locals.newOrder.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = 0; + output.orderId = locals.newOrder.orderId; + return; + } + + // [QVB-09] Undo liquidity reservation if we can't create the order + if (!input.fromQubicToEthereum) + { + state.mut().lockedTokens += input.amount; + } + + // No slots available at all - refund everything + if (locals.invReward > 0) qpi.transfer(qpi.invocator(), locals.invReward); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::noAvailableSlots, + 0, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::noAvailableSlots; + return; + } + + // Retrieve an order + struct getOrder_locals + { + EthBridgeLogger log; + BridgeOrder order; + OrderResponse orderResp; + uint64 i; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getOrder) + { + for (locals.i = 0; locals.i < state.get().orders.capacity(); ++locals.i) + { + locals.order = state.get().orders.get(locals.i); + if (locals.order.orderId == input.orderId && locals.order.status != 255) + { + // Populate OrderResponse with BridgeOrder data + locals.orderResp.orderId = locals.order.orderId; + locals.orderResp.originAccount = locals.order.qubicSender; + locals.orderResp.destinationAccount = locals.order.ethAddress; + locals.orderResp.amount = locals.order.amount; + locals.orderResp.sourceChain = state.get().sourceChain; + locals.orderResp.qubicDestination = locals.order.qubicDestination; + locals.orderResp.status = locals.order.status; + + output.status = 0; // Success + output.order = locals.orderResp; + return; + } + } + + // If order not found + output.status = 1; // Error + } + + // Multisig Proposal Functions + + // Create proposal structures + struct createProposal_input + { + uint8 proposalType; // Type of proposal + id targetAddress; // Target address (new admin/manager address) + id oldAddress; // Old address (for setAdmin: which admin to replace) + uint64 amount; // Amount (for withdrawFees or changeThreshold) + }; + + struct createProposal_output + { + uint8 status; + uint64 proposalId; + }; + + struct createProposal_locals + { + EthBridgeLogger log; + id invocatorAddress; + uint64 i; + uint64 j; + bit slotFound; + bit recyclableFound; + uint64 slotIndex; + AdminProposal newProposal; + bit isMultisigAdminResult; + }; + + // Approve proposal structures + struct approveProposal_input + { + uint64 proposalId; + }; + + struct approveProposal_output + { + uint8 status; + bit executed; + }; + + struct approveProposal_locals + { + EthBridgeLogger log; + id invocatorAddress; + AddressChangeLogger adminLog; + AdminProposal proposal; + uint64 i; + bit found; + bit alreadyApproved; + bit isMultisigAdminResult; + uint64 proposalIndex; + uint64 availableFees; + bit adminAdded; + uint64 managerCount; + bit actionSucceeded; + }; + + // Get proposal structures + struct getProposal_input + { + uint64 proposalId; + }; + + struct getProposal_output + { + uint8 status; + AdminProposal proposal; + }; + + struct getProposal_locals + { + uint64 i; + }; + + // Create a new proposal (only multisig admins can create) + PUBLIC_PROCEDURE_WITH_LOCALS(createProposal) + { + // Verify that the invocator is a multisig admin + locals.invocatorAddress = qpi.invocator(); + CALL(isMultisigAdmin, locals.invocatorAddress, locals.isMultisigAdminResult); + if (!locals.isMultisigAdminResult) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::notOwner, + 0, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::notOwner; + return; + } + + // Validate proposal type + if (input.proposalType < PROPOSAL_SET_ADMIN || input.proposalType > PROPOSAL_CHANGE_THRESHOLD) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidAmount, + 0, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidAmount; + return; + } + + // [QVB-18] Validate targetAddress for proposals that require it + if (input.proposalType == PROPOSAL_SET_ADMIN || + input.proposalType == PROPOSAL_ADD_MANAGER || + input.proposalType == PROPOSAL_REMOVE_MANAGER) + { + if (input.targetAddress == NULL_ID) + { + output.status = EthBridgeError::invalidAmount; + return; + } + } + if (input.proposalType == PROPOSAL_SET_ADMIN) + { + if (input.oldAddress == NULL_ID) + { + output.status = EthBridgeError::invalidAmount; + return; + } + } + + // [QVB-03] Single-pass: find empty slot AND track first recyclable + locals.slotFound = false; + locals.recyclableFound = false; + locals.slotIndex = 0; + locals.j = 0; + + for (locals.i = 0; locals.i < state.get().proposals.capacity(); ++locals.i) + { + if (!state.get().proposals.get(locals.i).active && state.get().proposals.get(locals.i).proposalId == 0) + { + // Empty slot + if (!locals.slotFound) + { + locals.slotFound = true; + locals.slotIndex = locals.i; + } + } + else if (!locals.recyclableFound && + (state.get().proposals.get(locals.i).executed || + (!state.get().proposals.get(locals.i).active && state.get().proposals.get(locals.i).proposalId > 0))) + { + // First recyclable slot (executed or abandoned) + locals.recyclableFound = true; + locals.j = locals.i; + } + } + + // Use empty slot if found, otherwise recycle + if (!locals.slotFound && locals.recyclableFound) + { + // Reuse the recyclable slot directly (will be overwritten with new proposal below) + locals.slotFound = true; + locals.slotIndex = locals.j; + } + + if (!locals.slotFound) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::maxProposalsReached, + 0, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::maxProposalsReached; + return; + } + + // Create the new proposal + locals.newProposal.proposalId = state.mut().nextProposalId++; + locals.newProposal.proposalType = input.proposalType; + locals.newProposal.targetAddress = input.targetAddress; + locals.newProposal.oldAddress = input.oldAddress; + locals.newProposal.amount = input.amount; + locals.newProposal.approvalsCount = 1; // Creator automatically approves + locals.newProposal.executed = false; + locals.newProposal.active = true; + + // Set creator as first approver + locals.newProposal.approvals.set(0, qpi.invocator()); + + // Store the proposal + state.mut().proposals.set(locals.slotIndex, locals.newProposal); + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, // No error + locals.newProposal.proposalId, + input.amount, + 0 }; + LOG_INFO(locals.log); + + output.status = 0; // Success + output.proposalId = locals.newProposal.proposalId; + } + + // Approve a proposal (only multisig admins can approve) + PUBLIC_PROCEDURE_WITH_LOCALS(approveProposal) + { + // Verify that the invocator is a multisig admin + locals.invocatorAddress = qpi.invocator(); + CALL(isMultisigAdmin, locals.invocatorAddress, locals.isMultisigAdminResult); + if (!locals.isMultisigAdminResult) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::notOwner, + 0, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::notOwner; + output.executed = false; + return; + } + + // Find the proposal + locals.found = false; + for (locals.i = 0; locals.i < state.get().proposals.capacity(); ++locals.i) + { + locals.proposal = state.get().proposals.get(locals.i); + if (locals.proposal.proposalId == input.proposalId && locals.proposal.active) + { + locals.found = true; + locals.proposalIndex = locals.i; + break; + } + } + + if (!locals.found) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::proposalNotFound, + input.proposalId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::proposalNotFound; + output.executed = false; + return; + } + + // Check if already executed + if (locals.proposal.executed) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::proposalAlreadyExecuted, + input.proposalId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::proposalAlreadyExecuted; + output.executed = false; + return; + } + + // Check if this owner has already approved + locals.alreadyApproved = false; + for (locals.i = 0; locals.i < (uint64)locals.proposal.approvalsCount; ++locals.i) + { + if (locals.proposal.approvals.get(locals.i) == qpi.invocator()) + { + locals.alreadyApproved = true; + break; + } + } + + if (locals.alreadyApproved) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::proposalAlreadyApproved, + input.proposalId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::proposalAlreadyApproved; + output.executed = false; + return; + } + + // Add approval + locals.proposal.approvals.set((uint64)locals.proposal.approvalsCount, qpi.invocator()); + locals.proposal.approvalsCount++; + + // Check if threshold reached and execute + if (locals.proposal.approvalsCount >= state.get().requiredApprovals) + { + // [QVB-20] Track whether the action actually succeeded + locals.actionSucceeded = false; + + // Execute the proposal based on type + if (locals.proposal.proposalType == PROPOSAL_SET_ADMIN) + { + // Replace existing admin with new admin (max 3 admins: 2 of 3 multisig) + locals.adminAdded = false; + for (locals.i = 0; locals.i < (uint64)state.get().numberOfAdmins; ++locals.i) + { + if (state.get().admins.get(locals.i) == locals.proposal.targetAddress) + { + locals.adminAdded = true; + break; + } + } + + if (!locals.adminAdded) + { + for (locals.i = 0; locals.i < state.get().admins.capacity(); ++locals.i) + { + if (state.get().admins.get(locals.i) == locals.proposal.oldAddress) + { + state.mut().admins.set(locals.i, locals.proposal.targetAddress); + locals.actionSucceeded = true; + locals.adminLog = AddressChangeLogger{ + locals.proposal.targetAddress, + CONTRACT_INDEX, + 1, // Admin changed + 0 }; + LOG_INFO(locals.adminLog); + break; + } + } + } + } + else if (locals.proposal.proposalType == PROPOSAL_ADD_MANAGER) + { + locals.adminAdded = false; + locals.managerCount = 0; + for (locals.i = 0; locals.i < state.get().managers.capacity(); ++locals.i) + { + if (state.get().managers.get(locals.i) == locals.proposal.targetAddress) + { + locals.adminAdded = true; + break; + } + if (state.get().managers.get(locals.i) != NULL_ID) + { + locals.managerCount++; + } + } + + if (locals.managerCount >= 3) + { + locals.adminAdded = true; + } + + if (!locals.adminAdded) + { + for (locals.i = 0; locals.i < state.get().managers.capacity(); ++locals.i) + { + if (state.get().managers.get(locals.i) == NULL_ID) + { + state.mut().managers.set(locals.i, locals.proposal.targetAddress); + locals.actionSucceeded = true; + locals.adminLog = AddressChangeLogger{ + locals.proposal.targetAddress, + CONTRACT_INDEX, + 2, // Manager added + 0 }; + LOG_INFO(locals.adminLog); + break; + } + } + } + } + else if (locals.proposal.proposalType == PROPOSAL_REMOVE_MANAGER) + { + for (locals.i = 0; locals.i < state.get().managers.capacity(); ++locals.i) + { + if (state.get().managers.get(locals.i) == locals.proposal.targetAddress) + { + state.mut().managers.set(locals.i, NULL_ID); + locals.actionSucceeded = true; + locals.adminLog = AddressChangeLogger{ + locals.proposal.targetAddress, + CONTRACT_INDEX, + 3, // Manager removed + 0 }; + LOG_INFO(locals.adminLog); + break; + } + } + } + else if (locals.proposal.proposalType == PROPOSAL_WITHDRAW_FEES) + { + locals.availableFees = (state.get()._earnedFees > (state.get()._distributedFees + state.get()._reservedFees)) + ? (state.get()._earnedFees - state.get()._distributedFees - state.get()._reservedFees) + : 0; + if (locals.proposal.amount <= locals.availableFees && locals.proposal.amount > 0) + { + if (qpi.transfer(state.get().feeRecipient, locals.proposal.amount) >= 0) + { + state.mut()._distributedFees += locals.proposal.amount; + locals.actionSucceeded = true; + } + } + } + else if (locals.proposal.proposalType == PROPOSAL_CHANGE_THRESHOLD) + { + if (locals.proposal.amount >= 2 && locals.proposal.amount <= (uint64)state.get().numberOfAdmins) + { + state.mut().requiredApprovals = (uint8)locals.proposal.amount; + locals.actionSucceeded = true; + } + } + + // [QVB-20] Only mark as executed if action actually succeeded + if (locals.actionSucceeded) + { + locals.proposal.executed = true; + output.executed = true; + } + else + { + // Action failed - deactivate proposal so admins can create a new one + locals.proposal.active = false; + output.executed = false; + + state.mut().proposals.set(locals.proposalIndex, locals.proposal); + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidOrderState, + input.proposalId, + locals.proposal.approvalsCount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidOrderState; + return; + } + } + else + { + output.executed = false; + } + + // Update the proposal + state.mut().proposals.set(locals.proposalIndex, locals.proposal); + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, // No error + input.proposalId, + locals.proposal.approvalsCount, + 0 }; + LOG_INFO(locals.log); + + output.status = 0; // Success + } + + // Get proposal details + PUBLIC_FUNCTION_WITH_LOCALS(getProposal) + { + for (locals.i = 0; locals.i < state.get().proposals.capacity(); ++locals.i) + { + if (state.get().proposals.get(locals.i).proposalId == input.proposalId) + { + output.proposal = state.get().proposals.get(locals.i); + output.status = 0; // Success + return; + } + } + + output.status = EthBridgeError::proposalNotFound; + } + + // Cancel proposal structures + struct cancelProposal_input + { + uint64 proposalId; + }; + + struct cancelProposal_output + { + uint8 status; + }; + + struct cancelProposal_locals + { + EthBridgeLogger log; + AdminProposal proposal; + uint64 i; + bit found; + }; + + // Cancel a proposal (only the creator can cancel) + PUBLIC_PROCEDURE_WITH_LOCALS(cancelProposal) + { + // Find the proposal + locals.found = false; + for (locals.i = 0; locals.i < state.get().proposals.capacity(); ++locals.i) + { + locals.proposal = state.get().proposals.get(locals.i); + if (locals.proposal.proposalId == input.proposalId && locals.proposal.active) + { + locals.found = true; + break; + } + } + + if (!locals.found) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::proposalNotFound, + input.proposalId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::proposalNotFound; + return; + } + + // Check if already executed + if (locals.proposal.executed) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::proposalAlreadyExecuted, + input.proposalId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::proposalAlreadyExecuted; + return; + } + + // Verify that the invocator is the creator (first approver) + if (locals.proposal.approvals.get(0) != qpi.invocator()) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::notAuthorized, + input.proposalId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::notAuthorized; + return; + } + + // Cancel the proposal by marking it as inactive + locals.proposal.active = false; + state.mut().proposals.set(locals.i, locals.proposal); + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, // No error + input.proposalId, + 0, + 0 }; + LOG_INFO(locals.log); + + output.status = 0; // Success + } + + struct getTotalReceivedTokens_locals + { + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getTotalReceivedTokens) + { + output.totalTokens = state.get().totalReceivedTokens; + } + + struct completeOrder_locals + { + EthBridgeLogger log; + id invocatorAddress; + bit isManagerOperating; + bit orderFound; + BridgeOrder order; + TokensLogger logTokens; + uint64 i; + uint64 feeOperator; + uint64 feeNetwork; + }; + + // Complete an order and release tokens + PUBLIC_PROCEDURE_WITH_LOCALS(completeOrder) + { + locals.invocatorAddress = qpi.invocator(); + locals.isManagerOperating = false; + CALL(isManager, locals.invocatorAddress, locals.isManagerOperating); + + // Verify that the invocator is a manager + if (!locals.isManagerOperating) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::onlyManagersCanCompleteOrders, + input.orderId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::onlyManagersCanCompleteOrders; // Error: not a manager + return; + } + + // Check if the order exists + locals.orderFound = false; + for (locals.i = 0; locals.i < state.get().orders.capacity(); ++locals.i) + { + if (state.get().orders.get(locals.i).orderId == input.orderId) + { + locals.order = state.get().orders.get(locals.i); + locals.orderFound = true; + break; + } + } + + // Order not found + if (!locals.orderFound) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::orderNotFound, + input.orderId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::orderNotFound; // Error + return; + } + + // Check order status + if (locals.order.status != 0) + { // Check it is not completed or refunded already + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidOrderState, + input.orderId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidOrderState; // Error + return; + } + + // Handle order based on transfer direction + if (locals.order.fromQubicToEthereum) + { + // Qubic → Ethereum + // [QVB-21] Only check that tokens were received (tokensLocked is set here as part of two-phase commit) + if (!locals.order.tokensReceived) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidOrderState, + input.orderId, + locals.order.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidOrderState; + return; + } + + // [QVB-21] Set tokensLocked as part of completion (two-phase commit) + locals.order.tokensLocked = true; + } + else + { + // EVM → Qubic + // [QVB-09] Liquidity already reserved at createOrder - just transfer to user + // [QVB-04] Use locals.order.amount directly (removed redundant netAmount) + if (qpi.transfer(locals.order.qubicDestination, locals.order.amount) < 0) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::transferFailed, + input.orderId, + locals.order.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::transferFailed; + return; + } + + locals.logTokens = TokensLogger{ + CONTRACT_INDEX, + state.get().lockedTokens, + state.get().totalReceivedTokens, + 0 }; + LOG_INFO(locals.logTokens); + } + + // Release reserved fees now that order is completed (fees can now be distributed) + locals.feeOperator = div(locals.order.amount * state.get()._tradeFeeBillionths, 1000000000ULL); + locals.feeNetwork = div(locals.order.amount * state.get()._tradeFeeBillionths, 1000000000ULL); + + // UNDERFLOW PROTECTION: Only release if enough reserved + if (state.get()._reservedFees >= locals.feeOperator) + { + state.mut()._reservedFees -= locals.feeOperator; + } + if (state.get()._reservedFeesQubic >= locals.feeNetwork) + { + state.mut()._reservedFeesQubic -= locals.feeNetwork; + } + + // Mark the order as completed + locals.order.status = 1; // Completed + state.mut().orders.set(locals.i, locals.order); // Use the loop index + + output.status = 0; // Success + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, // No error + input.orderId, + locals.order.amount, + 0 }; + LOG_INFO(locals.log); + } + + // Refund an order and unlock tokens + struct refundOrder_locals + { + EthBridgeLogger log; + id invocatorAddress; + bit isManagerOperating; + bit orderFound; + BridgeOrder order; + uint64 i; + uint64 feeOperator; + uint64 feeNetwork; + uint64 totalRefund; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(refundOrder) + { + locals.invocatorAddress = qpi.invocator(); + locals.isManagerOperating = false; + CALL(isManager, locals.invocatorAddress, locals.isManagerOperating); + + // Check if the order is handled by a manager + if (!locals.isManagerOperating) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::onlyManagersCanRefundOrders, + input.orderId, + 0, // No amount involved + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::onlyManagersCanRefundOrders; // Error + return; + } + + // Retrieve the order + locals.orderFound = false; + for (locals.i = 0; locals.i < state.get().orders.capacity(); ++locals.i) + { + if (state.get().orders.get(locals.i).orderId == input.orderId) + { + locals.order = state.get().orders.get(locals.i); + locals.orderFound = true; + break; + } + } + + // Order not found + if (!locals.orderFound) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::orderNotFound, + input.orderId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::orderNotFound; // Error + return; + } + + // Check order status + if (locals.order.status != 0) + { // Check it is not completed or refunded already + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidOrderState, + input.orderId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidOrderState; // Error + return; + } + + // Calculate fees for this order + locals.feeOperator = div(locals.order.amount * state.get()._tradeFeeBillionths, 1000000000ULL); + locals.feeNetwork = div(locals.order.amount * state.get()._tradeFeeBillionths, 1000000000ULL); + + // Handle refund based on transfer direction + if (locals.order.fromQubicToEthereum) + { + // Qubic → Ethereum refund + if (!locals.order.tokensReceived) + { + // Path A: No tokens transferred yet - refund only fees + // [QVB-12] Calculate refund amount WITHOUT modifying state first + locals.totalRefund = 0; + if (state.get()._reservedFees >= locals.feeOperator && state.get()._earnedFees >= locals.feeOperator) + { + locals.totalRefund += locals.feeOperator; + } + if (state.get()._reservedFeesQubic >= locals.feeNetwork && state.get()._earnedFeesQubic >= locals.feeNetwork) + { + locals.totalRefund += locals.feeNetwork; + } + + // Transfer first - if it fails, no state is corrupted + if (locals.totalRefund > 0) + { + if (qpi.transfer(locals.order.qubicSender, locals.totalRefund) < 0) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::transferFailed, + input.orderId, + locals.totalRefund, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::transferFailed; + return; + } + } + + // [QVB-12] Only update state AFTER successful transfer + if (state.get()._reservedFees >= locals.feeOperator && state.get()._earnedFees >= locals.feeOperator) + { + state.mut()._reservedFees -= locals.feeOperator; + state.mut()._earnedFees -= locals.feeOperator; + } + if (state.get()._reservedFeesQubic >= locals.feeNetwork && state.get()._earnedFeesQubic >= locals.feeNetwork) + { + state.mut()._reservedFeesQubic -= locals.feeNetwork; + state.mut()._earnedFeesQubic -= locals.feeNetwork; + } + + locals.order.status = 2; + state.mut().orders.set(locals.i, locals.order); + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, + input.orderId, + locals.totalRefund, + 0 }; + LOG_INFO(locals.log); + output.status = 0; + return; + } + else + { + // Path B: Tokens received - refund amount + fees + // [QVB-21] Handles both !tokensLocked (intermediate) and tokensLocked (defensive) states + if (state.get().lockedTokens < locals.order.amount) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::insufficientLockedTokens, + input.orderId, + locals.order.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::insufficientLockedTokens; + return; + } + + // [QVB-12] Calculate total refund WITHOUT modifying state + locals.totalRefund = locals.order.amount; + if (state.get()._reservedFees >= locals.feeOperator && state.get()._earnedFees >= locals.feeOperator) + { + locals.totalRefund += locals.feeOperator; + } + if (state.get()._reservedFeesQubic >= locals.feeNetwork && state.get()._earnedFeesQubic >= locals.feeNetwork) + { + locals.totalRefund += locals.feeNetwork; + } + + // Transfer first + if (qpi.transfer(locals.order.qubicSender, locals.totalRefund) < 0) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::transferFailed, + input.orderId, + locals.totalRefund, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::transferFailed; + return; + } + + // [QVB-12] Update state only after successful transfer + state.mut().lockedTokens -= locals.order.amount; + if (state.get()._reservedFees >= locals.feeOperator && state.get()._earnedFees >= locals.feeOperator) + { + state.mut()._reservedFees -= locals.feeOperator; + state.mut()._earnedFees -= locals.feeOperator; + } + if (state.get()._reservedFeesQubic >= locals.feeNetwork && state.get()._earnedFeesQubic >= locals.feeNetwork) + { + state.mut()._reservedFeesQubic -= locals.feeNetwork; + state.mut()._earnedFeesQubic -= locals.feeNetwork; + } + + locals.order.status = 2; + state.mut().orders.set(locals.i, locals.order); + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, + input.orderId, + locals.totalRefund, + 0 }; + LOG_INFO(locals.log); + output.status = 0; + return; + } + } + else + { + // EVM → Qubic refund + // [QVB-09] Restore reserved liquidity (was decremented at createOrder) + state.mut().lockedTokens += locals.order.amount; + + // Release fee reserves (fees stay earned - paid by bridge operator, not end user) + if (state.get()._reservedFees >= locals.feeOperator) + { + state.mut()._reservedFees -= locals.feeOperator; + } + if (state.get()._reservedFeesQubic >= locals.feeNetwork) + { + state.mut()._reservedFeesQubic -= locals.feeNetwork; + } + + locals.order.status = 2; + state.mut().orders.set(locals.i, locals.order); + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, + input.orderId, + locals.order.amount, + 0 }; + LOG_INFO(locals.log); + output.status = 0; + return; + } + } + + // Transfer tokens to the contract + struct transferToContract_locals + { + EthBridgeLogger log; + TokensLogger logTokens; + BridgeOrder order; + bit orderFound; + uint64 i; + uint64 depositAmount; + }; + + PUBLIC_PROCEDURE_WITH_LOCALS(transferToContract) + { + // [QVB-10] Capture invocationReward immediately so we can refund on any error + locals.depositAmount = qpi.invocationReward(); + + if (input.amount == 0) + { + if (locals.depositAmount > 0) qpi.transfer(qpi.invocator(), locals.depositAmount); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidAmount, + input.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidAmount; + return; + } + + // Find the order + locals.orderFound = false; + for (locals.i = 0; locals.i < state.get().orders.capacity(); ++locals.i) + { + if (state.get().orders.get(locals.i).orderId == input.orderId) + { + locals.order = state.get().orders.get(locals.i); + locals.orderFound = true; + break; + } + } + + if (!locals.orderFound) + { + if (locals.depositAmount > 0) qpi.transfer(qpi.invocator(), locals.depositAmount); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::orderNotFound, + input.orderId, + 0, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::orderNotFound; + return; + } + + // Verify sender is the original order creator + if (locals.order.qubicSender != qpi.invocator()) + { + if (locals.depositAmount > 0) qpi.transfer(qpi.invocator(), locals.depositAmount); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::notAuthorized, + input.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::notAuthorized; + return; + } + + // Verify order state + if (locals.order.status != 0) + { + if (locals.depositAmount > 0) qpi.transfer(qpi.invocator(), locals.depositAmount); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidOrderState, + input.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidOrderState; + return; + } + + // Verify tokens not already received + if (locals.order.tokensReceived) + { + if (locals.depositAmount > 0) qpi.transfer(qpi.invocator(), locals.depositAmount); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidOrderState, + input.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidOrderState; + return; + } + + // Verify amount matches order + if (input.amount != locals.order.amount) + { + if (locals.depositAmount > 0) qpi.transfer(qpi.invocator(), locals.depositAmount); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidAmount, + input.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidAmount; + return; + } + + // [QVB-22] Reject EVM→Qubic orders (they don't need transferToContract) + if (!locals.order.fromQubicToEthereum) + { + if (locals.depositAmount > 0) qpi.transfer(qpi.invocator(), locals.depositAmount); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidOrderState, + input.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidOrderState; + return; + } + + // Only Qubic→Ethereum orders proceed from here + // Check if user sent enough tokens + if (locals.depositAmount < input.amount) + { + // Not enough - refund everything and return error + if (locals.depositAmount > 0) + { + qpi.transfer(qpi.invocator(), locals.depositAmount); + } + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidAmount, + input.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidAmount; + return; + } + + // Lock only the required amount + state.mut().lockedTokens += input.amount; + state.mut().totalReceivedTokens += input.amount; + + // Refund excess if user sent too much + if (locals.depositAmount > input.amount) + { + qpi.transfer(qpi.invocator(), locals.depositAmount - input.amount); + } + + // [QVB-21] Only mark tokens as received, NOT locked + // tokensLocked will be set by completeOrder (two-phase commit) + locals.order.tokensReceived = true; + state.mut().orders.set(locals.i, locals.order); + + locals.logTokens = TokensLogger{ + CONTRACT_INDEX, + state.get().lockedTokens, + state.get().totalReceivedTokens, + 0 }; + LOG_INFO(locals.logTokens); + + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, + input.orderId, + input.amount, + 0 }; + LOG_INFO(locals.log); + output.status = 0; + } + + PUBLIC_FUNCTION_WITH_LOCALS(getTotalLockedTokens) + { + output.totalLockedTokens = state.get().lockedTokens; + } + + // Structure for the input of the getOrderByDetails function + struct getOrderByDetails_input + { + Array ethAddress; // Ethereum address + uint64 amount; // Transaction amount + uint8 status; // Order status (0 = created, 1 = completed, 2 = refunded) + }; + + // Structure for the output of the getOrderByDetails function + struct getOrderByDetails_output + { + uint8 status; // Operation status (0 = success, other = error) + uint64 orderId; // ID of the found order + id qubicDestination; // Destination address on Qubic (for EVM to Qubic orders) + }; + + // Function to search for an order by details + struct getOrderByDetails_locals + { + uint64 i; + uint64 j; + bit addressMatch; // Flag to check if addresses match + BridgeOrder order; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getOrderByDetails) + { + // Validate input parameters + if (input.amount == 0) + { + output.status = 2; // Error: invalid amount + output.orderId = 0; + return; + } + + // Iterate through all orders + for (locals.i = 0; locals.i < state.get().orders.capacity(); ++locals.i) + { + locals.order = state.get().orders.get(locals.i); + + // Check if the order matches the criteria + if (locals.order.status == 255) // Empty slot + continue; + + // Compare ethAddress arrays element by element + locals.addressMatch = true; + for (locals.j = 0; locals.j < 42; ++locals.j) + { + if (locals.order.ethAddress.get(locals.j) != input.ethAddress.get(locals.j)) + { + locals.addressMatch = false; + break; + } + } + + // Verify exact match + if (locals.addressMatch && + locals.order.amount == input.amount && + locals.order.status == input.status) + { + // Found an exact match + output.status = 0; // Success + output.orderId = locals.order.orderId; + return; + } + } + + // If no matching order was found + output.status = 1; // Not found + output.orderId = 0; + } + + // Add Liquidity structures + struct addLiquidity_input + { + // No input parameters - amount comes from qpi.invocationReward() + }; + + struct addLiquidity_output + { + uint8 status; // Operation status (0 = success, other = error) + uint64 addedAmount; // Amount of tokens added to liquidity + uint64 totalLocked; // Total locked tokens after addition + }; + + struct addLiquidity_locals + { + EthBridgeLogger log; + id invocatorAddress; + bit isManagerOperating; + bit isMultisigAdminResult; + uint64 depositAmount; + }; + + // Add liquidity to the bridge (for managers or multisig admins to provide initial/additional liquidity) + PUBLIC_PROCEDURE_WITH_LOCALS(addLiquidity) + { + locals.invocatorAddress = qpi.invocator(); + + // [QVB-10] Capture deposit immediately so we can refund on auth failure + locals.depositAmount = qpi.invocationReward(); + + locals.isManagerOperating = false; + CALL(isManager, locals.invocatorAddress, locals.isManagerOperating); + + locals.isMultisigAdminResult = false; + CALL(isMultisigAdmin, locals.invocatorAddress, locals.isMultisigAdminResult); + + // Verify that the invocator is a manager or multisig admin + if (!locals.isManagerOperating && !locals.isMultisigAdminResult) + { + // [QVB-10] Refund before returning + if (locals.depositAmount > 0) qpi.transfer(qpi.invocator(), locals.depositAmount); + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::notAuthorized, + 0, + 0, + 0 + }; + LOG_INFO(locals.log); + output.status = EthBridgeError::notAuthorized; + return; + } + + // Validate that some tokens were sent + if (locals.depositAmount == 0) + { + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + EthBridgeError::invalidAmount, + 0, + 0, + 0 + }; + LOG_INFO(locals.log); + output.status = EthBridgeError::invalidAmount; + return; + } + + // Add the deposited tokens to the locked tokens pool + state.mut().lockedTokens += locals.depositAmount; + state.mut().totalReceivedTokens += locals.depositAmount; + + // Log the successful liquidity addition + locals.log = EthBridgeLogger{ + CONTRACT_INDEX, + 0, // No error + 0, // No order ID involved + locals.depositAmount, // Amount added + 0 + }; + LOG_INFO(locals.log); + + // Set output values + output.status = 0; // Success + output.addedAmount = locals.depositAmount; + output.totalLocked = state.get().lockedTokens; + } + + + PUBLIC_FUNCTION(getAvailableFees) + { + // Available fees exclude those reserved for pending orders + output.availableFees = (state.get()._earnedFees > (state.get()._distributedFees + state.get()._reservedFees)) + ? (state.get()._earnedFees - state.get()._distributedFees - state.get()._reservedFees) + : 0; + output.totalEarnedFees = state.get()._earnedFees; + output.totalDistributedFees = state.get()._distributedFees; + } + + + struct getContractInfo_locals + { + uint64 i; + }; + + PUBLIC_FUNCTION_WITH_LOCALS(getContractInfo) + { + output.managers = state.get().managers; + output.nextOrderId = state.get().nextOrderId; + output.lockedTokens = state.get().lockedTokens; + output.totalReceivedTokens = state.get().totalReceivedTokens; + output.earnedFees = state.get()._earnedFees; + output.tradeFeeBillionths = state.get()._tradeFeeBillionths; + output.sourceChain = state.get().sourceChain; + + + output.totalOrdersFound = 0; + output.emptySlots = 0; + + for (locals.i = 0; locals.i < 16 && locals.i < state.get().orders.capacity(); ++locals.i) + { + output.firstOrders.set(locals.i, state.get().orders.get(locals.i)); + } + + // Count real orders vs empty ones + for (locals.i = 0; locals.i < state.get().orders.capacity(); ++locals.i) + { + if (state.get().orders.get(locals.i).status == 255) + { + output.emptySlots++; + } + else + { + output.totalOrdersFound++; + } + } + + // Multisig info + output.multisigAdmins = state.get().admins; + output.numberOfAdmins = state.get().numberOfAdmins; + output.requiredApprovals = state.get().requiredApprovals; + + // Count active proposals + output.totalProposals = 0; + for (locals.i = 0; locals.i < state.get().proposals.capacity(); ++locals.i) + { + if (state.get().proposals.get(locals.i).active && state.get().proposals.get(locals.i).proposalId > 0) + { + output.totalProposals++; + } + } + } + + // Called at the end of every tick to distribute earned fees + struct END_TICK_locals + { + uint64 feesToDistributeInThisTick; + uint64 amountPerComputor; + uint64 vottunFeesToDistribute; + }; + + END_TICK_WITH_LOCALS() + { + // Calculate available fees for distribution (earned - distributed - reserved for pending orders) + locals.feesToDistributeInThisTick = (state.get()._earnedFeesQubic > (state.get()._distributedFeesQubic + state.get()._reservedFeesQubic)) + ? (state.get()._earnedFeesQubic - state.get()._distributedFeesQubic - state.get()._reservedFeesQubic) + : 0; + + if (locals.feesToDistributeInThisTick > 0) + { + // Distribute fees to computors holding shares of this contract. + // NUMBER_OF_COMPUTORS is a Qubic global constant (typically 676). + locals.amountPerComputor = div(locals.feesToDistributeInThisTick, (uint64)NUMBER_OF_COMPUTORS); + + if (locals.amountPerComputor > 0) + { + if (qpi.distributeDividends(locals.amountPerComputor)) + { + state.mut()._distributedFeesQubic += locals.amountPerComputor * NUMBER_OF_COMPUTORS; + } + } + } + + // Distribution of Vottun fees to feeRecipient (excluding reserved fees) + locals.vottunFeesToDistribute = (state.get()._earnedFees > (state.get()._distributedFees + state.get()._reservedFees)) + ? (state.get()._earnedFees - state.get()._distributedFees - state.get()._reservedFees) + : 0; + + if (locals.vottunFeesToDistribute > 0 && state.get().feeRecipient != NULL_ID) + { + // [QVB-13] Check for non-negative return (success), not truthy (which -1 also satisfies) + if (qpi.transfer(state.get().feeRecipient, locals.vottunFeesToDistribute) >= 0) + { + state.mut()._distributedFees += locals.vottunFeesToDistribute; + } + } + } + + // Register Functions and Procedures + REGISTER_USER_FUNCTIONS_AND_PROCEDURES() + { + REGISTER_USER_FUNCTION(getOrder, 1); + REGISTER_USER_FUNCTION(isManager, 2); + REGISTER_USER_FUNCTION(getTotalReceivedTokens, 3); + REGISTER_USER_FUNCTION(getTotalLockedTokens, 4); + REGISTER_USER_FUNCTION(getOrderByDetails, 5); + REGISTER_USER_FUNCTION(getContractInfo, 6); + REGISTER_USER_FUNCTION(getAvailableFees, 7); + REGISTER_USER_FUNCTION(getProposal, 8); + + REGISTER_USER_PROCEDURE(createOrder, 1); + REGISTER_USER_PROCEDURE(completeOrder, 2); + REGISTER_USER_PROCEDURE(refundOrder, 3); + REGISTER_USER_PROCEDURE(transferToContract, 4); + REGISTER_USER_PROCEDURE(addLiquidity, 5); + REGISTER_USER_PROCEDURE(createProposal, 6); + REGISTER_USER_PROCEDURE(approveProposal, 7); + REGISTER_USER_PROCEDURE(cancelProposal, 8); + } + + // Initialize the contract with SECURE ADMIN CONFIGURATION + struct INITIALIZE_locals + { + uint64 i; + BridgeOrder emptyOrder; + AdminProposal emptyProposal; + }; + + INITIALIZE_WITH_LOCALS() + { + // Initialize the wallet that receives operator fees (Vottun) + state.mut().feeRecipient = ID(_M, _A, _G, _K, _B, _C, _B, _I, _X, _N, _W, _S, _K, _C, _I, _G, _J, _Y, _K, _G, _S, _N, _F, _S, _F, _R, _W, _A, _L, _H, _D, _F, _D, _B, _K, _K, _P, _C, _U, _N, _S, _E, _R, _I, _K, _L, _J, _G, _M, _D, _K, _L, _Z, _V, _V, _D); + + // Initialize the orders array. Good practice to zero first. + locals.emptyOrder = {}; // Sets all fields to 0 (including orderId and status). + locals.emptyOrder.status = 255; // Then set your status for empty. + + for (locals.i = 0; locals.i < state.get().orders.capacity(); ++locals.i) + { + state.mut().orders.set(locals.i, locals.emptyOrder); + } + + // Initialize the managers array with NULL_ID to mark slots as empty + for (locals.i = 0; locals.i < state.get().managers.capacity(); ++locals.i) + { + state.mut().managers.set(locals.i, NULL_ID); + } + + // Add the initial manager + state.mut().managers.set(0, ID(_U, _X, _V, _K, _B, _Y, _L, _Q, _Z, _I, _U, _L, _C, _B, _F, _F, _I, _L, _P, _T, _X, _O, _W, _Y, _M, _Y, _H, _D, _K, _P, _R, _Y, _W, _R, _E, _Y, _Q, _T, _Y, _Y, _A, _C, _T, _F, _W, _Q, _V, _O, _N, _P, _F, _E, _L, _P, _G, _A)); + + // Initialize the rest of the state variables + state.mut().nextOrderId = 1; // Start from 1 to avoid ID 0 + state.mut().lockedTokens = 0; + state.mut().totalReceivedTokens = 0; + state.mut().sourceChain = 0; // Arbitrary number. No-EVM chain + + // Initialize fee variables + state.mut()._tradeFeeBillionths = 5000000; // 0.5% == 5,000,000 / 1,000,000,000 + state.mut()._earnedFees = 0; + state.mut()._distributedFees = 0; + + state.mut()._earnedFeesQubic = 0; + state.mut()._distributedFeesQubic = 0; + + state.mut()._reservedFees = 0; + state.mut()._reservedFeesQubic = 0; + + // [QVB-09] Minimum order amount to prevent zero-fee spam + state.mut().minimumOrderAmount = 200; // Minimum 200 QU (fee = 1 QU per direction) + + // Initialize multisig admins (3 admins, requires 2 approvals) + state.mut().numberOfAdmins = 3; + state.mut().requiredApprovals = 2; // 2 of 3 threshold + + // Initialize admins array (REPLACE WITH ACTUAL ADMIN ADDRESSES) + state.mut().admins.set(0, ID(_U, _X, _V, _K, _B, _Y, _L, _Q, _Z, _I, _U, _L, _C, _B, _F, _F, _I, _L, _P, _T, _X, _O, _W, _Y, _M, _Y, _H, _D, _K, _P, _R, _Y, _W, _R, _E, _Y, _Q, _T, _Y, _Y, _A, _C, _T, _F, _W, _Q, _V, _O, _N, _P, _F, _E, _L, _P, _G, _A)); // Admin 1 + state.mut().admins.set(1, ID(_D, _F, _A, _C, _O, _J, _K, _F, _A, _F, _M, _V, _J, _B, _I, _X, _K, _C, _K, _N, _A, _Y, _S, _T, _B, _S, _D, _D, _X, _Y, _I, _A, _Y, _J, _Q, _P, _H, _Y, _D, _V, _D, _H, _A, _S, _B, _A, _T, _H, _A, _L, _D, _C, _O, _Q, _K, _F)); // Admin 2 (Manager) + state.mut().admins.set(2, ID(_O, _U, _T, _P, _L, _M, _H, _I, _P, _V, _D, _X, _P, _D, _J, _R, _S, _O, _D, _R, _A, _O, _U, _L, _V, _D, _V, _A, _T, _I, _D, _W, _X, _X, _L, _Q, _O, _F, _X, _O, _D, _D, _X, _P, _J, _M, _Q, _G, _C, _S, _J, _Y, _Q, _Q, _V, _D)); // Admin 3 (User) + + // Initialize remaining admin slots + for (locals.i = 3; locals.i < state.get().admins.capacity(); ++locals.i) + { + state.mut().admins.set(locals.i, NULL_ID); + } + + // Initialize proposals array properly (like orders array) + state.mut().nextProposalId = 1; + + // Initialize emptyProposal fields explicitly (avoid memset) + locals.emptyProposal.proposalId = 0; + locals.emptyProposal.proposalType = 0; + locals.emptyProposal.targetAddress = NULL_ID; + locals.emptyProposal.amount = 0; + locals.emptyProposal.approvalsCount = 0; + locals.emptyProposal.executed = false; + locals.emptyProposal.active = false; + // Initialize approvals array with NULL_ID + for (locals.i = 0; locals.i < locals.emptyProposal.approvals.capacity(); ++locals.i) + { + locals.emptyProposal.approvals.set(locals.i, NULL_ID); + } + + // Set all proposal slots with the empty proposal + for (locals.i = 0; locals.i < state.get().proposals.capacity(); ++locals.i) + { + state.mut().proposals.set(locals.i, locals.emptyProposal); + } + } +}; diff --git a/src/contracts/qpi.h b/src/contracts/qpi.h index a75490b65..a39cf9a40 100644 --- a/src/contracts/qpi.h +++ b/src/contracts/qpi.h @@ -2499,7 +2499,7 @@ namespace QPI * * - ORACLE_QUERY_STATUS_UNKNOWN: Query not found / not valid. * - ORACLE_QUERY_STATUS_PENDING: Query is being processed. - * - ORACLE_QUERY_STATUS_COMMITTED: The quorum has commited to a oracle reply, but it has not been revealed yet. + * - ORACLE_QUERY_STATUS_COMMITTED: The quorum has committed to an oracle reply, but it has not been revealed yet. * - ORACLE_QUERY_STATUS_SUCCESS: The oracle reply has been confirmed and is available. * - ORACLE_QUERY_STATUS_UNRESOLVABLE: No valid oracle reply is available, because computors disagreed about the value. * - ORACLE_QUERY_STATUS_TIMEOUT: No valid oracle reply is available and timeout has hit. @@ -2532,9 +2532,9 @@ namespace QPI template struct OracleNotificationInput { - sint64 queryId; ///< ID of the oracle query that led to this notification. - sint32 subscriptionId; ///< ID of the oracle subscription or -1 in case of a pure oracle query. - uint8 status; ///< Oracle query status as defined in `network_messages/common_def.h` + sint64 queryId; ///< ID of the oracle query that led to this notification. + sint32 subscriptionId; ///< ID of the oracle subscription or -1 in case of a one-time oracle query. + uint8 status; ///< Oracle query status as defined in `network_messages/common_def.h` uint8 __reserved0; uint16 __reserved1; typename OracleInterface::OracleReply reply; ///< Oracle reply if status == ORACLE_QUERY_STATUS_SUCCESS @@ -3083,22 +3083,24 @@ namespace QPI /** * @brief Initiate oracle query that will lead to notification later. + * @param OracleInterface Oracle interface struct of interface to query, e.g., OI::Price * @param query Details about which oracle to query for which information, as defined by a specific oracle interface. * @param userProcNotification User procedure that shall be executed when the oracle reply is available or an error occurs. * @param timeoutMillisec Maximum number of milliseconds to wait for reply. - * @return Oracle query ID that can be used to get the status of the query, or 0 on error. + * @return Oracle query ID that can be used to get the status of the query, or -1 on error. * * This will automatically burn the oracle query fee as defined by the oracle interface (burning without * adding to the contract's execution fee reserve). It will fail if the contract doesn't have enough QU. * * The notification callback will be executed when the reply is available or on error. - * The callback must be a user procedure of the contract calling qpi.queryOracle() with the procedure input type + * The callback must be a user procedure of the contract calling QUERY_ORACLE() with the procedure input type * OracleNotificationInput and NoData as output. The procedure must be registered with * REGISTER_USER_PROCEDURE_NOTIFICATION() in REGISTER_USER_FUNCTIONS_AND_PROCEDURES(). - * Success is indicated by input.status == ORACLE_QUERY_STATUS_SUCCESS. + * + * In the notification callback, success is indicated by input.status == ORACLE_QUERY_STATUS_SUCCESS. * If an error happened before the query has been created and sent, input.status is ORACLE_QUERY_STATUS_UNKNOWN - * and input.queryID is -1 (invalid). - * Other errors that may happen with valid input.queryID are input.status == ORACLE_QUERY_STATUS_TIMEOUT and + * and input.queryId is -1 (invalid). + * Other errors that may happen with valid input.queryId are input.status == ORACLE_QUERY_STATUS_TIMEOUT and * input.status == ORACLE_QUERY_STATUS_UNRESOLVABLE. */ #define QUERY_ORACLE(OracleInterface, query, userProcNotification, timeoutMillisec) qpi.__qpiQueryOracle(query, userProcNotification, __id_##userProcNotification, timeoutMillisec) @@ -3110,14 +3112,14 @@ namespace QPI * @param notificationPeriodInMilliseconds Number of milliseconds between consecutive queries/replies that the contract * is notified about. Currently, only multiples of 60000 are supported and other values are rejected with an error. * @param notifyWithPreviousReply Whether to immediately notify this contract with the most up-to-date value if any is available. - * @return Oracle subscription ID that can be used to get the status of the subscription, or -1 on error. + * @return Oracle subscription ID or -1 on error. * * Subscriptions automatically expire at the end of each epoch. So, a common pattern is to call SUBSCRIBE_ORACLE * in BEGIN_EPOCH. * * Subscriptions facilitate sharing common oracle queries among multiple contracts. This saves network resources and allows * to provide a fixed-price subscription for the whole epoch, which is usually much cheaper than the equivalent series of - * individual qpi.queryOracle() calls. + * individual QUERY_ORACLE() calls. * * The SUBSCRIBE_ORACLE call will automatically burn the oracle subscription fee as defined by the oracle interface * (burning without adding to the contract's execution fee reserve). It will fail if the contract doesn't have enough QU. @@ -3126,12 +3128,17 @@ namespace QPI * The callback must be a user procedure of the contract calling SUBSCRIBE_ORACLE with the procedure input type * OracleNotificationInput and NoData as output. The procedure must be registered with * REGISTER_USER_PROCEDURE_NOTIFICATION() in REGISTER_USER_FUNCTIONS_AND_PROCEDURES(). - * Success is indicated by input.status == ORACLE_QUERY_STATUS_SUCCESS. + * + * In the notification callback, success is indicated by input.status == ORACLE_QUERY_STATUS_SUCCESS. * If an error happened before the query has been created and sent, input.status is ORACLE_QUERY_STATUS_UNKNOWN - * and input.queryID is -1 (invalid). - * Other errors that may happen with valid input.queryID are input.status == ORACLE_QUERY_STATUS_TIMEOUT and + * and input.queryId is -1 (invalid). + * Other errors that may happen with valid input.queryId are input.status == ORACLE_QUERY_STATUS_TIMEOUT and * input.status == ORACLE_QUERY_STATUS_UNRESOLVABLE. * The timeout of subscription queries is always 60000 milliseconds. + * + * A contract may subscribe to the same oracle interface with multiple different queries. + * However, it cannot subscribe with the same query multiple times. + * In order to change the notification period of an existing query, it needs to be unsubscribed first and subscribed again afterwards. */ #define SUBSCRIBE_ORACLE(OracleInterface, query, userProcNotification, notificationPeriodInMilliseconds, notifyWithPreviousReply) qpi.__qpiSubscribeOracle(query, userProcNotification, __id_##userProcNotification, notificationPeriodInMilliseconds, notifyWithPreviousReply) diff --git a/src/network_messages/common_def.h b/src/network_messages/common_def.h index 3b0d8538f..732c14879 100644 --- a/src/network_messages/common_def.h +++ b/src/network_messages/common_def.h @@ -56,7 +56,7 @@ constexpr uint8_t ORACLE_QUERY_TYPE_USER_QUERY = 2; constexpr uint8_t ORACLE_QUERY_STATUS_UNKNOWN = 0; ///< Query not found / valid. constexpr uint8_t ORACLE_QUERY_STATUS_PENDING = 1; ///< Query is being processed. -constexpr uint8_t ORACLE_QUERY_STATUS_COMMITTED = 2; ///< The quorum has commited to a oracle reply, but it has not been revealed yet. +constexpr uint8_t ORACLE_QUERY_STATUS_COMMITTED = 2; ///< The quorum has committed to an oracle reply, but it has not been revealed yet. constexpr uint8_t ORACLE_QUERY_STATUS_SUCCESS = 3; ///< The oracle reply has been confirmed and is available. constexpr uint8_t ORACLE_QUERY_STATUS_UNRESOLVABLE = 5;///< No valid oracle reply is available, because computors disagreed about the value. constexpr uint8_t ORACLE_QUERY_STATUS_TIMEOUT = 4; ///< No valid oracle reply is available and timeout has hit. diff --git a/src/network_messages/network_message_type.h b/src/network_messages/network_message_type.h index 7ec9a6af7..bf7d424db 100644 --- a/src/network_messages/network_message_type.h +++ b/src/network_messages/network_message_type.h @@ -51,6 +51,8 @@ enum NetworkMessageType : unsigned char RESPOND_ACTIVE_IPO = 65, REQUEST_ORACLE_DATA = 66, RESPOND_ORACLE_DATA = 67, + BROADCAST_CUSTOM_MINING_TASK = 68, + BROADCAST_CUSTOM_MINING_SOLUTION = 69, ORACLE_MACHINE_QUERY = 190, // only on communication channel Core node <-> OM node ORACLE_MACHINE_REPLY = 191, // only on communication channel Core node <-> OM node REQUEST_TX_STATUS = 201, // tx addon only diff --git a/src/oracle_core/oracle_interfaces_def.h b/src/oracle_core/oracle_interfaces_def.h index e1db23125..458b8e497 100644 --- a/src/oracle_core/oracle_interfaces_def.h +++ b/src/oracle_core/oracle_interfaces_def.h @@ -13,6 +13,8 @@ namespace OI #include "oracle_interfaces/Mock.h" #undef ORACLE_INTERFACE_INDEX +// add new interface above this line (define ORACLE_INTERFACE_INDEX, include the header file and undef ORACLE_INTERFACE_INDEX) + #define DEFINE_ORACLE_INTERFACE(Interface) {sizeof(Interface::OracleQuery), sizeof(Interface::OracleReply)} constexpr struct { @@ -21,6 +23,7 @@ namespace OI } oracleInterfaces[] = { DEFINE_ORACLE_INTERFACE(Price), DEFINE_ORACLE_INTERFACE(Mock), + // add new interface above this line (with DEFINE_ORACLE_INTERFACE; the order must match the interfaces indices) }; static constexpr uint32_t oracleInterfacesCount = sizeof(oracleInterfaces) / sizeof(oracleInterfaces[0]); @@ -49,6 +52,7 @@ namespace OI REGISTER_ORACLE_INTERFACE(Price); REGISTER_ORACLE_INTERFACE(Mock); + // add new interface above this line (with REGISTER_ORACLE_INTERFACE) for (uint32_t idx = 0; idx < oracleInterfacesCount; ++idx) { diff --git a/src/oracle_interfaces/Price.h b/src/oracle_interfaces/Price.h index ca2d51155..4cb367adf 100644 --- a/src/oracle_interfaces/Price.h +++ b/src/oracle_interfaces/Price.h @@ -7,8 +7,8 @@ using namespace QPI; * the same input and output structs (OracleQuery and OracleReply). * * It also defines the oracle query and subscription fees through the member functions getQueryFee() and -* getSubscriptionFee(). The subscription fee needs to be paid for each call to qpi.subscribeOracle(), -* which is usually once per epoch. The query fee needs to be paid for each call to qpi.queryOracle() +* getSubscriptionFee(). The subscription fee needs to be paid for each call to SUBSCRIBE_ORACLE(), +* which is usually once per epoch. The query fee needs to be paid for each call to QUERY_ORACLE() * and as the amount of each user oracle query transaction. * * Each oracle interface is internally identified through the oracleInterfaceIndex. diff --git a/src/private_settings.h b/src/private_settings.h index b8737a8d3..4b267542f 100644 --- a/src/private_settings.h +++ b/src/private_settings.h @@ -27,6 +27,8 @@ static const unsigned char whiteListPeers[][4] = { }; */ +// Enter static IPs of one or multiple oracle machine node(s). This node will connect to these and try to keep the +// connection open for low latency. The oracle machine nodes also need to whitelist the IP of this core node. static const unsigned char oracleMachineIPs[][4] = { {127, 0, 0, 1}, // REMOVE THIS ENTRY AND REPLACE IT WITH YOUR OWN IP ADDRESSES }; diff --git a/src/public_settings.h b/src/public_settings.h index b4d1c0542..65c7c5d4b 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -30,8 +30,8 @@ // The tick duration used to calculate the size of memory buffers. // This determines the memory footprint of the application. -#define TICK_DURATION_FOR_ALLOCATION_MS 750 -#define TRANSACTION_SPARSENESS 1 +#define TICK_DURATION_FOR_ALLOCATION_MS 350 +#define TRANSACTION_SPARSENESS 3 // Number of ticks that are stored in the pending txs pool. This also defines how many ticks in advance a tx can be registered. #define PENDING_TXS_POOL_NUM_TICKS (1000 * 60 * 10ULL / TICK_DURATION_FOR_ALLOCATION_MS) // 10 minutes @@ -66,12 +66,12 @@ static_assert(AUTO_FORCE_NEXT_TICK_THRESHOLD* TARGET_TICK_DURATION >= PEER_REFRE // Config options that should NOT be changed by operators #define VERSION_A 1 -#define VERSION_B 282 +#define VERSION_B 283 #define VERSION_C 0 // Epoch and initial tick for node startup -#define EPOCH 204 -#define TICK 45693000 +#define EPOCH 205 +#define TICK 46310000 #define TICK_IS_FIRST_TICK_OF_EPOCH 1 // Set to 0 if the network is restarted during the EPOCH with a new initial TICK #define ARBITRATOR "AFZPUAIYVPNUYGJRQVLUKOPPVLHAZQTGLYAAUUNBXFTVTAMSBKQBLEIEPCVJ" @@ -103,7 +103,7 @@ static constexpr unsigned long long ADDITION_NUMBER_OF_TICKS = 1000; static constexpr unsigned long long ADDITION_NUMBER_OF_NEIGHBORS = 728; // 2M. Must be divided by 2 static constexpr unsigned long long ADDITION_NUMBER_OF_MUTATIONS = 300; static constexpr unsigned long long ADDITION_POPULATION_THRESHOLD = ADDITION_NUMBER_OF_INPUT_NEURONS + ADDITION_NUMBER_OF_OUTPUT_NEURONS + ADDITION_NUMBER_OF_MUTATIONS; // P -static constexpr unsigned int ADDITION_SOLUTION_THRESHOLD_DEFAULT = 74500; +static constexpr unsigned int ADDITION_SOLUTION_THRESHOLD_DEFAULT = 74800; // Multipler of score static constexpr unsigned int HYPERIDENTITY_SOLUTION_MULTIPLER = 1; diff --git a/src/qubic.cpp b/src/qubic.cpp index 4e3e04f40..fed64dd8d 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -1,5 +1,6 @@ #define SINGLE_COMPILE_UNIT -// #define OLD_QVAULT + +// #define NO_VOTTUN //#define INCLUDE_CONTRACT_TEST_EXAMPLES @@ -1476,10 +1477,61 @@ static void processRequestedCustomMiningSolutionVerificationRequest(Peer* peer, } } +// Hardcoded doge dispatcher public key (identity: XPILPIJYHRBTACMMIRSJLIZWCXDBHWVEOTZBQFBXWEUXDZGGDEKDQPIEQKQK) +static const unsigned char dogeDispatcherPubkey[32] = { + 0x25, 0x98, 0x6d, 0x38, 0xa6, 0x3d, 0xd6, 0x45, + 0x0c, 0x07, 0x34, 0xd8, 0xaa, 0x47, 0x95, 0x27, + 0xd7, 0x2c, 0x0f, 0x9b, 0x3a, 0x86, 0x0a, 0xa8, + 0x9e, 0x9f, 0xb1, 0xf3, 0xfd, 0x3d, 0x1f, 0x95 +}; + +// Process a doge custom mining task broadcast (type 68). +// Verifies the signature against the hardcoded doge dispatcher public key and relays if valid. +static void processBroadcastCustomMiningTask(RequestResponseHeader* header) +{ + if (!header->isDejavuZero()) + return; + const unsigned int messageSize = header->size() - sizeof(RequestResponseHeader); + if (messageSize <= SIGNATURE_SIZE) + return; + const unsigned char* payload = (const unsigned char*)header->getPayload(); + m256i digest; + KangarooTwelve(payload, messageSize - SIGNATURE_SIZE, &digest, sizeof(digest)); + if (verify(dogeDispatcherPubkey, digest.m256i_u8, payload + (messageSize - SIGNATURE_SIZE))) + { + enqueueResponse(NULL, header); + } +} + +// Process a doge custom mining solution broadcast (type 69). +// Verifies the signature against the sender's public key (first 32 bytes of payload) +// and relays if the sender is a computor or has enough balance. +static void processBroadcastCustomMiningSolution(RequestResponseHeader* header) +{ + if (!header->isDejavuZero()) + return; + const unsigned int messageSize = header->size() - sizeof(RequestResponseHeader); + if (messageSize <= SIGNATURE_SIZE) + return; + const unsigned char* payload = (const unsigned char*)header->getPayload(); + const m256i* sourcePublicKey = (const m256i*)payload; + + m256i digest; + KangarooTwelve(payload, messageSize - SIGNATURE_SIZE, &digest, sizeof(digest)); + if (verify(sourcePublicKey->m256i_u8, digest.m256i_u8, payload + (messageSize - SIGNATURE_SIZE))) + { + if (computorIndex(*sourcePublicKey) >= 0 + || (::spectrumIndex(*sourcePublicKey) >= 0 && energy(::spectrumIndex(*sourcePublicKey)) >= MESSAGE_DISSEMINATION_THRESHOLD)) + { + enqueueResponse(NULL, header); + } + } +} + // Process custom mining data requests. // Currently supports: // - Requesting a range of tasks (using Unix timestamps as unique indexes; each task has only one unique index). -// - Requesting all solutions corresponding to a specific task index. +// - Requesting all solutions corresponding to a specific task index. // The total size of the response will not exceed CUSTOM_MINING_RESPOND_MESSAGE_MAX_SIZE. // For the solution respond, only respond solution that has not been verified yet static void processCustomMiningDataRequest(Peer* peer, const unsigned long long processorNumber, RequestResponseHeader* header) @@ -2285,6 +2337,18 @@ static void requestProcessor(void* ProcedureArgument) } break; + case BROADCAST_CUSTOM_MINING_TASK: + { + processBroadcastCustomMiningTask(header); + } + break; + + case BROADCAST_CUSTOM_MINING_SOLUTION: + { + processBroadcastCustomMiningSolution(header); + } + break; + case RequestCustomMiningSolutionVerification::type(): { processRequestedCustomMiningSolutionVerificationRequest(peer, header); @@ -3374,7 +3438,7 @@ static void processTick(unsigned long long processorNumber) PROFILE_SCOPE_END(); } - // Generate subscription queries (may create queries that immediately timout if the network was stuck) + // Generate subscription queries (may create queries that immediately timeout if the network was stuck) oracleEngine.generateSubscriptionQueries(); // Check for oracle query timeouts (may schedule notification) @@ -4335,6 +4399,8 @@ static bool saveAllNodeStates() logToConsole(L"Failed to save universe"); return false; } + if (!saveSnapshotUniverseIndex(L"snapshotUniverseIndex", directory)) + return false; CONTRACT_FILE_NAME[sizeof(CONTRACT_FILE_NAME) / sizeof(CONTRACT_FILE_NAME[0]) - 4] = L'0'; CONTRACT_FILE_NAME[sizeof(CONTRACT_FILE_NAME) / sizeof(CONTRACT_FILE_NAME[0]) - 3] = L'0'; @@ -4504,14 +4570,18 @@ static bool loadAllNodeStates() return false; } + // When loading from a snapshot, the universe index lists must not be rebuilt, because this may change the + // order of asset iteration and lead to misalignment. Instead, the original index must be saved/loaded. UNIVERSE_FILE_NAME[sizeof(UNIVERSE_FILE_NAME) / sizeof(UNIVERSE_FILE_NAME[0]) - 4] = L'0'; UNIVERSE_FILE_NAME[sizeof(UNIVERSE_FILE_NAME) / sizeof(UNIVERSE_FILE_NAME[0]) - 3] = L'0'; UNIVERSE_FILE_NAME[sizeof(UNIVERSE_FILE_NAME) / sizeof(UNIVERSE_FILE_NAME[0]) - 2] = L'0'; - if (!loadUniverse(UNIVERSE_FILE_NAME, directory)) + if (!loadUniverse(UNIVERSE_FILE_NAME, directory, /*rebuildIndexLists=*/false)) { logToConsole(L"Failed to load universe"); return false; } + if (!loadSnapshotUniverseIndex(L"snapshotUniverseIndex", directory)) + return false; CONTRACT_FILE_NAME[sizeof(CONTRACT_FILE_NAME) / sizeof(CONTRACT_FILE_NAME[0]) - 4] = L'0'; CONTRACT_FILE_NAME[sizeof(CONTRACT_FILE_NAME) / sizeof(CONTRACT_FILE_NAME[0]) - 3] = L'0'; @@ -6119,7 +6189,7 @@ static bool initialize() } if (!loadContractStateFiles()) return false; -#ifndef START_NETWORK_FROM_SCRATCH +#if !START_NETWORK_FROM_SCRATCH if (!loadContractExecFeeFiles()) return false; #endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6723ec0a1..d429b8716 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -33,6 +33,7 @@ add_executable( # contract_qearn.cpp # contract_qvault.cpp # contract_qx.cpp + contract_vottunbridge.cpp # kangaroo_twelve.cpp m256.cpp math_lib.cpp diff --git a/test/contract_vottunbridge.cpp b/test/contract_vottunbridge.cpp new file mode 100644 index 000000000..b1b055296 --- /dev/null +++ b/test/contract_vottunbridge.cpp @@ -0,0 +1,916 @@ +#define NO_UEFI + +#include + +#include "contract_testing.h" + +namespace { +constexpr unsigned short PROCEDURE_CREATE_ORDER = 1; +constexpr unsigned short PROCEDURE_TRANSFER_TO_CONTRACT = 4; + +uint64 requiredFee(uint64 amount) +{ + // Total fee is 0.5% (ETH) + 0.5% (Qubic) = 1% of amount + return 2 * ((amount * 5000000ULL) / 1000000000ULL); +} +} + +class ContractTestingVottunBridge : protected ContractTesting +{ +public: + using ContractTesting::invokeUserProcedure; + + ContractTestingVottunBridge() + { + initEmptySpectrum(); + initEmptyUniverse(); + INIT_CONTRACT(VOTTUNBRIDGE); + callSystemProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, INITIALIZE); + } + + VOTTUNBRIDGE::StateData* state() + { + return reinterpret_cast(contractStates[VOTTUNBRIDGE_CONTRACT_INDEX]); + } + + bool findOrder(uint64 orderId, VOTTUNBRIDGE::BridgeOrder& out) + { + for (uint64 i = 0; i < state()->orders.capacity(); ++i) + { + VOTTUNBRIDGE::BridgeOrder order = state()->orders.get(i); + if (order.orderId == orderId) + { + out = order; + return true; + } + } + return false; + } + + bool findProposal(uint64 proposalId, VOTTUNBRIDGE::AdminProposal& out) + { + for (uint64 i = 0; i < state()->proposals.capacity(); ++i) + { + VOTTUNBRIDGE::AdminProposal proposal = state()->proposals.get(i); + if (proposal.proposalId == proposalId) + { + out = proposal; + return true; + } + } + return false; + } + + bool setOrderById(uint64 orderId, const VOTTUNBRIDGE::BridgeOrder& updated) + { + for (uint64 i = 0; i < state()->orders.capacity(); ++i) + { + VOTTUNBRIDGE::BridgeOrder order = state()->orders.get(i); + if (order.orderId == orderId) + { + state()->orders.set(i, updated); + return true; + } + } + return false; + } + + bool setProposalById(uint64 proposalId, const VOTTUNBRIDGE::AdminProposal& updated) + { + for (uint64 i = 0; i < state()->proposals.capacity(); ++i) + { + VOTTUNBRIDGE::AdminProposal proposal = state()->proposals.get(i); + if (proposal.proposalId == proposalId) + { + state()->proposals.set(i, updated); + return true; + } + } + return false; + } + + VOTTUNBRIDGE::createOrder_output createOrder( + const id& user, uint64 amount, bit fromQubicToEthereum, uint64 fee) + { + VOTTUNBRIDGE::createOrder_input input{}; + VOTTUNBRIDGE::createOrder_output output{}; + input.qubicDestination = id(9, 0, 0, 0); + input.amount = amount; + input.fromQubicToEthereum = fromQubicToEthereum; + for (uint64 i = 0; i < 42; ++i) + { + input.ethAddress.set(i, static_cast('A')); + } + + this->invokeUserProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, PROCEDURE_CREATE_ORDER, + input, output, user, static_cast(fee)); + return output; + } + + void seedBalance(const id& user, uint64 amount) + { + increaseEnergy(user, amount); + } + + VOTTUNBRIDGE::transferToContract_output transferToContract( + const id& user, uint64 amount, uint64 orderId, uint64 invocationReward) + { + VOTTUNBRIDGE::transferToContract_input input{}; + VOTTUNBRIDGE::transferToContract_output output{}; + input.amount = amount; + input.orderId = orderId; + + this->invokeUserProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, PROCEDURE_TRANSFER_TO_CONTRACT, + input, output, user, static_cast(invocationReward)); + return output; + } + + VOTTUNBRIDGE::createProposal_output createProposal( + const id& admin, uint8 proposalType, const id& target, const id& oldAddress, uint64 amount) + { + VOTTUNBRIDGE::createProposal_input input{}; + VOTTUNBRIDGE::createProposal_output output{}; + input.proposalType = proposalType; + input.targetAddress = target; + input.oldAddress = oldAddress; + input.amount = amount; + + this->invokeUserProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, 6, input, output, admin, 0); + return output; + } + + VOTTUNBRIDGE::approveProposal_output approveProposal(const id& admin, uint64 proposalId) + { + VOTTUNBRIDGE::approveProposal_input input{}; + VOTTUNBRIDGE::approveProposal_output output{}; + input.proposalId = proposalId; + + this->invokeUserProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, 7, input, output, admin, 0); + return output; + } + + VOTTUNBRIDGE::cancelProposal_output cancelProposal(const id& user, uint64 proposalId) + { + VOTTUNBRIDGE::cancelProposal_input input{}; + VOTTUNBRIDGE::cancelProposal_output output{}; + input.proposalId = proposalId; + + this->invokeUserProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, 8, input, output, user, 0); + return output; + } + + VOTTUNBRIDGE::refundOrder_output refundOrder(const id& manager, uint64 orderId) + { + VOTTUNBRIDGE::refundOrder_input input{}; + VOTTUNBRIDGE::refundOrder_output output{}; + input.orderId = orderId; + + this->invokeUserProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, 3, input, output, manager, 0); + return output; + } + + void callEndTick() + { + callSystemProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, END_TICK); + } +}; + +TEST(VottunBridge, CreateOrder_RequiresFee) +{ + ContractTestingVottunBridge bridge; + const id user = id(1, 0, 0, 0); + const uint64 amount = 1000; + const uint64 fee = requiredFee(amount); + + std::cout << "[VottunBridge] CreateOrder_RequiresFee: amount=" << amount + << " fee=" << fee << " (sending fee-1)" << std::endl; + + increaseEnergy(user, fee - 1); + auto output = bridge.createOrder(user, amount, true, fee - 1); + EXPECT_EQ(output.status, static_cast(VOTTUNBRIDGE::EthBridgeError::insufficientTransactionFee)); +} + +TEST(VottunBridge, TransferToContract_RejectsMissingReward) +{ + ContractTestingVottunBridge bridge; + const id user = id(2, 0, 0, 0); + const uint64 amount = 200; + const uint64 fee = requiredFee(amount); + const id contractId = id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0); + + std::cout << "[VottunBridge] TransferToContract_RejectsMissingReward: amount=" << amount + << " fee=" << fee << " reward=0 contractBalanceSeed=1000" << std::endl; + + // Seed balances: user only has fees; contract already has balance > amount + increaseEnergy(user, fee); + increaseEnergy(contractId, 1000); + + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + const uint64 lockedBefore = bridge.state()->lockedTokens; + const long long contractBalanceBefore = getBalance(contractId); + const long long userBalanceBefore = getBalance(user); + + auto transferOutput = bridge.transferToContract(user, amount, orderOutput.orderId, 0); + + EXPECT_EQ(transferOutput.status, static_cast(VOTTUNBRIDGE::EthBridgeError::invalidAmount)); + EXPECT_EQ(bridge.state()->lockedTokens, lockedBefore); + EXPECT_EQ(getBalance(contractId), contractBalanceBefore); + EXPECT_EQ(getBalance(user), userBalanceBefore); +} + +TEST(VottunBridge, TransferToContract_AcceptsExactReward) +{ + ContractTestingVottunBridge bridge; + const id user = id(3, 0, 0, 0); + const uint64 amount = 500; + const uint64 fee = requiredFee(amount); + const id contractId = id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0); + + std::cout << "[VottunBridge] TransferToContract_AcceptsExactReward: amount=" << amount + << " fee=" << fee << " reward=amount" << std::endl; + + increaseEnergy(user, fee + amount); + + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + const uint64 lockedBefore = bridge.state()->lockedTokens; + const long long contractBalanceBefore = getBalance(contractId); + + auto transferOutput = bridge.transferToContract(user, amount, orderOutput.orderId, amount); + + EXPECT_EQ(transferOutput.status, 0); + EXPECT_EQ(bridge.state()->lockedTokens, lockedBefore + amount); + EXPECT_EQ(getBalance(contractId), contractBalanceBefore + amount); +} + +TEST(VottunBridge, TransferToContract_OrderNotFound) +{ + ContractTestingVottunBridge bridge; + const id user = id(5, 0, 0, 0); + const uint64 amount = 100; + + bridge.seedBalance(user, amount); + + auto output = bridge.transferToContract(user, amount, 9999, amount); + EXPECT_EQ(output.status, static_cast(VOTTUNBRIDGE::EthBridgeError::orderNotFound)); +} + +TEST(VottunBridge, TransferToContract_InvalidAmountMismatch) +{ + ContractTestingVottunBridge bridge; + const id user = id(6, 0, 0, 0); + const uint64 amount = 1000; // Must be >= minimumOrderAmount (200) + const uint64 fee = requiredFee(amount); + + bridge.seedBalance(user, fee + amount + 1); + + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + auto output = bridge.transferToContract(user, amount + 1, orderOutput.orderId, amount + 1); + EXPECT_EQ(output.status, static_cast(VOTTUNBRIDGE::EthBridgeError::invalidAmount)); +} + +TEST(VottunBridge, TransferToContract_InvalidOrderState) +{ + ContractTestingVottunBridge bridge; + const id user = id(7, 0, 0, 0); + const uint64 amount = 1000; + const uint64 fee = requiredFee(amount); + + bridge.seedBalance(user, fee + amount); + + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + VOTTUNBRIDGE::BridgeOrder order{}; + ASSERT_TRUE(bridge.findOrder(orderOutput.orderId, order)); + order.status = 1; // completed + ASSERT_TRUE(bridge.setOrderById(orderOutput.orderId, order)); + + auto output = bridge.transferToContract(user, amount, orderOutput.orderId, amount); + EXPECT_EQ(output.status, static_cast(VOTTUNBRIDGE::EthBridgeError::invalidOrderState)); +} + +TEST(VottunBridge, CreateOrder_CleansCompletedAndRefundedSlots) +{ + ContractTestingVottunBridge bridge; + const id user = id(4, 0, 0, 0); + const uint64 amount = 1000; + const uint64 fee = requiredFee(amount); + + VOTTUNBRIDGE::BridgeOrder filledOrder{}; + filledOrder.orderId = 1; + filledOrder.amount = amount; + filledOrder.status = 1; // completed + filledOrder.fromQubicToEthereum = true; + filledOrder.qubicSender = user; + + for (uint64 i = 0; i < bridge.state()->orders.capacity(); ++i) + { + filledOrder.orderId = i + 1; + filledOrder.status = (i % 2 == 0) ? 1 : 2; // completed/refunded + bridge.state()->orders.set(i, filledOrder); + } + + increaseEnergy(user, fee); + auto output = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(output.status, 0); + + VOTTUNBRIDGE::BridgeOrder createdOrder{}; + EXPECT_TRUE(bridge.findOrder(output.orderId, createdOrder)); + EXPECT_EQ(createdOrder.status, 0); + + // Single-pass recycling: the recyclable slot is directly overwritten + // with the new order (status=0), so no slots end up as 255. + // Verify that the number of completed/refunded orders decreased by 1 + // (one was recycled for the new order). + uint64 recycledCount = 0; + for (uint64 i = 0; i < bridge.state()->orders.capacity(); ++i) + { + uint8 s = bridge.state()->orders.get(i).status; + if (s == 1 || s == 2) + { + recycledCount++; + } + } + EXPECT_EQ(recycledCount, bridge.state()->orders.capacity() - 1); +} + +TEST(VottunBridge, CreateProposal_CleansExecutedProposalsWhenFull) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(10, 0, 0, 0); + const id admin2 = id(11, 0, 0, 0); + + bridge.state()->numberOfAdmins = 2; + bridge.state()->requiredApprovals = 2; + bridge.state()->admins.set(0, admin1); + bridge.state()->admins.set(1, admin2); + for (uint64 i = 2; i < bridge.state()->admins.capacity(); ++i) + { + bridge.state()->admins.set(i, NULL_ID); + } + + bridge.seedBalance(admin1, 1); + bridge.seedBalance(admin2, 1); + + VOTTUNBRIDGE::AdminProposal proposal{}; + proposal.approvalsCount = 1; + proposal.active = true; + proposal.executed = false; + for (uint64 i = 0; i < bridge.state()->proposals.capacity(); ++i) + { + proposal.proposalId = i + 1; + proposal.executed = (i % 2 == 0); + bridge.state()->proposals.set(i, proposal); + } + + auto output = bridge.createProposal(admin1, VOTTUNBRIDGE::PROPOSAL_CHANGE_THRESHOLD, + NULL_ID, NULL_ID, 2); + + EXPECT_EQ(output.status, 0); + + VOTTUNBRIDGE::AdminProposal createdProposal{}; + EXPECT_TRUE(bridge.findProposal(output.proposalId, createdProposal)); + EXPECT_TRUE(createdProposal.active); + EXPECT_FALSE(createdProposal.executed); + + // Single-pass recycling: the executed slot is cleared and immediately + // filled with the new proposal, so no slots end up fully empty. + // Verify that the number of executed proposals decreased by 1. + uint64 executedCount = 0; + for (uint64 i = 0; i < bridge.state()->proposals.capacity(); ++i) + { + VOTTUNBRIDGE::AdminProposal p = bridge.state()->proposals.get(i); + if (p.executed) + { + executedCount++; + } + } + // Originally half (16) were executed; one was recycled for the new proposal + EXPECT_EQ(executedCount, 15); +} + +TEST(VottunBridge, CreateProposal_InvalidTypeRejected) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(10, 0, 0, 0); + + bridge.state()->numberOfAdmins = 1; + bridge.state()->requiredApprovals = 1; + bridge.state()->admins.set(0, admin1); + bridge.seedBalance(admin1, 1); + + auto output = bridge.createProposal(admin1, 99, NULL_ID, NULL_ID, 0); + EXPECT_EQ(output.status, static_cast(VOTTUNBRIDGE::EthBridgeError::invalidAmount)); +} + +TEST(VottunBridge, ApproveProposal_NotOwnerRejected) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(10, 0, 0, 0); + const id admin2 = id(11, 0, 0, 0); + const id outsider = id(99, 0, 0, 0); + + bridge.state()->numberOfAdmins = 2; + bridge.state()->requiredApprovals = 2; + bridge.state()->admins.set(0, admin1); + bridge.state()->admins.set(1, admin2); + bridge.seedBalance(admin1, 1); + bridge.seedBalance(admin2, 1); + bridge.seedBalance(outsider, 1); + + auto proposalOutput = bridge.createProposal(admin1, VOTTUNBRIDGE::PROPOSAL_CHANGE_THRESHOLD, + NULL_ID, NULL_ID, 2); + EXPECT_EQ(proposalOutput.status, 0); + + auto approveOutput = bridge.approveProposal(outsider, proposalOutput.proposalId); + EXPECT_EQ(approveOutput.status, static_cast(VOTTUNBRIDGE::EthBridgeError::notOwner)); + EXPECT_FALSE(approveOutput.executed); +} + +TEST(VottunBridge, ApproveProposal_DoubleApprovalRejected) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(10, 0, 0, 0); + const id admin2 = id(11, 0, 0, 0); + + bridge.state()->numberOfAdmins = 2; + bridge.state()->requiredApprovals = 2; + bridge.state()->admins.set(0, admin1); + bridge.state()->admins.set(1, admin2); + bridge.seedBalance(admin1, 1); + bridge.seedBalance(admin2, 1); + + auto proposalOutput = bridge.createProposal(admin1, VOTTUNBRIDGE::PROPOSAL_CHANGE_THRESHOLD, + NULL_ID, NULL_ID, 2); + EXPECT_EQ(proposalOutput.status, 0); + + auto approveOutput = bridge.approveProposal(admin1, proposalOutput.proposalId); + EXPECT_EQ(approveOutput.status, static_cast(VOTTUNBRIDGE::EthBridgeError::proposalAlreadyApproved)); + EXPECT_FALSE(approveOutput.executed); +} + +TEST(VottunBridge, ApproveProposal_ExecutesChangeThreshold) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(10, 0, 0, 0); + const id admin2 = id(11, 0, 0, 0); + + bridge.state()->numberOfAdmins = 2; + bridge.state()->requiredApprovals = 2; + bridge.state()->admins.set(0, admin1); + bridge.state()->admins.set(1, admin2); + bridge.seedBalance(admin1, 1); + bridge.seedBalance(admin2, 1); + + auto proposalOutput = bridge.createProposal(admin1, VOTTUNBRIDGE::PROPOSAL_CHANGE_THRESHOLD, + NULL_ID, NULL_ID, 2); + EXPECT_EQ(proposalOutput.status, 0); + + auto approveOutput = bridge.approveProposal(admin2, proposalOutput.proposalId); + EXPECT_EQ(approveOutput.status, 0); + EXPECT_TRUE(approveOutput.executed); + EXPECT_EQ(bridge.state()->requiredApprovals, 2); +} + +TEST(VottunBridge, ApproveProposal_ProposalNotFound) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(12, 0, 0, 0); + + bridge.state()->numberOfAdmins = 1; + bridge.state()->requiredApprovals = 1; + bridge.state()->admins.set(0, admin1); + bridge.seedBalance(admin1, 1); + + auto output = bridge.approveProposal(admin1, 12345); + EXPECT_EQ(output.status, static_cast(VOTTUNBRIDGE::EthBridgeError::proposalNotFound)); + EXPECT_FALSE(output.executed); +} + +TEST(VottunBridge, ApproveProposal_AlreadyExecuted) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(13, 0, 0, 0); + const id admin2 = id(14, 0, 0, 0); + + bridge.state()->numberOfAdmins = 2; + bridge.state()->requiredApprovals = 2; + bridge.state()->admins.set(0, admin1); + bridge.state()->admins.set(1, admin2); + bridge.seedBalance(admin1, 1); + bridge.seedBalance(admin2, 1); + + auto proposalOutput = bridge.createProposal(admin1, VOTTUNBRIDGE::PROPOSAL_CHANGE_THRESHOLD, + NULL_ID, NULL_ID, 2); + EXPECT_EQ(proposalOutput.status, 0); + + auto approveOutput = bridge.approveProposal(admin2, proposalOutput.proposalId); + EXPECT_EQ(approveOutput.status, 0); + EXPECT_TRUE(approveOutput.executed); + + auto secondApprove = bridge.approveProposal(admin1, proposalOutput.proposalId); + EXPECT_EQ(secondApprove.status, static_cast(VOTTUNBRIDGE::EthBridgeError::proposalAlreadyExecuted)); + EXPECT_FALSE(secondApprove.executed); +} + +TEST(VottunBridge, TransferToContract_RefundsExcess) +{ + ContractTestingVottunBridge bridge; + const id user = id(20, 0, 0, 0); + const uint64 amount = 500; + const uint64 excess = 100; + const uint64 fee = requiredFee(amount); + const id contractId = id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0); + + std::cout << "[VottunBridge] TransferToContract_RefundsExcess: amount=" << amount + << " excess=" << excess << " fee=" << fee << std::endl; + + increaseEnergy(user, fee + amount + excess); + + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + const uint64 lockedBefore = bridge.state()->lockedTokens; + const long long userBalanceBefore = getBalance(user); + + // Send more than required (amount + excess) + auto transferOutput = bridge.transferToContract(user, amount, orderOutput.orderId, amount + excess); + + EXPECT_EQ(transferOutput.status, 0); + // Should lock only the required amount + EXPECT_EQ(bridge.state()->lockedTokens, lockedBefore + amount); + // User should get excess back + EXPECT_EQ(getBalance(user), userBalanceBefore - amount); +} + +TEST(VottunBridge, TransferToContract_RefundsAllOnInsufficient) +{ + ContractTestingVottunBridge bridge; + const id user = id(21, 0, 0, 0); + const uint64 amount = 500; + const uint64 insufficientAmount = 200; + const uint64 fee = requiredFee(amount); + const id contractId = id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0); + + std::cout << "[VottunBridge] TransferToContract_RefundsAllOnInsufficient: amount=" << amount + << " sent=" << insufficientAmount << std::endl; + + increaseEnergy(user, fee + insufficientAmount); + + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + const uint64 lockedBefore = bridge.state()->lockedTokens; + const long long userBalanceBefore = getBalance(user); + + // Send less than required + auto transferOutput = bridge.transferToContract(user, amount, orderOutput.orderId, insufficientAmount); + + EXPECT_EQ(transferOutput.status, static_cast(VOTTUNBRIDGE::EthBridgeError::invalidAmount)); + // Should NOT lock anything + EXPECT_EQ(bridge.state()->lockedTokens, lockedBefore); + // User should get everything back + EXPECT_EQ(getBalance(user), userBalanceBefore); +} + +TEST(VottunBridge, CancelProposal_Success) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(22, 0, 0, 0); + + bridge.state()->numberOfAdmins = 1; + bridge.state()->requiredApprovals = 1; + bridge.state()->admins.set(0, admin1); + bridge.seedBalance(admin1, 1); + + auto proposalOutput = bridge.createProposal(admin1, VOTTUNBRIDGE::PROPOSAL_CHANGE_THRESHOLD, + NULL_ID, NULL_ID, 2); + EXPECT_EQ(proposalOutput.status, 0); + + // Verify proposal is active + VOTTUNBRIDGE::AdminProposal proposal; + EXPECT_TRUE(bridge.findProposal(proposalOutput.proposalId, proposal)); + EXPECT_TRUE(proposal.active); + + // Cancel the proposal + auto cancelOutput = bridge.cancelProposal(admin1, proposalOutput.proposalId); + EXPECT_EQ(cancelOutput.status, 0); + + // Verify proposal is inactive + EXPECT_TRUE(bridge.findProposal(proposalOutput.proposalId, proposal)); + EXPECT_FALSE(proposal.active); +} + +TEST(VottunBridge, CancelProposal_NotCreatorRejected) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(23, 0, 0, 0); + const id admin2 = id(24, 0, 0, 0); + + bridge.state()->numberOfAdmins = 2; + bridge.state()->requiredApprovals = 2; + bridge.state()->admins.set(0, admin1); + bridge.state()->admins.set(1, admin2); + bridge.seedBalance(admin1, 1); + bridge.seedBalance(admin2, 1); + + // Admin1 creates proposal + auto proposalOutput = bridge.createProposal(admin1, VOTTUNBRIDGE::PROPOSAL_CHANGE_THRESHOLD, + NULL_ID, NULL_ID, 2); + EXPECT_EQ(proposalOutput.status, 0); + + // Admin2 tries to cancel (should fail - not the creator) + auto cancelOutput = bridge.cancelProposal(admin2, proposalOutput.proposalId); + EXPECT_EQ(cancelOutput.status, static_cast(VOTTUNBRIDGE::EthBridgeError::notAuthorized)); + + // Verify proposal is still active + VOTTUNBRIDGE::AdminProposal proposal; + EXPECT_TRUE(bridge.findProposal(proposalOutput.proposalId, proposal)); + EXPECT_TRUE(proposal.active); +} + +TEST(VottunBridge, CancelProposal_AlreadyExecutedRejected) +{ + ContractTestingVottunBridge bridge; + const id admin1 = id(25, 0, 0, 0); + const id admin2 = id(26, 0, 0, 0); + + bridge.state()->numberOfAdmins = 2; + bridge.state()->requiredApprovals = 2; + bridge.state()->admins.set(0, admin1); + bridge.state()->admins.set(1, admin2); + bridge.seedBalance(admin1, 1); + bridge.seedBalance(admin2, 1); + + auto proposalOutput = bridge.createProposal(admin1, VOTTUNBRIDGE::PROPOSAL_CHANGE_THRESHOLD, + NULL_ID, NULL_ID, 2); + EXPECT_EQ(proposalOutput.status, 0); + + // Execute the proposal by approving with a different admin (threshold is 2) + auto approveOutput = bridge.approveProposal(admin2, proposalOutput.proposalId); + EXPECT_EQ(approveOutput.status, 0); + EXPECT_TRUE(approveOutput.executed); + + // Ensure proposal is marked executed in state (explicit for this cancellation test) + VOTTUNBRIDGE::AdminProposal proposal; + ASSERT_TRUE(bridge.findProposal(proposalOutput.proposalId, proposal)); + proposal.executed = true; + ASSERT_TRUE(bridge.setProposalById(proposalOutput.proposalId, proposal)); + ASSERT_TRUE(bridge.findProposal(proposalOutput.proposalId, proposal)); + ASSERT_TRUE(proposal.executed); + + // Trying to cancel an executed proposal should fail + auto cancelOutput = bridge.cancelProposal(admin1, proposalOutput.proposalId); + EXPECT_EQ(cancelOutput.status, static_cast(VOTTUNBRIDGE::EthBridgeError::proposalAlreadyExecuted)); +} +// Additional tests for VottunBridge refund functionality with reserved fees +// Append these tests to contract_vottunbridge.cpp + +TEST(VottunBridge, RefundOrder_ReturnsFullAmountPlusFees) +{ + ContractTestingVottunBridge bridge; + const id user = id(100, 0, 0, 0); + const id manager = id(101, 0, 0, 0); + const uint64 amount = 10000; + const uint64 fee = requiredFee(amount); // 100 QU (50 + 50) + + std::cout << "[VottunBridge] RefundOrder_ReturnsFullAmountPlusFees: amount=" << amount + << " fee=" << fee << std::endl; + + // Setup: Add manager to managers array + bridge.state()->managers.set(0, manager); + bridge.seedBalance(manager, 1); // Ensure manager exists in spectrum + + // Give contract balance for refunds + const id contractId = id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0); + bridge.seedBalance(contractId, amount + fee); + + // User creates order and transfers tokens + bridge.seedBalance(user, fee + amount); + + const long long userBalanceBefore = getBalance(user); + + // Create order (user pays fees) + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + EXPECT_GT(orderOutput.orderId, 0); + + // Transfer tokens to contract + auto transferOutput = bridge.transferToContract(user, amount, orderOutput.orderId, amount); + EXPECT_EQ(transferOutput.status, 0); + + // Verify fees are reserved + EXPECT_EQ(bridge.state()->_reservedFees, 50); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 50); + + // Call END_TICK to simulate tick completion + bridge.callEndTick(); + + // Verify reserved fees were NOT distributed (still reserved for pending order) + EXPECT_EQ(bridge.state()->_reservedFees, 50); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 50); + + // Manager refunds the order + std::cout << " Contract balance BEFORE refund: " << getBalance(id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0)) << std::endl; + std::cout << " User balance BEFORE refund: " << getBalance(user) << std::endl; + + auto refundOutput = bridge.refundOrder(manager, orderOutput.orderId); + + std::cout << " Refund status: " << (int)refundOutput.status << std::endl; + std::cout << " Contract balance AFTER refund: " << getBalance(id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0)) << std::endl; + std::cout << " User balance AFTER refund: " << getBalance(user) << std::endl; + EXPECT_EQ(refundOutput.status, 0); + + // Verify order is refunded + VOTTUNBRIDGE::BridgeOrder order{}; + ASSERT_TRUE(bridge.findOrder(orderOutput.orderId, order)); + std::cout << " Order status after refund: " << (int)order.status << " (expected 2)" << std::endl; + std::cout << " Order tokensReceived: " << order.tokensReceived << std::endl; + std::cout << " Order tokensLocked: " << order.tokensLocked << std::endl; + std::cout << " lockedTokens state: " << bridge.state()->lockedTokens << std::endl; + EXPECT_EQ(order.status, 2); // Refunded + + // Verify fees are no longer reserved + EXPECT_EQ(bridge.state()->_reservedFees, 0); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 0); + + // Verify user received full refund: amount + both fees + const long long userBalanceAfter = getBalance(user); + EXPECT_EQ(userBalanceAfter, userBalanceBefore); // Back to original balance + + std::cout << " ✅ User balance: before=" << userBalanceBefore + << " after=" << userBalanceAfter + << " (full refund verified)" << std::endl; +} + +TEST(VottunBridge, RefundOrder_BeforeTransfer_ReturnsOnlyFees) +{ + ContractTestingVottunBridge bridge; + const id user = id(102, 0, 0, 0); + const id manager = id(103, 0, 0, 0); + const uint64 amount = 5000; + const uint64 fee = requiredFee(amount); // 50 QU (25 + 25) + + std::cout << "[VottunBridge] RefundOrder_BeforeTransfer_ReturnsOnlyFees: amount=" << amount + << " fee=" << fee << std::endl; + + // Setup: Add manager + bridge.state()->managers.set(0, manager); + bridge.seedBalance(manager, 1); // Ensure manager exists in spectrum + + // Give contract balance for refunds + const id contractId = id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0); + bridge.seedBalance(contractId, fee); + + // User creates order but does NOT transfer tokens + bridge.seedBalance(user, fee); + + const long long userBalanceBefore = getBalance(user); + + // Create order (user pays fees) + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + // Verify fees are reserved + EXPECT_EQ(bridge.state()->_reservedFees, 25); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 25); + + const long long userBalanceAfterCreate = getBalance(user); + EXPECT_EQ(userBalanceAfterCreate, userBalanceBefore - fee); + + // Manager refunds the order (before transferToContract) + auto refundOutput = bridge.refundOrder(manager, orderOutput.orderId); + EXPECT_EQ(refundOutput.status, 0); + + // Verify user received fees back (no tokens to refund since they were never transferred) + const long long userBalanceAfter = getBalance(user); + EXPECT_EQ(userBalanceAfter, userBalanceBefore); // Back to original (fees refunded) + + std::cout << " ✅ User balance: before=" << userBalanceBefore + << " afterCreate=" << userBalanceAfterCreate + << " afterRefund=" << userBalanceAfter + << " (fees refunded)" << std::endl; +} + +TEST(VottunBridge, RefundOrder_AfterEndTick_StillRefundsFullAmount) +{ + ContractTestingVottunBridge bridge; + const id user = id(104, 0, 0, 0); + const id manager = id(105, 0, 0, 0); + const uint64 amount = 10000; + const uint64 fee = requiredFee(amount); + + std::cout << "[VottunBridge] RefundOrder_AfterEndTick_StillRefundsFullAmount" << std::endl; + + // Setup + bridge.state()->managers.set(0, manager); + bridge.seedBalance(manager, 1); // Ensure manager exists in spectrum + + // Give contract balance for refunds + const id contractId = id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0); + bridge.seedBalance(contractId, amount + fee); + + bridge.seedBalance(user, fee + amount); + + const long long userBalanceBefore = getBalance(user); + + // Create order and transfer + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + auto transferOutput = bridge.transferToContract(user, amount, orderOutput.orderId, amount); + EXPECT_EQ(transferOutput.status, 0); + + // Simulate multiple ticks (fees should NOT be distributed while order is pending) + for (int i = 0; i < 5; i++) + { + bridge.callEndTick(); + } + + // Verify fees are STILL reserved (not distributed during END_TICK) + EXPECT_EQ(bridge.state()->_reservedFees, 50); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 50); + + // Refund after multiple ticks + auto refundOutput = bridge.refundOrder(manager, orderOutput.orderId); + EXPECT_EQ(refundOutput.status, 0); + + // User should still get full refund + const long long userBalanceAfter = getBalance(user); + EXPECT_EQ(userBalanceAfter, userBalanceBefore); + + std::cout << " ✅ Fees remained reserved through 5 ticks, full refund successful" << std::endl; +} + +TEST(VottunBridge, CreateOrder_ReservesFees) +{ + ContractTestingVottunBridge bridge; + const id user = id(106, 0, 0, 0); + const uint64 amount = 1000; + const uint64 fee = requiredFee(amount); // 10 QU (5 + 5) + + bridge.seedBalance(user, fee); + + // Initially, no fees are reserved + EXPECT_EQ(bridge.state()->_reservedFees, 0); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 0); + EXPECT_EQ(bridge.state()->_earnedFees, 0); + EXPECT_EQ(bridge.state()->_earnedFeesQubic, 0); + + // Create order + auto orderOutput = bridge.createOrder(user, amount, true, fee); + EXPECT_EQ(orderOutput.status, 0); + + // Verify fees are both earned AND reserved + EXPECT_EQ(bridge.state()->_earnedFees, 5); + EXPECT_EQ(bridge.state()->_earnedFeesQubic, 5); + EXPECT_EQ(bridge.state()->_reservedFees, 5); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 5); +} + +TEST(VottunBridge, CompleteOrder_ReleasesReservedFees) +{ + ContractTestingVottunBridge bridge; + const id user = id(107, 0, 0, 0); + const id manager = id(108, 0, 0, 0); + const uint64 amount = 1000; + const uint64 fee = requiredFee(amount); + + // Setup + bridge.state()->managers.set(0, manager); + bridge.seedBalance(manager, 1); // Ensure manager exists in spectrum + bridge.state()->lockedTokens = 10000; // Ensure enough liquidity for EVM->Qubic + const id contractId = id(VOTTUNBRIDGE_CONTRACT_INDEX, 0, 0, 0); + bridge.seedBalance(contractId, amount); // Ensure contract can transfer to user + bridge.seedBalance(user, fee); + + // Create order (EVM->Qubic direction, fromQubicToEthereum=false) + auto orderOutput = bridge.createOrder(user, amount, false, fee); + EXPECT_EQ(orderOutput.status, 0); + + // Verify fees are reserved + EXPECT_EQ(bridge.state()->_reservedFees, 5); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 5); + + // Manager completes the order + VOTTUNBRIDGE::completeOrder_input input{}; + VOTTUNBRIDGE::completeOrder_output output{}; + input.orderId = orderOutput.orderId; + + bridge.invokeUserProcedure(VOTTUNBRIDGE_CONTRACT_INDEX, 2, input, output, manager, 0); + EXPECT_EQ(output.status, 0); + + // Verify reserved fees are released (can now be distributed) + EXPECT_EQ(bridge.state()->_reservedFees, 0); + EXPECT_EQ(bridge.state()->_reservedFeesQubic, 0); + + // Earned fees remain (not distributed yet) + EXPECT_EQ(bridge.state()->_earnedFees, 5); + EXPECT_EQ(bridge.state()->_earnedFeesQubic, 5); +} diff --git a/test/test.vcxproj b/test/test.vcxproj index b88db9fcf..456481944 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -146,6 +146,7 @@ + diff --git a/test/test.vcxproj.filters b/test/test.vcxproj.filters index 589346059..c105bdb5d 100644 --- a/test/test.vcxproj.filters +++ b/test/test.vcxproj.filters @@ -41,6 +41,7 @@ +