diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..1c9509c9c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: Bug report +about: You might have found a bug and decide to report it +title: '' +labels: 'bug :bug:' +assignees: '' +--- + +## Context & versions + + +## Steps to reproduce + + +## Actual behavior + + +## Expected behavior + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3353ae86b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,13 @@ +blank_issues_enabled: true +contact_links: + - name: Contributing guidelines + url: https://github.com/input-output-hk/voltaire-era/blob/master/CONTRIBUTING.md + about: Some rules & processes we honor. + + - name: Feature ideas + url: https://github.com/input-output-hk/voltaire-era/discussions/categories/ideas + about: Maybe someone else had the same or a similar idea already? + + - name: All issues + url: https://github.com/input-output-hk/voltaire-era/issues + about: Check whether your issue is not already covered here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_idea.yml b/.github/ISSUE_TEMPLATE/feature_idea.yml new file mode 100644 index 000000000..985a4c27a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_idea.yml @@ -0,0 +1,44 @@ +name: Feature idea +description: Idea or request for some feature on the GovTool roadmap +labels: [':thought_balloon: idea'] +body: + - type: markdown + attributes: + value: + value: | + **Thank you for contributing to our project!** :green_heart: + + Instead of opening this issue, consider [starting a new idea discussion](https://github.com/input-output-hk/voltaire-era/discussions/new?category=ideas). + That way, we can discuss & refine your idea together, before we adopt it as a feature into the roadmap. + + - type: textarea + id: why + attributes: + label: Why + description: Why do we need or want this feature + placeholder: | + Give context and describe the problem, challenge or opportunity you see + validations: + required: true + + - type: textarea + id: what + attributes: + label: What + description: What is this feature roughly about + placeholder: | + For example describe a new API endpoint, a change in messaging formats, + a new configuration option, ... + validations: + required: true + + - type: textarea + id: how + attributes: + label: How + description: How could we realize this feature + placeholder: | + Which technical solutions, libraries or systems should be used, which + components need to change, steps how to implement this, ... + validations: + required: true \ No newline at end of file diff --git a/.github/dbsync-schema.sql b/.github/dbsync-schema.sql new file mode 100644 index 000000000..014727435 --- /dev/null +++ b/.github/dbsync-schema.sql @@ -0,0 +1,990 @@ +create sequence schema_version_id_seq + as integer; + +create domain lovelace as numeric(20, 0) + constraint lovelace_check check ((VALUE >= (0)::numeric) AND (VALUE <= '18446744073709551615'::numeric)); + +create domain txindex as smallint + constraint txindex_check check (VALUE >= 0); + +create domain word31type as integer + constraint word31type_check check (VALUE >= 0); + +create domain hash32type as bytea + constraint hash32type_check check (octet_length(VALUE) = 32); + +create domain hash28type as bytea + constraint hash28type_check check (octet_length(VALUE) = 28); + +create domain addr29type as bytea + constraint addr29type_check check (octet_length(VALUE) = 29); + +create domain word128type as numeric(39, 0) + constraint word128type_check check ((VALUE >= (0)::numeric) AND + (VALUE <= '340282366920938463463374607431768211455'::numeric)); + +create domain word64type as numeric(20, 0) + constraint word64type_check check ((VALUE >= (0)::numeric) AND (VALUE <= '18446744073709551615'::numeric)); + +create domain outsum as word128type; + +create domain asset32type as bytea + constraint asset32type_check check (octet_length(VALUE) <= 32); + +create domain int65type as numeric(20, 0) + constraint int65type_check check ((VALUE >= '-18446744073709551615'::numeric) AND + (VALUE <= '18446744073709551615'::numeric)); + +create type rewardtype as enum ('leader', 'member', 'reserves', 'treasury', 'refund'); + +create type syncstatetype as enum ('lagging', 'following'); + +create domain word63type as bigint + constraint word63type_check check (VALUE >= 0); + +create type scriptpurposetype as enum ('spend', 'mint', 'cert', 'reward'); + +create type scripttype as enum ('multisig', 'timelock', 'plutusV1', 'plutusV2'); + +create table schema_version +( + id bigint default nextval('schema_version_id_seq'::regclass) not null + primary key, + stage_one bigint not null, + stage_two bigint not null, + stage_three bigint not null +); + + +alter sequence schema_version_id_seq owned by schema_version.id; + +create table pool_hash +( + id bigserial + primary key, + hash_raw hash28type not null + constraint unique_pool_hash + unique, + view varchar not null +); + + +create table slot_leader +( + id bigserial + primary key, + hash hash28type not null + constraint unique_slot_leader + unique, + pool_hash_id bigint, + description varchar not null +); + + +create index idx_slot_leader_pool_hash_id + on slot_leader (pool_hash_id); + +create table block +( + id bigserial + primary key, + hash hash32type not null + constraint unique_block + unique, + epoch_no word31type, + slot_no word63type, + epoch_slot_no word31type, + block_no word31type, + previous_id bigint, + slot_leader_id bigint not null, + size word31type not null, + time timestamp not null, + tx_count bigint not null, + proto_major word31type not null, + proto_minor word31type not null, + vrf_key varchar, + op_cert hash32type, + op_cert_counter word63type +); + + +create index idx_block_slot_no + on block (slot_no); + +create index idx_block_block_no + on block (block_no); + +create index idx_block_epoch_no + on block (epoch_no); + +create index idx_block_previous_id + on block (previous_id); + +create index idx_block_time + on block (time); + +create index idx_block_slot_leader_id + on block (slot_leader_id); + +create table tx +( + id bigserial + primary key, + hash hash32type not null + constraint unique_tx + unique, + block_id bigint not null, + block_index word31type not null, + out_sum lovelace not null, + fee lovelace not null, + deposit bigint not null, + size word31type not null, + invalid_before word64type, + invalid_hereafter word64type, + valid_contract boolean not null, + script_size word31type not null +); + + +create index idx_tx_block_id + on tx (block_id); + +create table stake_address +( + id bigserial + primary key, + hash_raw addr29type not null + constraint unique_stake_address + unique, + view varchar not null, + script_hash hash28type +); + + +create index idx_stake_address_hash_raw + on stake_address (hash_raw); + +create index idx_stake_address_view + on stake_address using hash (view); + +create table tx_out +( + id bigserial + primary key, + tx_id bigint not null, + index txindex not null, + address varchar not null, + address_raw bytea not null, + address_has_script boolean not null, + payment_cred hash28type, + stake_address_id bigint, + value lovelace not null, + data_hash hash32type, + inline_datum_id bigint, + reference_script_id bigint, + constraint unique_txout + unique (tx_id, index) +); + + +create index idx_tx_out_address + on tx_out using hash (address); + +create index idx_tx_out_payment_cred + on tx_out (payment_cred); + +create index idx_tx_out_tx_id + on tx_out (tx_id); + +create index idx_tx_out_stake_address_id + on tx_out (stake_address_id); + +create index tx_out_inline_datum_id_idx + on tx_out (inline_datum_id); + +create index tx_out_reference_script_id_idx + on tx_out (reference_script_id); + +create table datum +( + id bigserial + primary key, + hash hash32type not null + constraint unique_datum + unique, + tx_id bigint not null, + value jsonb, + bytes bytea not null +); + + +create index idx_datum_tx_id + on datum (tx_id); + +create table redeemer +( + id bigserial + primary key, + tx_id bigint not null, + unit_mem word63type not null, + unit_steps word63type not null, + fee lovelace, + purpose scriptpurposetype not null, + index word31type not null, + script_hash hash28type, + redeemer_data_id bigint not null +); + + +create index redeemer_redeemer_data_id_idx + on redeemer (redeemer_data_id); + +create table tx_in +( + id bigserial + primary key, + tx_in_id bigint not null, + tx_out_id bigint not null, + tx_out_index txindex not null, + redeemer_id bigint +); + + +create index idx_tx_in_source_tx + on tx_in (tx_in_id); + +create index idx_tx_in_tx_in_id + on tx_in (tx_in_id); + +create index idx_tx_in_tx_out_id + on tx_in (tx_out_id); + +create index idx_tx_in_redeemer_id + on tx_in (redeemer_id); + +create table collateral_tx_in +( + id bigserial + primary key, + tx_in_id bigint not null, + tx_out_id bigint not null, + tx_out_index txindex not null +); + + +create index idx_collateral_tx_in_tx_out_id + on collateral_tx_in (tx_out_id); + +create table meta +( + id bigserial + primary key, + start_time timestamp not null + constraint unique_meta + unique, + network_name varchar not null, + version varchar not null +); + + +create table epoch +( + id bigserial + primary key, + out_sum word128type not null, + fees lovelace not null, + tx_count word31type not null, + blk_count word31type not null, + no word31type not null + constraint unique_epoch + unique, + start_time timestamp not null, + end_time timestamp not null +); + + +create index idx_epoch_no + on epoch (no); + +create table ada_pots +( + id bigserial + primary key, + slot_no word63type not null, + epoch_no word31type not null, + treasury lovelace not null, + reserves lovelace not null, + rewards lovelace not null, + utxo lovelace not null, + deposits lovelace not null, + fees lovelace not null, + block_id bigint not null +); + + +create table pool_metadata_ref +( + id bigserial + primary key, + pool_id bigint not null, + url varchar not null, + hash hash32type not null, + registered_tx_id bigint not null, + constraint unique_pool_metadata_ref + unique (pool_id, url, hash) +); + + +create index idx_pool_metadata_ref_registered_tx_id + on pool_metadata_ref (registered_tx_id); + +create index idx_pool_metadata_ref_pool_id + on pool_metadata_ref (pool_id); + +create table pool_update +( + id bigserial + primary key, + hash_id bigint not null, + cert_index integer not null, + vrf_key_hash hash32type not null, + pledge lovelace not null, + active_epoch_no bigint not null, + meta_id bigint, + margin double precision not null, + fixed_cost lovelace not null, + registered_tx_id bigint not null, + reward_addr_id bigint not null +); + + +create index idx_pool_update_hash_id + on pool_update (hash_id); + +create index idx_pool_update_registered_tx_id + on pool_update (registered_tx_id); + +create index idx_pool_update_meta_id + on pool_update (meta_id); + +create index idx_pool_update_reward_addr + on pool_update (reward_addr_id); + +create index idx_pool_update_active_epoch_no + on pool_update (active_epoch_no); + +create table pool_owner +( + id bigserial + primary key, + addr_id bigint not null, + pool_update_id bigint not null +); + + +create index pool_owner_pool_update_id_idx + on pool_owner (pool_update_id); + +create table pool_retire +( + id bigserial + primary key, + hash_id bigint not null, + cert_index integer not null, + announced_tx_id bigint not null, + retiring_epoch word31type not null +); + + +create index idx_pool_retire_hash_id + on pool_retire (hash_id); + +create index idx_pool_retire_announced_tx_id + on pool_retire (announced_tx_id); + +create table pool_relay +( + id bigserial + primary key, + update_id bigint not null, + ipv4 varchar, + ipv6 varchar, + dns_name varchar, + dns_srv_name varchar, + port integer +); + + +create index idx_pool_relay_update_id + on pool_relay (update_id); + +create table stake_registration +( + id bigserial + primary key, + addr_id bigint not null, + cert_index integer not null, + epoch_no word31type not null, + tx_id bigint not null +); + + +create index idx_stake_registration_tx_id + on stake_registration (tx_id); + +create index idx_stake_registration_addr_id + on stake_registration (addr_id); + +create table stake_deregistration +( + id bigserial + primary key, + addr_id bigint not null, + cert_index integer not null, + epoch_no word31type not null, + tx_id bigint not null, + redeemer_id bigint +); + + +create index idx_stake_deregistration_tx_id + on stake_deregistration (tx_id); + +create index idx_stake_deregistration_addr_id + on stake_deregistration (addr_id); + +create index idx_stake_deregistration_redeemer_id + on stake_deregistration (redeemer_id); + +create table delegation +( + id bigserial + primary key, + addr_id bigint not null, + cert_index integer not null, + pool_hash_id bigint not null, + active_epoch_no bigint not null, + tx_id bigint not null, + slot_no word63type not null, + redeemer_id bigint +); + + +create index idx_delegation_pool_hash_id + on delegation (pool_hash_id); + +create index idx_delegation_tx_id + on delegation (tx_id); + +create index idx_delegation_addr_id + on delegation (addr_id); + +create index idx_delegation_active_epoch_no + on delegation (active_epoch_no); + +create index idx_delegation_redeemer_id + on delegation (redeemer_id); + +create table tx_metadata +( + id bigserial + primary key, + key word64type not null, + json jsonb, + bytes bytea not null, + tx_id bigint not null +); + + +create index idx_tx_metadata_tx_id + on tx_metadata (tx_id); + +create table reward +( + id bigserial + primary key, + addr_id bigint not null, + type rewardtype not null, + amount lovelace not null, + earned_epoch bigint not null, + spendable_epoch bigint not null, + pool_id bigint, + constraint unique_reward + unique (addr_id, type, earned_epoch, pool_id) +); + + +create index idx_reward_pool_id + on reward (pool_id); + +create index idx_reward_earned_epoch + on reward (earned_epoch); + +create index idx_reward_addr_id + on reward (addr_id); + +create index idx_reward_spendable_epoch + on reward (spendable_epoch); + +create table withdrawal +( + id bigserial + primary key, + addr_id bigint not null, + amount lovelace not null, + redeemer_id bigint, + tx_id bigint not null +); + + +create index idx_withdrawal_tx_id + on withdrawal (tx_id); + +create index idx_withdrawal_addr_id + on withdrawal (addr_id); + +create index idx_withdrawal_redeemer_id + on withdrawal (redeemer_id); + +create table epoch_stake +( + id bigserial + primary key, + addr_id bigint not null, + pool_id bigint not null, + amount lovelace not null, + epoch_no word31type not null, + constraint unique_stake + unique (epoch_no, addr_id, pool_id) +); + + +create index idx_epoch_stake_pool_id + on epoch_stake (pool_id); + +create index idx_epoch_stake_epoch_no + on epoch_stake (epoch_no); + +create index idx_epoch_stake_addr_id + on epoch_stake (addr_id); + +create table treasury +( + id bigserial + primary key, + addr_id bigint not null, + cert_index integer not null, + amount int65type not null, + tx_id bigint not null +); + + +create index idx_treasury_tx_id + on treasury (tx_id); + +create index idx_treasury_addr_id + on treasury (addr_id); + +create table reserve +( + id bigserial + primary key, + addr_id bigint not null, + cert_index integer not null, + amount int65type not null, + tx_id bigint not null +); + + +create index idx_reserve_tx_id + on reserve (tx_id); + +create index idx_reserve_addr_id + on reserve (addr_id); + +create table pot_transfer +( + id bigserial + primary key, + cert_index integer not null, + treasury int65type not null, + reserves int65type not null, + tx_id bigint not null +); + + +create table epoch_sync_time +( + id bigserial + primary key, + no bigint not null + constraint unique_epoch_sync_time + unique, + seconds word63type not null, + state syncstatetype not null +); + + +create table ma_tx_mint +( + id bigserial + primary key, + quantity int65type not null, + tx_id bigint not null, + ident bigint not null +); + + +create index idx_ma_tx_mint_tx_id + on ma_tx_mint (tx_id); + +create table ma_tx_out +( + id bigserial + primary key, + quantity word64type not null, + tx_out_id bigint not null, + ident bigint not null +); + + +create index idx_ma_tx_out_tx_out_id + on ma_tx_out (tx_out_id); + +create table script +( + id bigserial + primary key, + tx_id bigint not null, + hash hash28type not null + constraint unique_script + unique, + type scripttype not null, + json jsonb, + bytes bytea, + serialised_size word31type +); + + +create index idx_script_tx_id + on script (tx_id); + +create table cost_model +( + id bigserial + primary key, + costs jsonb not null, + hash hash32type not null + constraint unique_cost_model + unique +); + + +create table param_proposal +( + id bigserial + primary key, + epoch_no word31type not null, + key hash28type not null, + min_fee_a word64type, + min_fee_b word64type, + max_block_size word64type, + max_tx_size word64type, + max_bh_size word64type, + key_deposit lovelace, + pool_deposit lovelace, + max_epoch word64type, + optimal_pool_count word64type, + influence double precision, + monetary_expand_rate double precision, + treasury_growth_rate double precision, + decentralisation double precision, + entropy hash32type, + protocol_major word31type, + protocol_minor word31type, + min_utxo_value lovelace, + min_pool_cost lovelace, + cost_model_id bigint, + price_mem double precision, + price_step double precision, + max_tx_ex_mem word64type, + max_tx_ex_steps word64type, + max_block_ex_mem word64type, + max_block_ex_steps word64type, + max_val_size word64type, + collateral_percent word31type, + max_collateral_inputs word31type, + registered_tx_id bigint not null, + coins_per_utxo_size lovelace +); + + +create index idx_param_proposal_registered_tx_id + on param_proposal (registered_tx_id); + +create index idx_param_proposal_cost_model_id + on param_proposal (cost_model_id); + +create table epoch_param +( + id bigserial + primary key, + epoch_no word31type not null, + min_fee_a word31type not null, + min_fee_b word31type not null, + max_block_size word31type not null, + max_tx_size word31type not null, + max_bh_size word31type not null, + key_deposit lovelace not null, + pool_deposit lovelace not null, + max_epoch word31type not null, + optimal_pool_count word31type not null, + influence double precision not null, + monetary_expand_rate double precision not null, + treasury_growth_rate double precision not null, + decentralisation double precision not null, + protocol_major word31type not null, + protocol_minor word31type not null, + min_utxo_value lovelace not null, + min_pool_cost lovelace not null, + nonce hash32type, + cost_model_id bigint, + price_mem double precision, + price_step double precision, + max_tx_ex_mem word64type, + max_tx_ex_steps word64type, + max_block_ex_mem word64type, + max_block_ex_steps word64type, + max_val_size word64type, + collateral_percent word31type, + max_collateral_inputs word31type, + block_id bigint not null, + extra_entropy hash32type, + coins_per_utxo_size lovelace +); + + +create index idx_epoch_param_block_id + on epoch_param (block_id); + +create index idx_epoch_param_cost_model_id + on epoch_param (cost_model_id); + +create table pool_offline_data +( + id bigserial + primary key, + pool_id bigint not null, + ticker_name varchar not null, + hash hash32type not null, + json jsonb not null, + bytes bytea not null, + pmr_id bigint not null, + constraint unique_pool_offline_data + unique (pool_id, hash) +); + + +create index idx_pool_offline_data_pmr_id + on pool_offline_data (pmr_id); + +create table pool_offline_fetch_error +( + id bigserial + primary key, + pool_id bigint not null, + fetch_time timestamp not null, + pmr_id bigint not null, + fetch_error varchar not null, + retry_count word31type not null, + constraint unique_pool_offline_fetch_error + unique (pool_id, fetch_time, retry_count) +); + + +create index idx_pool_offline_fetch_error_pmr_id + on pool_offline_fetch_error (pmr_id); + +create table reserved_pool_ticker +( + id bigserial + primary key, + name varchar not null + constraint unique_reserved_pool_ticker + unique, + pool_hash hash28type not null +); + + +create index idx_reserved_pool_ticker_pool_hash + on reserved_pool_ticker (pool_hash); + +create table multi_asset +( + id bigserial + primary key, + policy hash28type not null, + name asset32type not null, + fingerprint varchar not null, + constraint unique_multi_asset + unique (policy, name) +); + + +create table delisted_pool +( + id bigserial + primary key, + hash_raw hash28type not null + constraint unique_delisted_pool + unique +); + + +create table extra_key_witness +( + id bigserial + primary key, + hash hash28type not null, + tx_id bigint not null +); + + +create index idx_extra_key_witness_tx_id + on extra_key_witness (tx_id); + +create table collateral_tx_out +( + id bigserial + primary key, + tx_id bigint not null, + index txindex not null, + address varchar not null, + address_raw bytea not null, + address_has_script boolean not null, + payment_cred hash28type, + stake_address_id bigint, + value lovelace not null, + data_hash hash32type, + multi_assets_descr varchar not null, + inline_datum_id bigint, + reference_script_id bigint +); + + +create index collateral_tx_out_stake_address_id_idx + on collateral_tx_out (stake_address_id); + +create index collateral_tx_out_inline_datum_id_idx + on collateral_tx_out (inline_datum_id); + +create index collateral_tx_out_reference_script_id_idx + on collateral_tx_out (reference_script_id); + +create table reference_tx_in +( + id bigserial + primary key, + tx_in_id bigint not null, + tx_out_id bigint not null, + tx_out_index txindex not null +); + + +create index reference_tx_in_tx_out_id_idx + on reference_tx_in (tx_out_id); + +create table redeemer_data +( + id bigserial + primary key, + hash hash32type not null + constraint unique_redeemer_data + unique, + tx_id bigint not null, + value jsonb, + bytes bytea not null +); + + +create index redeemer_data_tx_id_idx + on redeemer_data (tx_id); + +create table tx_confirmed +( + tx_hash bytea not null + primary key, + block_hash bytea, + slot_no bigint, + block_no bigint, + pool_id varchar(100), + confirmation_time timestamp +); + + +create table reverse_index +( + id bigserial + primary key, + block_id bigint not null, + min_ids varchar +); + + +create view tx_log(tx_hash, block_hash, slot_no, pool_id, block_no, confirmation_time) as +SELECT tx.hash AS tx_hash, + b.hash AS block_hash, + b.slot_no, + ph.view AS pool_id, + b.block_no, + b."time" AS confirmation_time +FROM tx + JOIN block b ON b.id = tx.block_id + JOIN slot_leader sl ON b.slot_leader_id = sl.id + JOIN pool_hash ph ON sl.pool_hash_id = ph.id; + + +create view utxo_byron_view + (id, tx_id, index, address, address_raw, address_has_script, payment_cred, stake_address_id, value, + data_hash, inline_datum_id, reference_script_id) +as +SELECT tx_out.id, + tx_out.tx_id, + tx_out.index, + tx_out.address, + tx_out.address_raw, + tx_out.address_has_script, + tx_out.payment_cred, + tx_out.stake_address_id, + tx_out.value, + tx_out.data_hash, + tx_out.inline_datum_id, + tx_out.reference_script_id +FROM tx_out + LEFT JOIN tx_in ON tx_out.tx_id = tx_in.tx_out_id AND tx_out.index::smallint = tx_in.tx_out_index::smallint +WHERE tx_in.tx_in_id IS NULL; + + +create view utxo_view + (id, tx_id, index, address, address_raw, address_has_script, payment_cred, stake_address_id, value, + data_hash, inline_datum_id, reference_script_id) +as +SELECT tx_out.id, + tx_out.tx_id, + tx_out.index, + tx_out.address, + tx_out.address_raw, + tx_out.address_has_script, + tx_out.payment_cred, + tx_out.stake_address_id, + tx_out.value, + tx_out.data_hash, + tx_out.inline_datum_id, + tx_out.reference_script_id +FROM tx_out + LEFT JOIN tx_in ON tx_out.tx_id = tx_in.tx_out_id AND tx_out.index::smallint = tx_in.tx_out_index::smallint + LEFT JOIN tx ON tx.id = tx_out.tx_id + LEFT JOIN block ON tx.block_id = block.id +WHERE tx_in.tx_in_id IS NULL + AND block.epoch_no IS NOT NULL; + diff --git a/.github/images/govtool-logo.png b/.github/images/govtool-logo.png new file mode 100644 index 000000000..5c9d0e1bf Binary files /dev/null and b/.github/images/govtool-logo.png differ diff --git a/.github/images/sanchonet-govtool-header.png b/.github/images/sanchonet-govtool-header.png new file mode 100644 index 000000000..cd72a1b21 Binary files /dev/null and b/.github/images/sanchonet-govtool-header.png differ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..f1b28c435 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +## List of changes + +Please include a summary of the changes and the related issue. + +- I created a blue button +- When pressed the button turns purple + +## Checklist + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works + +## Screenshots of change (for FE) diff --git a/.github/vva-be-config.json b/.github/vva-be-config.json new file mode 100644 index 000000000..9b3329863 --- /dev/null +++ b/.github/vva-be-config.json @@ -0,0 +1,18 @@ +{ + "dbsyncconfig" : { + "host" : "localhost", + "dbname" : "cexplorer", + "user" : "postgres", + "password" : "MTnk8lsuMM41RgAh1y2WTAUdObsb", + "port" : 5432 + }, + "fakedbsyncconfig" : { + "host" : "localhost", + "dbname" : "vva", + "user" : "postgres", + "password" : "MTnk8lsuMM41RgAh1y2WTAUdObsb", + "port" : 5432 + }, + "port" : 8080, + "host" : "localhost" +} diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml new file mode 100644 index 000000000..aecdcf1fe --- /dev/null +++ b/.github/workflows/build-and-deploy.yml @@ -0,0 +1,235 @@ +name: Build and deploy app +run-name: Deploy to ${{ inputs.environment }}/${{ inputs.cardano_network }} by @${{ github.actor }} + +on: + workflow_dispatch: + inputs: + cardano_network: + required: true + type: choice + default: "preprod" + options: + - "preprod" + - "sanchonet" + environment: + required: true + type: choice + default: "dev" + options: + - "dev" + - "test" + - "staging" + - "beta" + skip_build: + required: true + type: boolean + default: false + +env: + ENVIRONMENT: ${{ inputs.environment || 'dev' }} + CARDANO_NETWORK: ${{ inputs.cardano_network || 'preprod' }} + +jobs: + check_environment_exists: + name: Check if target environment exists before proceeding + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Check environment exists + run: | + make check-env-defined + build_backend: + name: Build and push backend Docker image + if: ${{ ! inputs.skip_build }} + needs: + - check_environment_exists + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.GHA_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.GHA_AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + - name: Login to AWS ECR + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-region: eu-west-1 + - name: Build and push images + run: | + make docker-login + make build-backend + make push-backend + build_frontend: + name: Build and push frontend Docker image + if: ${{ ! inputs.skip_build }} + needs: + - check_environment_exists + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.GHA_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.GHA_AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + - name: Login to AWS ECR + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-region: eu-west-1 + - name: Build and push images + env: + GTM_ID: ${{ secrets.GTM_ID }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN_FRONTEND }} + run: | + make docker-login + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + make build-frontend + make push-frontend + deploy: + name: Deploy app + needs: + - build_backend + - build_frontend + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src + env: + DBSYNC_POSTGRES_DB: "cexplorer" + DBSYNC_POSTGRES_USER: "postgres" + DBSYNC_POSTGRES_PASSWORD: "pSa8JCpQOACMUdGb" + FAKEDBSYNC_POSTGRES_DB: "vva" + FAKEDBSYNC_POSTGRES_USER: "test" + FAKEDBSYNC_POSTGRES_PASSWORD: "test" + GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} + GRAFANA_SLACK_RECIPIENT: ${{ secrets.GRAFANA_SLACK_RECIPIENT }} + GRAFANA_SLACK_OAUTH_TOKEN: ${{ secrets.GRAFANA_SLACK_OAUTH_TOKEN }} + SENTRY_DSN_BACKEND: ${{ secrets.SENTRY_DSN_BACKEND }} + TRAEFIK_LE_EMAIL: "admin+vva@binarapps.com" + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.GHA_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.GHA_AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + - name: Login to AWS ECR + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-region: eu-west-1 + - name: Setup SSH agent + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.GHA_SSH_PRIVATE_KEY }} + - name: Prepare and upload app config + run: | + make prepare-config + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + make upload-config + - name: Deploy app + run: | + make docker-login + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + make deploy-stack + - name: Reprovision Grafana + run: | + sleep 30 # give grafana time to start up + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + DOMAIN=${DOMAIN:-$ENVIRONMENT-$CARDANO_NETWORK.govtool.byron.network} + curl -X POST -u "admin:$GRAFANA_ADMIN_PASSWORD" https://$DOMAIN/grafana/api/admin/provisioning/alerting/reload + curl -X POST -u "admin:$GRAFANA_ADMIN_PASSWORD" https://$DOMAIN/grafana/api/admin/provisioning/dashboards/reload + curl -X POST -u "admin:$GRAFANA_ADMIN_PASSWORD" https://$DOMAIN/grafana/api/admin/provisioning/notifications/reload + - name: Notify on Slack + env: + SLACK_WEBHOOK_URL: ${{ secrets.DEPLOY_NOTIFY_SLACK_WEBHOOK_URL }} + run: | + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + make notify + deploy_without_build: + name: Deploy app without building + if: ${{ inputs.skip_build }} + needs: + - check_environment_exists + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src + env: + DBSYNC_POSTGRES_DB: "cexplorer" + DBSYNC_POSTGRES_USER: "postgres" + DBSYNC_POSTGRES_PASSWORD: "pSa8JCpQOACMUdGb" + FAKEDBSYNC_POSTGRES_DB: "vva" + FAKEDBSYNC_POSTGRES_USER: "test" + FAKEDBSYNC_POSTGRES_PASSWORD: "test" + GRAFANA_ADMIN_PASSWORD: ${{ secrets.GRAFANA_ADMIN_PASSWORD }} + GRAFANA_SLACK_RECIPIENT: ${{ secrets.GRAFANA_SLACK_RECIPIENT }} + GRAFANA_SLACK_OAUTH_TOKEN: ${{ secrets.GRAFANA_SLACK_OAUTH_TOKEN }} + SENTRY_DSN_BACKEND: ${{ secrets.SENTRY_DSN_BACKEND }} + TRAEFIK_LE_EMAIL: "admin+vva@binarapps.com" + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.GHA_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.GHA_AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + - name: Login to AWS ECR + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-region: eu-west-1 + - name: Setup SSH agent + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.GHA_SSH_PRIVATE_KEY }} + - name: Prepare and upload app config + run: | + make prepare-config + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + make upload-config + - name: Deploy app + run: | + make docker-login + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + make deploy-stack + - name: Reprovision Grafana + run: | + sleep 30 # give grafana time to start up + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + DOMAIN=${DOMAIN:-$ENVIRONMENT-$CARDANO_NETWORK.govtool.byron.network} + curl -X POST -u "admin:$GRAFANA_ADMIN_PASSWORD" https://$DOMAIN/grafana/api/admin/provisioning/alerting/reload + curl -X POST -u "admin:$GRAFANA_ADMIN_PASSWORD" https://$DOMAIN/grafana/api/admin/provisioning/dashboards/reload + curl -X POST -u "admin:$GRAFANA_ADMIN_PASSWORD" https://$DOMAIN/grafana/api/admin/provisioning/notifications/reload + - name: Notify on Slack + env: + SLACK_WEBHOOK_URL: ${{ secrets.DEPLOY_NOTIFY_SLACK_WEBHOOK_URL }} + run: | + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + make notify diff --git a/.github/workflows/frontend_sonar_scan.yml b/.github/workflows/frontend_sonar_scan.yml new file mode 100644 index 000000000..166a87075 --- /dev/null +++ b/.github/workflows/frontend_sonar_scan.yml @@ -0,0 +1,29 @@ +name: SonarQube Static Analysis + +on: + push: + paths: + - src/vva-fe/** + - .github/workflows/frontend_sonar_scan.yml + +jobs: + execute_sonar_scanner: + name: Execute sonar-scanner on vva-fe + runs-on: ubuntu-latest + permissions: read-all + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - uses: sonarsource/sonarqube-scan-action@master + with: + projectBaseDir: src/vva-fe + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + + # Fail the build if it doesn't meet quality gate + # - uses: sonarsource/sonarqube-quality-gate-action@master + # timeout-minutes: 5 + # env: + # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml new file mode 100644 index 000000000..6c930760e --- /dev/null +++ b/.github/workflows/lighthouse.yml @@ -0,0 +1,59 @@ +name: Lighthouse + +on: + push: + paths: + - src/vva-fe/** + - .github/workflows/lighthouse.yml + +jobs: + lighthouse: + runs-on: ubuntu-latest + env: + NODE_OPTIONS: --max_old_space_size=4096 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: Install dependencies + run: npm install + working-directory: ./src/vva-fe + + - name: Cache npm dependencies + id: npm-cache + uses: actions/cache@v3 + with: + path: | + ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('src/vva-fe/package-lock.json', 'tests/vva-fe/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - run: npm install -g @lhci/cli@0.12.x + + - name: Run build and lighthouse task + working-directory: ./src/vva-fe + run: | + npm install + VITE_BASE_URL=https://staging.govtool.byron.network/api npm run build + lhci collect + + - name: Evaluate reports + if: github.repository_owner != 'input-output-hk' + run: | + lhci assert --preset "lighthouse:recommended" + + + - name: Publish reports + working-directory: ./src/vva-fe + if: github.repository_owner == 'input-output-hk' + run: | + lhci assert --preset lighthouse:recommended || echo "LightHouse Assertion error ignored ..." + lhci upload --githubAppToken="${{ secrets.LHCI_GITHUB_APP_TOKEN }}" --token="${{ secrets.LHCI_SERVER_TOKEN }}" --serverBaseUrl=https://lighthouse.cardanoapi.io --ignoreDuplicateBuildFailure + curl -X POST https://ligththouse.cardanoapi.io/api/metrics/build-reports \ + -d "@./lighthouseci/$(ls ./.lighthouseci |grep 'lhr.*\.json' | head -n 1)" \ + -H "commit-hash: $(git rev-parse HEAD)" \ + -H "secret-token: ${{ secrets.METRICS_SERVER_SECRET_TOKEN }}" \ + -H 'Content-Type: application/json' || echo "Metric Upload error ignored ..." \ No newline at end of file diff --git a/.github/workflows/test_backend.yml b/.github/workflows/test_backend.yml new file mode 100644 index 000000000..1a3a198d3 --- /dev/null +++ b/.github/workflows/test_backend.yml @@ -0,0 +1,49 @@ +name: Backend Test + +on: + push: + paths: + - .github/workflows/test_backend.yml + # - src/vva-be + # - tests/vva-be + + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + inputs: + deployment: + required: true + type: choice + default: "staging.govtool.byron.network/api" + options: + - "sanchogov.tools/api" + - "staging.govtool.byron.network/api" + - "vva-sanchonet.cardanoapi.io/api" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11.4 + cache: 'pip' + + - name: Run Backend Test + working-directory: tests/vva-be + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pytest -v --github-report + env: + BASE_URL: https://${{inputs.deployment || 'staging.govtool.byron.network/api' }} + METRICS_URL: https://metrics.cardanoapi.io + METRICS_API_SECRET: "${{ secrets.METRICS_SERVER_SECRET_TOKEN }}" + + # - uses: schemathesis/action@v1 + # with: + # schema: "http://localhost:8080/swagger.json" diff --git a/.github/workflows/test_integration_cypress.yml b/.github/workflows/test_integration_cypress.yml new file mode 100644 index 000000000..5378ebd02 --- /dev/null +++ b/.github/workflows/test_integration_cypress.yml @@ -0,0 +1,73 @@ +name: Integration Test [Cypress] +run-name: Integration Test on ${{ inputs.network ||'sanchonet' }} [${{ inputs.deployment || 'staging.govtool.byron.network' }}] + +on: + push: + branches: [feat/integration-test] + schedule: + - cron: '0 0 * * *' + + workflow_dispatch: + inputs: + network: + required: true + type: choice + default: "sanchonet" + options: + - "preprod" + - "sanchonet" + deployment: + required: true + type: choice + default: "staging.govtool.byron.network" + options: + - "sanchogov.tools" + - "staging.govtool.byron.network" + - "vva-sanchonet.cardanoapi.io" + +jobs: + cypress-tests: + defaults: + run: + working-directory: ./tests/vva-fe + runs-on: ubuntu-latest + env: + NODE_OPTIONS: --max_old_space_size=4096 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + id: yarn-cache + with: + path: | + ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: + ${{ runner.os }}-yarn-${{hashFiles('tests/vva-fe/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cypress run + uses: cypress-io/github-action@v6 + with: + record: true + working-directory: ./tests/vva-fe + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + # pass GitHub token to allow accurately detecting a build vs a re-run build + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CYPRESS_baseUrl: https://${{inputs.deployment || 'staging.govtool.byron.network' }} + CYPRESS_apiUrl: https://${{ inputs.deployment || 'staging.govtool.byron.network' }}/api + CYPRESS_kuberApiUrl: https://${{ inputs.network || 'sanchonet' }}.kuber.cardanoapi.io + CYPRESS_kuberApiKey: ${{secrets.KUBER_API_KEY}} + CYPRESS_faucetApiUrl: https://faucet.${{inputs.network || 'sanchonet'}}.world.dev.cardano.org + CYPRESS_faucetApiKey: ${{ secrets.FAUCET_API_KEY }} + \ No newline at end of file diff --git a/.github/workflows/test_storybook.yml b/.github/workflows/test_storybook.yml new file mode 100644 index 000000000..ac1935e2a --- /dev/null +++ b/.github/workflows/test_storybook.yml @@ -0,0 +1,34 @@ +name: Storybook Test + +on: + push: + paths: + - src/vva-fe/** + - .github/workflows/test_storybook.yml + +defaults: + run: + working-directory: ./src/vva-fe + +jobs: + storybook: + timeout-minutes: 60 + runs-on: ubuntu-latest + env: + NODE_OPTIONS: --max_old_space_size=4096 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 + with: + node-version: 16 + - name: Install dependencies + run: npm install + - name: Install Playwright + run: npx playwright install --with-deps + - name: Build Storybook + run: NODE_OPTIONS="--max-old-space-size=8046" npm run build-storybook --quiet + - name: Serve Storybook and run tests + run: | + npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \ + "npx http-server storybook-static --port 6006 --silent" \ + "npx wait-on tcp:6006 && npm run test-storybook" diff --git a/.github/workflows/toggle-maintenance.yml b/.github/workflows/toggle-maintenance.yml new file mode 100644 index 000000000..4fd2c7518 --- /dev/null +++ b/.github/workflows/toggle-maintenance.yml @@ -0,0 +1,79 @@ +name: Toggle (enable/disable) maintenance page +run-name: Maintenance mode set to ${{ inputs.maintenance }} on ${{ inputs.environment }}/${{ inputs.cardano_network }} by @${{ github.actor }} + +on: + workflow_dispatch: + inputs: + cardano_network: + required: true + type: choice + default: "preprod" + options: + - "preprod" + - "sanchonet" + environment: + required: true + type: choice + default: "dev" + options: + - "dev" + - "test" + - "staging" + - "beta" + maintenance: + required: true + type: choice + default: "enable" + options: + - "enable" + - "disable" + +env: + ENVIRONMENT: ${{ inputs.environment || 'dev' }} + CARDANO_NETWORK: ${{ inputs.cardano_network || 'preprod' }} + +jobs: + check_environment_exists: + name: Check if target environment exists before proceeding + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Check environment exists + run: | + make check-env-defined cardano_network=$CARDANO_NETWORK env=$ENVIRONMENT + + toggle_maintenance: + name: Toggl maintenance state + needs: + - check_environment_exists + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./src + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.GHA_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.GHA_AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + - name: Login to AWS ECR + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-region: eu-west-1 + - name: Setup SSH agent + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.GHA_SSH_PRIVATE_KEY }} + - name: Toggle maintenance + run: | + make docker-login + if [[ "${{ inputs.environment }}" == "staging" ]]; then export DOMAIN=staging.govtool.byron.network; fi; + if [[ "${{ inputs.environment }}" == "beta" ]]; then export DOMAIN=sanchogov.tools; fi; + make toggle-maintenance cardano_network=$CARDANO_NETWORK env=$ENVIRONMENT maintenance=${{ inputs.maintenance || 'disable' }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..15ae2a0d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +### Python ### +.env/ +__pycache__/ + +# Voting Node Service +node_storage/ +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Other +book/html +book/tmp +book/src/06_rust_api/rust +book/src/08_event-db/db-diagrams/*.dot +.stoplight + +/vendor + +# Used by nix +result* +/.direnv/ +/.pre-commit-config.yaml + +# Development Environments +.vscode +**/.idea/ +.temp/ + +# std +.std + +# nixago: ignore-linked-files +.prettierrc +lefthook.yml +treefmt.toml + +# local earthly Environments +local/ + +# used by haskell +src/vva-be/dist-newstyle/ + +# target environment config dir +src/config/target + +# terraform +src/terraform/.terraform* + +# local env files +.env +.envrc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..377d4315b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# GovTool Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +As a minor extension, we also keep a semantic version for the `UNRELEASED` +changes. diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md new file mode 100644 index 000000000..279206db0 --- /dev/null +++ b/CODE-OF-CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[XXXX@intersectmbo.org](XXXX@intersectmbo.org). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..eb3c0431f --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,24 @@ +# GovTool Project Codeowners + +# These owners will be the default owners for everything in the repository. +* @Ryun1 @kickloop + +# Frontend assets templates +src/vva-fe/* @Sworzen1 @JanJaroszczak @kickloop +*.tsx @Sworzen1 @JanJaroszczak @kickloop +*.ts @Sworzen1 @JanJaroszczak @kickloop +*.css @Sworzen1 @JanJaroszczak @kickloop + +# Backend +src/vva-be/* @jankun4 @kickloop + +# DevOps +.github/workflows/* @adgud @placek @kickloop +src/config/* @adgud @placek @kickloop +src/scripts/* @adgud @placek @kickloop +src/terraform/* @adgud @placek @kickloop + +# Testing +src/load-testing/* @mesudip @kickloop +src/governance-action-loader/* @mesudip @kickloop +tests/* @mesudip @kickloop \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..c566d4fbc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,170 @@ +# Contributing to the `GovTool` project + +Thanks for considering contributing and helping us on creating GovTool! 😎 + +The best way to contribute right now is to try things out and provide feedback, but we also accept contributions to the documentation and the obviously to the code itself. + +This document contains guidelines to help you get started and how to make sure your contribution gets accepted, making you our newest GovTool contributor! + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Ask for Help](#ask-for-help) +- [Roles and Responsibilities](#roles-and-responsibilities) +- [I Want To Contribute](#i-want-to-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Your First Code Contribution](#your-first-code-contribution) +- [Working Conventions](#working-conventions) + - [Pull Requests](#pull-requests) + - [Commit Messages](#commit-messages) + - [Merge Commit PRs and Rebase Branches on top of Main](#merge-commit-prs-and-rebase-branches-on-top-of-main) + - [Versioning and Changelog](#versioning-and-changelog) + - [Style Guides](#style-guides) + +## Code of Conduct + +This project and everyone participating in it is governed by the [Code of Conduct](https://github.com/IntersectMBO/govtool/blob/main/CODE_OF_CONDUCT.md). +By participating, you are expected to uphold this code. + +## Ask for Help + +See [`SUPPORT.md`](SUPPORT.md) should you have any questions or need some help in getting set up. + +## Roles and Responsibilities + +We maintain a [CODEOWNERS file](https://github.com/IntersectMBO/govtool/CODEOWNERS) which provides information who should review a contributing PR. +Note that you might need to get approvals from all code owners (even though GitHub doesn't give a way to enforce it). + +## I Want To Contribute + +#### Before Submitting a Bug Report + +A good bug report shouldn't leave others needing to chase you up for more information. +Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. +Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side. + e.g. using incompatible environment components/versions. + If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having. +- Also make sure to search the internet (including Stack Overflow) + to see if users outside of the GitHub community have discussed the issue. +- Collect information about the bug: + - Stack trace (Traceback) + - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) + - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. + - Possibly your input and the output + - Can you reliably reproduce the issue? And can you also reproduce it with older versions? + +#### How Do I Submit a Good Bug Report? + +We use GitHub issues to track bugs and errors. If you run into an issue with the project: + +- Open an [Issue](https://github.com/IntersectMBO/govtool/issues/new). + (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) +- Explain the behavior you would expect and the actual behavior. +- Please provide as much context as possible. + Describe the *reproduction steps* that someone else can follow to recreate the issue on their own. + This usually includes your code. + For good bug reports you should isolate the problem and create a reduced test case. +- Provide the information you collected in the previous section. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. + If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps. + The issue would then be marked as `needs-repro`. + Bugs with the `needs-repro` tag will not be addressed until they are reproduced. +- If the team is able to reproduce the issue, it will be marked `needs-fix`. + It may possibly be marked with other tags (such as `critical`). + The issue will then be left to be [implemented by someone](#your-first-code-contribution). + +#### Your First Code Contribution + +TODO + +## Working Conventions + +### Pull Requests + +Thank you for contributing your changes by opening a pull requests! + +To get something merged we usually require: +- Follow the Pull Request template +- Description of the changes - if your commit messages are great, this is less important +- Quality of changes is ensured - through new or updated automated tests +- Change is related to an issue, feature (idea) or bug report - ideally discussed beforehand +- Well-scoped - we prefer multiple PRs, rather than a big one + +### Commit Messages + +Please make informative commit messages! It makes it much easier to work out why things are the way they are when you’re debugging things later. + +A commit message is communication, so as usual, put yourself in the position of the reader: what does a reviewer, or someone reading the commit message later need to do their job? Write it down! It is even better to include this information in the code itself, but sometimes it doesn’t belong there (e.g. ticket info). + +Also, include any relevant meta-information, such as ticket numbers. +If a commit completely addresses a ticket, you can put that in the headline if you want, but it’s fine to just put it in the body. + +Here are seven rules for great git commit messages: + +1. Separate subject from body with a blank line +2. Limit the subject line to 50 characters (soft limit) +3. Capitalize the subject line +4. Do not end the subject line with a period +5. Use the imperative mood in the subject line and suffix with ticket number if applicable +6. Wrap the body at 72 characters (hard limit) +7. Use the body to explain what and why vs. how + +There is plenty to say on this topic, but broadly the guidelines in [this post](https://cbea.ms/git-commit/) are good. + +#### Rationale + +Git commit messages are our only source of why something was changed the way it was changed. +So we better make the readable, concise and detailed (when required). + +### Merge Commit PRs and Rebase Branches on top of Main + +When closing branches / PRs use merge commits, so we have a history of PRs also in the git history. +Do not merge main into side branches, instead rebase them on top of main. +Try to keep branches up-to-date with main (not strict requirement though). +Once merged to main, please delete the branch. + +**Tip:** Use Github's merge button in PRs to merge with commit. +If a branch is outdated, use the rebase button in PRs to rebase feature branches (NOT update via merge). + +#### Rationale + +Keeping branches ahead of main not only make the git history a lot nicer to process, it also makes conflict resolutions easier. +Merging main into a branch repeatedly is a good recipe to introduce invalid conflict resolutions and loose track of the actual changes brought by a the branch. + +### Versioning + +Not all releases are declared stable. +Releases that aren't stable will be released as pre-releases and will append a -pre tag indicating it is not ready for running on production networks. + +### Changelog + +During development +- Make sure `CHANGELOG.md` is kept up-to-date with high-level, technical, but user-focused list of changes according to [keepachangelog](https://keepachangelog.com/en/1.0.0/). +- Bump `UNRELEASED` version in `CHANGELOG.md` according to [semver](https://semver.org/). + +### Style Guides + +#### React + +Please see [React Style Guide](./docs/style-guides/react/). + +#### CSS in Javascript + +Please see [CSS in Javascript Style Guide](./docs/style-guides/css-in-js/). + +#### CSS / SASS + +Please see [CSS / SASS Style Guide](./docs/style-guides/css-sass/). + +#### Haskell + +TODO diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..c38410929 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright © 2018-2021 IOHK + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 9e260b516..295b44abf 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ -# GovTool Placeholder Repo \ No newline at end of file +

+ +

+ +

+ Monorepo containing SanchoNet GovTool and supporting utilities +

+ +
+ +[![Build Status](https://img.shields.io/travis/npm/npm/latest.svg?style=flat-square)](https://travis-ci.org/npm/npm) [![npm](https://img.shields.io/npm/v/npm.svg?style=flat-square)](https://www.npmjs.com/package/npm) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](./LICENSE) + +
+ +
+ +## 🌄 Purpose +The SanchoNet GovTool enables ada holders to experience some of the governance features described in [CIP-1694](https://github.com/cardano-foundation/CIPs/blob/master/CIP-1694/README.md) and to test governance features on [SanchoNet](https://sancho.network/) through a guided and straightforward experience. +The SanchoNet GovTool is currently open for beta testing and can be accessed at [sanchogov.tools](https://sanchogov.tools/). + +Learn more; [docs.sanchogov.tools](https://docs.sanchogov.tools/). + +## 📍 Navigation +- [GovTool Backend](./src/vva-be/README.md) +- [GovTool Frontend](./src/vva-fe/README.md) +- [Documentation](./docs/) +- [Tests](./tests/) + +### Utilities +- [Governance Action Loader](./src/governance-action-loader/) + +## 🔩 Architecture +GovTool consists of a Haskell backend and a React Typescript frontend. + +### Backend +GovTool backend implements an API wrapper around an instance of [DB-Sync](https://github.com/IntersectMBO/cardano-db-sync) which interfaces with a [Cardano Node](https://github.com/IntersectMBO/cardano-node). +The API exposes endpoints making the querying of governance related data from DB-Sync straight forward. + +#### API Reference +[`Swagger documentation`]() + +### Frontend +GovTool frontend web app communicates with the backend over a REST interface, reading and displaying on-chain governance data. +Frontend is able to connect to Cardano wallets over the [CIP-30](https://github.com/cardano-foundation/CIPs/blob/master/CIP-0030/README.md) and [CIP-95](https://github.com/cardano-foundation/CIPs/blob/master/CIP-0095/README.md) standards. + +## 🤝 Contributing +Thanks for considering contributing and helping us on creating GovTool! 😎 +Please checkout our [Contributing Documentation](./CONTRIBUTING.md). \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..d7856a7fb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report (suspected) security vulnerabilities to [XXXX@intersectmbo.org](XXXX@intersectmbo.org). +You will receive a response from us within 48 hours. +If the issue is confirmed, we will release a patch as soon as possible. + +Please provide a clear and concise description of the vulnerability, including: + +* the affected component(s) and version(s), +* steps that can be followed to exercise the vulnerability, +* any workarounds or mitigations + +If you have developed any code or utilities that can help demonstrate the suspected vulnerability, please mention them in your email but ***DO NOT*** attempt to include them as attachments as this may cause your Email to be blocked by spam filters. \ No newline at end of file diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 000000000..6f1d20227 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,15 @@ +# Ask for help + +Should you have any questions or need some help in getting set up, you can use +these communication channels to reach the GovTool team and get answers in a way +where others can benefit from it as well: + +- Github [Discussions](https://github.com/IntersectMBO/govtool/discussions) + +# Reporting a Vulnerability + +See [`SECURITY.md`](SECURITY.md) on how to report a security vulnerability. + +# Contributions + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) on how to contribute. \ No newline at end of file diff --git a/docs/architecture/.gitignore b/docs/architecture/.gitignore new file mode 100644 index 000000000..bce10f1e2 --- /dev/null +++ b/docs/architecture/.gitignore @@ -0,0 +1,10 @@ +# arch structurizr +arch-structurizr/.structurizr +arch-structurizr/workspace.json + +#other +.vscode + +#oura install +oura/ +./oura/ \ No newline at end of file diff --git a/docs/architecture/arch-structurizr/README.md b/docs/architecture/arch-structurizr/README.md new file mode 100644 index 000000000..924bd9ce2 --- /dev/null +++ b/docs/architecture/arch-structurizr/README.md @@ -0,0 +1,28 @@ +# Voltaire dApp Architecture Documentation + +## Architecture as code + +- Describe the architecture logically with a domain specific language and generate all the diagrams +with different levels of details based on the C4Model (Context, Containers, Components, Code). +- Accompanied diagrams with documentation setting the project within its environment. + +## How to Run + +```bash +docker-compose up +``` + +## Diagrams +```bash +http://localhost:8080/workspace/diagrams +``` + +## Documentation +```bash +http://localhost:8080/workspace/documentation/* +``` + +## Decision Log +```bash +http://localhost:8080/workspace/decisions/* +``` \ No newline at end of file diff --git a/docs/architecture/arch-structurizr/dapp.dsl b/docs/architecture/arch-structurizr/dapp.dsl new file mode 100644 index 000000000..dabfa3e1f --- /dev/null +++ b/docs/architecture/arch-structurizr/dapp.dsl @@ -0,0 +1,80 @@ + +user = person "ADA Holder" "😀" + +userDRep = person "dRep" "😎" +userCCMember = person "CC Member" "🧐" +userSPO = person "SPO" "🤠" + +userGovActSub = person "Gov Action Submitter" "😛" + +browser = softwareSystem "Browser" "Firefox, Chrome, Safari, Edge" "Browser" + +cardanoWallet = softwareSystem "Cardano Wallet" "" "Owned by Other Entities" + +hwWallet = softwareSystem "HW Wallet" "Cardano Hardware Wallet" "Owned by Other Entities" + +group "CardanoWorld" { + cardanoNode = softwareSystem "CardanoNode" + cardanoCLI = softwareSystem "cardano-cli Tool" + cardanoCLI -> cardanoNode "uses" +} + +group "Somewhere?" { + metadataServer = softwareSystem "dRep/Governance Action Metadata Server" "Off chain metadata storage used to fetch metadata from dRep registrations + governance actions that happen on chain" "" +} + +# group "Community Tooling" { +# communityFE = softwareSystem + +# } + + +group "Owned by Gov Analysis Squad"{ + dAppFrontEnd = softwareSystem "Voltaire dApp Frontend" "Web App" "" { + walletConnector = container "walletConnector" "" "" "" + httpClient = container "HTTP Client" "" "" "" + + walletConnector -> cardanoWallet "(dAPP CONNECTOR CIP) \nPOST /drep-reg/ \nPOST /vote\nPOST /drep-ret/\n GET /stake-key/ " + + } + + dAppBackEnd = softwareSystem "Voltaire dApp Backend" "HTTP Service in front of a chain follower and DB" { + + database = container "Database" "" "Some Database" "Database" + voltaireAPI = container "Voltaire DB API" "REST API that offers the ability for clients to find Voltaire related chain data" "" + chainFollower = container "Chain Follower" "Follows the Cardano chain, reduces data, sinks it a store" "txpipe/oura" + txValidationService = container "Validation Service" "Consumes ordered events read from the sink, validates the transactions and when valid stores in store" "" + kafka = container "Message Broker" "Message / Event broker" "apache/kafka" "Message Broker" + + chainFollower -> cardanoNode "follows" + chainFollower -> chainFollower "applyFilter(rawBlock)" + chainFollower -> kafka "sink filtered dapp registration" + kafka -> txValidationService "consumes dapp registrations" + txValidationService -> metadataServer "GET /gov-act-meta" + txValidationService -> database "stores dapp registrations" + voltaireAPI -> database "reads" + + dAppFrontEnd.httpClient -> voltaireAPI "GET /dreps \nGET /gov-act" + + dAppFrontEnd.httpClient -> metadataServer "GET /gov-metadata" + } +} + +user -> browser "uses" +userDRep -> browser "uses" +userDRep -> metadataServer "POST /drep-meta/" +userSPO -> cardanoCLI "uses" +userCCMember -> cardanoCLI "uses" + +userGovActSub -> metadataServer "POST /gov-act-meta/" + +# userGovActSub -> cardanoCLI "POST /gov-act/" + +browser -> dAppFrontEnd "connects" + +// Light wallet and HW wallet +hwWallet -> cardanoWallet "integrates" + +// User's browser attaches to GVC FE +browser -> dAppFrontEnd "connects" + diff --git a/docs/architecture/arch-structurizr/decisions/0001-decision b/docs/architecture/arch-structurizr/decisions/0001-decision new file mode 100644 index 000000000..d01970187 --- /dev/null +++ b/docs/architecture/arch-structurizr/decisions/0001-decision @@ -0,0 +1,18 @@ +# 1. Record architecture decisions + +Date: 2023- + +## Status + +Accepted + +## Context + + + +## Decision + + + +## Consequences + diff --git a/docs/architecture/arch-structurizr/docker-compose.yml b/docs/architecture/arch-structurizr/docker-compose.yml new file mode 100644 index 000000000..a55ac8367 --- /dev/null +++ b/docs/architecture/arch-structurizr/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3.6" + +services: + voltaire-structurizr: + container_name: voltaire-dapp-architecture + image: structurizr/lite:latest + ports: + - '8080:8080' + volumes: + - .:/usr/local/structurizr:rw \ No newline at end of file diff --git a/docs/architecture/arch-structurizr/documentation/documentation.md b/docs/architecture/arch-structurizr/documentation/documentation.md new file mode 100644 index 000000000..6844a6b12 --- /dev/null +++ b/docs/architecture/arch-structurizr/documentation/documentation.md @@ -0,0 +1 @@ +## Voltaire Voting App diff --git a/docs/architecture/arch-structurizr/workspace.dsl b/docs/architecture/arch-structurizr/workspace.dsl new file mode 100644 index 000000000..096ccc31e --- /dev/null +++ b/docs/architecture/arch-structurizr/workspace.dsl @@ -0,0 +1,84 @@ +workspace "Voltaire Implementation Draft" { + !docs ./documentation + !adrs ./decisions + !identifiers hierarchical + + model { + !include dapp.dsl + } + + views { + systemLandscape all { + title "Volatire Tech Implementation System Overview [Draft]" + include * + autoLayout lr + } + + systemContext dAppFrontEnd { + include * + autoLayout lr + } + + systemContext dAppBackEnd { + include * + autoLayout lr + } + + container dAppBackEnd { + include * + autolayout lr + } + + container dAppFrontEnd { + include * + autolayout lr + } + + // Colour pallette: https://colorbrewer2.org/#type=sequential&scheme=PuBu&n=4 + styles { + element "Software System" { + background #0570b0 + color #ffffff + shape RoundedBox + } + + element "Container" { + background #74a9cf + color #ffffff + shape RoundedBox + } + + element "Component" { + background #bdc9e1 + color #ffffff + shape RoundedBox + } + + element "Person" { + background #66c2a5 + color #ffffff + shape person + } + + element "Owned by Other Entities" { + background #999999 + color #ffffff + } + + element "Browser" { + background #999999 + color #ffffff + shape WebBrowser + } + + element "Database" { + shape Cylinder + } + } + + branding { + logo data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAfUAAABkCAMAAACo21lxAAAArlBMVEX///8AM60AMKwAKasAL6wAK6sALasAJqoZPK8AI6kAKKuruODc4PCls90AIakcS7dXc8Vmd8NGZcCkrtudptZogcoAHqj09vz5+/4AFacAGqg9Xb3Ez+wAOrHt8fq2weTY3vFzhcrP1+4ADabu8fnj6PV/kc9SbcIgQ7KNn9Y6VrjAyujM1O2+xuVScMQyULaUn9N3jM4AAKVue8M/Yb6Fl9ItTLUmRrNndsJjfsljQZZ0AAAWOElEQVR4nO1diXbiuLbFkgdswMy2MZMhmCkkhNchufz/jz1PR5ZsSYZUp26lrvfq7rUaT5L20dGZpDQaX8Ds9XwJv/JgjZ+LQMPI6P63W1Hjm9Dm/3w1FUXxn7nXwpn3jQ2q8e2YfSoDl3dha0SsO0vepbWuN2vd/4MRdjXkj3hX1v+nI2zw5rQ7wIrRq2f7z0UwRwr+h3upNZ3vAt6F8FNXcM36T8bKx3aHf8kNBcxeTcPnqv4aPwTutv84ga1t6xuaUqNGjRo1atSoUaNGjfsRHMYv1R74YnTbBt/fmBq/B625bdkrbmSWQvvsYLM5+y0tqvHtWNiqoigTbmSWwi4OzevKb2lSjW9Hx4noVLQB82NwuA2ZfNxij+LbJoIkXY0fhqFdZr1j2dhRTtQvi2bC+lPN+t+BhR3z6dAa/sWJf9LnNMWrWDjwx+9uXY1vwkg1LPOVtuZecTyvFftA/RYObNM8r39342p8F9bXC5t526sJ67hH/+i13lqL39quGr8V71rCunH9bzekxm9Ex49JR06tz/8OrF/uYdK7+Jqu8Yurire2Dvyiyhp/DK6Kjd7uuM8b7s67ewouNn3fNL+0EHgJvvJkDcB9Q/hiIkW1gn/xu61oLUDWgytBezm6Xsa9wfv77jJqrWvqH8fiZfi2XfV377v+avvWmcni6Ye4zN0e/otfX1niwnkuvGDURdgwMNZ0VdewgdX563NVEiDCet8EKBfJfUO1KcTn6+HEr+ruKLz79+/j0al9R+NibPNXfAaS++bpPfOjoL58hNIbVGFALGwNFM0wLKxF67CGLcPQ57sXYbl6Kw6+Oo9lUOQzsTWJ5jq+f64ve4qtJlG+HJEQvHcqJ/zIRADVkNw3dJAQqmb4+z6vPrRjcB/QsenMB9t7+ucqOnnMlllEWE1vMnr86yM7ve4IWD/1945WGEIFafbHKhC0bOfjyU0su6fr2cHHQ97JWV97evJvL8InvMuTPblbeQRTv9TeBLozr7AivCb1oExdDU3eB2gZ889l3dSxxA/o1uTYqpTKNLCdPSJLUmmkF3zZGGUdMLmsB7sJ5g6homJnzI+nuMObuP3uVTNUJZpJKJsN3lVPvoDxRSgp3mkYCDtY7M9c47Y3bfNYqklffOpmbScmoYr1+HG1NN4S1mPoxlQs+SkGOnW/L9GnZAyQeuJdl7HuDedY0kyj+3jJc9/JhAgZ6ahcDZAqZ/zw28q42HwhhSZPZZG/G9NbPRDeeAfrCnIOhacqWI8kxSw+wuKk0J3DK/GdueRr5w3nuoz1sXwII+m8w9sObsrnG3z5LVdRyI9Ng9Mkf53ZIY8Y2vtXojfeyqbbF62ZarQQ0j9hkX0Tf7bJ3GqJR/Ue1qP+FEankvVIIe94HAFWzAvQXNwVSt9ZN851MetenxlCJR5CnbWSkC8XzggvioVUZ5pqVrer5g8nsdgxNb30aapT13uMkKZXqTsOrtTbVEtTmtPp8bOJDOpnayB8GoYCtNFcqOIJ6wgXoFEdRBarWAjr9FMarbQjqRQ3r9GwEN0+meVBr3IOx7IUs76iBDpqp6ocp9PpWVENaqVHWgXtrpEMg71N/m9NzWxFPy4a4Z6WojSx7vUSknTlYR/7JX+9bncPy2wurDt9O+fdFsZ7suFUoU2WcP8NWFXovBqPx/G/gNezaZIuGaz3B6yjLnliddtNdd+gmLdvwm6npSkKakIfz8KRoFlH87LaFLJ+yGe6as9XrexRd90Zq0bO1ZPcj24Z2Uhu6I9lrQkbgUP9oNjJMIfZ6GuPTvbwg0w048i6aeGKNBlpAm9llg2qMsteowk3WMJcx0VTxPPc8NAEEtGe0cHAuka7U567WV/f1VxFCFdNr5dyaY1uGamq0J5jLFrto9QPEevrXGTxx4FdbcLtnrxW7Uo3mg+NbCgTZXcwaNZRxDptNWest7Pf8KNb3a5ETu1+yVYfEQ2FeQtdhEuqD3B/A6ayKeoazPUS6wnWH0A7uzufy3oCbzYn8wEpgq8GmWSgE/ga1lbQPpZ1xSjdJ2Dd2xGdaO/KzQg/SCtNWRCrsUzv09OFvUVPbfUcafg5reEniT5ZnJNBQziQvbiMNnkXV4nnq2qT+2JoSiRsI0M0WhmEcz17FQg37vNaUGY9wpUMjciKvKRv1Y+NMDOPkCay/Qreq1+cQALWW2TeYC6r3hhuQKrU2m4mDcgMj5A2QmPPw91R7UPNdIa+JaJs9mWv5WBExpq/NK5Ajg2uEsnmb7LumCCXglGVz/X8UypTDCZlvXElY8NfgiCEFIsivJ9nqCUosK7uA/a6gPUjKClDIHk5X2I9EyP4dLAxAcmhPeJJvCotac8NLMOVaZpPY5kPw4EH/gGa833ytp7pAsyVp6zH2q5B6nwULAhIVMz1xks2Nmz8TM56bj0b3HnWyp62g1xn6gOB5VGMVOF39jqf9TXoG30vsmhC4uGo0oCXN7pcgvyh3OBKxWlHRBwPCM/h4fDwVog1mAhFN5mgB1zwXLLnrDd2TPRbNiiJCHBQxfoaPH9EC2AF6wswRtUPzsoOtpweH/LjOSBV3MhbmXVwogB81g/AhcRrhqFRnAfMrk7qQyFtkqnhxaufTDLV5vX1AYClWDCcKSxBlCccMd1a1NNrCO34fLVRxXr4mT3PxPcqWM89HMzxi8BWMBKFCIpWZHkA6zpMMqQyRHJZd8GKhcAJt2vg14r6zkWw03zHV3p5IzpH23H886/Wz8FAiAOVxNzzy5bIJjPxUyvGI6PKb1UV620YGp2WwCrWG5A1sjgqfpux7iRvfAYbZsJ/E9A3eCdeJOOQDHmshyB10jz5FmyW411lre4mdL34MJrTcs0cSLMJl61wkdyxPm2+WP8QnjOxFh2CkxxylOYXOWfedDL7LLsE5prOj9tXsX7K5As9ouHzQCUn/LLJzA49tQ8Xx6y3Aj2bcY23M+Ia4x6l4aADNs060YWWTOu+wOrCCf6UENz2zsSZSnLc3vDs+L6/+1qJHMlMTIQiuBhkxQZ6aahgcqMsq+6Ch2Jw7YsqG74Dq80j1lw0oPBVv3wJs9MQpr4gkgSsrxq5B05ng7hz/ZK1T2gjpgA5squjaAclUaG68S4y/cKulbRVE3rJUhAR1MX3eIDSlVO2kBN/BKYd356rmOse2GU6YzpXsh7AwjAJipdAKj8zmtowL7XSrTGA9X+olBJCOU0j3lwfZMuaJV9sQakalTmYtwl82+AfNNnYnIlQ+l8pvwIi9OMXHiZTB4FNvCT2EE/bVbA+AkrYzGkl6wvwl0snbi4gXglOpwcLNt/yyC5q0e0HEv5RP8nQc1mHrxvy8QfvWyt2PhiOGKX/TEViHf5U7uW3II32R7zOeCXwT2hcoC2iIZUiG9Rct7lEonl+oFTDuyTeghDjGFWyTixSszjuh4ylvHqQLCJN3izSctYbPTKd8vM6eTb8AhwPUZQC2mLAYEXPLPKvDxXbRrSYv1LBGfTJmz0BHZqlwyhe38AWJdCnA/9gYZBAfiixAhCJpOqNrtC3KWdUyVy/eS6LTTg65kE2dtGtZr2fdaKoPDfZzEZN8sIQNLfG44hmPWySGAmG/vFYJz6ZLresOiThGIneUzfIfl7HXiLS8mc9nY62c5vZYvIy83ykE0LyOqDWxJ5wef0V1iEColAJ8Ta4MT5H0QDraP8+ZdG17Dxt+sQ+Ws06RFqL4c5T9j1anb+Cp8qLNNKsN1p5Zgd2GvFYz91NeYQMbE702eiqCqnHTF9JxRVDJlRk85wNug6QSTul+ol4kJEORBqvLb1Kd12MIKODVthEEngpuryqQtXVpNgE/sOUOBX0fzXrsEwVHfZMV6I5JUYnGDGHozsZ1qmlXT+nN/8K62S5njcUandq+koqdfQrrK9Y1gdYUZu8tvwK62DLMQsaWTg5K9KQrTLiA58LPuT9rBc6scg8DH1K/wjGF6/YjmXdOxMCjFQSf4l1soI1Lr5tw5g9xxUMyMyH0GPqrrjVEoyGV/f5hWX8Ml0Jsv+dzZ8sbhiGaPjHay7drLuIyTm4YIuUTKu76uaQWarQ+/JcH2UPsik2YlUdy3kqlvVGO1/aUwNdvq7fOdej2fd8yLXP1rSwsaKGcEDnU7lx8jZtzTFJp6tu2/O8u5sFPxcHS6KoZkIC8NIKua6+OFJWzbqGykXY1axDlAAz67oLthxbHtmGdCUuT6MC69HcIcOfpAa4rIMNr8mtOWpdZ/G8GjMl8S9UNYXNN7f6+UCieUBfOY3uqYYHd0mUJpMA6C1spXuGFpVjJpWs4yPHBqxm/T/c8D8kQIuLVxdiMa+lFxVZb4wJ7VocJ+Ox7n7c57m1chu+AqM8HMzzhCJspsS7mwgj6RKAPyEpIRQgzNqmvbIuIUwxjqlA1nWks4CR5WbwK1knSS92UYHFyyjMQbIqPpWC0CXWvSNRt+ZYkHO7M0pDfNrqyMjFSL6qGl1RmDzcJxFZhPGvRWTRo0/C+pjEY7z4Hy/deQfqo5zvzj23wTuFwTQbNxI4ZVDJegizjYnNgeZFc7p5Xlywkt1uljaOU7G5DNQOCmfGz76QRKN8Hzqxm+/wkZcD33f85lCWfZk6/kTpf+1oyRPYK0/iLwTLDDSLkM1S1PcBiym8suR1kChNUQtAEQ63HKaS9RPMxwmToIUpvS+0b0BKpN+Ltk5prkevIVVL+ruXFbEWsi8kvCnNvoD4cH2xIrzN6TmJ3y2CViukm7mJfpiFyS3rjfvFTGsbBN8R2yJbR0vg00vIC3FE1IK6JnZvadUQxuGJ1cRzoitZh7grG5E45u3grycReUUrojzXc/Ml1g2wODBznSzY3CAvYAHSo90RJyfYdiMnz5nmgrKcOrbt73+xqoKsiZJUECkypBUoHS0WoXgIg5B1F3Q8rzCiknWo/GMs0mBSbk8RJceFx/riM6/TH4MBQbNO8ni8cGTeC5CN/f2HQZ72ZtI1fdJLp7vXm6SF0Hbz184lAv1UVncAUudO19IsnqoHtRQhFefclplpiPRyb6pYn5FUHa2LLndIZamGnqPhI1/AyneCwJdo5shaJ8tgkSrZijTX7HAgbaLyqUYqnxeiYPGRKJb15VJ9xEABeSWISFIDWJF8SoO9VQdc4q0erCSJWfdgBwQnPF7Butcn1ZyUxJAaISmsgtnNZb3xZhSfY6sloThKsGMgwQzeId1/u+g9mflZQlvqu5M4uDCjphopp+hNDOxPHzxN1gVJFnrssGyq1A6gzXu+OkpQqLmS5NehgLno/TcqWV+CO8gct9u6J/hbqm/ksx7Hs1mwrJNaHn7teAJSMu/LyqzeY56Rk8rigt6AnZiK/9A5WCP16tJkwcPHyZJdDoKtUiEUvtL6+pRv5uABmls47VrCuncUTnY56ydS9MKYHWSUpe0rFoAKWA8/C4qjsAuCBEiFZc9k1zCWKfh1tuMp1d4neltbHJpl97Sm3YW6cO1BF24JDVKbPDVBkmhMqvdGlFqXB1LXzpYGymppOrDS2MXJLmV9vQd69TM1bwPSKW77ujB0BR9SwHrjuWDFFFgnO1pRk79M5klbSxbAA2fTSPQBs6dVscNGmzl9IY02wPYKfpZFAhLt1zlt9nrQI3pUYXObaL/MguySYlYxGevEjNeKW9ElrC9G+T52gx5PiBSJignheiEzKGKdWWLLrG9yzWLxHOA3LFB+BWQObDaqnWI+tcB6UuqR/W2AqnBwGcTQUPR5q+ByBlPuAT3Dquj9gFhY9MIprZvrgGirhayIiHUvPJzzveHMuQUbcObMgN8+oj3ZzXtC1qldqxzWqe2nSL0UJ0K7n28H16XO+ia90Uh3ujCnFsR7WkmSOEG6pzU9Jf4rpxZcqKKRc4cS/tNKI6Tr51wgPJiX/D8I3qBMLMaek1dLgiRrhcJNYF0dnGaAl85oNbDMfLnVGTcYHEFNUGOaL8VsJEnIeqPNnNhSOrUgL19Exny7zL/abt1wLjClM3cKWGINISuLS7tniuRkKbpSGkftpjy7R1uNvvl4Xbz3mWd0dUM5j6/D4ei6ms71vL3MIZWwN04tFkAQkO0GzADKWSch1ILln++lzsNrmmFgWvBVzEwiUDXihMiImxkUs95Y0vuKS6wv8ixNnBJRPvqj4XB42O2RRSkJ6TkqCU5TTb/By0f0aUTx8Ad+LnpE9YaHffP2oOOWfmtOjSBSsRE5jdGoMtJNj9+Km89mAPEERJuIctaJAivM0DtOI9IQo6hJMbsh3NqR30IHNyWsN27URCufS7NWaCGMxtCMx1BjNIQlPcmLA3LCEZw8tiThZPvRDesczBTxaXPJV5lae1JHMBGHBUlA1ORsHREU7gyJGc9wWMk6Mj7YhkCEQVY0ALEd5tAQGetubuLwTh4L9hXNROaAR/p6KF7q3VWyOiBMKqc7n1ZcUoutrTDm77bGwzsPWl0fZW3W5oxfkNeUS94IixJ9U9XeF5h/apf5XMVwasqqEEyG95iSzUUkJmlT64mM9UY7P3yGd95cu2ciRQxVX/EMrjffftqKLbGXnv/0hG65UC+2Xf/JWomXcbc3wZPenbQvxk+C0zAV3SlUuED0VHokK6mboYoaqnY3kmMzGMtaynp8CGlR4zwTs0MSB/PAeKRrKaWsU1Fo/omib4Yh4l13+PbWKZYU+QnPDx7Znhy+4999ItV6hTiNRpozLZhE4FHIws78DdtVuxs3IE/6BzV7O6UwePZ9pFv2J2eDDyntktaZgEFMHxWT7YkWRVbJsRg233pabPc25zBehM2joEAiiU19bYeiAIkJaN/zhwYyBJemHhlxKpggCBvafPdSzMVt/fSgP1teatdz0tsMTGbccJL+5IhKM9+yV+MnSo10fKt4KGF6LGFzeuEdLh4oRnKL9STNRs6s7DY/t+fs9CfnP/xHFp/ZI8I/mBgu3xXL0LKTORFSI9NYU24zkRl3ik+KvX9m3oFYe6BS4YAcs852cN4rlj+Z+Npnf/hSXiC8ZStFJ5C+KujAfWHpJ1HQ2GsBqMW23eLgZRaI1HcIX6koWlmSb5F5CL+IVC5piuTwn7B17R27Td2emKjZnY5HnDHMcTFMX342cwr3MD2O7+LyoNpK5bbZEjzX3aT4an3O/zzIELpu1Rh6L9fqk87j4hdf1y18V4rldNffD6rx5+OWxl2bv3YGUY2fhcw0FkbAa/wFCFtvjN6HHGfhdLjh5Rr81nbV+EbM9qZp72i7PysdZ/6OxWJnW6byla0vNf5EJIdMO3SC4C2JkWBmQ2ZSO6Xad4bhavzhSMNg6gfFp3eYGNhhY41pzLte6v8SBBnrTGhnfb0V/uz2sWb9r0KST7Wr9pYnWr/W8H8Nnucm9oW7UgDuysH2/NG/AVHjj8X60u9UV2B4y/Fb8P2NqVGjRo0aNWrUqFGjhgSt7eNRl2XvWldC/GR0HFO0wX0TCqh9s7H/b1be1fjNcAeaovP/qufoOO9xL3jRMyr3QPEaPwPuACsW9yir5ZOOrDn3oauD8H1/QqjGn4l2U59z62+TTWacP7LViPdRK+S0+Ro/Eu6Mr6tX8VYQ0Q6zr+xtrPEDcHI0ZL5X31fjr8Jy9yHe0Vjjz8f/A58flJOaxNzMAAAAAElFTkSuQmCC + # font + } + } +} \ No newline at end of file diff --git a/docs/architecture/dapp-arch-v1.1.PNG b/docs/architecture/dapp-arch-v1.1.PNG new file mode 100644 index 000000000..90ed44335 Binary files /dev/null and b/docs/architecture/dapp-arch-v1.1.PNG differ diff --git a/docs/architecture/dapp-user-states.PNG b/docs/architecture/dapp-user-states.PNG new file mode 100644 index 000000000..ff04ecf3c Binary files /dev/null and b/docs/architecture/dapp-user-states.PNG differ diff --git a/docs/architecture/documentation.md b/docs/architecture/documentation.md new file mode 100644 index 000000000..9f56155a6 --- /dev/null +++ b/docs/architecture/documentation.md @@ -0,0 +1,397 @@ +## Voltaire Voting App + +### Introduction + +The Voltaire Voting App (VVA) allows users to interact with Cardano's protocol governance as introduced in [CIP-1694 | A proposal for entering the Voltaire phase](https://github.com/cardano-foundation/CIPs/pull/380). Offering the ability for DRep Registration, Vote Delegation and Voting on Governance Actions. + +VVA is likely to be some of the first tooling to be implemented for the Cardano eco-system to engage with CIP-1694. Other tooling efforts should fully support all users of CIP-1694. + +Here we introduce the necessary concepts, similar approaches and then describe the intended technical architecture and approach for VVA. + +### Background - Cardano's Governance + +In this section we will introduce the environment in which VVA in habits, we will focus on the elements most important to VVA's design, summarizing CIP-1694. Please see CIP-1694 for full details. + +[CIP-1694](https://github.com/cardano-foundation/CIPs/pull/380) proposes a significant revision of Cardano's on-chain governance system to support the new requirements for Cardano's Voltaire era. This proposal depreciates the previous governance structure, removing MIR certificates and current processes for protocol parameter updates. To be able support the new system, two new fields must be added to transaction bodies; governance actions and votes. + +Governance actions will act as on-chain ballots, with any user able to submit them to enact change (see [here](https://github.com/JaredCorduan/CIPs/blob/voltaire-v1/CIP-1694/README.md#governance-actions)), with three distinct user groups elidible to cast their votes: +1. A constitutional committee +2. A group of delegate representatives (DReps) +3. the stake pool operators (SPOs) + +Each governance action must be ratified by two of these three governance bodies using their on-chain votes. The type of action and the state of the governance system determines which bodies must ratify it. + +Ratified governance actions may then be enacted on-chain, following the rules layed out in CIP-1694. + +#### Governance Actors + +Whilst only three governance bodies are able to cast votes, additional consideration should be placed on those actors submitting governance actions and the Ada holders supporting. Note that these link to the personas described in XXXX. + +##### **[Delegated Representatives](https://github.com/JaredCorduan/CIPs/blob/voltaire-v1/CIP-1694/README.md#delegated-representatives-dreps)** + +DReps are modelled on, the existing, stake pool mechanism. Whereby any Ada holder is able to register a stake pool themselves to actively take part in block production or an Ada holder is able to delegate their rights to another stake pool who will act on their behalf. Instead of delegating Ada to take part in block production CIP-1694 describes delegating one's voting rights. + +These entities can be identified by a verification key (Ed25519), or by a blake2b-224 hash digest of a serialized DRep credential is called the DRep ID. + +Similarly to [stake pool process](https://developers.cardano.org/docs/operate-a-stake-pool/), DReps register their intent to be DReps on-chain via a certificate and can retire this duty via a retirement certificate. + +They are identified on-chain by blake2b-224 hash digest of a serialized DRep credential. + +##### **Ada Holders** + +Ada holders delegate their voting rights to DReps who then vote on the Ada holder's behalf. This is known as vote delegation and it associates a Ada holder's staked Ada with a DRep's ID. The generation and submission of this certificate is to be supported by VVA. These actors are identified on-chain via their staking credentials, or stake keys. + +Conversely, Ada holders are also able to register as DReps and actively vote on their own behalf. + +##### **[Constitutional Committee Members](https://github.com/JaredCorduan/CIPs/blob/voltaire-v1/CIP-1694/README.md#the-constitutional-committee)** + +Constitutional committee (CC) members are a set of individuals or entities (each associated with a pair of Ed25519 credentials) that are collectively responsible for ensuring that the Constitution is respected. This system is modelled on the current system of genesis keys, whereby a quorum of keys must be present to administer governance over protocol parameter changes and MIR transfers. + +How CC members engage with 1694 are beyond the scope of this document. + +##### **SPOs** + +SPOs are the called upon to vote on three out of the eleven types of governance action, this makes them the least active voters. They are identified on-chain using their stake pool cold keys. + +How SPOs engage with 1694 are beyond the scope of this document. + +##### **Governance Action Submitters** + +Gov action submitters could be any of the other actors. They are not identified on-chain in any particular manner. It is assumed that there will be some off-chain processing and refinement before a action is submitted to chain, but this is out of scope for this document. + +#### On-Chain Entities + +##### **[Vote Delegation Certificate](https://github.com/JaredCorduan/CIPs/blob/voltaire-v1/CIP-1694/README.md#vote-delegation-certificates)** + +This certificate is used by Ada holders to associate the voting rights of their staked Ada with a DRep ID. The chain does not make it clear if the DRep ID is controlled by the issuer of the certificate or not. This certificate only includes the issuer's stake credential and a DRep ID, with this being signed using the stake credential's private key. There is no enforcement or checking by the Ledger that a provided DRep ID does actually belong to an DRep active DRep, this burden is on the Ada holder. + +##### **[DRep Registration Certificate](https://github.com/JaredCorduan/CIPs/blob/voltaire-v1/CIP-1694/README.md#drep-registration-certificates)** + +This certificate is used by an Ada holder to become a DRep. This certificate matches how SPOs register their intent to become SPOs. Included in this certificate is the issuer's DRep ID and a metadata anchor consisting of a URL to a JSON payload and a hash of the payload. This certificate is signed using the secret key associated with the DRep ID pre-hash key. The structure and format of this metadata is deliberately left open in CIP-1694, but it can be assumed to contain profile information about the DRep. Such as links to social media profiles, articulation of their beliefs to indicate to delegators the DRep's likely voting direction, expertise etc. + +It is assumed that DReps will submit registrations and then campaign with their DRep ID via social media channels to seek delegation. These users are incentivized to provide accurate and relevant information in their metadata anchor, to increase the chances of delegation. The intension is that clients use the metadata hash to be able verify the correctness of the plain text at the metadata URL. Metadata can be updated via the submission of a new certificate with updated metadata anchor. + +Although many DReps will seek delegation and want to advertise their DRep ID this is not guaranteed. There can and will likely be Private DReps who register but do not publicly advertise their DRep ID or provide accurate metadata. These entities could be small communities of friends nominating one person to vote on their behalf, or whales. + +##### **[DRep Retirement Certificate](https://github.com/JaredCorduan/CIPs/blob/voltaire-v1/CIP-1694/README.md#drep-retirement-certificates)** + +This certificate is used to take a DRep to become a retired DRep, or back to being an Ada holder. This mirrors stake pool retirement certificates. This certificate includes the issuers DRep ID and the epoch number where the DRep will retire. Again, this certificate is signed using the secret DRep credential used to create the DRep's ID. + +Although there is no action forcing in-active DReps to submit this certificate, rather this is a way for the DRep to tell delegators that they do not wish to continue to engage as DRep. + +##### **[Governance Action Transaction](https://github.com/JaredCorduan/CIPs/blob/voltaire-v1/CIP-1694/README.md#content)** + +The governance action field of transactions can be populated with a lot of data. All governance action transactions should contain a deposit amount, reward address, metadata anchor and a hash value. Accompanying this is action specific content such as a hash of the new constitution for "Update to the Constitution". The precise details on these contents are likely to be adjusted as CIP-1694 matures. + +Although not described within the CIP, it is likely that there will be some informal off-chain process which prospective proposal submitters follower before posting these transactions to chain. + +Once this transaction is accepted on-chain then it will be assigned a unique identifier (a.k.a. the governance action ID), consisting of the transaction hash that created it and the index within the transaction body that points to it. + +##### **[Vote Transaction](https://github.com/JaredCorduan/CIPs/blob/voltaire-v1/CIP-1694/README.md#votes)** + +Vote transactions are used by the governance groups; CC, SPOs and DReps to articulate their feelings towards active governance actions. These field of transaction should be populated with the target governance action ID, the user's role (CC, SPO or DRep) a accompanying governance credential, a metadata anchor and their choice ('Yes'/'No'/'Abstain'). + +This should be signed using the provided secret side of governance credential, this will be checked to ensure it aligns with the provided role. Votes can be cast multiple times for each governance action by a single credential. Correctly submitted votes override any older votes for the same credential and role. + +#### Definitions + +| Term | Definition | Pseudonym(s) | +| ------------------------- | ---------- | ------------ | +| Ada holder | | | +| DRep | Delegate representative as described in CIP-1694. | | +| Retired DRep | A DRep who previously registered but has now retired. | | +| Vote Delegation | The act of submitting a vote delegation certificate, to associate ones's staked Ada with a DRep ID. | | +| Voting Power | The amount of Ada a DRep yields from vote delegations. | | +| DRep Registration | The act of submitting a DRep registration certificate to chain, to become a DRep. | | +| DRep Retirement | The act of submitting a DRep retirement certificate to chain, to stop being a DRep. | | +| Current governance action | Governance actions which are elidible for voting. | | +| Metadata Anchor | A URL and hash, used to link on chain data to off-chain data using the hash to ensure correctness. | | +| Private DReps | Registered DReps who do not advertise their DRep ID for delegations. | | +| Governance State | | | +| | | | +| | | | +| | | | + +#### Cardano's Project Catalyst + +Project Catalyst is a community-driven governance platform developed on top of Cardano. It is designed to allow the community to propose challenges and then vote on the allocation of funding to projects which address these challenges. + +The goal of Project Catalyst is to allow the Cardano community to have a say in the direction of the Cardano eco-system and to encourage participation and collaboration within the community. It is intended to be a transparent and democratic process for making decisions about the future of Cardano. + +Participation during the innovation phase takes place primarily on [Catalyst's Ideascale website](https://cardano.ideascale.com/). Moving to [Catalyst Voting App](https://apps.apple.com/gb/app/catalyst-voting/id1517473397) to engage with the governance phase, submitting votes to the [Vote Storage Ledger](https://input-output-hk.github.io/catalyst-core/core-ledger-doc/introduction.html) Jormungandr. + +Although the underlying technology used by Project Catalyst is substantially different from that of Voltaire, similar governance flows and parallels exist. Catalyst has been acting as Cardano's governance playground. + +##### **GVC** + +To replace its Voting App, Catalyst is currently developing its own dApp, known as Governance Voting Center or GVC. This dApp allows users to connect their Cardano wallet to a web page via [CIP-0062? | Cardano dApp-Wallet Web Bridge Catalyst Extension](https://github.com/cardano-foundation/CIPs/pull/296). This CIP-62 connection is used to allow wallet's to share credentials with the dApp and for the dApp to use the wallet to sign and submit [CIP-36 | Catalyst/Voltaire Registration Transaction Metadata Format (Updated)](https://github.com/cardano-foundation/CIPs/blob/master/CIP-0036/README.md) compliant transactions. + +This dApp allows users to the ability to register to vote, delegate voting rights and or sign up to be a Catalyst dRep. These flows are being inherited into VVA, and we will provide URLs GVC components to act as examples. + +### App Tech Design + +The VVA will not facilitate all interactions for all 1694 actors, rather VVA will focus solely on the requirements of Ada holders and DReps. + +Here we will give a description of VVA's technical design, throughout we will explain and justify the choice of design elements. This should act a suggested direction rather than a prescription. + +#### High level + +VVA will consist of a web app frontend, which can connect to Cardano wallets. A backend which can follow the Cardano blockchain and serve the frontend necessary data. + +![dApp Architecture](./dapp-arch-v1.1.png) + +Here the approach is that users interact with the VVA's frontend UI, connecting their Cardano wallet over the CIP-??? API. VVA's backend uses it's chain follower to look for CIP-1694 on-chain entities and logs these into a database. The VVA FE is then able to query this database to look up a user's governance state. + +One primary goal of VVA's design is to minimize the required work for supporting wallets. This is achieved by moving the burden of tracking a user's governance state on to the dApp. This is opposed to how staking is achieved with most wallet implementors integrating the certificate creation inside of their own software, to avoid the need to a dApp supported flow. + +#### Alternative Approaches + +While web-based stacks with wallet connectivity are able to offer users familiar experiences, this lowers the technical bar to entry for users to engage. This flow matches Catalyst's GVC overall design. + +The primary alternative approach to this is wallet providers integrating this functionality fully inside of wallet software, matching how staking is often implemented. We deem this approach as preferable from a security standpoint for combined functionality and would encourage wallet providers to pursue this. But +we understand that this may add overhead to wallet designs so offer this dApp based/wallet-connect as an alternative. + +#### MVP v Post MVP Elements + +| MVP | Post MVP | +| ---- | --- | +| DRep Registration | Anchored metadata hosting | +| DRep Retirement | DRep aggregator/ explorer | +| DRep Voting | Proposing governance actions | +| Vote Delegation by Ada holders | Submitting governance actions to chain | +| Viewing and filtering current governance actions | CC Voting | +| | SPO Voting | + +#### Feature Elements Overview + +VVA will use its backend chain follower to follow the on-chain state of governance to allow users to: +- View and filter current governance actions +- Tell you current governance state - are you a DRep? +- Tell you current governance state - have you delegated? +- Tell DReps which way they have voted +- Tell delegators who they have delegated to + +VVA will use connected wallet to +- Sign and submit DRep registration certificates +- Sign and submit DRep retirement certificates +- Sign and submit vote delegation certificates +- Sign and submit vote transactions + +// add in links to Thomas' doc + +#### dApp-Wallet Connector + +In order to allow the creation of VVA we have defined a dApp-wallet web bridge. This specifies the communication between Cardano wallets and the VVA for CIP-1694 related data exchange. Particularly this specifies what credentials wallets should share with dApps and what data should be passed to wallets to build, sign and submit [CIP-1694 chain entities](#on-chain-entities). + +This standard extends the [CIP-30 | Cardano dApp-Wallet Web Bridge](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0030) to introduce specific functionality described in CIP-1694. + +#### Key Management + +Since VVA and CIP-???? are only concerned with DReps and Ada holders only the credentials of these entities are needed to be considered. As described in prior sections DReps are identified by a Ed25519 key pair and Ada Holders are identified by stake credentials. + +Since there are two sets of credentials to track this means a single wallet user can be both a delegator and a DRep at once. + +##### **Stake Key** + +Staking keys are derivation is well described by [CIP-11](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0011) which builds on Cardano's key derivation as described in [CIP-1852](https://github.com/cardano-foundation/CIPs/blob/master/CIP-1852/README.md). Most (all) Cardano wallets follow these standards, allowing them to find a user's stake keys from a provided mnemonic. This means that wallets will easily be able to share this information with VVA, to allow VVA to find a user's governance state. + +One note here is that while most wallets support single stake key wallets, some do support Multi-stake Key Wallets ([CIP-18](https://github.com/cardano-foundation/CIPs/tree/master/CIP-0018)) meaning that a single user account can control multiple stake keys. This means that if a user's wallet supports this a user is able to delegate to multiple DReps at once. This does introduce some complexity on the side of the dApp's implementation. The way we have planned to navigate this is on wallet connection the user selects which stake key from their wallet they wish to use for that session. Each of the user's stake keys can be delegated or not to multiple DReps, at once. + +VVA can use a user's stake key to index the BE for the key's latest delegation certificate, with this the FE can show the user the ID of the DRep they most recently delegated to. + +##### **DRep Key** + +As described in CIP-1694 DReps can be identified via a Ed25519 public key. Whilst this affords the system a lot of freedom without a derivation path, key discovery for wallets is impossible. Without a derivation path wallets are unable to derive these keys from a user's mnemonic. To allow wallet derivation in CIP-???? we define a derivation path for DRep keys. With this path wallets can deterministically find a user's DRep key and share this with VVA. + +Unlike stake keys CIP-???? suggests that only one DRep key should be derived per wallet account. This avoids the need for DRep Key discovery. + +Recall from CIP-1694 a DRep ID is a blake2b-224 hash digest of a serialized DRep credential. This means that once a wallet shares the user's DRep key VVA can hash it to find the wallet's DRep ID. VVA can use a user's DRep ID and index the BE's list of active DRep's to see if the user is registered and if so their voting power. + +#### User States vs Personas + +Here we define five distinct user states, these somewhat differ from defined personas. + +![dApp User States](./dapp-user-states.PNG) + +1. **dApp Visitor:** This is any user who visits the hosted web page frontend of VVA. In this state the user is able to view and filter governance actions. To move from this state the user must connect their Cardano wallet, this can transition the user to any other state. +2. **Logged in User:** A dApp visitor may transition to state 2 by connecting a wallet which does not control stake keys which have delegated and does not control a DRep key which is not registered. They may delegate to a DRep to transition to state 3, or register as a DRep to move to state 4. To transition back to a dApp visitor they can disconnect their wallet. +3. **Delegator:** Delegators may stay in the same state by re-delegating to a different DRep. They may register to become a DRep to transfer to state 5 or disconnect their wallet to return to state 1. +4. **DRep:** DReps may delegate to move to state 5, submit a retirement to move to state 2 or disconnect their wallet to move back to 1. They may submit a new registration transaction to update their metadata keeping them in the same state. +5. **DRep + Delegator:** In this state users can re-delegate or re-register and stay in the same state. Users may submit a DRep retirement to return them back to state 3, or disconnect their wallet to return to state 1. + +All of the described flows are captured in the personas articulated in the XXXX document. Notably the personas are likely to span and overlap with what states the user will enter. + +#### Data Flows Summary + +Here we will summarize the data flows needed to be invoked by the VVA frontend. See swagger for full break down. + +We can categorize the calls into two distinct groups based on the source or the location that is being called, either the VVA backend or the connected wallet. The general approach is that the FE fetches data from the BE to show the user. The FE connects to the users wallet to fetch credentials to be able to identify the user and the FE uses the connect wallet to sign and submit transactions to Cardano. + +##### FE <-> Wallet + +All of these endpoints are described within CIP-????, here we describe them with user states and action. + +| User state | Action | Req Link | Endpoint | +| -------------- | --- | --- | --- | +| dApp Visitor | Login - Share stakes key(s) | N/A | GET /stake-key/ | +| dApp Visitor | Login - Share DRep key | N/A | GET /DRep-key/ | +| Logged In user | Register as dRep | N/A | POST /drep | +| Logged In user | Delegate | N/A | POST /delegation | +| Delegator | Register as dRep | N/A | POST /drep | +| Delegator | Delegate | N/A | POST /delegation | +| DRep | Re-register as dRep | N/A | POST /drep | +| DRep | Delegate | N/A | POST /delegation | +| DRep | Retire | N/A | POST /retirement | + +##### FE <- BE + +| User state | Action | Req Link | Prerequisite | Endpoint | BE Source | Processing | +| --- | --- | --- | --- | --- | --- | --- | + + +#### Frontend + +We will briefly outline what the frontend web app could look like. We not wish to be too prescriptive here past an API driven *some web app* that has the ability to talk to the backend and Cardano wallets and the BE. + +// compare to GVC + +##### wallet-connector + +This component of the frontend is responsible for communication with the wallet. This should capture the API offered. + +// compare to GVC + +#### Backend + +Here we will describe what BE components we believe are necessary for VVA to meet its requirements. + +The VVA backend must be able to read transaction and governance state information from Cardano, this data must then be saved in a database which can then be queried by the VVA FE. + +##### **Chain Follower** + +A chain follower/indexer is a software component that continuously monitors the Cardano blockchain for new blocks and transactions. It then processes the information contained in these blocks and transactions and makes it available for further analysis and processing. + +**Why do we need it?** + +The chain follower is used to extract the Voltaire related data from blocks and transactions and store it in a database which can then be easily transferred. This is necessary because blockchains in general are difficult to index. By filtering block data to only include Voltaire adjacent information we allow much more efficient indexing. + +The FE needs to be able to quickly lookup the governance state of a user (is a DRep? Is a delegator?) from user login time. + +**How a Chain Follower/Indexer Works?** + +A chain follower/indexer typically works in the following way: + +Connect to the Cardano blockchain: The chain follower/indexer connects to a Cardano node to access the blockchain data. + +Monitor new blocks: The chain follower/indexer monitors the Cardano blockchain for new blocks and transactions. It keeps track of the current block number, and waits for new blocks to be added to the blockchain. + +Process Blocks and Transactions: Once a new block is added to the blockchain, the chain follower/indexer processes it to extract relevant information. The relevant information is configurable for our use case this is Voltaire related information. + +Save Information to a Database: After processing the block, the chain follower/indexer saves the extracted information to a database. The database can be a traditional SQL database or a NoSQL database like MongoDB, depending on the use case. + +Handle Reorganizations and Forks: The chain follower/indexer must also handle chain reorganizations and forks. If the chain is reorganized or forked, the chain follower/indexer must be able to detect this and adjust accordingly. + +**What it looks like deployed?** + +When deployed, the chain follower/indexer is typically a standalone software component that runs on a server. It connects to a Cardano node via an API or socket, and interacts with a database to store and retrieve blockchain data. The system can be monitored and managed via a web-based interface, which displays metrics like the current block number, processing rate, and database size. + +**How other systems do this / technology choices** +- Lace +- GVC +- Blockfrost + +**Our Filters** + +// vs ledger state + +Technical Choices: + +// what is it +// why do we need +// how does it work +// what could it look like +// alternatives + +##### **Database** + +- what data we need + + +### Technical Design + +Here I will outline specific implementation details for each feature works. + +#### Wallet Connection + +We equate connecting a wallet to VVA as logging into VVA. VVA uses the CIP-??? standard to connect to and communicate with Cardano wallets. This process involves a user granting VVA access to their wallet’s CIP-???? API. Since CIP-???? is an extension to CIP-30, access to CIP-???? API implicitly enables access to the CIP-30 API. + +![Wallet Connection](./sequence-diagrams/wallet-connect.png) + +#### Vote Delegation + +The act of delegating is when a user associates the voting rights of their staked Ada with a DRep ID, so that the DRep can vote using the voting power of the staked Ada. This action can be performed from any user state in VVA, as + +Once logged in, a VVA FE a user enters the DRep ID of the DRep they wish to delegate to and VVA will pass the DRep ID along with the user's stake key to the wallet. The wallet can then +use this information to construct a vote delegation certificate, then ask the user for permission to sign and submit this certificate in a transaction to chain. + +Returned back to the FE is a signed object, which contains a the submitted certificate, the signature/witness on it and the hash of the transaction submitted to chain which contains this certificate. + +![Vote Delegation](./sequence-diagrams/delegation.png) + +#### DRep Registration + +The act of DRep registration is when a user submits a DRep registration certificate to chain, declaring their DRep ID and linking a metadata anchor. This logs their DRep key on-chain in the form of a hash, this is important as it will be checked by the ledger when they submit a vote. + +Once a user is logged into VVA, they are able to register as a DRep for the first time or re-register if they wish to update their metadata hash. This can be initiated by the user pressing 'Register as DRep' button. Next the user provides their metadata anchor to the FE, following this the FE generates the certificate object as described in CIP-????. The FE uses the user's DRep key provided previously along with the metadata anchor to construct this object and pass it to the wallet. The wallet can then use this information to construct a DRep registration certificate, and ask the user for permission to sign and submit this certificate in a transaction to chain. + +Returned back to the FE is a signed object, which contains a the submitted certificate, the signature/witness on it and the hash of the transaction submitted to chain which contains this certificate. + +![DRep Registration](./sequence-diagrams/drep-registration.png) + +#### DRep Retirement + +The act of DRep registration is when a user submits a DRep retirement certificate to chain, declaring their DRep ID is not going to be associated with a active DRep anymore. This is an on-chain mechanism to tell people currently delegated to that DRep that they should move their delegation. In VVA this action can be performed by any user who has previously registered as a DRep. + +Once logged in, a DRep can retire by pressing the 'Retire as DRep' button. This causes the VVA FE to construct a DRep retirement certificate object as described in CIP-???? passing in the connected wallet's DRep ID and a epoch retirement time of **TODO**. The wallet can then use this information to construct a DRep retirement certificate, and ask the user for permission to sign and submit this certificate in a transaction to chain. + +Returned back to the FE is a signed object, which contains a the submitted certificate, the signature/witness on it and the hash of the transaction submitted to chain which contains this certificate. + +![DRep Retirement](./sequence-diagrams/drep-retirement.png) + +#### Voting + +The act of voting via VVA is when DReps choose submit a vote transaction emitting their opinion for a given current governance action. This is only possible for those registered as active DReps. In the VVA FE this will take place on the governance action screen where the user is presented with the live governance actions. + +The user should indicate to the dApp, through it's UI which governance action to vote on and the DRep's choice (yes, no or abstain). Additionally, DReps have the option to supply a metadata anchor. With this information the VVA FE can construct the vote object using the shared DRep credentials, this is then passed to the wallet. Where the wallet asks the user for permission to sign and submit. + +Returned back to the FE is a signed object, which contains a the submitted certificate, the signature/witness on it and the hash of the transaction submitted to chain which contains this certificate. + +![Voting](./sequence-diagrams/voting.png) + +#### View Governance Actions + +// TODO + +#### Is user DRep? + +This is an internal call which requires no user interaction. This is likely to be used by the FE to work out if a newly connect wallet belongs to a DRep. This is likely to be a component part of a larger login flow, being preceded by a wallet connection. + +![Is user DRep?](./sequence-diagrams/drep-status.png) + +#### Is user Delegator? + +This is an internal call which requires no user interaction. This is likely to be used by the FE to work out if a newly connect wallet belongs to a delegator. This is likely to be a component part of a larger login flow, being preceded by a wallet connection. + +![Is user Delegator?](./sequence-diagrams/delegation-status.png) + +#### Full Login Flow + +When a user connects their wallet to VVA a flow is activated to check the user's governance state to be able to serve them the correct user interface.S This flow combines the [wallet connect](#wallet-connection) flow with the [Is user DRep](#is-user-drep) and [Is user delegator](#is-user-delegator). + + +![Login flow](./sequence-diagrams/login.png) + +### Edge Cases, whats missing? + +- explicit definitions on guard rails/warnings: + - for example: should we warn users if they try to re-delegate to the same DRep? (wasting transaction fees) \ No newline at end of file diff --git a/docs/architecture/sequence-diagrams/delegation-status.png b/docs/architecture/sequence-diagrams/delegation-status.png new file mode 100644 index 000000000..93bc3905e Binary files /dev/null and b/docs/architecture/sequence-diagrams/delegation-status.png differ diff --git a/docs/architecture/sequence-diagrams/delegation.png b/docs/architecture/sequence-diagrams/delegation.png new file mode 100644 index 000000000..e6e31ac0b Binary files /dev/null and b/docs/architecture/sequence-diagrams/delegation.png differ diff --git a/docs/architecture/sequence-diagrams/drep-registration.png b/docs/architecture/sequence-diagrams/drep-registration.png new file mode 100644 index 000000000..c826e3141 Binary files /dev/null and b/docs/architecture/sequence-diagrams/drep-registration.png differ diff --git a/docs/architecture/sequence-diagrams/drep-retirement.png b/docs/architecture/sequence-diagrams/drep-retirement.png new file mode 100644 index 000000000..bf3db2611 Binary files /dev/null and b/docs/architecture/sequence-diagrams/drep-retirement.png differ diff --git a/docs/architecture/sequence-diagrams/drep-status.png b/docs/architecture/sequence-diagrams/drep-status.png new file mode 100644 index 000000000..ada0d9fc3 Binary files /dev/null and b/docs/architecture/sequence-diagrams/drep-status.png differ diff --git a/docs/architecture/sequence-diagrams/login.png b/docs/architecture/sequence-diagrams/login.png new file mode 100644 index 000000000..3e8489247 Binary files /dev/null and b/docs/architecture/sequence-diagrams/login.png differ diff --git a/docs/architecture/sequence-diagrams/raw/delegation-status.txt b/docs/architecture/sequence-diagrams/raw/delegation-status.txt new file mode 100644 index 000000000..2d7f117de --- /dev/null +++ b/docs/architecture/sequence-diagrams/raw/delegation-status.txt @@ -0,0 +1,11 @@ +title Is User Delegator + +participant dApp Frontend +participant Wallet +participant dApp Backend + +dApp Frontend->Wallet: ""API.getActiveStakeKeys()"" +Wallet->dApp Frontend: ""[pubStakeKey]"" + +dApp Frontend->dApp Backend: ""GET delegation/{pubStakeKey} +dApp Backend->dApp Frontend: ""delegationCert"" diff --git a/docs/architecture/sequence-diagrams/raw/delegation.txt b/docs/architecture/sequence-diagrams/raw/delegation.txt new file mode 100644 index 000000000..30501025e --- /dev/null +++ b/docs/architecture/sequence-diagrams/raw/delegation.txt @@ -0,0 +1,13 @@ +title Vote Delegation + +participant User +participant dApp Frontend +participant Wallet +participant Cardano Node + +User->dApp Frontend: Enter ""dRepID"" +dApp Frontend->Wallet:""API.submitDelegation(dRepID, PubStakeKey)"" +Wallet->User: Ask permission popup (Wallet UI) +User->Wallet: Access granted (Wallet UI) +Wallet->Cardano Node: Submit transaction: \n""POST /delegation/{delegation-cert} +Wallet->dApp Frontend: ""SignedDelegationCertificate"" \ No newline at end of file diff --git a/docs/architecture/sequence-diagrams/raw/drep-registration.txt b/docs/architecture/sequence-diagrams/raw/drep-registration.txt new file mode 100644 index 000000000..eac9db47a --- /dev/null +++ b/docs/architecture/sequence-diagrams/raw/drep-registration.txt @@ -0,0 +1,15 @@ +title DRep Registration + +participant User +participant dApp Frontend +participant Wallet +participant Cardano Node + +User->dApp Frontend: 'Register as a dRep' button pressed +User->dApp Frontend: Supply metadata anchor +dApp Frontend->dApp Frontend: Construct ""DRepRegistrationCertificate"" +dApp Frontend->Wallet: Pass certificate to wallet:\n""API.submitDRepRegistration(DRepRegistrationCertificate)"" +Wallet->User: Ask permission popup (Wallet UI) +User->Wallet: Access granted (Wallet UI) +Wallet->Cardano Node: Submit transaction: \n""POST /registration/{registration-cert} +Wallet->dApp Frontend: ""SignedDRepRegistrationCertificate"" \ No newline at end of file diff --git a/docs/architecture/sequence-diagrams/raw/drep-retirement.txt b/docs/architecture/sequence-diagrams/raw/drep-retirement.txt new file mode 100644 index 000000000..cbd99a2a4 --- /dev/null +++ b/docs/architecture/sequence-diagrams/raw/drep-retirement.txt @@ -0,0 +1,14 @@ +title DRep Retirement + +participant User (dRep) +participant dApp Frontend +participant Wallet +participant Cardano Node + +User (dRep)->dApp Frontend: 'Retire' button pressed +dApp Frontend->dApp Frontend: Construct ""DRepRetirementCertificate"" +dApp Frontend->Wallet: Pass certificate to wallet:\n""API.submitDRepRetirementCertificate(DRepRetirementCertificate)"" +Wallet->User (dRep): Ask permission popup (Wallet UI) +User (dRep)->Wallet: Access granted (Wallet UI) +Wallet->Cardano Node: Submit transaction: \n""POST /retirement/{retirement-cert} +Wallet->dApp Frontend: ""SignedDRepRetirementCertificate"" \ No newline at end of file diff --git a/docs/architecture/sequence-diagrams/raw/drep-status.txt b/docs/architecture/sequence-diagrams/raw/drep-status.txt new file mode 100644 index 000000000..398a99bdb --- /dev/null +++ b/docs/architecture/sequence-diagrams/raw/drep-status.txt @@ -0,0 +1,11 @@ +title Is User DRep + +participant dApp Frontend +participant Wallet +participant dApp Backend + +dApp Frontend->Wallet: ""API.getDRepKey()"" +Wallet->dApp Frontend: ""pubDRepKey"" + +dApp Frontend->dApp Backend: ""GET drep/{pubDRepKey} +dApp Backend->dApp Frontend: ""bool"" \ No newline at end of file diff --git a/docs/architecture/sequence-diagrams/raw/login.txt b/docs/architecture/sequence-diagrams/raw/login.txt new file mode 100644 index 000000000..8340cc37a --- /dev/null +++ b/docs/architecture/sequence-diagrams/raw/login.txt @@ -0,0 +1,36 @@ +title User Login + +participant User +participant dApp Frontend +participant Wallet +participant dApp Backend + +note over dApp Frontend: Connect Wallet + +User->dApp Frontend: 'Connect Wallet' Button pressed +dApp Frontend->User: Wallet Selection prompt +User->dApp Frontend: Wallet Selected ""walletName +dApp Frontend->Wallet: ""cardano.{walletName}.enable({"cip": ?}) +Wallet->User: Ask permission popup (Wallet UI) +User->Wallet: Access granted (Wallet UI) +Wallet->dApp Frontend: ""API"" object + +note over dApp Frontend: Identify the user's stake key delegator status +dApp Frontend->Wallet: ""API.getActiveStakeKeys()"" +Wallet->dApp Frontend: ""[pubStakeKey]"" + +dApp Frontend->User: Select ""pubStakeKey"" to use +User->dApp Frontend: Choose which stake key to engage with + +dApp Frontend->dApp Backend: ""GET delegation/{pubStakeKey} +dApp Backend->dApp Frontend: ""delegationCert"" + +note over dApp Frontend: Identify the user's DRep status + +dApp Frontend->Wallet: ""API.getDRepKey()"" +Wallet->dApp Frontend: ""pubDRepKey"" + +dApp Frontend->dApp Backend: ""GET drep/{pubDRepKey} +dApp Backend->dApp Frontend: ""DRepCert"" + +dApp Frontend->User: Serve correct UI \ No newline at end of file diff --git a/docs/architecture/sequence-diagrams/raw/voting.txt b/docs/architecture/sequence-diagrams/raw/voting.txt new file mode 100644 index 000000000..2c311ed14 --- /dev/null +++ b/docs/architecture/sequence-diagrams/raw/voting.txt @@ -0,0 +1,16 @@ +title DRep Voting + +participant User (dRep) +participant dApp Frontend +participant Wallet +participant Cardano Node + +User (dRep)->dApp Frontend: Select Governance Action + choice +User (dRep)->dApp Frontend: Supply metadata anchor + +dApp Frontend->dApp Frontend: Construct ""Vote"" +dApp Frontend->Wallet: Pass object to wallet:\n""API.submitVote(Vote)"" +Wallet->User (dRep): Ask permission popup (Wallet UI) +User (dRep)->Wallet: Access granted (Wallet UI) +Wallet->Cardano Node: Submit transaction: \n""POST /vote/{vote} +Wallet->dApp Frontend: ""SignedVote"" \ No newline at end of file diff --git a/docs/architecture/sequence-diagrams/raw/wallet-connect.txt b/docs/architecture/sequence-diagrams/raw/wallet-connect.txt new file mode 100644 index 000000000..3119dc48c --- /dev/null +++ b/docs/architecture/sequence-diagrams/raw/wallet-connect.txt @@ -0,0 +1,13 @@ +title Wallet Connection + +participant User +participant dApp Frontend +participant Wallet + +User->dApp Frontend: 'Connect Wallet' Button pressed +dApp Frontend->User: Wallet Selection prompt +User->dApp Frontend: Wallet Selected ""walletName +dApp Frontend->Wallet: ""cardano.{walletName}.enable({"cip": ?}) +Wallet->User: Ask permission popup (Wallet UI) +User->Wallet: Access granted (Wallet UI) +Wallet->dApp Frontend: ""API"" object \ No newline at end of file diff --git a/docs/architecture/sequence-diagrams/voting.png b/docs/architecture/sequence-diagrams/voting.png new file mode 100644 index 000000000..fda17428b Binary files /dev/null and b/docs/architecture/sequence-diagrams/voting.png differ diff --git a/docs/architecture/sequence-diagrams/wallet-connect.png b/docs/architecture/sequence-diagrams/wallet-connect.png new file mode 100644 index 000000000..4f849f7cf Binary files /dev/null and b/docs/architecture/sequence-diagrams/wallet-connect.png differ diff --git a/docs/operations/README.md b/docs/operations/README.md new file mode 100644 index 000000000..bfe06e2dc --- /dev/null +++ b/docs/operations/README.md @@ -0,0 +1,67 @@ +# Overview + +The application is setup with the following tools: +* Terraform - for creating infrastructure in AWS for each environment +* Docker - to build and run application components +* Docker Compose - to connect the application components together and deploy them as a stack +* make - to simplify operations tasks +* Prometheus - to gather metrics from application host and Docker containers +* Grafana - to visualize metrics gathered from Prometheus and handle alerting + +# Environments + +The application is hosted on AWS, there are several application environments, each of them is described with Terraform in `src/terraform/main.tf` file. Terraform is executed manually, in order to add/modify/delete an environment, modify the code and run `terraform plan` to see the changes and `terraform apply` to execute them. + +Each environment consists of: + +* VPC network (with subnets, route tables, IGW) +* Security Groups +* EC2 instance +* Elastic IPs associated with EC2 instance +* Route 53 record (only for environments using `govtool.byron.network` domain) + +For each environment, the frontend is hosted at root and the backend is at `/api`. + +## List of public environments + +### beta + +A beta environment connected to `sanchonet` Cardano network. + +Available at https://sanchogov.tools/. The DNS record for this domain is created manually. + +# Deployment + +Deployment is performed via GitHub Actions workflow (`.github/workflows/build-and-deploy.yml`). + +The workflow performs the following steps: +* check if the environment is defined in Terraform (to avoid deployment attempt to inexistant environment) +* build of frontend app +* build of backend app +* generate configuration files and upload them (over SSH) to the target environment +* setup the application compoments with Docker Compose on the target environment + +The workflow can be triggered directly from GitHub Actions panel. When ruuning the workflow, you need to specify: +* Cardano network to be used +* environment name +* optionally skip the build process (frontend and backend) - useful when there are plain configuration changes that do not require the application to be rebuild + +# Monitoring + +Monitoring is achieved with Prometheus and Grafana, which are deployed together with each environment. Grafana is available at `/grafana`, the `admin` password is defined in a GitHub Actions secret `GRAFANA_ADMIN_PASSWORD`. + +Each Grafana instance is managed as code and provisioned with YAML files located at `src/config/grafana-provisioning`. This includes a default datasource, dashboard and alerting. The alerts are configured to be sent to Slack via Slack bot. + +The Slack bot OAuth token and recipient are defined with GitHub Actions secrets `GRAFANA_SLACK_OAUTH_TOKEN` and `GRAFANA_SLACK_RECIPIENT`, respectively. + +### Frontend + +Deploying new versions of the application is done using Github actions + +1. Open [`repository`](https://github.com/IntersectMBO/govtool) +2. Select "Actions" tab +3. From left menu choose "Build and deploy app" +4. From the droping options - "Run workflow", select the branch, Cardano network and type of environment for your deployment +5. Press "Run workflow" +6. Wait for the final effect. It's done. + diff --git a/docs/style-guides/css-in-js/README.md b/docs/style-guides/css-in-js/README.md new file mode 100644 index 000000000..061830964 --- /dev/null +++ b/docs/style-guides/css-in-js/README.md @@ -0,0 +1,432 @@ +# CSS-in-JavaScript Style Guide + +*A mostly reasonable approach to CSS-in-JavaScript, adopted from Airbnb* + +## Table of Contents + +1. [Naming](#naming) +1. [Ordering](#ordering) +1. [Nesting](#nesting) +1. [Inline](#inline) +1. [Themes](#themes) + +## Naming + + - Use camelCase for object keys (i.e. "selectors"). + + > Why? We access these keys as properties on the `styles` object in the component, so it is most convenient to use camelCase. + + ```js + // bad + { + 'bermuda-triangle': { + display: 'none', + }, + } + + // good + { + bermudaTriangle: { + display: 'none', + }, + } + ``` + + - Use an underscore for modifiers to other styles. + + > Why? Similar to BEM, this naming convention makes it clear that the styles are intended to modify the element preceded by the underscore. Underscores do not need to be quoted, so they are preferred over other characters, such as dashes. + + ```js + // bad + { + bruceBanner: { + color: 'pink', + transition: 'color 10s', + }, + + bruceBannerTheHulk: { + color: 'green', + }, + } + + // good + { + bruceBanner: { + color: 'pink', + transition: 'color 10s', + }, + + bruceBanner_theHulk: { + color: 'green', + }, + } + ``` + + - Use `selectorName_fallback` for sets of fallback styles. + + > Why? Similar to modifiers, keeping the naming consistent helps reveal the relationship of these styles to the styles that override them in more adequate browsers. + + ```js + // bad + { + muscles: { + display: 'flex', + }, + + muscles_sadBears: { + width: '100%', + }, + } + + // good + { + muscles: { + display: 'flex', + }, + + muscles_fallback: { + width: '100%', + }, + } + ``` + + - Use a separate selector for sets of fallback styles. + + > Why? Keeping fallback styles contained in a separate object clarifies their purpose, which improves readability. + + ```js + // bad + { + muscles: { + display: 'flex', + }, + + left: { + flexGrow: 1, + display: 'inline-block', + }, + + right: { + display: 'inline-block', + }, + } + + // good + { + muscles: { + display: 'flex', + }, + + left: { + flexGrow: 1, + }, + + left_fallback: { + display: 'inline-block', + }, + + right_fallback: { + display: 'inline-block', + }, + } + ``` + + - Use device-agnostic names (e.g. "small", "medium", and "large") to name media query breakpoints. + + > Why? Commonly used names like "phone", "tablet", and "desktop" do not match the characteristics of the devices in the real world. Using these names sets the wrong expectations. + + ```js + // bad + const breakpoints = { + mobile: '@media (max-width: 639px)', + tablet: '@media (max-width: 1047px)', + desktop: '@media (min-width: 1048px)', + }; + + // good + const breakpoints = { + small: '@media (max-width: 639px)', + medium: '@media (max-width: 1047px)', + large: '@media (min-width: 1048px)', + }; + ``` + +## Ordering + + - Define styles after the component. + + > Why? We use a higher-order component to theme our styles, which is naturally used after the component definition. Passing the styles object directly to this function reduces indirection. + + ```jsx + // bad + const styles = { + container: { + display: 'inline-block', + }, + }; + + function MyComponent({ styles }) { + return ( +
+ Never doubt that a small group of thoughtful, committed citizens can + change the world. Indeed, it’s the only thing that ever has. +
+ ); + } + + export default withStyles(() => styles)(MyComponent); + + // good + function MyComponent({ styles }) { + return ( +
+ Never doubt that a small group of thoughtful, committed citizens can + change the world. Indeed, it’s the only thing that ever has. +
+ ); + } + + export default withStyles(() => ({ + container: { + display: 'inline-block', + }, + }))(MyComponent); + ``` + +## Nesting + + - Leave a blank line between adjacent blocks at the same indentation level. + + > Why? The whitespace improves readability and reduces the likelihood of merge conflicts. + + ```js + // bad + { + bigBang: { + display: 'inline-block', + '::before': { + content: "''", + }, + }, + universe: { + border: 'none', + }, + } + + // good + { + bigBang: { + display: 'inline-block', + + '::before': { + content: "''", + }, + }, + + universe: { + border: 'none', + }, + } + ``` + +## Inline + + - Use inline styles for styles that have a high cardinality (e.g. uses the value of a prop) and not for styles that have a low cardinality. + + > Why? Generating themed stylesheets can be expensive, so they are best for discrete sets of styles. + + ```jsx + // bad + export default function MyComponent({ spacing }) { + return ( +
+ ); + } + + // good + function MyComponent({ styles, spacing }) { + return ( +
+ ); + } + export default withStyles(() => ({ + periodic: { + display: 'table', + }, + }))(MyComponent); + ``` + +## Themes + + - Use an abstraction layer such as [react-with-styles](https://github.com/airbnb/react-with-styles) that enables theming. *react-with-styles gives us things like `withStyles()`, `ThemedStyleSheet`, and `css()` which are used in some of the examples in this document.* + + > Why? It is useful to have a set of shared variables for styling your components. Using an abstraction layer makes this more convenient. Additionally, this can help prevent your components from being tightly coupled to any particular underlying implementation, which gives you more freedom. + + - Define colors only in themes. + + ```js + // bad + export default withStyles(() => ({ + chuckNorris: { + color: '#bada55', + }, + }))(MyComponent); + + // good + export default withStyles(({ color }) => ({ + chuckNorris: { + color: color.badass, + }, + }))(MyComponent); + ``` + + - Define fonts only in themes. + + ```js + // bad + export default withStyles(() => ({ + towerOfPisa: { + fontStyle: 'italic', + }, + }))(MyComponent); + + // good + export default withStyles(({ font }) => ({ + towerOfPisa: { + fontStyle: font.italic, + }, + }))(MyComponent); + ``` + + - Define fonts as sets of related styles. + + ```js + // bad + export default withStyles(() => ({ + towerOfPisa: { + fontFamily: 'Italiana, "Times New Roman", serif', + fontSize: '2em', + fontStyle: 'italic', + lineHeight: 1.5, + }, + }))(MyComponent); + + // good + export default withStyles(({ font }) => ({ + towerOfPisa: { + ...font.italian, + }, + }))(MyComponent); + ``` + + - Define base grid units in theme (either as a value or a function that takes a multiplier). + + ```js + // bad + export default withStyles(() => ({ + rip: { + bottom: '-6912px', // 6 feet + }, + }))(MyComponent); + + // good + export default withStyles(({ units }) => ({ + rip: { + bottom: units(864), // 6 feet, assuming our unit is 8px + }, + }))(MyComponent); + + // good + export default withStyles(({ unit }) => ({ + rip: { + bottom: 864 * unit, // 6 feet, assuming our unit is 8px + }, + }))(MyComponent); + ``` + + - Define media queries only in themes. + + ```js + // bad + export default withStyles(() => ({ + container: { + width: '100%', + + '@media (max-width: 1047px)': { + width: '50%', + }, + }, + }))(MyComponent); + + // good + export default withStyles(({ breakpoint }) => ({ + container: { + width: '100%', + + [breakpoint.medium]: { + width: '50%', + }, + }, + }))(MyComponent); + ``` + + - Define tricky fallback properties in themes. + + > Why? Many CSS-in-JavaScript implementations merge style objects together which makes specifying fallbacks for the same property (e.g. `display`) a little tricky. To keep the approach unified, put these fallbacks in the theme. + + ```js + // bad + export default withStyles(() => ({ + .muscles { + display: 'flex', + }, + + .muscles_fallback { + 'display ': 'table', + }, + }))(MyComponent); + + // good + export default withStyles(({ fallbacks }) => ({ + .muscles { + display: 'flex', + }, + + .muscles_fallback { + [fallbacks.display]: 'table', + }, + }))(MyComponent); + + // good + export default withStyles(({ fallback }) => ({ + .muscles { + display: 'flex', + }, + + .muscles_fallback { + [fallback('display')]: 'table', + }, + }))(MyComponent); + ``` + + - Create as few custom themes as possible. Many applications may only have one theme. + + - Namespace custom theme settings under a nested object with a unique and descriptive key. + + ```js + // bad + ThemedStyleSheet.registerTheme('mySection', { + mySectionPrimaryColor: 'green', + }); + + // good + ThemedStyleSheet.registerTheme('mySection', { + mySection: { + primaryColor: 'green', + }, + }); + ``` + +--- + +CSS puns adapted from [Saijo George](https://saijogeorge.com/css-puns/). diff --git a/docs/style-guides/css-sass/README.md b/docs/style-guides/css-sass/README.md new file mode 100644 index 000000000..836fc7e52 --- /dev/null +++ b/docs/style-guides/css-sass/README.md @@ -0,0 +1,315 @@ +# CSS / Sass Styleguide + +*A mostly reasonable approach to CSS and Sass, adopted from Airbnb* + +## Table of Contents + +1. [Terminology](#terminology) + - [Rule Declaration](#rule-declaration) + - [Selectors](#selectors) + - [Properties](#properties) +1. [CSS](#css) + - [Formatting](#formatting) + - [Comments](#comments) + - [OOCSS and BEM](#oocss-and-bem) + - [ID Selectors](#id-selectors) + - [JavaScript hooks](#javascript-hooks) + - [Border](#border) +1. [Sass](#sass) + - [Syntax](#syntax) + - [Ordering](#ordering-of-property-declarations) + - [Variables](#variables) + - [Mixins](#mixins) + - [Extend directive](#extend-directive) + - [Nested selectors](#nested-selectors) +1. [Translation](#translation) + +## Terminology + +### Rule declaration + +A “rule declaration” is the name given to a selector (or a group of selectors) with an accompanying group of properties. Here's an example: + +```css +.listing { + font-size: 18px; + line-height: 1.2; +} +``` + +### Selectors + +In a rule declaration, “selectors” are the bits that determine which elements in the DOM tree will be styled by the defined properties. Selectors can match HTML elements, as well as an element's class, ID, or any of its attributes. Here are some examples of selectors: + +```css +.my-element-class { + /* ... */ +} + +[aria-hidden] { + /* ... */ +} +``` + +### Properties + +Finally, properties are what give the selected elements of a rule declaration their style. Properties are key-value pairs, and a rule declaration can contain one or more property declarations. Property declarations look like this: + +```css +/* some selector */ { + background: #f1f1f1; + color: #333; +} +``` + +**[⬆ back to top](#table-of-contents)** + +## CSS + +### Formatting + +* Use soft tabs (2 spaces) for indentation. +* Prefer dashes over camelCasing in class names. + - Underscores and PascalCasing are okay if you are using BEM (see [OOCSS and BEM](#oocss-and-bem) below). +* Do not use ID selectors. +* When using multiple selectors in a rule declaration, give each selector its own line. +* Put a space before the opening brace `{` in rule declarations. +* In properties, put a space after, but not before, the `:` character. +* Put closing braces `}` of rule declarations on a new line. +* Put blank lines between rule declarations. + +**Bad** + +```css +.avatar{ + border-radius:50%; + border:2px solid white; } +.no, .nope, .not_good { + // ... +} +#lol-no { + // ... +} +``` + +**Good** + +```css +.avatar { + border-radius: 50%; + border: 2px solid white; +} + +.one, +.selector, +.per-line { + // ... +} +``` + +### Comments + +* Prefer line comments (`//` in Sass-land) to block comments. +* Prefer comments on their own line. Avoid end-of-line comments. +* Write detailed comments for code that isn't self-documenting: + - Uses of z-index + - Compatibility or browser-specific hacks + +### OOCSS and BEM + +We encourage some combination of OOCSS and BEM for these reasons: + + * It helps create clear, strict relationships between CSS and HTML + * It helps us create reusable, composable components + * It allows for less nesting and lower specificity + * It helps in building scalable stylesheets + +**OOCSS**, or “Object Oriented CSS”, is an approach for writing CSS that encourages you to think about your stylesheets as a collection of “objects”: reusable, repeatable snippets that can be used independently throughout a website. + + * Nicole Sullivan's [OOCSS wiki](https://github.com/stubbornella/oocss/wiki) + * Smashing Magazine's [Introduction to OOCSS](http://www.smashingmagazine.com/2011/12/12/an-introduction-to-object-oriented-css-oocss/) + +**BEM**, or “Block-Element-Modifier”, is a _naming convention_ for classes in HTML and CSS. It was originally developed by Yandex with large codebases and scalability in mind, and can serve as a solid set of guidelines for implementing OOCSS. + + * CSS Trick's [BEM 101](https://css-tricks.com/bem-101/) + * Harry Roberts' [introduction to BEM](http://csswizardry.com/2013/01/mindbemding-getting-your-head-round-bem-syntax/) + +We recommend a variant of BEM with PascalCased “blocks”, which works particularly well when combined with components (e.g. React). Underscores and dashes are still used for modifiers and children. + +**Example** + +```jsx +// ListingCard.jsx +function ListingCard() { + return ( + + ); +} +``` + +```css +/* ListingCard.css */ +.ListingCard { } +.ListingCard--featured { } +.ListingCard__title { } +.ListingCard__content { } +``` + + * `.ListingCard` is the “block” and represents the higher-level component + * `.ListingCard__title` is an “element” and represents a descendant of `.ListingCard` that helps compose the block as a whole. + * `.ListingCard--featured` is a “modifier” and represents a different state or variation on the `.ListingCard` block. + +### ID selectors + +While it is possible to select elements by ID in CSS, it should generally be considered an anti-pattern. ID selectors introduce an unnecessarily high level of [specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity) to your rule declarations, and they are not reusable. + +For more on this subject, read [CSS Wizardry's article](http://csswizardry.com/2014/07/hacks-for-dealing-with-specificity/) on dealing with specificity. + +### JavaScript hooks + +Avoid binding to the same class in both your CSS and JavaScript. Conflating the two often leads to, at a minimum, time wasted during refactoring when a developer must cross-reference each class they are changing, and at its worst, developers being afraid to make changes for fear of breaking functionality. + +We recommend creating JavaScript-specific classes to bind to, prefixed with `.js-`: + +```html + +``` + +### Border + +Use `0` instead of `none` to specify that a style has no border. + +**Bad** + +```css +.foo { + border: none; +} +``` + +**Good** + +```css +.foo { + border: 0; +} +``` +**[⬆ back to top](#table-of-contents)** + +## Sass + +### Syntax + +* Use the `.scss` syntax, never the original `.sass` syntax +* Order your regular CSS and `@include` declarations logically (see below) + +### Ordering of property declarations + +1. Property declarations + + List all standard property declarations, anything that isn't an `@include` or a nested selector. + + ```scss + .btn-green { + background: green; + font-weight: bold; + // ... + } + ``` + +2. `@include` declarations + + Grouping `@include`s at the end makes it easier to read the entire selector. + + ```scss + .btn-green { + background: green; + font-weight: bold; + @include transition(background 0.5s ease); + // ... + } + ``` + +3. Nested selectors + + Nested selectors, _if necessary_, go last, and nothing goes after them. Add whitespace between your rule declarations and nested selectors, as well as between adjacent nested selectors. Apply the same guidelines as above to your nested selectors. + + ```scss + .btn { + background: green; + font-weight: bold; + @include transition(background 0.5s ease); + + .icon { + margin-right: 10px; + } + } + ``` + +### Variables + +Prefer dash-cased variable names (e.g. `$my-variable`) over camelCased or snake_cased variable names. It is acceptable to prefix variable names that are intended to be used only within the same file with an underscore (e.g. `$_my-variable`). + +### Mixins + +Mixins should be used to DRY up your code, add clarity, or abstract complexity--in much the same way as well-named functions. Mixins that accept no arguments can be useful for this, but note that if you are not compressing your payload (e.g. gzip), this may contribute to unnecessary code duplication in the resulting styles. + +### Extend directive + +`@extend` should be avoided because it has unintuitive and potentially dangerous behavior, especially when used with nested selectors. Even extending top-level placeholder selectors can cause problems if the order of selectors ends up changing later (e.g. if they are in other files and the order the files are loaded shifts). Gzipping should handle most of the savings you would have gained by using `@extend`, and you can DRY up your stylesheets nicely with mixins. + +### Nested selectors + +**Do not nest selectors more than three levels deep!** + +```scss +.page-container { + .content { + .profile { + // STOP! + } + } +} +``` + +When selectors become this long, you're likely writing CSS that is: + +* Strongly coupled to the HTML (fragile) *—OR—* +* Overly specific (powerful) *—OR—* +* Not reusable + + +Again: **never nest ID selectors!** + +If you must use an ID selector in the first place (and you should really try not to), they should never be nested. If you find yourself doing this, you need to revisit your markup, or figure out why such strong specificity is needed. If you are writing well formed HTML and CSS, you should **never** need to do this. + +**[⬆ back to top](#table-of-contents)** + +## Translation + + This style guide is also available in other languages: + + - ![id](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Indonesia.png) **Bahasa Indonesia**: [mazipan/css-style-guide](https://github.com/mazipan/css-style-guide) + - ![tw](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Taiwan.png) **Chinese (Traditional)**: [ArvinH/css-style-guide](https://github.com/ArvinH/css-style-guide) + - ![cn](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/China.png) **Chinese (Simplified)**: [Zhangjd/css-style-guide](https://github.com/Zhangjd/css-style-guide) + - ![fr](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/France.png) **French**: [mat-u/css-style-guide](https://github.com/mat-u/css-style-guide) + - ![ka](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Georgia.png) **Georgian**: [DavidKadaria/css-style-guide](https://github.com/davidkadaria/css-style-guide) + - ![ja](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Japan.png) **Japanese**: [nao215/css-style-guide](https://github.com/nao215/css-style-guide) + - ![ko](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/South-Korea.png) **Korean**: [CodeMakeBros/css-style-guide](https://github.com/CodeMakeBros/css-style-guide) + - ![PT-BR](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Brazil.png) **Portuguese (Brazil)**: [felipevolpatto/css-style-guide](https://github.com/felipevolpatto/css-style-guide) + - ![pt-PT](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Portugal.png) **Portuguese (Portugal)**: [SandroMiguel/airbnb-css-style-guide](https://github.com/SandroMiguel/airbnb-css-style-guide) + - ![ru](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Russia.png) **Russian**: [rtplv/airbnb-css-ru](https://github.com/rtplv/airbnb-css-ru) + - ![es](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Spain.png) **Spanish**: [ismamz/guia-de-estilo-css](https://github.com/ismamz/guia-de-estilo-css) + - ![vn](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Vietnam.png) **Vietnamese**: [trungk18/css-style-guide](https://github.com/trungk18/css-style-guide) + - ![vn](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Italy.png) **Italian**: [antoniofull/linee-guida-css](https://github.com/antoniofull/linee-guida-css) + - ![de](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Germany.png) **German**: [tderflinger/css-styleguide](https://github.com/tderflinger/css-styleguide) + +**[⬆ back to top](#table-of-contents)** diff --git a/docs/style-guides/react/README.md b/docs/style-guides/react/README.md new file mode 100644 index 000000000..7b82997d6 --- /dev/null +++ b/docs/style-guides/react/README.md @@ -0,0 +1,757 @@ +# React/JSX Style Guide + +*A mostly reasonable approach to React and JSX, adopted from Airbnb* + +This style guide is mostly based on the standards that are currently prevalent in JavaScript, although some conventions (i.e async/await or static class fields) may still be included or prohibited on a case-by-case basis. Currently, anything prior to stage 3 is not included nor recommended in this guide. + +## Table of Contents + + 1. [Basic Rules](#basic-rules) + 1. [Class vs `React.createClass` vs stateless](#class-vs-reactcreateclass-vs-stateless) + 1. [Mixins](#mixins) + 1. [Naming](#naming) + 1. [Declaration](#declaration) + 1. [Alignment](#alignment) + 1. [Quotes](#quotes) + 1. [Spacing](#spacing) + 1. [Props](#props) + 1. [Refs](#refs) + 1. [Parentheses](#parentheses) + 1. [Tags](#tags) + 1. [Methods](#methods) + 1. [Ordering](#ordering) + 1. [`isMounted`](#ismounted) + +## Basic Rules + + - Only include one React component per file. + - However, multiple [Stateless, or Pure, Components](https://facebook.github.io/react/docs/reusable-components.html#stateless-functions) are allowed per file. eslint: [`react/no-multi-comp`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-multi-comp.md#ignorestateless). + - Always use JSX syntax. + - Do not use `React.createElement` unless you’re initializing the app from a file that is not JSX. + - [`react/forbid-prop-types`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/forbid-prop-types.md) will allow `arrays` and `objects` only if it is explicitly noted what `array` and `object` contains, using `arrayOf`, `objectOf`, or `shape`. + +## Class vs `React.createClass` vs stateless + + - If you have internal state and/or refs, prefer `class extends React.Component` over `React.createClass`. eslint: [`react/prefer-es6-class`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prefer-es6-class.md) [`react/prefer-stateless-function`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/prefer-stateless-function.md) + + ```jsx + // bad + const Listing = React.createClass({ + // ... + render() { + return
{this.state.hello}
; + } + }); + + // good + class Listing extends React.Component { + // ... + render() { + return
{this.state.hello}
; + } + } + ``` + + And if you don’t have state or refs, prefer normal functions (not arrow functions) over classes: + + ```jsx + // bad + class Listing extends React.Component { + render() { + return
{this.props.hello}
; + } + } + + // bad (relying on function name inference is discouraged) + const Listing = ({ hello }) => ( +
{hello}
+ ); + + // good + function Listing({ hello }) { + return
{hello}
; + } + ``` + +## Mixins + + - [Do not use mixins](https://facebook.github.io/react/blog/2016/07/13/mixins-considered-harmful.html). + + > Why? Mixins introduce implicit dependencies, cause name clashes, and cause snowballing complexity. Most use cases for mixins can be accomplished in better ways via components, higher-order components, or utility modules. + +## Naming + + - **Extensions**: Use `.jsx` extension for React components. eslint: [`react/jsx-filename-extension`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md) + - **Filename**: Use PascalCase for filenames. E.g., `ReservationCard.jsx`. + - **Reference Naming**: Use PascalCase for React components and camelCase for their instances. eslint: [`react/jsx-pascal-case`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-pascal-case.md) + + ```jsx + // bad + import reservationCard from './ReservationCard'; + + // good + import ReservationCard from './ReservationCard'; + + // bad + const ReservationItem = ; + + // good + const reservationItem = ; + ``` + + - **Component Naming**: Use the filename as the component name. For example, `ReservationCard.jsx` should have a reference name of `ReservationCard`. However, for root components of a directory, use `index.jsx` as the filename and use the directory name as the component name: + + ```jsx + // bad + import Footer from './Footer/Footer'; + + // bad + import Footer from './Footer/index'; + + // good + import Footer from './Footer'; + ``` + + - **Higher-order Component Naming**: Use a composite of the higher-order component’s name and the passed-in component’s name as the `displayName` on the generated component. For example, the higher-order component `withFoo()`, when passed a component `Bar` should produce a component with a `displayName` of `withFoo(Bar)`. + + > Why? A component’s `displayName` may be used by developer tools or in error messages, and having a value that clearly expresses this relationship helps people understand what is happening. + + ```jsx + // bad + export default function withFoo(WrappedComponent) { + return function WithFoo(props) { + return ; + } + } + + // good + export default function withFoo(WrappedComponent) { + function WithFoo(props) { + return ; + } + + const wrappedComponentName = WrappedComponent.displayName + || WrappedComponent.name + || 'Component'; + + WithFoo.displayName = `withFoo(${wrappedComponentName})`; + return WithFoo; + } + ``` + + - **Props Naming**: Avoid using DOM component prop names for different purposes. + + > Why? People expect props like `style` and `className` to mean one specific thing. Varying this API for a subset of your app makes the code less readable and less maintainable, and may cause bugs. + + ```jsx + // bad + + + // bad + + + // good + + ``` + +## Declaration + + - Do not use `displayName` for naming components. Instead, name the component by reference. + + ```jsx + // bad + export default React.createClass({ + displayName: 'ReservationCard', + // stuff goes here + }); + + // good + export default class ReservationCard extends React.Component { + } + ``` + +## Alignment + + - Follow these alignment styles for JSX syntax. eslint: [`react/jsx-closing-bracket-location`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-closing-bracket-location.md) [`react/jsx-closing-tag-location`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-closing-tag-location.md) + + ```jsx + // bad + + + // good + + + // if props fit in one line then keep it on the same line + + + // children get indented normally + + + + + // bad + {showButton && + + ); +} diff --git a/src/vva-fe/src/components/atoms/Radio.tsx b/src/vva-fe/src/components/atoms/Radio.tsx new file mode 100644 index 000000000..87c64ea05 --- /dev/null +++ b/src/vva-fe/src/components/atoms/Radio.tsx @@ -0,0 +1,56 @@ +import { Box, Typography } from "@mui/material"; +import { UseFormRegister, UseFormSetValue } from "react-hook-form"; + +type RadioProps = { + isChecked: boolean; + name: string; + title: string; + value: string; + setValue: UseFormSetValue; + register: UseFormRegister; + dataTestId?: string; +}; + +export const Radio = ({ ...props }: RadioProps) => { + const { isChecked, name, setValue, title, value, dataTestId, register } = + props; + + const handleClick = () => { + setValue(name, value); + }; + + return ( + + + + + {title} + + + + ); +}; diff --git a/src/vva-fe/src/components/atoms/ScrollToManage.tsx b/src/vva-fe/src/components/atoms/ScrollToManage.tsx new file mode 100644 index 000000000..49046d36b --- /dev/null +++ b/src/vva-fe/src/components/atoms/ScrollToManage.tsx @@ -0,0 +1,52 @@ +import { PATHS } from "@/consts"; +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +export function debounce( + fn: (...params: any) => void, + wait: number +): (...params: any) => void { + let timer: any = null; + return function (...params: any) { + clearTimeout(timer); + timer = setTimeout(() => { + fn(...params); + }, wait); + }; +} + +export const pathMap = new Map(); + +export const ScrollToManage = () => { + const { pathname } = useLocation(); + + useEffect(() => { + if (pathMap.has(pathname)) { + window.scrollTo(0, pathMap.get(pathname)!); + } else { + if ( + pathname === PATHS.dashboard_governance_actions || + pathname === PATHS.governance_actions + ) { + pathMap.set(pathname, 0); + } + window.scrollTo(0, 0); + } + }, [pathname]); + + useEffect(() => { + const fn = debounce(() => { + if ( + pathname === PATHS.dashboard_governance_actions || + pathname === PATHS.governance_actions + ) { + pathMap.set(pathname, window.scrollY); + } + }, 200); + + window.addEventListener("scroll", fn); + return () => window.removeEventListener("scroll", fn); + }, [pathname]); + + return <>; +}; diff --git a/src/vva-fe/src/components/atoms/ScrollToTop.tsx b/src/vva-fe/src/components/atoms/ScrollToTop.tsx new file mode 100644 index 000000000..bf31b9e3e --- /dev/null +++ b/src/vva-fe/src/components/atoms/ScrollToTop.tsx @@ -0,0 +1,12 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +export function ScrollToTop() { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return null; +} diff --git a/src/vva-fe/src/components/atoms/StakeRadio.tsx b/src/vva-fe/src/components/atoms/StakeRadio.tsx new file mode 100644 index 000000000..cb846b68d --- /dev/null +++ b/src/vva-fe/src/components/atoms/StakeRadio.tsx @@ -0,0 +1,88 @@ +import { Dispatch, FC, SetStateAction } from "react"; +import { Box, IconButton, Typography } from "@mui/material"; + +import { ICONS } from "@consts"; +import { theme } from "@/theme"; +import { useGetAdaHolderVotingPowerQuery, useScreenDimension } from "@/hooks"; +import { correctAdaFormat } from "@/utils/adaFormat"; + +type StakeRadioProps = { + isChecked?: boolean; + stakeKey: string; + onChange: Dispatch>; + dataTestId?: string; +}; + +export const StakeRadio: FC = ({ ...props }) => { + const { dataTestId, isChecked = false, stakeKey, onChange } = props; + const { + palette: { boxShadow1 }, + } = theme; + const { isMobile } = useScreenDimension(); + const { powerIsLoading, votingPower } = + useGetAdaHolderVotingPowerQuery(stakeKey); + + return ( + onChange(stakeKey)} + sx={[{ "&:hover": { cursor: "pointer" } }]} + > + + + + {stakeKey} + + + copy { + navigator.clipboard.writeText(stakeKey); + e.stopPropagation(); + }} + src={isChecked ? ICONS.copyWhiteIcon : ICONS.copyIcon} + /> + + + + + Voting power: + + {powerIsLoading ? ( + + {" "} + Loading... + + ) : ( + + ₳ {correctAdaFormat(votingPower) ?? 0} + + )} + + + + ); +}; diff --git a/src/vva-fe/src/components/atoms/Tooltip.tsx b/src/vva-fe/src/components/atoms/Tooltip.tsx new file mode 100644 index 000000000..a463dcd59 --- /dev/null +++ b/src/vva-fe/src/components/atoms/Tooltip.tsx @@ -0,0 +1,61 @@ +import { styled } from "@mui/material"; +import * as TooltipMUI from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + +type TooltipProps = Omit & { + heading?: string; + paragraphOne?: string; + paragraphTwo?: string; +}; + +export const Tooltip = ({ + heading, + paragraphOne, + paragraphTwo, + ...tooltipProps +}: TooltipProps) => { + return ( + + {heading && ( + + {heading} + + )} + + {paragraphOne && paragraphOne} + {paragraphTwo && ( + <> +

+ {paragraphTwo} + + )} +
+ + } + /> + ); +}; + +const StyledTooltip = styled( + ({ className, ...props }: TooltipMUI.TooltipProps) => ( + + ) +)(() => ({ + [`& .${TooltipMUI.tooltipClasses.arrow}`]: { + color: "rgb(36, 34, 50)", + }, + [`& .${TooltipMUI.tooltipClasses.tooltip}`]: { + backgroundColor: "rgb(36, 34, 50)", + padding: 12, + }, +})); diff --git a/src/vva-fe/src/components/atoms/Typography.tsx b/src/vva-fe/src/components/atoms/Typography.tsx new file mode 100644 index 000000000..b4ea2b86f --- /dev/null +++ b/src/vva-fe/src/components/atoms/Typography.tsx @@ -0,0 +1,81 @@ +import { + Typography as MUITypography, + SxProps, + TypographyProps as MUITypographyProps, +} from "@mui/material"; + +interface TypographyProps + extends Pick { + children?: React.ReactNode; + fontSize?: number; + fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; + variant?: + | "headline1" + | "headline2" + | "headline3" + | "headline4" + | "headline5" + | "title1" + | "title2" + | "body1" + | "body2" + | "caption"; + sx?: SxProps; +} + +export const Typography = ({ + color, + variant = "body1", + ...props +}: TypographyProps) => { + const fontSize = { + headline1: 100, + headline2: 50, + headline3: 36, + headline4: 32, + headline5: 28, + title1: 24, + title2: 22, + body1: 16, + body2: 14, + caption: 12, + }[variant]; + + const fontWeight = { + headline1: 600, + headline2: 600, + headline3: 400, + headline4: 600, + headline5: 500, + title1: 400, + title2: 500, + body1: 600, + body2: 500, + caption: 400, + }[variant]; + + const lineHeight = { + headline1: "110px", + headline2: "57px", + headline3: "44px", + headline4: "40px", + headline5: "36px", + title1: "32px", + title2: "28px", + body1: "24px", + body2: "20px", + caption: "16px", + }[variant]; + + return ( + + {props.children} + + ); +}; diff --git a/src/vva-fe/src/components/atoms/VotePill.tsx b/src/vva-fe/src/components/atoms/VotePill.tsx new file mode 100644 index 000000000..d16766d07 --- /dev/null +++ b/src/vva-fe/src/components/atoms/VotePill.tsx @@ -0,0 +1,42 @@ +import { Vote } from "@models"; +import { Box, Typography } from "@mui/material"; + +export const VotePill = ({ + vote, + width, + maxWidth, +}: { + vote: Vote; + width?: number; + maxWidth?: number; +}) => { + const VOTE = vote.toLowerCase(); + return ( + + + {vote} + + + ); +}; diff --git a/src/vva-fe/src/components/atoms/VotingPowerChips.tsx b/src/vva-fe/src/components/atoms/VotingPowerChips.tsx new file mode 100644 index 000000000..aad43c7d8 --- /dev/null +++ b/src/vva-fe/src/components/atoms/VotingPowerChips.tsx @@ -0,0 +1,76 @@ +import { Box, CircularProgress } from "@mui/material"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; + +import { Typography } from "@atoms"; +import { useCardano } from "@context"; +import { + useGetAdaHolderVotingPowerQuery, + useGetDRepVotingPowerQuery, + useScreenDimension, +} from "@hooks"; +import { correctAdaFormat } from "@utils"; +import { Tooltip } from "@atoms"; +import { tooltips } from "@/consts/texts"; + +export const VotingPowerChips = () => { + const { dRep, stakeKey, isDrepLoading } = useCardano(); + const { data: drepVotingPower, isLoading: drepPowerIsLoading } = + useGetDRepVotingPowerQuery(); + const { votingPower, powerIsLoading } = + useGetAdaHolderVotingPowerQuery(stakeKey); + const { isMobile, screenWidth } = useScreenDimension(); + + return ( + + {dRep?.isRegistered && ( + + + + )} + {screenWidth >= 1024 && ( + + Voting power: + + )} + {(dRep?.isRegistered && drepPowerIsLoading) || + (!dRep?.isRegistered && powerIsLoading) || + isDrepLoading ? ( + + ) : ( + + ₳{" "} + {dRep?.isRegistered + ? correctAdaFormat(drepVotingPower) ?? 0 + : correctAdaFormat(votingPower) ?? 0} + + )} + + ); +}; diff --git a/src/vva-fe/src/components/atoms/index.ts b/src/vva-fe/src/components/atoms/index.ts new file mode 100644 index 000000000..2b3428352 --- /dev/null +++ b/src/vva-fe/src/components/atoms/index.ts @@ -0,0 +1,24 @@ +export * from "./ActionRadio"; +export * from "./Background"; +export * from "./Button"; +export * from "./ClickOutside"; +export * from "./CopyButton"; +export * from "./DrawerLink"; +export * from "./Link"; +export * from "./LoadingButton"; +export * from "./modal/Modal"; +export * from "./modal/ModalContents"; +export * from "./modal/ModalHeader"; +export * from "./modal/ModalWrapper"; +export * from "./Radio"; +export * from "./ScrollToManage"; +export * from "./ScrollToTop"; +export * from "./snackbar/Snackbar"; +export * from "./snackbar/SnackbarMessage"; +export * from "./StakeRadio"; +export * from "./Tooltip"; +export * from "./Typography"; +export * from "./VotePill"; +export * from "./VotingPowerChips"; +export * from "./Input"; +export * from "./HighlightedText"; diff --git a/src/vva-fe/src/components/atoms/modal/Modal.tsx b/src/vva-fe/src/components/atoms/modal/Modal.tsx new file mode 100644 index 000000000..4c4912266 --- /dev/null +++ b/src/vva-fe/src/components/atoms/modal/Modal.tsx @@ -0,0 +1,22 @@ +import MuiModal from "@mui/material/Modal"; +import type { JSXElementConstructor, ReactElement } from "react"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type MuiModalChildren = ReactElement< + any, + string | JSXElementConstructor +>; + +interface Props { + open: boolean; + children: MuiModalChildren; + handleClose?: () => void; +} + +export function Modal({ open, children, handleClose }: Props) { + return ( + + <>{children} + + ); +} diff --git a/src/vva-fe/src/components/atoms/modal/ModalContents.tsx b/src/vva-fe/src/components/atoms/modal/ModalContents.tsx new file mode 100644 index 000000000..fdfb04670 --- /dev/null +++ b/src/vva-fe/src/components/atoms/modal/ModalContents.tsx @@ -0,0 +1,21 @@ +import { useScreenDimension } from "@/hooks"; +import { Box } from "@mui/material"; + +interface Props { + children: React.ReactNode; +} + +export function ModalContents({ children }: Props) { + const { isMobile } = useScreenDimension(); + + return ( + + {children} + + ); +} diff --git a/src/vva-fe/src/components/atoms/modal/ModalHeader.tsx b/src/vva-fe/src/components/atoms/modal/ModalHeader.tsx new file mode 100644 index 000000000..b94913e9f --- /dev/null +++ b/src/vva-fe/src/components/atoms/modal/ModalHeader.tsx @@ -0,0 +1,21 @@ +import Typography from "@mui/material/Typography"; +import type { SxProps } from "@mui/system"; + +interface Props { + children: React.ReactNode; + sx?: SxProps; +} + +export function ModalHeader({ children, sx }: Props) { + return ( + + {children} + + ); +} diff --git a/src/vva-fe/src/components/atoms/modal/ModalWrapper.tsx b/src/vva-fe/src/components/atoms/modal/ModalWrapper.tsx new file mode 100644 index 000000000..5300f0fd9 --- /dev/null +++ b/src/vva-fe/src/components/atoms/modal/ModalWrapper.tsx @@ -0,0 +1,75 @@ +import { SxProps, styled } from "@mui/material/styles"; + +import { ICONS } from "@consts"; +import { useModal } from "@context"; +import { callAll } from "@utils"; + +interface Props { + variant?: "modal" | "popup"; + onClose?: () => void; + hideCloseButton?: boolean; + children: React.ReactNode; + dataTestId?: string; + sx?: SxProps; +} + +export function ModalWrapper({ + children, + onClose, + variant = "modal", + hideCloseButton = false, + dataTestId = "modal", + sx, +}: Props) { + const { closeModal } = useModal(); + + return ( + + {variant !== "popup" && !hideCloseButton && ( + + )} + {children} + + ); +} + +export const BaseWrapper = styled("div")>` + box-shadow: 1px 2px 11px 0px #00123d5e; + max-height: 90vh; + position: absolute; + top: 50%; + left: 50%; + display: flex; + flex-direction: column; + background: #fbfbff; + border-radius: 24px; + transform: translate(-50%, -50%); + + ${({ variant }) => { + if (variant === "modal") { + return ` + width: 80vw; + max-width: 510px; + padding: 52px 24px 34px 24px; + `; + } + if (variant === "popup") { + return ` + width: 320px; + height: 320px; + `; + } + }} +`; + +export const CloseButton = styled("img")` + cursor: pointer; + position: absolute; + top: 24px; + right: 24px; +`; diff --git a/src/vva-fe/src/components/atoms/snackbar/Snackbar.tsx b/src/vva-fe/src/components/atoms/snackbar/Snackbar.tsx new file mode 100644 index 000000000..b1234c0a8 --- /dev/null +++ b/src/vva-fe/src/components/atoms/snackbar/Snackbar.tsx @@ -0,0 +1,39 @@ +import type { GrowProps } from "@mui/material/Grow"; +import Grow from "@mui/material/Grow"; +import type { SnackbarProps } from "@mui/material/Snackbar"; +import MuiSnackbar from "@mui/material/Snackbar"; + +import { theme } from "@/theme"; +import type { SnackbarSeverity } from "@models"; + +function GrowTransition(props: GrowProps) { + return ; +} + +interface Props extends SnackbarProps { + severity: SnackbarSeverity; +} + +export function Snackbar({ severity, ...props }: Props) { + return ( + + ); +} diff --git a/src/vva-fe/src/components/atoms/snackbar/SnackbarMessage.tsx b/src/vva-fe/src/components/atoms/snackbar/SnackbarMessage.tsx new file mode 100644 index 000000000..8c4c34dd9 --- /dev/null +++ b/src/vva-fe/src/components/atoms/snackbar/SnackbarMessage.tsx @@ -0,0 +1,49 @@ +import { styled } from "@mui/material/styles"; + +import { ICONS } from "@consts"; +import type { SnackbarSeverity } from "@models"; + +interface Props { + message: string; + onClose?: (_event: React.SyntheticEvent | Event, reason?: string) => void; + severity: SnackbarSeverity; +} + +export function SnackbarMessage({ message, severity, onClose }: Props) { + return ( + + {severity === "success" ? ( + + ) : ( + + )} +
{message}
+ {onClose && ( + close icon + )} +
+ ); +} + +const SnackContainer = styled("span")` + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + color: white; + font-weight: 500; + font-size: 14px; + line-height: 24px; + width: 100%; +`; diff --git a/src/vva-fe/src/components/molecules/ActionCard.tsx b/src/vva-fe/src/components/molecules/ActionCard.tsx new file mode 100644 index 000000000..63fc9eb03 --- /dev/null +++ b/src/vva-fe/src/components/molecules/ActionCard.tsx @@ -0,0 +1,122 @@ +import { Box } from "@mui/material"; +import { FC } from "react"; + +import { Button, Typography } from "@atoms"; +import { theme } from "@/theme"; +import { useScreenDimension } from "@hooks"; + +type ActionCardProps = { + description?: string; + firstButtonAction?: () => void; + firstButtonLabel?: string; + imageHeight?: number; + imageURL?: string; + imageWidth?: number; + secondButtonAction?: () => void; + secondButtonLabel?: string; + title?: string; + dataTestIdFirstButton?: string; + dataTestIdSecondButton?: string; +}; + +export const ActionCard: FC = ({ ...props }) => { + const { + dataTestIdFirstButton, + dataTestIdSecondButton, + description, + firstButtonAction, + firstButtonLabel, + imageHeight = 80, + imageURL, + imageWidth = 80, + secondButtonAction, + secondButtonLabel, + title, + } = props; + const { isMobile, screenWidth } = useScreenDimension(); + const MOBILE_AND_WIDE_CONDITION = isMobile || screenWidth >= 1920; + + const { + palette: { boxShadow2 }, + } = theme; + + return ( + + + {imageURL ? ( + + ) : null} + {title ? ( + + {title} + + ) : null} + {description ? ( + + {description} + + ) : null} + + + {firstButtonLabel ? ( + + ) : null} + + + + ); +}; diff --git a/src/vva-fe/src/components/molecules/DRepInfoCard.tsx b/src/vva-fe/src/components/molecules/DRepInfoCard.tsx new file mode 100644 index 000000000..83d49f480 --- /dev/null +++ b/src/vva-fe/src/components/molecules/DRepInfoCard.tsx @@ -0,0 +1,31 @@ +import { Box, Typography } from "@mui/material"; + +import { useCardano } from "@context"; +import { CopyButton } from "@atoms"; + +export const DRepInfoCard = () => { + const { dRepIDBech32 } = useCardano(); + + return ( + + + + My DRep ID: + + + + + + {dRepIDBech32} + + + + ); +}; diff --git a/src/vva-fe/src/components/molecules/DashboardActionCard.tsx b/src/vva-fe/src/components/molecules/DashboardActionCard.tsx new file mode 100644 index 000000000..62708e524 --- /dev/null +++ b/src/vva-fe/src/components/molecules/DashboardActionCard.tsx @@ -0,0 +1,251 @@ +import { Box, ButtonProps, Skeleton } from "@mui/material"; +import { FC, ReactNode } from "react"; + +import { Button, CopyButton, Typography } from "@atoms"; +import { useScreenDimension } from "@hooks"; +import { theme } from "@/theme"; + +type DashboardActionCardProps = { + description?: ReactNode; + firstButtonAction?: () => void; + firstButtonDisabled?: boolean; + firstButtonLabel?: string; + firstButtonVariant?: ButtonProps["variant"]; + imageHeight?: number; + imageURL?: string; + imageWidth?: number; + secondButtonAction?: () => void; + secondButtonLabel?: string; + secondButtonVariant?: ButtonProps["variant"]; + title?: ReactNode; + cardTitle?: string; + cardId?: string; + inProgress?: boolean; + isLoading?: boolean; + dataTestidFirstButton?: string; + dataTestidSecondButton?: string; + dataTestidDrepIdBox?: string; + dataTestidDelegationStatus?: string; +}; + +export const DashboardActionCard: FC = ({ + ...props +}) => { + const { + dataTestidFirstButton, + dataTestidSecondButton, + dataTestidDrepIdBox, + description, + firstButtonAction, + firstButtonDisabled = false, + firstButtonLabel, + firstButtonVariant = "contained", + imageURL, + secondButtonAction, + secondButtonLabel, + secondButtonVariant = "outlined", + title, + cardId, + cardTitle, + inProgress, + isLoading = false, + } = props; + + const { + palette: { boxShadow2 }, + } = theme; + const { isMobile, screenWidth } = useScreenDimension(); + + return ( + + {inProgress && !isLoading && ( + + + In progress + + + )} + + {imageURL ? ( + isLoading ? ( + + ) : ( + + ) + ) : null} + {title ? ( + + {isLoading ? : title} + + ) : null} + {inProgress && !isLoading ? ( + + in progress + + ) : null} + {description ? ( + + {isLoading ? ( + + ) : ( + description + )} + + ) : null} + {cardId && ( + + + + {cardTitle} + + + {cardId} + + + + + )} + + {isLoading ? ( + + + + + ) : ( + + {firstButtonLabel ? ( + + ) : null} + {secondButtonLabel ? ( + + ) : null} + + )} + + ); +}; diff --git a/src/vva-fe/src/components/molecules/DataActionsBar.tsx b/src/vva-fe/src/components/molecules/DataActionsBar.tsx new file mode 100644 index 000000000..86613a8bc --- /dev/null +++ b/src/vva-fe/src/components/molecules/DataActionsBar.tsx @@ -0,0 +1,110 @@ +import { Dispatch, FC, SetStateAction } from "react"; +import { Box, InputBase } from "@mui/material"; +import Search from "@mui/icons-material/Search"; + +import { GovernanceActionsFilters, GovernanceActionsSorting } from "."; +import { OrderActionsChip } from "./OrderActionsChip"; +import { ClickOutside } from "../atoms"; + +import { theme } from "@/theme"; + +type DataActionsBarProps = { + chosenFilters?: string[]; + chosenFiltersLength?: number; + chosenSorting: string; + closeFilters?: () => void; + closeSorts: () => void; + filtersOpen?: boolean; + isFiltering?: boolean; + searchText: string; + setChosenFilters?: Dispatch>; + setChosenSorting: Dispatch>; + setFiltersOpen?: Dispatch>; + setSearchText: Dispatch>; + setSortOpen: Dispatch>; + sortingActive: boolean; + sortOpen: boolean; +}; + +export const DataActionsBar: FC = ({ ...props }) => { + const { + chosenFilters = [], + chosenFiltersLength, + chosenSorting, + closeFilters = () => {}, + closeSorts, + filtersOpen, + isFiltering = true, + searchText, + setChosenFilters = () => {}, + setChosenSorting, + setFiltersOpen, + setSearchText, + setSortOpen, + sortingActive, + sortOpen, + } = props; + const { + palette: { boxShadow2 }, + } = theme; + + return ( + <> + + setSearchText(e.target.value)} + placeholder="Search..." + value={searchText} + startAdornment={ + + } + sx={{ + bgcolor: "white", + border: 1, + borderColor: "secondaryBlue", + borderRadius: 50, + boxShadow: `2px 2px 20px 0px ${boxShadow2}`, + fontSize: 11, + fontWeight: 500, + height: 48, + padding: "16px 24px", + width: 231, + }} + /> + + + {filtersOpen && ( + + + + )} + {sortOpen && ( + + + + )} + + ); +}; diff --git a/src/vva-fe/src/components/molecules/GovActionDetails.tsx b/src/vva-fe/src/components/molecules/GovActionDetails.tsx new file mode 100644 index 000000000..bbf49932e --- /dev/null +++ b/src/vva-fe/src/components/molecules/GovActionDetails.tsx @@ -0,0 +1,47 @@ +import { Typography } from "../atoms"; + +export const GovActionDetails = ({ + title, + value, +}: { + title: string; + value: any; +}) => { + if (Array.isArray(value)) { + return ( +
+ {title}: +
    + {value.map((item, index) => ( +
  • + + {item} + +
  • + ))} +
+
+ ); + } else if (typeof value === "boolean") { + return ( + + {title}: {value ? "True" : "False"} + + ); + } else { + return ( + + {title}: {value} + + ); + } +}; diff --git a/src/vva-fe/src/components/molecules/GovernanceActionCard.tsx b/src/vva-fe/src/components/molecules/GovernanceActionCard.tsx new file mode 100644 index 000000000..73a50af91 --- /dev/null +++ b/src/vva-fe/src/components/molecules/GovernanceActionCard.tsx @@ -0,0 +1,245 @@ +import { FC } from "react"; +import { Box } from "@mui/material"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; + +import { Button, Typography, Tooltip } from "@atoms"; +import { tooltips } from "@consts"; +import { useScreenDimension } from "@hooks"; +import { theme } from "@/theme"; +import { + formatDisplayDate, + getFullGovActionId, + getProposalTypeLabel, + getShortenedGovActionId, +} from "@utils"; + +interface ActionTypeProps + extends Omit< + ActionType, + | "yesVotes" + | "noVotes" + | "abstainVotes" + | "metadataHash" + | "url" + | "details" + | "id" + | "txHash" + | "index" + > { + onClick?: () => void; + inProgress?: boolean; + txHash: string; + index: number; +} + +export const GovernanceActionCard: FC = ({ ...props }) => { + const { + type, + inProgress = false, + expiryDate, + onClick, + createdDate, + txHash, + index, + } = props; + const { isMobile, screenWidth } = useScreenDimension(); + + const { + palette: { lightBlue }, + } = theme; + + const govActionId = getFullGovActionId(txHash, index); + const proposalTypeNoEmptySpaces = getProposalTypeLabel(type).replace( + / /g, + "" + ); + + return ( + + {inProgress && ( + + + In progress + + + )} + + + + Governance Action Type: + + + + + {getProposalTypeLabel(type)} + + + + + + + Governance Action ID: + + + + + {getShortenedGovActionId(txHash, index)} + + + + + + {createdDate ? ( + + + Submission date: + + + {formatDisplayDate(createdDate)} + + + + + + ) : null} + {expiryDate ? ( + + + Expiry date: + + + {formatDisplayDate(expiryDate)} + + + + + + ) : null} + + + + + ); +}; diff --git a/src/vva-fe/src/components/molecules/GovernanceActionsFilters.tsx b/src/vva-fe/src/components/molecules/GovernanceActionsFilters.tsx new file mode 100644 index 000000000..0a7426b90 --- /dev/null +++ b/src/vva-fe/src/components/molecules/GovernanceActionsFilters.tsx @@ -0,0 +1,91 @@ +import { Dispatch, SetStateAction, useCallback } from "react"; +import { + Box, + Checkbox, + FormControlLabel, + FormLabel, + Typography, +} from "@mui/material"; + +import { GOVERNANCE_ACTIONS_FILTERS } from "@consts"; + +interface Props { + chosenFilters: string[]; + setChosenFilters: Dispatch>; +} + +export const GovernanceActionsFilters = ({ + chosenFilters, + setChosenFilters, +}: Props) => { + const handleFilterChange = useCallback( + (e: React.ChangeEvent) => { + e.target.name, e.target.checked; + let filters = [...chosenFilters]; + if (e.target.checked) { + filters.push(e.target.name); + } else { + filters = filters.filter((str) => str !== e.target.name); + } + setChosenFilters(filters); + }, + [chosenFilters, setChosenFilters] + ); + + return ( + + + Governance Action Type + + {GOVERNANCE_ACTIONS_FILTERS.map((item) => { + return ( + + + } + label={ + + {item.label} + + } + /> + + ); + })} + + ); +}; diff --git a/src/vva-fe/src/components/molecules/GovernanceActionsSorting.tsx b/src/vva-fe/src/components/molecules/GovernanceActionsSorting.tsx new file mode 100644 index 000000000..9abce9ae3 --- /dev/null +++ b/src/vva-fe/src/components/molecules/GovernanceActionsSorting.tsx @@ -0,0 +1,80 @@ +import { Dispatch, SetStateAction } from "react"; +import { + Box, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Typography, +} from "@mui/material"; + +import { GOVERNANCE_ACTIONS_SORTING } from "@consts"; + +interface Props { + chosenSorting: string; + setChosenSorting: Dispatch>; +} + +export const GovernanceActionsSorting = ({ + chosenSorting, + setChosenSorting, +}: Props) => { + return ( + + + + + Sort by + + setChosenSorting("")}> + + Clear + + + + { + setChosenSorting(e.target.value); + }} + > + {GOVERNANCE_ACTIONS_SORTING.map((item) => { + return ( + + } + label={item.label} + /> + ); + })} + + + + ); +}; diff --git a/src/vva-fe/src/components/molecules/GovernanceVotedOnCard.tsx b/src/vva-fe/src/components/molecules/GovernanceVotedOnCard.tsx new file mode 100644 index 000000000..855281658 --- /dev/null +++ b/src/vva-fe/src/components/molecules/GovernanceVotedOnCard.tsx @@ -0,0 +1,281 @@ +import { useNavigate } from "react-router-dom"; +import { Box } from "@mui/material"; +import CheckIcon from "@mui/icons-material/Check"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; + +import { Button, VotePill, Typography } from "@atoms"; +import { PATHS } from "@consts"; +import { useScreenDimension } from "@hooks"; +import { VotedProposal } from "@models"; +import { theme } from "@/theme"; +import { + formatDisplayDate, + getFullGovActionId, + getProposalTypeLabel, + getShortenedGovActionId, + openInNewTab, +} from "@utils"; +import { Tooltip } from "@atoms"; +import { tooltips } from "@/consts/texts"; + +interface Props { + votedProposal: VotedProposal; + searchPhrase?: string; + inProgress?: boolean; +} + +export const GovernanceVotedOnCard = ({ votedProposal, inProgress }: Props) => { + const navigate = useNavigate(); + const { proposal, vote } = votedProposal; + const { + palette: { lightBlue }, + } = theme; + const { isMobile } = useScreenDimension(); + + const proposalTypeNoEmptySpaces = getProposalTypeLabel(proposal.type).replace( + / /g, + "" + ); + + return ( + + + + {inProgress ? ( + "In progress" + ) : ( + <> + + Vote submitted + + )} + + + + + + Governance Action Type: + + + + + {getProposalTypeLabel(proposal.type)} + + + + + + + Governance Action ID: + + + + + {getShortenedGovActionId(proposal.txHash, proposal.index)} + + + + + + + My Vote: + + + + + + + + + + {proposal.createdDate ? ( + + + Submission date: + + + {formatDisplayDate(proposal.createdDate)} + + + + + + ) : null} + {proposal.expiryDate ? ( + + + Expiry date: + + + {formatDisplayDate(proposal.expiryDate)} + + + + + + ) : null} + + + + + ); +}; diff --git a/src/vva-fe/src/components/molecules/OrderActionsChip.tsx b/src/vva-fe/src/components/molecules/OrderActionsChip.tsx new file mode 100644 index 000000000..ecf6093f3 --- /dev/null +++ b/src/vva-fe/src/components/molecules/OrderActionsChip.tsx @@ -0,0 +1,124 @@ +import { Dispatch, SetStateAction } from "react"; +import { Box, Typography } from "@mui/material"; + +import { ICONS } from "@consts"; +import { theme } from "@/theme"; + +interface Props { + filtersOpen?: boolean; + setFiltersOpen?: Dispatch>; + chosenFiltersLength?: number; + sortOpen: boolean; + setSortOpen: Dispatch>; + sortingActive: boolean; + isFiltering?: boolean; +} + +export const OrderActionsChip = (props: Props) => { + const { + palette: { secondary }, + } = theme; + const { + filtersOpen, + setFiltersOpen = () => {}, + chosenFiltersLength = 0, + sortOpen, + setSortOpen, + sortingActive, + isFiltering = true, + } = props; + + return ( + + {isFiltering && ( + + filter { + setSortOpen(false); + if (isFiltering) { + setFiltersOpen(!filtersOpen); + } + }} + src={filtersOpen ? ICONS.filterWhiteIcon : ICONS.filterIcon} + style={{ + background: filtersOpen ? secondary.main : "transparent", + borderRadius: "100%", + cursor: "pointer", + padding: "14px", + overflow: "visible", + height: 20, + width: 20, + objectFit: "contain", + }} + /> + {!filtersOpen && chosenFiltersLength > 0 && ( + + + {chosenFiltersLength} + + + )} + + )} + + sort { + if (isFiltering) { + setFiltersOpen(false); + } + setSortOpen(!sortOpen); + }} + src={sortOpen ? ICONS.sortWhiteIcon : ICONS.sortIcon} + style={{ + background: sortOpen ? secondary.main : "transparent", + borderRadius: "100%", + cursor: "pointer", + padding: "14px", + height: 24, + width: 24, + objectFit: "contain", + }} + /> + {!sortOpen && sortingActive && ( + + sorting active + + )} + + + ); +}; diff --git a/src/vva-fe/src/components/molecules/VoteActionForm.tsx b/src/vva-fe/src/components/molecules/VoteActionForm.tsx new file mode 100644 index 000000000..591bf39ec --- /dev/null +++ b/src/vva-fe/src/components/molecules/VoteActionForm.tsx @@ -0,0 +1,276 @@ +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useLocation } from "react-router-dom"; +import { Box, Link } from "@mui/material"; + +import { Button, Input, LoadingButton, Radio, Typography } from "@atoms"; +import { ICONS } from "@consts"; +import { useCardano, useModal } from "@context"; +import { useScreenDimension, useVoteActionForm } from "@hooks"; +import { openInNewTab } from "@utils"; + +export const VoteActionForm = ({ + voteFromEP, + yesVotes, + noVotes, + abstainVotes, +}: { + voteFromEP?: string; + yesVotes: number; + noVotes: number; + abstainVotes: number; +}) => { + const { state } = useLocation(); + const [isContext, setIsContext] = useState(false); + const { isMobile, screenWidth } = useScreenDimension(); + const { openModal } = useModal(); + const { dRep } = useCardano(); + + const { + setValue, + control, + confirmVote, + vote, + registerInput, + errors, + isDirty, + clearErrors, + areFormErrors, + isLoading, + } = useVoteActionForm(); + + useEffect(() => { + if (state && state.vote) { + setValue("vote", state.vote); + } else if (voteFromEP) { + setValue("vote", voteFromEP); + } + }, [state, voteFromEP, setValue]); + + useEffect(() => { + clearErrors(); + }, [isContext]); + + const handleContext = useCallback(() => { + setIsContext((prev) => !prev); + }, []); + + const renderCancelButton = useMemo(() => { + return ( + + ); + }, [state]); + + const renderChangeVoteButton = useMemo(() => { + return ( + + Change vote + + ); + }, [confirmVote, areFormErrors, vote]); + + return ( + + + Choose how you want to vote: + + + + + + + + + + + {dRep?.isRegistered && ( + + )} + +

+ Provide context about your vote{" "} + + (optional) + arrow + +

+
+ {isContext && ( + + + + + openInNewTab( + "https://docs.sanchogov.tools/faqs/how-to-create-a-metadata-anchor" + ) + } + mb={isMobile ? 2 : 8} + sx={{ cursor: "pointer" }} + textAlign={"center"} + visibility={!isContext ? "hidden" : "visible"} + > + + How to create URL and hash? + + + + )} +
+ + Select a different option to change your vote + + {(state?.vote && state?.vote !== vote) || + (voteFromEP && voteFromEP !== vote) ? ( + + {isMobile ? renderChangeVoteButton : renderCancelButton} + + {isMobile ? renderCancelButton : renderChangeVoteButton} + + ) : ( + + )} + + ); +}; diff --git a/src/vva-fe/src/components/molecules/VotesSubmitted.tsx b/src/vva-fe/src/components/molecules/VotesSubmitted.tsx new file mode 100644 index 000000000..dc7b9a199 --- /dev/null +++ b/src/vva-fe/src/components/molecules/VotesSubmitted.tsx @@ -0,0 +1,108 @@ +import { IMAGES } from "@/consts"; +import { Box, Typography } from "@mui/material"; + +import { theme } from "@/theme"; +import { VotePill } from "@atoms"; +import { useScreenDimension } from "@/hooks"; +import { correctAdaFormat } from "@/utils/adaFormat"; + +interface Props { + yesVotes: number; + noVotes: number; + abstainVotes: number; +} + +export const VotesSubmitted = ({ yesVotes, noVotes, abstainVotes }: Props) => { + const { + palette: { lightBlue }, + } = theme; + const { isMobile } = useScreenDimension(); + + return ( + + ga icon + + Votes Submitted + + + for this Governance Action + + + Votes submitted on-chain by DReps, SPOs and Constitutional Committee + members. + + + Votes: + + + + + + ₳ {correctAdaFormat(yesVotes)} + + + + + + ₳ {correctAdaFormat(abstainVotes)} + + + + + + ₳ {correctAdaFormat(noVotes)} + + + + + ); +}; diff --git a/src/vva-fe/src/components/molecules/WalletInfoCard.tsx b/src/vva-fe/src/components/molecules/WalletInfoCard.tsx new file mode 100644 index 000000000..6b646a9e4 --- /dev/null +++ b/src/vva-fe/src/components/molecules/WalletInfoCard.tsx @@ -0,0 +1,77 @@ +import { useNavigate } from "react-router-dom"; +import { Box, Button, Typography } from "@mui/material"; + +import { PATHS } from "@consts"; +import { useCardano } from "@context"; +import { theme } from "@/theme"; + +export const WalletInfoCard = () => { + const { address, disconnectWallet, isMainnet } = useCardano(); + const navigate = useNavigate(); + const { + palette: { lightBlue }, + } = theme; + + return ( + address && ( + + + + {isMainnet ? "mainnet" : "testnet"} + + + + Connected Wallet: + + + + {address} + + + + + + + ) + ); +}; diff --git a/src/vva-fe/src/components/molecules/WalletOption.tsx b/src/vva-fe/src/components/molecules/WalletOption.tsx new file mode 100644 index 000000000..94c5f9ed7 --- /dev/null +++ b/src/vva-fe/src/components/molecules/WalletOption.tsx @@ -0,0 +1,74 @@ +import { Box, Typography } from "@mui/material"; +import { FC } from "react"; + +import { useCardano } from "@context"; +import { theme } from "@/theme"; +import { useNavigate } from "react-router-dom"; +import { PATHS } from "@/consts"; + +export interface WalletOption { + icon: string; + label: string; + name: string; + cip95Available: boolean; + dataTestId?: string; +} + +export const WalletOptionButton: FC = ({ ...props }) => { + const { enable } = useCardano(); + const { + palette: { lightBlue }, + } = theme; + const navigate = useNavigate(); + + const { dataTestId, icon, label, name, cip95Available } = props; + + return ( + { + const result = await enable(name); + if (result?.stakeKey) { + navigate(PATHS.dashboard); + return; + } + navigate(PATHS.stakeKeys); + }} + > + {`${name} + + {name ?? label} + + {`${name} + + ); +}; diff --git a/src/vva-fe/src/components/molecules/index.ts b/src/vva-fe/src/components/molecules/index.ts new file mode 100644 index 000000000..61171332c --- /dev/null +++ b/src/vva-fe/src/components/molecules/index.ts @@ -0,0 +1,14 @@ +export * from "./ActionCard"; +export * from "./DashboardActionCard"; +export * from "./DataActionsBar"; +export * from "./DRepInfoCard"; +export * from "./GovActionDetails"; +export * from "./GovernanceActionCard"; +export * from "./GovernanceActionsFilters"; +export * from "./GovernanceActionsSorting"; +export * from "./GovernanceVotedOnCard"; +export * from "./OrderActionsChip"; +export * from "./VoteActionForm"; +export * from "./VotesSubmitted"; +export * from "./WalletInfoCard"; +export * from "./WalletOption"; diff --git a/src/vva-fe/src/components/organisms/ChooseStakeKeyPanel.tsx b/src/vva-fe/src/components/organisms/ChooseStakeKeyPanel.tsx new file mode 100644 index 000000000..fbc07b1b4 --- /dev/null +++ b/src/vva-fe/src/components/organisms/ChooseStakeKeyPanel.tsx @@ -0,0 +1,126 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Box, Button, Grid, Typography } from "@mui/material"; + +import { StakeRadio } from "@atoms"; +import { useCardano, useSnackbar } from "@context"; +import { PATHS } from "@consts"; +import { setItemToLocalStorage, WALLET_LS_KEY } from "@utils"; +import { theme } from "@/theme"; +import { useScreenDimension } from "@hooks"; + +export const ChooseStakeKeyPanel = () => { + const { disconnectWallet, stakeKeys, setStakeKey } = useCardano(); + const navigate = useNavigate(); + const { addSuccessAlert } = useSnackbar(); + const [chosenKey, setChosenKey] = useState(""); + const { isMobile } = useScreenDimension(); + const { + palette: { boxShadow2 }, + } = theme; + + const renderCancelButton = useMemo(() => { + return ( + + ); + }, [isMobile]); + + const renderSelectButton = useMemo(() => { + return ( + + ); + }, [isMobile, chosenKey, setStakeKey]); + + return ( + + + + + Pick Stake Key + + + Select the stake key you want to use: + + + {stakeKeys.map((k) => { + return ( + + + + ); + })} + + + + {isMobile ? renderSelectButton : renderCancelButton} + + {isMobile ? renderCancelButton : renderSelectButton} + + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/ChooseWalletModal.tsx b/src/vva-fe/src/components/organisms/ChooseWalletModal.tsx new file mode 100644 index 000000000..cd4cc6fce --- /dev/null +++ b/src/vva-fe/src/components/organisms/ChooseWalletModal.tsx @@ -0,0 +1,113 @@ +import { Box, Link, Typography } from "@mui/material"; +import { useMemo } from "react"; + +import { ModalContents, ModalHeader, ModalWrapper } from "@atoms"; +import type { WalletOption } from "@molecules"; +import { WalletOptionButton } from "@molecules"; +import { openInNewTab } from "@utils"; + +export function ChooseWalletModal() { + const walletOptions: WalletOption[] = useMemo(() => { + if (!window.cardano) return []; + const keys = Object.keys(window.cardano); + const resultWallets: WalletOption[] = []; + keys.forEach((k: string) => { + const { icon, name, supportedExtensions } = window.cardano[k]; + if (icon && name && supportedExtensions) { + // Check if the name already exists in resultWallets + const isNameDuplicate = resultWallets.some(wallet => wallet.label === name); + // Check if the supportedExtensions array contains an entry with cip === 95 + const isCip95Available = Boolean( + supportedExtensions?.find((i) => i.cip === 95) + ); + // If the name is not a duplicate and cip === 95 is available, add it to resultWallets + if (!isNameDuplicate && isCip95Available) { + resultWallets.push({ + icon, + label: name, + name: k, + cip95Available: true, + }); + } + } + }); + return resultWallets; + }, [window]); + + return ( + + Connect your Wallet + + + Choose the wallet you want to connect with: + + + {!walletOptions.length ? ( + + You don't have wallets to connect, install a wallet and refresh + the page and try again + + ) : ( + walletOptions.map(({ icon, label, name, cip95Available }) => { + return ( + + ); + }) + )} + + + Can’t see your wallet? Check what wallets are currently compatible + with GovTool{" "} + + openInNewTab( + "https://docs.sanchogov.tools/how-to-use-the-govtool/getting-started/get-a-compatible-wallet" + ) + } + sx={{ cursor: "pointer" }} + > + here + + . + + + + ); +} diff --git a/src/vva-fe/src/components/organisms/DashboardCards.tsx b/src/vva-fe/src/components/organisms/DashboardCards.tsx new file mode 100644 index 000000000..e29782299 --- /dev/null +++ b/src/vva-fe/src/components/organisms/DashboardCards.tsx @@ -0,0 +1,412 @@ +import { useNavigate } from "react-router-dom"; +import { Box, CircularProgress, Typography } from "@mui/material"; + +import { IMAGES, PATHS } from "@consts"; +import { useCardano, useModal } from "@context"; +import { useGetAdaHolderVotingPowerQuery, useScreenDimension } from "@hooks"; +import { DashboardActionCard } from "@molecules"; +import { useCallback, useMemo, useState } from "react"; +import { useGetAdaHolderCurrentDelegationQuery } from "@hooks"; +import { correctAdaFormat, formHexToBech32, openInNewTab } from "@utils"; + +export const DashboardCards = () => { + const { + dRepIDBech32: drepId, + dRepID, + dRep, + stakeKey, + buildSignSubmitConwayCertTx, + delegateTransaction, + buildDRepRetirementCert, + registerTransaction, + delegateTo, + isPendingTransaction, + isDrepLoading, + } = useCardano(); + const navigate = useNavigate(); + const { currentDelegation, isCurrentDelegationLoading } = + useGetAdaHolderCurrentDelegationQuery(stakeKey); + const { screenWidth, isMobile } = useScreenDimension(); + const { openModal } = useModal(); + const [isLoading, setIsLoading] = useState(false); + const { votingPower, powerIsLoading } = + useGetAdaHolderVotingPowerQuery(stakeKey); + + const retireAsDrep = useCallback(async () => { + try { + setIsLoading(true); + const isPendingTx = isPendingTransaction(); + if (isPendingTx) return; + const certBuilder = await buildDRepRetirementCert(); + const result = await buildSignSubmitConwayCertTx({ + certBuilder, + type: "registration", + registrationType: "retirement", + }); + if (result) + openModal({ + type: "statusModal", + state: { + status: "success", + title: "Retirement Transaction Submitted!", + message: + "The confirmation of your retirement might take a bit of time but you can track it using.", + link: `https://adanordic.com/latest_transactions`, + buttonText: "Go to dashboard", + dataTestId: "retirement-transaction-submitted-modal", + }, + }); + } catch (error: any) { + const errorMessage = error.info ? error.info : error; + + setIsLoading(false); + openModal({ + type: "statusModal", + state: { + status: "warning", + message: errorMessage, + buttonText: "Go to dashboard", + title: "Oops!", + dataTestId: "retirement-transaction-error-modal", + }, + }); + } finally { + setIsLoading(false); + } + }, [buildDRepRetirementCert, buildSignSubmitConwayCertTx]); + + const delegationDescription = useMemo(() => { + const correctAdaRepresentation = ( + {correctAdaFormat(votingPower)} + ); + if (currentDelegation === dRepID) { + return ( + <> + You have delegated your voting power of ₳{correctAdaRepresentation} to + yourself. + + ); + } else if (currentDelegation === "drep_always_no_confidence") { + return ( + <> + You have delegated your voting power of ₳{correctAdaRepresentation}. + You are going to vote 'NO' as default. + + ); + } else if (currentDelegation === "drep_always_abstain") { + return ( + <> + You have delegated your voting power of ₳{correctAdaRepresentation}. + You are going to vote 'ABSTAIN' as default. + + ); + } else if (currentDelegation) { + return ( + <> + You have delegated your voting power of ₳{correctAdaRepresentation} to + a selected DRep. + + ); + } else { + return ( + <> + If you want to delegate your own voting power of ₳ + {correctAdaRepresentation}. + + ); + } + }, [currentDelegation, drepId, votingPower]); + + const delegationStatusTestForId = useMemo(() => { + if (currentDelegation === dRepID) { + return "myself"; + } else if (currentDelegation === "drep_always_no_confidence") { + return "no-confidence"; + } else if (currentDelegation === "drep_always_abstain") { + return "abstain"; + } else if (currentDelegation) { + return "dRep"; + } else { + return "not_delegated"; + } + }, [currentDelegation, drepId, votingPower]); + + const progressDescription = useMemo(() => { + const correctAdaRepresentation = ( + {correctAdaFormat(votingPower)} + ); + if (delegateTo === dRepID) { + return ( + <> + Your own voting power of ₳{correctAdaRepresentation} is in progress of + being delegated. You are going to delegate your voting power to + yourself. + + ); + } + if (delegateTo === "no confidence") { + return ( + <> + Your own voting power of ₳{correctAdaRepresentation} is in progress of + being delegated. You are going to vote ‘NO’ as default. + + ); + } + if (delegateTo === "abstain") { + return ( + <> + Your own voting power of ₳{correctAdaRepresentation} is in progress of + being delegated. You are going to vote ‘ABSTAIN’ as default. + + ); + } + if (delegateTo) { + return ( + <> + Your own voting power of ₳{correctAdaRepresentation} is progress of + being delegated. You are going to delegate your voting power to a + selected DRep. + + ); + } + }, [delegateTo, votingPower]); + + const navigateTo = useCallback( + (path: string) => { + const isPendingTx = isPendingTransaction(); + if (isPendingTx) return; + navigate(path); + }, + [isPendingTransaction, navigate] + ); + + const displayedDelegationId = useMemo(() => { + const restrictedNames = [ + dRepID, + "drep_always_abstain", + "drep_always_no_confidence", + "abstain", + "no confidence", + ]; + if (delegateTransaction?.transactionHash) { + if (!restrictedNames.includes(delegateTo)) { + return delegateTo.includes("drep") + ? delegateTo + : formHexToBech32(delegateTo); + } + return undefined; + } + if (!restrictedNames.includes(currentDelegation)) { + return formHexToBech32(currentDelegation); + } else { + return undefined; + } + }, [ + currentDelegation, + dRepID, + delegateTo, + delegateTransaction, + formHexToBech32, + ]); + + const renderGovActionSection = useCallback(() => { + return ( + <> + + See Active Governance Actions + + + + navigate(PATHS.dashboard_governance_actions) + } + firstButtonLabel={ + dRep?.isRegistered ? "Review and vote" : "View governance actions" + } + imageURL={IMAGES.govActionListImage} + title="View Governance Actions" + /> + {screenWidth < 1024 ? null : ( + <> + + + + )} + + + ); + }, [screenWidth, isMobile, dRep?.isRegistered]); + + return isDrepLoading ? ( + + + + ) : ( + + {dRep?.isRegistered && renderGovActionSection()} + + Your Participation + + + navigateTo(PATHS.delegateTodRep)} + firstButtonLabel={ + delegateTransaction?.transactionHash + ? "" + : currentDelegation + ? "Change delegation" + : "Delegate" + } + imageHeight={55} + imageWidth={65} + firstButtonVariant={currentDelegation ? "outlined" : "contained"} + imageURL={IMAGES.govActionDelegateImage} + cardId={displayedDelegationId} + inProgress={!!delegateTransaction?.transactionHash} + cardTitle="DRep you delegated to" + secondButtonAction={ + delegateTransaction?.transactionHash + ? () => openInNewTab("https://adanordic.com/latest_transactions") + : () => + openInNewTab( + "https://docs.sanchogov.tools/faqs/ways-to-use-your-voting-power" + ) + } + secondButtonLabel={ + delegateTransaction?.transactionHash + ? "See transaction" + : currentDelegation + ? "" + : "Learn more" + } + title={ + delegateTransaction?.transactionHash ? ( + "Voting Power Delegation" + ) : currentDelegation ? ( + <> + Your Voting Power is Delegated + + ) : ( + "Use your Voting Power" + ) + } + /> + + navigateTo(PATHS.registerAsdRep) + } + firstButtonLabel={ + registerTransaction?.transactionHash + ? "" + : dRep?.isRegistered + ? "Retire as a DRep" + : "Register" + } + inProgress={!!registerTransaction?.transactionHash} + imageURL={IMAGES.govActionRegisterImage} + secondButtonAction={ + registerTransaction?.transactionHash + ? () => openInNewTab("https://adanordic.com/latest_transactions") + : dRep?.isRegistered && drepId + ? () => { + navigateTo(PATHS.updateMetadata); + } + : () => + openInNewTab( + "https://docs.sanchogov.tools/faqs/what-does-it-mean-to-register-as-a-drep" + ) + } + secondButtonLabel={ + registerTransaction?.transactionHash + ? "See transaction" + : dRep?.isRegistered + ? "Change metadata" + : "Learn more" + } + cardId={dRep?.isRegistered || dRep?.wasRegistered ? drepId : ""} + cardTitle={ + dRep?.isRegistered || dRep?.wasRegistered ? "My DRep ID" : "" + } + title={ + registerTransaction?.transactionHash + ? registerTransaction?.type === "retirement" + ? "DRep Retirement" + : registerTransaction?.type === "registration" + ? "DRep Registration" + : "DRep Update" + : dRep?.isRegistered + ? "You are Registered as a DRep" + : dRep?.wasRegistered + ? "Register Again as a dRep" + : "Register as a DRep" + } + /> + + {!dRep?.isRegistered && renderGovActionSection()} + + ); +}; diff --git a/src/vva-fe/src/components/organisms/DashboardGovernanceActionDetails.tsx b/src/vva-fe/src/components/organisms/DashboardGovernanceActionDetails.tsx new file mode 100644 index 000000000..e952b5ab4 --- /dev/null +++ b/src/vva-fe/src/components/organisms/DashboardGovernanceActionDetails.tsx @@ -0,0 +1,158 @@ +import { + useNavigate, + useLocation, + NavLink, + useParams, + generatePath, +} from "react-router-dom"; +import { + Box, + Breadcrumbs, + CircularProgress, + Link, + Typography, +} from "@mui/material"; + +import { ICONS, PATHS } from "@consts"; +import { useCardano } from "@context"; +import { useGetProposalQuery, useScreenDimension } from "@hooks"; +import { GovernanceActionDetailsCard } from "@organisms"; +import { + formatDisplayDate, + getShortenedGovActionId, + getProposalTypeLabel, +} from "@utils"; + +export const DashboardGovernanceActionDetails = () => { + const { dRep } = useCardano(); + const { state, hash } = useLocation(); + const navigate = useNavigate(); + const { isMobile, screenWidth } = useScreenDimension(); + const { proposalId } = useParams(); + const fullProposalId = proposalId + hash; + + const { data, isLoading } = useGetProposalQuery(fullProposalId ?? "", !state); + + const shortenedGovActionId = getShortenedGovActionId( + state ? state.txHash : data?.proposal.txHash ?? "", + state ? state.index : data?.proposal.index ?? "" + ); + + const breadcrumbs = [ + + + Governance Actions + + , + + Vote on Governance Action + , + ]; + + return ( + + + {breadcrumbs} + + + navigate( + state && state.openedFromCategoryPage + ? generatePath(PATHS.dashboard_governance_actions_category, { + category: state.type, + }) + : PATHS.dashboard_governance_actions, + { + state: { + isVotedListOnLoad: state && state.vote ? true : false, + }, + } + ) + } + > + arrow + + Back to the list + + + + {isLoading ? ( + + + + ) : data || state ? ( + + ) : ( + + + Governnance action with id  + + {` ${shortenedGovActionId} `} +  does not exist. + + )} + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/DashboardGovernanceActions.tsx b/src/vva-fe/src/components/organisms/DashboardGovernanceActions.tsx new file mode 100644 index 000000000..8f1a456d5 --- /dev/null +++ b/src/vva-fe/src/components/organisms/DashboardGovernanceActions.tsx @@ -0,0 +1,160 @@ +import { useState, useCallback, useEffect } from "react"; +import { Box, Tab, Tabs, styled } from "@mui/material"; +import { useLocation } from "react-router-dom"; + +import { useCardano } from "@context"; +import { useScreenDimension } from "@hooks"; +import { DataActionsBar } from "@molecules"; +import { + GovernanceActionsToVote, + DashboardGovernanceActionsVotedOn, +} from "@organisms"; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function CustomTabPanel(props: TabPanelProps) { + const { children, value, index } = props; + + return ( + + ); +} + +type StyledTabProps = { + label: string; +}; + +const StyledTab = styled((props: StyledTabProps) => ( + +))(() => ({ + textTransform: "none", + fontWeight: 400, + fontSize: 16, + color: "#242232", + "&.Mui-selected": { + color: "#FF640A", + fontWeight: 500, + }, +})); + +export const DashboardGovernanceActions = () => { + const [searchText, setSearchText] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const [chosenFilters, setChosenFilters] = useState([]); + const [sortOpen, setSortOpen] = useState(false); + const [chosenSorting, setChosenSorting] = useState(""); + + const { state } = useLocation(); + const [content, setContent] = useState( + state && state.isVotedListOnLoad ? 1 : 0 + ); + + const { dRep } = useCardano(); + const { isMobile } = useScreenDimension(); + + const handleChange = (_event: React.SyntheticEvent, newValue: number) => { + setContent(newValue); + }; + + const closeFilters = useCallback(() => { + setFiltersOpen(false); + }, [setFiltersOpen]); + + const closeSorts = useCallback(() => { + setSortOpen(false); + }, [setSortOpen]); + + useEffect(() => { + window.history.replaceState({}, document.title); + }, []); + + return ( + + + {dRep?.isRegistered && ( + + + + + )} + + + + + + + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/DashboardGovernanceActionsVotedOn.tsx b/src/vva-fe/src/components/organisms/DashboardGovernanceActionsVotedOn.tsx new file mode 100644 index 000000000..f0143e13e --- /dev/null +++ b/src/vva-fe/src/components/organisms/DashboardGovernanceActionsVotedOn.tsx @@ -0,0 +1,99 @@ +import { useMemo } from "react"; +import { Box, Typography, CircularProgress } from "@mui/material"; + +import { GovernanceVotedOnCard } from "@molecules"; +import { Slider } from "."; +import { useGetDRepVotesQuery, useScreenDimension } from "@hooks"; +import { getProposalTypeLabel } from "@/utils/getProposalTypeLabel"; +import { getFullGovActionId } from "@/utils"; +import { useCardano } from "@/context"; + +interface DashboardGovernanceActionsVotedOnProps { + filters: string[]; + searchPhrase?: string; + sorting: string; +} + +export const DashboardGovernanceActionsVotedOn = ({ + filters, + searchPhrase, + sorting, +}: DashboardGovernanceActionsVotedOnProps) => { + const { data, dRepVotesAreLoading } = useGetDRepVotesQuery(filters, sorting); + const { isMobile } = useScreenDimension(); + const { voteTransaction } = useCardano(); + + const filteredData = useMemo(() => { + if (data.length && searchPhrase) { + return data + .map((entry) => { + return { + ...entry, + actions: entry.actions.filter((action) => { + return getFullGovActionId( + action.proposal.txHash, + action.proposal.index + ) + .toLowerCase() + .includes(searchPhrase.toLowerCase()); + }), + }; + }) + .filter((entry) => entry.actions.length > 0); + } else { + return data; + } + }, [data, searchPhrase, voteTransaction.transactionHash]); + + return dRepVotesAreLoading ? ( + + + + ) : ( + <> + {!data.length ? ( + + You haven't voted on any Governance Actions yet. Check the 'To + vote on' section to vote on Governance Actions. + + ) : !filteredData?.length ? ( + + No results for the search. + + ) : ( + <> + {filteredData?.map((item) => ( +
+ { + return ( +
+ +
+ ); + })} + /> + +
+ ))} + + )} + + ); +}; diff --git a/src/vva-fe/src/components/organisms/DashboardTopNav.tsx b/src/vva-fe/src/components/organisms/DashboardTopNav.tsx new file mode 100644 index 000000000..b3389cd38 --- /dev/null +++ b/src/vva-fe/src/components/organisms/DashboardTopNav.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Box, Grid, IconButton, SwipeableDrawer } from "@mui/material"; + +import { Background, Link, VotingPowerChips, Typography } from "@atoms"; +import { useScreenDimension } from "@hooks"; +import { ICONS, PATHS } from "@consts"; +import { useCardano } from "@context"; +import { DRepInfoCard, WalletInfoCard } from "@molecules"; +import { openInNewTab } from "@utils"; + +type DashboardTopNavProps = { + imageSRC?: string; + imageWidth?: number; + imageHeight?: number; + title: string; + isDrawer?: boolean; +}; + +const DRAWER_PADDING = 2; +const CALCULATED_DRAWER_PADDING = DRAWER_PADDING * 8 * 2; + +export const DashboardTopNav = ({ + title, + imageSRC, + imageWidth, + imageHeight, +}: DashboardTopNavProps) => { + const { isMobile, screenWidth } = useScreenDimension(); + const { dRep } = useCardano(); + const navigate = useNavigate(); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + return ( + + + {imageSRC ? ( + { + navigate(PATHS.dashboard); + }} + > + + + ) : null} + {!isMobile && title ? ( + {title} + ) : null} + + + + {isMobile && ( + setIsDrawerOpen(true)} + > + + + )} + + {isMobile && ( + setIsDrawerOpen(false)} + onOpen={() => setIsDrawerOpen(true)} + > + + + + + + setIsDrawerOpen(false)} + > + + + + + + { + setIsDrawerOpen(false); + }} + isConnectWallet + /> + + + { + setIsDrawerOpen(false); + }} + isConnectWallet + /> + + + { + openInNewTab( + "https://docs.sanchogov.tools/about/what-is-sanchonet-govtool" + ); + setIsDrawerOpen(false); + }} + isConnectWallet + /> + + + { + openInNewTab("https://docs.sanchogov.tools/faqs"); + setIsDrawerOpen(false); + }} + isConnectWallet + /> + + + + {dRep?.isRegistered && } + + + + + + )} + + ); +}; diff --git a/src/vva-fe/src/components/organisms/DelegateTodRepStepOne.tsx b/src/vva-fe/src/components/organisms/DelegateTodRepStepOne.tsx new file mode 100644 index 000000000..6febb38ae --- /dev/null +++ b/src/vva-fe/src/components/organisms/DelegateTodRepStepOne.tsx @@ -0,0 +1,299 @@ +import { useEffect, useState, useCallback, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { Box, Grid } from "@mui/material"; + +import { ActionRadio, Button, Typography } from "@atoms"; +import { ICONS, PATHS } from "@consts"; +import { useCardano, useModal } from "@context"; +import { + useGetAdaHolderCurrentDelegationQuery, + useGetAdaHolderVotingPowerQuery, + useScreenDimension, +} from "@hooks"; +import { theme } from "@/theme"; +import { tooltips } from "@/consts/texts"; +import { correctAdaFormat } from "@utils"; + +interface DelegateProps { + setStep: (newStep: number) => void; +} + +export const DelegateTodRepStepOne = ({ setStep }: DelegateProps) => { + const navigate = useNavigate(); + const { + dRep, + dRepID, + buildSignSubmitConwayCertTx, + buildVoteDelegationCert, + stakeKey, + } = useCardano(); + const { currentDelegation } = useGetAdaHolderCurrentDelegationQuery(stakeKey); + const { openModal, closeModal } = useModal(); + const [areOptions, setAreOptions] = useState(false); + const [chosenOption, setChosenOption] = useState(""); + const { + palette: { boxShadow2 }, + } = theme; + const { isMobile } = useScreenDimension(); + + const { votingPower } = useGetAdaHolderVotingPowerQuery(stakeKey); + const correctAdaRepresentation = correctAdaFormat(votingPower); + + const openSuccessDelegationModal = useCallback(() => { + openModal({ + type: "statusModal", + state: { + status: "success", + title: "Delegation Transaction Submitted!", + message: + "The confirmation of your actual delegation might take a bit of time but you can track it using.", + link: "https://adanordic.com/latest_transactions", + buttonText: "Go to dashboard", + onSubmit: () => { + navigate(PATHS.dashboard); + closeModal(); + }, + dataTestId: "delegation-transaction-submitted-modal", + }, + }); + }, []); + + const openErrorDelegationModal = useCallback((errorMessage: string) => { + openModal({ + type: "statusModal", + state: { + status: "warning", + title: "Oops!", + message: errorMessage, + isWarning: true, + buttonText: "Go to dashboard", + onSubmit: () => { + navigate(PATHS.dashboard); + closeModal(); + }, + dataTestId: "delegation-transaction-error-modal", + }, + }); + }, []); + + useEffect(() => { + if ( + !areOptions && + (chosenOption === "no confidence" || chosenOption === "abstain") + ) { + setChosenOption(""); + } + }, [chosenOption, areOptions]); + + const delegate = useCallback(async () => { + try { + const certBuilder = await buildVoteDelegationCert(chosenOption); + const result = await buildSignSubmitConwayCertTx({ + certBuilder, + type: "delegation", + }); + if (result) openSuccessDelegationModal(); + } catch (error: any) { + const errorMessage = error.info ? error.info : error; + + openErrorDelegationModal(errorMessage); + } + }, [chosenOption, buildSignSubmitConwayCertTx, buildVoteDelegationCert]); + + const renderDelegateButton = useMemo(() => { + return ( + + ); + }, [chosenOption, dRep?.isRegistered, isMobile, delegate, dRepID]); + + const renderCancelButton = useMemo(() => { + return ( + + ); + }, [isMobile]); + + return ( + + + {!isMobile && ( + + + + + Voting power to delegate: + + + {`₳ ${correctAdaRepresentation}`} + + + + + )} + + Use your Voting Power + + + You can delegate your voting power to a DRep or to a pre-defined + voting option. + + + {dRep?.isRegistered && currentDelegation !== dRepID && ( + + + + )} + + + + setAreOptions((prev) => !prev)} + textAlign="center" + sx={[ + { + "&:hover": { cursor: "pointer" }, + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + ]} + > + Other options + arrow + + {areOptions ? ( + <> + + + + + + + + ) : null} + + + + {isMobile ? renderDelegateButton : renderCancelButton} + + {isMobile ? renderCancelButton : renderDelegateButton} + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/DelegateTodRepStepTwo.tsx b/src/vva-fe/src/components/organisms/DelegateTodRepStepTwo.tsx new file mode 100644 index 000000000..6ac689264 --- /dev/null +++ b/src/vva-fe/src/components/organisms/DelegateTodRepStepTwo.tsx @@ -0,0 +1,119 @@ +import { useMemo } from "react"; +import { Box, Link } from "@mui/material"; + +import { Button, Input, Typography } from "../atoms"; +import { useScreenDimension, useDelegateTodRepForm } from "@hooks"; +import { theme } from "@/theme"; +import { openInNewTab } from "@utils"; + +interface DelegateProps { + setStep: (newStep: number) => void; +} + +export const DelegateTodRepStepTwo = ({ setStep }: DelegateProps) => { + const { isMobile } = useScreenDimension(); + + const { + palette: { boxShadow2 }, + } = theme; + + const { control, isDelegateButtonDisabled, delegate } = + useDelegateTodRepForm(); + + const renderDelegateButton = useMemo(() => { + return ( + + ); + }, [isDelegateButtonDisabled, delegate, isMobile]); + + const renderBackButton = useMemo(() => { + return ( + + ); + }, [isMobile]); + + return ( + + + + Paste DRep ID + + + The DRep ID is the identifier of a DRep. + + + + + + openInNewTab( + "https://docs.sanchogov.tools/faqs/where-can-i-find-a-drep-id" + ) + } + alignSelf={"center"} + mt={4} + sx={[{ "&:hover": { cursor: "pointer" } }]} + > + + Where can I find a DRep ID? + + + + + {isMobile ? renderDelegateButton : renderBackButton} + + {isMobile ? renderBackButton : renderDelegateButton} + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/Drawer.tsx b/src/vva-fe/src/components/organisms/Drawer.tsx new file mode 100644 index 000000000..ee8ec677f --- /dev/null +++ b/src/vva-fe/src/components/organisms/Drawer.tsx @@ -0,0 +1,86 @@ +import { Box, Grid } from "@mui/material"; +import { NavLink } from "react-router-dom"; + +import { DrawerLink, Typography } from "@atoms"; +import { CONNECTED_NAV_ITEMS, ICONS, IMAGES, PATHS } from "@consts"; +import { useCardano } from "@context"; +import { WalletInfoCard, DRepInfoCard } from "@molecules"; +import { openInNewTab } from "@/utils"; + +export const Drawer = () => { + const { dRep } = useCardano(); + + return ( + + + + + + + {CONNECTED_NAV_ITEMS.map((navItem) => ( + + openInNewTab(navItem.newTabLink) + : undefined + } + /> + + ))} + + + {dRep?.isRegistered && } + + + + + openInNewTab( + "https://docs.sanchogov.tools/support/get-help-in-discord" + ) + } + /> + + + © 2023 Voltaire Gov Tool + + + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/DrawerMobile.tsx b/src/vva-fe/src/components/organisms/DrawerMobile.tsx new file mode 100644 index 000000000..f43174244 --- /dev/null +++ b/src/vva-fe/src/components/organisms/DrawerMobile.tsx @@ -0,0 +1,86 @@ +import { Dispatch, SetStateAction } from "react"; +import { Box, Grid, IconButton, SwipeableDrawer } from "@mui/material"; + +import { Background, Button, Link } from "../atoms"; +import { ICONS, IMAGES, NAV_ITEMS } from "@consts"; +import { useScreenDimension } from "@hooks"; +import { useModal } from "@context"; +import { openInNewTab } from "@utils"; + +type DrawerMobileProps = { + isConnectButton: boolean; + isDrawerOpen: boolean; + setIsDrawerOpen: Dispatch>; +}; + +const DRAWER_PADDING = 2; +const CALCULATED_DRAWER_PADDING = DRAWER_PADDING * 8 * 2; + +export const DrawerMobile = ({ + isConnectButton, + isDrawerOpen, + setIsDrawerOpen, +}: DrawerMobileProps) => { + const { screenWidth } = useScreenDimension(); + const { openModal } = useModal(); + + return ( + setIsDrawerOpen(false)} + onOpen={() => setIsDrawerOpen(true)} + open={isDrawerOpen} + > + + + + + setIsDrawerOpen(false)} + > + + + + {isConnectButton ? ( + + ) : null} + + {NAV_ITEMS.map((navItem) => ( + + { + if (navItem.newTabLink) openInNewTab(navItem.newTabLink); + setIsDrawerOpen(false); + }} + size="big" + /> + + ))} + + + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/ExternalLinkModal.tsx b/src/vva-fe/src/components/organisms/ExternalLinkModal.tsx new file mode 100644 index 000000000..b0cf54d94 --- /dev/null +++ b/src/vva-fe/src/components/organisms/ExternalLinkModal.tsx @@ -0,0 +1,103 @@ +import { Box, Button, Typography } from "@mui/material"; + +import { ModalContents, ModalHeader, ModalWrapper } from "@atoms"; +import { IMAGES } from "@consts"; +import { useModal } from "@context"; +import { useScreenDimension } from "@hooks"; +import { theme } from "@/theme"; +import { openInNewTab } from "@utils"; + +export interface ExternalLinkModalState { + externalLink: string; +} + +export function ExternalLinkModal() { + const { state, closeModal } = useModal(); + const { isMobile } = useScreenDimension(); + const { + palette: { primaryBlue, fadedPurple }, + } = theme; + + return ( + + Status icon + + {isMobile ? "External Link Safety" : "Be Careful!"} + + + + {isMobile + ? "This is an external link:" + : "You are about to open an external link to:"} + + + {state?.externalLink} + + + Exercise caution and verify the website's authenticity before sharing + personal information. To proceed, click 'Continue'. To stay on + Voltaire, click 'Cancel'. + + + + + + + + ); +} diff --git a/src/vva-fe/src/components/organisms/Footer.tsx b/src/vva-fe/src/components/organisms/Footer.tsx new file mode 100644 index 000000000..c5ce46466 --- /dev/null +++ b/src/vva-fe/src/components/organisms/Footer.tsx @@ -0,0 +1,43 @@ +import { Box, Link } from "@mui/material"; + +import { Typography } from "@atoms"; +import { useScreenDimension } from "@hooks"; +import { openInNewTab } from "@utils"; + +export const Footer = () => { + const { isMobile, pagePadding } = useScreenDimension(); + + return ( + + + + © 2023 Voltaire Gov Tool + + + + + openInNewTab("https://docs.sanchogov.tools/legal/privacy-policy") + } + sx={[{ textDecoration: "none" }]} + mr={6} + > + + Privacy policy + + + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/GovernanceActionDetailsCard.tsx b/src/vva-fe/src/components/organisms/GovernanceActionDetailsCard.tsx new file mode 100644 index 000000000..24c594043 --- /dev/null +++ b/src/vva-fe/src/components/organisms/GovernanceActionDetailsCard.tsx @@ -0,0 +1,243 @@ +import { useScreenDimension } from "@hooks"; +import { Box } from "@mui/material"; +import { Button, Typography } from "../atoms"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { GovActionDetails, VoteActionForm, VotesSubmitted } from "../molecules"; +import { useModal } from "@context"; +import { ICONS } from "@consts"; +import { Tooltip } from "@atoms"; +import { tooltips } from "@/consts/texts"; + +type GovernanceActionDetailsCardProps = { + abstainVotes: number; + createdDate: string; + details: any; + expiryDate: string; + noVotes: number; + type: string; + url: string; + yesVotes: number; + shortenedGovActionId: string; + isDRep?: boolean; + voteFromEP?: string; +}; + +export const GovernanceActionDetailsCard = ({ + abstainVotes, + createdDate, + details, + expiryDate, + noVotes, + type, + url, + yesVotes, + isDRep, + voteFromEP, + shortenedGovActionId, +}: GovernanceActionDetailsCardProps) => { + const { screenWidth } = useScreenDimension(); + const { openModal } = useModal(); + + return ( + + + + + + Submission date: + + + {createdDate} + + + + + + + + Expiry date: + + + {expiryDate} + + + + + + + + + + Governance Action Type: + + + + {type} + + + + + + Governance Action ID: + + + + + {shortenedGovActionId} + + + + + + + Governance Details: + + {typeof details === "object" && details !== null ? ( + Object.entries(details).map(([key, value]) => { + return ( +
+ {} +
+ ); + }) + ) : ( + + {details} + + )} +
+
+ +
+ + {isDRep ? ( + + ) : ( + + )} + +
+ ); +}; diff --git a/src/vva-fe/src/components/organisms/GovernanceActionsToVote.tsx b/src/vva-fe/src/components/organisms/GovernanceActionsToVote.tsx new file mode 100644 index 000000000..4792b1d3f --- /dev/null +++ b/src/vva-fe/src/components/organisms/GovernanceActionsToVote.tsx @@ -0,0 +1,188 @@ +import { useMemo } from "react"; +import { useNavigate, generatePath } from "react-router-dom"; +import { Box, CircularProgress } from "@mui/material"; + +import { Slider } from "./Slider"; + +import { Typography } from "@atoms"; +import { + useGetDRepVotesQuery, + useGetProposalsQuery, + useScreenDimension, +} from "@hooks"; +import { GovernanceActionCard } from "@molecules"; +import { GOVERNANCE_ACTIONS_FILTERS, PATHS } from "@consts"; +import { useCardano } from "@context"; +import { getProposalTypeLabel, getFullGovActionId, openInNewTab } from "@utils"; + +type GovernanceActionsToVoteProps = { + filters: string[]; + onDashboard?: boolean; + searchPhrase?: string; + sorting: string; +}; + +const defaultCategories = GOVERNANCE_ACTIONS_FILTERS.map( + (category) => category.key +); + +export const GovernanceActionsToVote = ({ + filters, + onDashboard = true, + searchPhrase = "", + sorting, +}: GovernanceActionsToVoteProps) => { + const { dRep, voteTransaction } = useCardano(); + const { data: dRepVotes, dRepVotesAreLoading } = useGetDRepVotesQuery([], ""); + const navigate = useNavigate(); + const { isMobile } = useScreenDimension(); + + const queryFilters = filters.length > 0 ? filters : defaultCategories; + + const { proposals, isLoading } = useGetProposalsQuery(queryFilters, sorting); + + const groupedByType = (data?: ActionType[]) => { + return data?.reduce((groups, item) => { + const itemType = item.type; + + if (!groups[itemType]) { + groups[itemType] = { + title: itemType, + actions: [], + }; + } + + groups[itemType].actions.push(item); + + return groups; + }, {}); + }; + + const mappedData = useMemo(() => { + if (onDashboard && dRep?.isRegistered && dRepVotes) { + const filteredBySearchPhrase = proposals?.filter((i) => + getFullGovActionId(i.txHash, i.index) + .toLowerCase() + .includes(searchPhrase.toLowerCase()) + ); + const filteredData = filteredBySearchPhrase?.filter((i) => { + return !dRepVotes + .flatMap((item) => item.actions.map((item) => item.proposal.id)) + .includes(i.id); + }); + const groupedData = groupedByType(filteredData); + return Object.values(groupedData ?? []) as ToVoteDataType; + } + const groupedData = groupedByType( + proposals?.filter((i) => + getFullGovActionId(i.txHash, i.index) + .toLowerCase() + .includes(searchPhrase.toLowerCase()) + ) + ); + return Object.values(groupedData ?? []) as ToVoteDataType; + }, [ + proposals, + onDashboard, + dRep?.isRegistered, + dRepVotes, + searchPhrase, + voteTransaction.proposalId, + ]); + + return !isLoading && !dRepVotesAreLoading ? ( + <> + {!mappedData.length ? ( + + No results for the search + + ) : ( + <> + {mappedData?.map((item, index) => { + return ( + + { + return ( +
+ + onDashboard && + voteTransaction?.proposalId === + item?.txHash + item?.index + ? openInNewTab( + "https://adanordic.com/latest_transactions" + ) + : navigate( + onDashboard + ? generatePath( + PATHS.dashboard_governance_actions_action, + { + proposalId: getFullGovActionId( + item.txHash, + item.index + ), + } + ) + : PATHS.governance_actions_action.replace( + ":proposalId", + getFullGovActionId( + item.txHash, + item.index + ) + ), + { + state: { ...item }, + } + ) + } + /> +
+ ); + })} + dataLength={item.actions.slice(0, 6).length} + notSlicedDataLength={item.actions.length} + filters={filters} + onDashboard={onDashboard} + searchPhrase={searchPhrase} + sorting={sorting} + title={getProposalTypeLabel(item.title)} + navigateKey={item.title} + /> + {index < mappedData.length - 1 && ( + + )} + + ); + })} + + )} + + ) : ( + + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/Hero.tsx b/src/vva-fe/src/components/organisms/Hero.tsx new file mode 100644 index 000000000..a1a0f1bfe --- /dev/null +++ b/src/vva-fe/src/components/organisms/Hero.tsx @@ -0,0 +1,83 @@ +import { Box } from "@mui/material"; +import { useNavigate } from "react-router-dom"; + +import { Button, Typography } from "@atoms"; +import { IMAGES, PATHS } from "@consts"; +import { useCardano, useModal } from "@context"; +import { useScreenDimension } from "@hooks"; + +export const Hero = () => { + const { isEnabled } = useCardano(); + const { openModal } = useModal(); + const navigate = useNavigate(); + const { isMobile, screenWidth, pagePadding } = useScreenDimension(); + const IMAGE_SIZE = + screenWidth < 768 + ? 140 + : screenWidth < 1024 + ? 400 + : screenWidth < 1440 + ? 500 + : screenWidth < 1920 + ? 600 + : 720; + + return ( + + + + SanchoNet +
+ Governance Tool +
+ + Interact with SanchoNet using GovTool - a friendly user{" "} + {!isMobile &&
} + interface connected to SanchoNet. You can delegate{" "} + {!isMobile &&
} + your voting power (tAda) or become a SanchoNet DRep{" "} + {!isMobile &&
} + to allow people to delegate voting power to you. +
+ +
+ + + +
+ ); +}; diff --git a/src/vva-fe/src/components/organisms/HomeCards.tsx b/src/vva-fe/src/components/organisms/HomeCards.tsx new file mode 100644 index 000000000..3989558f8 --- /dev/null +++ b/src/vva-fe/src/components/organisms/HomeCards.tsx @@ -0,0 +1,94 @@ +import { useNavigate } from "react-router-dom"; +import { Box } from "@mui/material"; + +import { IMAGES, PATHS } from "@consts"; +import { useModal } from "@context"; +import { ActionCard } from "@molecules"; +import { useScreenDimension } from "@hooks"; +import { openInNewTab } from "@utils"; + +export const HomeCards = () => { + const navigate = useNavigate(); + const { openModal } = useModal(); + const { isMobile, screenWidth } = useScreenDimension(); + + return ( + = 1920 ? "1fr 1fr 1fr" : "1fr"} + mb={6} + mt={screenWidth < 1024 ? 6 : 14} + px={ + screenWidth < 768 + ? 2 + : screenWidth < 1024 + ? 12 + : screenWidth < 1440 + ? 24 + : 34 + } + rowGap={6} + > + + openModal({ type: "chooseWallet" })} + firstButtonLabel="Connect to delegate" + imageHeight={80} + imageURL={IMAGES.govActionDelegateImage} + imageWidth={115} + secondButtonAction={() => + openInNewTab( + "https://docs.sanchogov.tools/faqs/ways-to-use-your-voting-power" + ) + } + secondButtonLabel="Learn more" + title="Use your Voting Power" + /> + + + openModal({ type: "chooseWallet" })} + firstButtonLabel="Connect to register" + imageHeight={80} + imageURL={IMAGES.govActionRegisterImage} + imageWidth={70} + secondButtonAction={() => + openInNewTab( + "https://docs.sanchogov.tools/faqs/what-does-it-mean-to-register-as-a-drep" + ) + } + secondButtonLabel="Learn more" + title="Register as a DRep" + /> + + + navigate(PATHS.governance_actions)} + firstButtonLabel="View governance actions" + imageHeight={80} + imageURL={IMAGES.govActionListImage} + imageWidth={80} + title="Governance Actions" + /> + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/RegisterAsdRepStepOne.tsx b/src/vva-fe/src/components/organisms/RegisterAsdRepStepOne.tsx new file mode 100644 index 000000000..df466b4db --- /dev/null +++ b/src/vva-fe/src/components/organisms/RegisterAsdRepStepOne.tsx @@ -0,0 +1,135 @@ +import { Dispatch, SetStateAction, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { Box, Link } from "@mui/material"; + +import { Button, Input, Typography } from "@atoms"; +import { PATHS } from "@consts"; +import { useScreenDimension, useRegisterAsdRepFormContext } from "@hooks"; +import { theme } from "@/theme"; +import { openInNewTab } from "@utils"; + +interface Props { + setStep: Dispatch>; +} + +export const RegisterAsdRepStepOne = ({ setStep }: Props) => { + const navigate = useNavigate(); + const { + palette: { boxShadow2 }, + } = theme; + const { isMobile, pagePadding, screenWidth } = useScreenDimension(); + const { control, errors, isValid, showSubmitButton } = + useRegisterAsdRepFormContext(); + + const renderCancelButton = useMemo(() => { + return ( + + ); + }, [isMobile]); + + const renderConfirmButton = useMemo(() => { + return ( + + ); + }, [isMobile, isValid, showSubmitButton]); + + return ( + + + + OPTIONAL + + + Add Information + + + You can include extra information about yourself by adding a URL and + its hash. + + + + + openInNewTab( + "https://docs.sanchogov.tools/faqs/how-to-create-a-metadata-anchor" + ) + } + alignSelf={"center"} + mt={5} + sx={{ cursor: "pointer" }} + > + + How to create URL and hash? + + + + + {isMobile ? renderConfirmButton : renderCancelButton} + + {isMobile ? renderCancelButton : renderConfirmButton} + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/RegisterAsdRepStepTwo.tsx b/src/vva-fe/src/components/organisms/RegisterAsdRepStepTwo.tsx new file mode 100644 index 000000000..60ddbdd85 --- /dev/null +++ b/src/vva-fe/src/components/organisms/RegisterAsdRepStepTwo.tsx @@ -0,0 +1,95 @@ +import { Dispatch, SetStateAction, useMemo } from "react"; +import { Box } from "@mui/material"; + +import { LoadingButton, Button, Typography } from "@atoms"; +import { theme } from "@/theme"; +import { useRegisterAsdRepFormContext, useScreenDimension } from "@hooks"; + +interface Props { + setStep: Dispatch>; +} + +export const RegisterAsdRepStepTwo = ({ setStep }: Props) => { + const { + palette: { boxShadow2 }, + } = theme; + const { isLoading, submitForm } = useRegisterAsdRepFormContext(); + const { isMobile, pagePadding, screenWidth } = useScreenDimension(); + + const renderBackButton = useMemo(() => { + return ( + + ); + }, [isMobile]); + + const renderRegisterButton = useMemo(() => { + return ( + + Register + + ); + }, [isLoading, isMobile, submitForm]); + + return ( + + + + Confirm DRep registration + + + By clicking register you create your DRep ID within your wallet and + become a DRep.
+
+ Once the registration has completed your DRep ID will be shown on your + dashboard. You will be able to share your DRep ID so that other ada + holders can delegate their voting power to you. +
+
+ + {isMobile ? renderRegisterButton : renderBackButton} + + {isMobile ? renderBackButton : renderRegisterButton} + + + ); +}; diff --git a/src/vva-fe/src/components/organisms/Slider.tsx b/src/vva-fe/src/components/organisms/Slider.tsx new file mode 100644 index 000000000..39eb899f9 --- /dev/null +++ b/src/vva-fe/src/components/organisms/Slider.tsx @@ -0,0 +1,198 @@ +import { useEffect, useMemo } from "react"; +import { Box, Link, Typography } from "@mui/material"; +import { KeenSliderOptions } from "keen-slider"; +import "keen-slider/keen-slider.min.css"; + +import styles from "./slider.module.css"; + +import { ICONS, PATHS } from "@consts"; +import { useCardano } from "@context"; +import { useScreenDimension, useSlider } from "@hooks"; +import { generatePath, useNavigate } from "react-router-dom"; + +const SLIDER_MAX_LENGTH = 1000; + +type SliderProps = { + title: string; + navigateKey: string; + data: React.ReactNode; + isShowAll?: boolean; + dataLength?: number; + notSlicedDataLength?: number; + onDashboard?: boolean; + searchPhrase?: string; + sorting?: string; + filters?: string[]; +}; + +export const Slider = ({ + data, + title, + navigateKey, + isShowAll = true, + dataLength = 0, + notSlicedDataLength = 0, + onDashboard = false, + filters, + searchPhrase, + sorting, +}: SliderProps) => { + const { isMobile, screenWidth, pagePadding } = useScreenDimension(); + const navigate = useNavigate(); + const { voteTransaction } = useCardano(); + + const DEFAULT_SLIDER_CONFIG = { + mode: "free", + initial: 0, + slides: { + perView: "auto", + spacing: 24, + }, + } as KeenSliderOptions; + + const { + currentRange, + sliderRef, + setPercentageValue, + instanceRef, + setCurrentRange, + } = useSlider({ + config: DEFAULT_SLIDER_CONFIG, + sliderMaxLength: SLIDER_MAX_LENGTH, + }); + + const paddingOffset = useMemo(() => { + const padding = onDashboard ? (isMobile ? 2 : 3.5) : pagePadding; + return padding * 8 * 2; + }, [isMobile, pagePadding]); + + const refresh = () => { + instanceRef.current?.update(instanceRef.current?.options); + setCurrentRange(0); + instanceRef.current?.track.to(0); + instanceRef.current?.moveToIdx(0); + }; + + useEffect(() => { + refresh(); + }, [filters, sorting, searchPhrase, voteTransaction?.proposalId, data]); + + const rangeSliderCalculationElement = + dataLength < notSlicedDataLength + ? (screenWidth + + (onDashboard ? -290 - paddingOffset : -paddingOffset + 250)) / + 437 + : (screenWidth + (onDashboard ? -280 - paddingOffset : -paddingOffset)) / + 402; + + return ( + + + + {title} + + {isMobile && isShowAll && ( + + onDashboard + ? navigate( + generatePath(PATHS.dashboard_governance_actions_category, { + category: navigateKey, + }) + ) + : navigate( + generatePath(PATHS.governance_actions_category, { + category: navigateKey, + }) + ) + } + > + + Show all + + + )} + +
+ {data} + {!isMobile && isShowAll && dataLength < notSlicedDataLength && ( +
+ + onDashboard + ? navigate( + generatePath( + PATHS.dashboard_governance_actions_category, + { + category: navigateKey, + } + ) + ) + : navigate( + generatePath(PATHS.governance_actions_category, { + category: navigateKey, + }) + ) + } + > + + View all + + arrow + +
+ )} +
+ {!isMobile && Math.floor(rangeSliderCalculationElement) < dataLength && ( + + + + )} +
+ ); +}; diff --git a/src/vva-fe/src/components/organisms/StatusModal.tsx b/src/vva-fe/src/components/organisms/StatusModal.tsx new file mode 100644 index 000000000..0d36546f7 --- /dev/null +++ b/src/vva-fe/src/components/organisms/StatusModal.tsx @@ -0,0 +1,73 @@ +import { Button, Link, Typography } from "@mui/material"; + +import { ModalContents, ModalHeader, ModalWrapper } from "@atoms"; +import { ICONS, IMAGES } from "@consts"; +import { useModal } from "@context"; +import { openInNewTab } from "@/utils"; +import { useScreenDimension } from "@/hooks"; + +export interface StatusModalState { + buttonText?: string; + status: "warning" | "info" | "success"; + isInfo?: boolean; + link?: string; + message: React.ReactNode; + onSubmit?: () => void; + title: string; + dataTestId: string; +} + +export function StatusModal() { + const { state, closeModal } = useModal(); + const { isMobile } = useScreenDimension(); + + return ( + + Status icon + + {state?.title} + + + + {state?.message}{" "} + {state?.link && ( + openInNewTab(state?.link || "")} + target="_blank" + sx={[{ "&:hover": { cursor: "pointer" } }]} + > + this link + + )} + + + + + ); +} diff --git a/src/vva-fe/src/components/organisms/TopNav.tsx b/src/vva-fe/src/components/organisms/TopNav.tsx new file mode 100644 index 000000000..c7c42f2e2 --- /dev/null +++ b/src/vva-fe/src/components/organisms/TopNav.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { NavLink, useNavigate } from "react-router-dom"; +import { AppBar, Box, Grid, IconButton } from "@mui/material"; + +import { Button, Link, Modal } from "@atoms"; +import { ICONS, IMAGES, PATHS, NAV_ITEMS } from "@consts"; +import { useCardano, useModal } from "@context"; +import { useScreenDimension } from "@hooks"; +import { DrawerMobile } from "./DrawerMobile"; +import { openInNewTab } from "@utils"; + +const POSITION_TO_BLUR = 50; + +export const TopNav = ({ isConnectButton = true }) => { + const [windowScroll, setWindowScroll] = useState(0); + const { openModal, closeModal, modal } = useModal(); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const { screenWidth, isMobile } = useScreenDimension(); + const { isEnabled, disconnectWallet, stakeKey } = useCardano(); + const navigate = useNavigate(); + + useEffect(() => { + const onScroll = () => { + setWindowScroll(window.scrollY); + }; + + window.addEventListener("scroll", onScroll, { + passive: true, + }); + + return () => window.removeEventListener("scroll", onScroll); + }, []); + + return ( + + POSITION_TO_BLUR ? "blur(10px)" : "none", + backgroundColor: + windowScroll > POSITION_TO_BLUR + ? "rgba(256, 256, 256, 0.7)" + : isMobile + ? "white" + : "transparent", + borderBottom: isMobile ? 1 : 0, + borderColor: "lightblue", + boxShadow: 0, + flex: 1, + flexDirection: "row", + justifyContent: "space-between", + position: "fixed", + px: screenWidth >= 1920 ? 37 : isMobile ? 2 : 5, + py: 3, + }} + > + (isConnectButton ? {} : disconnectWallet())} + to={PATHS.home} + > + + + {!isMobile ? ( + + ) : ( + <> + + {isConnectButton ? ( + + ) : null} + setIsDrawerOpen(true)} + sx={{ padding: 0 }} + > + + + + + + )} + + {modal?.component && ( + + {modal.component} + + )} + + ); +}; diff --git a/src/vva-fe/src/components/organisms/VotingPowerModal.tsx b/src/vva-fe/src/components/organisms/VotingPowerModal.tsx new file mode 100644 index 000000000..2fc2588ea --- /dev/null +++ b/src/vva-fe/src/components/organisms/VotingPowerModal.tsx @@ -0,0 +1,95 @@ +import { Box } from "@mui/material"; + +import { ModalContents, ModalWrapper, Typography, VotePill } from "@atoms"; +import { useModal } from "@context"; +import { correctAdaFormat } from "@utils"; +import { Vote } from "@/models"; +import { useScreenDimension } from "@/hooks"; + +export interface VotingPowerModalState { + yesVotes: number; + noVotes: number; + abstainVotes: number; + vote?: string; +} + +export function VotingPowerModal() { + const { state } = useModal(); + const VOTES = [ + { title: "yes", vote: state?.yesVotes }, + { title: "abstain", vote: state?.abstainVotes }, + { title: "no", vote: state?.noVotes }, + ]; + const { isMobile } = useScreenDimension(); + + return ( + + + + + Governance Action votes + + + Votes submitted by DReps + + {VOTES.map((vote, index) => ( + + {state?.vote?.toLocaleLowerCase() === vote.title ? ( + + Your vote + + ) : null} + + + ₳ {correctAdaFormat(vote.vote)} + + + ))} + + + + ); +} diff --git a/src/vva-fe/src/components/organisms/index.ts b/src/vva-fe/src/components/organisms/index.ts new file mode 100644 index 000000000..20051f5cf --- /dev/null +++ b/src/vva-fe/src/components/organisms/index.ts @@ -0,0 +1,24 @@ +export * from "./ChooseStakeKeyPanel"; +export * from "./ChooseWalletModal"; +export * from "./DashboardCards"; +export * from "./DashboardCards"; +export * from "./DashboardGovernanceActionDetails"; +export * from "./DashboardGovernanceActions"; +export * from "./DashboardGovernanceActionsVotedOn"; +export * from "./DashboardTopNav"; +export * from "./DelegateTodRepStepOne"; +export * from "./DelegateTodRepStepTwo"; +export * from "./Drawer"; +export * from "./DrawerMobile"; +export * from "./ExternalLinkModal"; +export * from "./Footer"; +export * from "./GovernanceActionDetailsCard"; +export * from "./GovernanceActionsToVote"; +export * from "./Hero"; +export * from "./HomeCards"; +export * from "./RegisterAsdRepStepOne"; +export * from "./RegisterAsdRepStepTwo"; +export * from "./Slider"; +export * from "./StatusModal"; +export * from "./TopNav"; +export * from "./VotingPowerModal"; diff --git a/src/vva-fe/src/components/organisms/slider.module.css b/src/vva-fe/src/components/organisms/slider.module.css new file mode 100644 index 000000000..34174c197 --- /dev/null +++ b/src/vva-fe/src/components/organisms/slider.module.css @@ -0,0 +1,32 @@ +.rangeSlider { + background-color: rgba(214, 226, 255, 0.2); + height: 10px; + position: relative; + width: 100%; + border-radius: 20px; + + -webkit-appearance: none; + appearance: none; + outline: none; + &:hover { + cursor: pointer; + } + &::-webkit-slider-thumb { + background-color: rgba(102, 133, 206, 1); + height: 10px; + width: 10%; + border-radius: 20px; + + -webkit-appearance: none; + appearance: none; + cursor: grab; + } + &::-moz-range-thumb { + background-color: rgba(102, 133, 206, 1); + height: 10px; + width: 10%; + border-radius: 20px; + + cursor: grab; + } +} diff --git a/src/vva-fe/src/consts/governanceActionsFilters.ts b/src/vva-fe/src/consts/governanceActionsFilters.ts new file mode 100644 index 000000000..26b18c02b --- /dev/null +++ b/src/vva-fe/src/consts/governanceActionsFilters.ts @@ -0,0 +1,30 @@ +export const GOVERNANCE_ACTIONS_FILTERS = [ + { + key: "NoConfidence", + label: "No Confidence", + }, + { + key: "NewCommittee", + label: "New Constitutional Committee or Quorum Size", + }, + { + key: "NewConstitution", + label: "Update to the Constitution", + }, + { + key: "HardForkInitiation", + label: "Hard Fork", + }, + { + key: "ParameterChange", + label: "Protocol Parameter Changes", + }, + { + key: "TreasuryWithdrawals", + label: "Treasury Withdrawals", + }, + { + key: "InfoAction", + label: "Info Action", + }, +]; diff --git a/src/vva-fe/src/consts/governanceActionsSorting.ts b/src/vva-fe/src/consts/governanceActionsSorting.ts new file mode 100644 index 000000000..d2095fc43 --- /dev/null +++ b/src/vva-fe/src/consts/governanceActionsSorting.ts @@ -0,0 +1,14 @@ +export const GOVERNANCE_ACTIONS_SORTING = [ + { + key: "SoonestToExpire", + label: "Soon to expire", + }, + { + key: "NewestCreated", + label: "Newest first", + }, + { + key: "MostYesVotes", + label: "Highest amount of 'Yes' votes", + }, +]; diff --git a/src/vva-fe/src/consts/icons.ts b/src/vva-fe/src/consts/icons.ts new file mode 100644 index 000000000..c6facbf60 --- /dev/null +++ b/src/vva-fe/src/consts/icons.ts @@ -0,0 +1,30 @@ +export const ICONS = { + appLogoIcon: "/icons/AppLogo.svg", + arrowDownIcon: "/icons/ArrowDown.svg", + arrowRightIcon: "/icons/ArrowRight.svg", + checkCircleIcon: "/icons/CheckCircle.svg", + closeDrawerIcon: "/icons/CloseIcon.svg", + closeIcon: "/icons/Close.svg", + closeWhiteIcon: "/icons/CloseWhite.svg", + copyBlueIcon: "/icons/CopyBlue.svg", + copyIcon: "/icons/Copy.svg", + copyWhiteIcon: "/icons/CopyWhite.svg", + dashboardActiveIcon: "/icons/DashboardActive.svg", + dashboardIcon: "/icons/Dashboard.svg", + drawerIcon: "/icons/DrawerIcon.svg", + externalLinkIcon: "/icons/ExternalLink.svg", + faqsActiveIcon: "/icons/FaqsActive.svg", + faqsIcon: "/icons/Faqs.svg", + filterIcon: "/icons/Filter.svg", + filterWhiteIcon: "/icons/FilterWhite.svg", + governanceActionsActiveIcon: "/icons/GovernanceActionsActive.svg", + governanceActionsIcon: "/icons/GovernanceActions.svg", + guidesActiveIcon: "/icons/GuidesActive.svg", + guidesIcon: "/icons/Guides.svg", + helpIcon: "/icons/Help.svg", + sortActiveIcon: "/icons/SortActive.svg", + sortIcon: "/icons/Sort.svg", + sortWhiteIcon: "/icons/SortWhite.svg", + timerIcon: "/icons/Timer.svg", + warningIcon: "/icons/Warning.svg", +}; diff --git a/src/vva-fe/src/consts/images.ts b/src/vva-fe/src/consts/images.ts new file mode 100644 index 000000000..f9a098d4f --- /dev/null +++ b/src/vva-fe/src/consts/images.ts @@ -0,0 +1,15 @@ +export const IMAGES = { + appLogo: "/images/SanchoLogo.png", + appLogoWithoutText: "/images/AppLogoWithoutText.png", + errorPageImage: "/images/ErrorPageImage.png", + heroImage: "/images/HeroImage.png", + govActionDelegateImage: "/images/GovActionDelegate.png", + govActionRegisterImage: "/images/GovActionRegister.png", + govActionListImage: "/images/GovActionList.png", + govActionDefaultImage: "/images/GovActionDefault.png", + successImage: "/images/Success.png", + warningImage: "/images/Warning.png", + warningYellowImage: "/images/WarningYellow.png", + bgOrange: "/images/BGOrange.png", + bgBlue: "/images/BGBlue.png", +}; diff --git a/src/vva-fe/src/consts/index.ts b/src/vva-fe/src/consts/index.ts new file mode 100644 index 000000000..b704a0b14 --- /dev/null +++ b/src/vva-fe/src/consts/index.ts @@ -0,0 +1,8 @@ +export * from "./governanceActionsFilters"; +export * from "./governanceActionsSorting"; +export * from "./icons"; +export * from "./images"; +export * from "./navItems"; +export * from "./paths"; +export * from "./queryKeys"; +export * from "./texts"; diff --git a/src/vva-fe/src/consts/navItems.ts b/src/vva-fe/src/consts/navItems.ts new file mode 100644 index 000000000..ddaf508e5 --- /dev/null +++ b/src/vva-fe/src/consts/navItems.ts @@ -0,0 +1,64 @@ +import { ICONS } from "./icons"; +import { PATHS } from "./paths"; + +export const NAV_ITEMS = [ + { + dataTestId: "home-link", + navTo: PATHS.home, + label: "Home", + newTabLink: null, + }, + { + dataTestId: "governance-actions-link", + navTo: PATHS.governance_actions, + label: "Governance Actions", + newTabLink: null, + }, + { + dataTestId: "guides-link", + navTo: "", + label: "Guides", + newTabLink: "https://docs.sanchogov.tools/about/what-is-sanchonet-govtool", + }, + { + dataTestId: "faqs-link", + navTo: "", + label: "FAQs", + newTabLink: "https://docs.sanchogov.tools/faqs", + }, +]; + +export const CONNECTED_NAV_ITEMS = [ + { + dataTestId: "dashboard-link", + label: "Dashboard", + navTo: PATHS.dashboard, + activeIcon: ICONS.dashboardActiveIcon, + icon: ICONS.dashboardIcon, + newTabLink: null, + }, + { + dataTestId: "governance-actions-link", + label: "Governance Actions", + navTo: PATHS.dashboard_governance_actions, + activeIcon: ICONS.governanceActionsActiveIcon, + icon: ICONS.governanceActionsIcon, + newTabLink: null, + }, + { + dataTestId: "guides-link", + label: "Guides", + navTo: "", + activeIcon: ICONS.guidesActiveIcon, + icon: ICONS.guidesIcon, + newTabLink: "https://docs.sanchogov.tools/about/what-is-sanchonet-govtool", + }, + { + dataTestId: "faqs-link", + label: "FAQs", + navTo: "", + activeIcon: ICONS.faqsActiveIcon, + icon: ICONS.faqsIcon, + newTabLink: "https://docs.sanchogov.tools/faqs", + }, +]; diff --git a/src/vva-fe/src/consts/paths.ts b/src/vva-fe/src/consts/paths.ts new file mode 100644 index 000000000..645f4e695 --- /dev/null +++ b/src/vva-fe/src/consts/paths.ts @@ -0,0 +1,21 @@ +export const PATHS = { + home: "/", + dashboard: "/dashboard", + error: "/error", + governance_actions: "/governance_actions", + governance_actions_category: "/governance_actions/category/:category", + governance_actions_category_action: + "/governance_actions/category/:category/:proposalId", + governance_actions_action: "/governance_actions/:proposalId", + dashboard_governance_actions: "/connected/governance_actions", + dashboard_governance_actions_action: + "/connected/governance_actions/:proposalId", + dashboard_governance_actions_category: + "/connected/governance_actions/category/:category", + guides: "/guides", + faqs: "/faqs", + delegateTodRep: "/delegate", + registerAsdRep: "/register", + stakeKeys: "/stake_keys", + updateMetadata: "/update_metadata", +}; diff --git a/src/vva-fe/src/consts/queryKeys.ts b/src/vva-fe/src/consts/queryKeys.ts new file mode 100644 index 000000000..9725de743 --- /dev/null +++ b/src/vva-fe/src/consts/queryKeys.ts @@ -0,0 +1,11 @@ +export const QUERY_KEYS = { + getAdaHolderCurrentDelegationKey: "getAdaHolderCurrentDelegationKey", + getAdaHolderVotingPowerKey: "getAdaHolderVotingPowerKey", + useGetDRepListKey: "useGetDRepListKey", + useGetDRepVotesKey: "useGetDRepVotesKey", + useGetDRepVotingPowerKey: "useGetDRepVotingPowerKey", + useGetProposalKey: "useGetProposalKey", + useGetProposalsKey: "useGetProposalsKey", + useGetProposalsInfiniteKey: "useGetProposalsInfiniteKey", + useGetDRepInfoKey: "useGetDRepInfoKey", +}; diff --git a/src/vva-fe/src/consts/texts.ts b/src/vva-fe/src/consts/texts.ts new file mode 100644 index 000000000..a66fca000 --- /dev/null +++ b/src/vva-fe/src/consts/texts.ts @@ -0,0 +1,42 @@ +export const tooltips = { + submissionDate: { + heading: "Submission Date", + paragraphOne: "The date when the governance action was submitted on-chain.", + }, + expiryDate: { + heading: "Expiry Date", + paragraphOne: + "The date when the governance action will expiry if it doesn’t reach ratification thresholds.", + paragraphTwo: + "IMPORTANT: If the governance action is ratified before the expiry date it will be considered ratified and it will not be available to vote on afterwards.", + }, + votingPower: { + heading: "DRep Voting Power", + paragraphOne: + "This is the voting power delegated to you as a DRep and it is calculated at the end of every epoch for the epoch that just ended.", + paragraphTwo: + "IMPORTANT: When voting, the voting power provides an indication and not the exact number.", + }, + delegateTodRep: { + toMyself: { + heading: "Delegate to myself", + paragraphOne: + "If you are registered as DRep you can delegate your voting power on yourself.", + }, + todRep: { + heading: "Delegation to DRep", + paragraphOne: + "DReps are representatives of the ada holders that can vote on governance actions.", + }, + noConfidence: { + heading: "No confidence", + paragraphOne: + "If you don’t have trust in the current constitutional committee you signal ‘No-confidence’. By voting ‘No’ means you don’t want governance actions to be ratified.", + }, + abstain: { + heading: "Abstaining", + paragraphOne: + "Select this to signal no confidence in the current constitutional committee by voting NO on every proposal and voting YES to no-confidence proposals.", + }, + }, +}; diff --git a/src/vva-fe/src/context/index.tsx b/src/vva-fe/src/context/index.tsx new file mode 100644 index 000000000..e8a118ad3 --- /dev/null +++ b/src/vva-fe/src/context/index.tsx @@ -0,0 +1,19 @@ +import { CardanoProvider, useCardano } from "./wallet"; +import { ModalProvider, useModal } from "./modal"; +import { SnackbarProvider, useSnackbar } from "./snackbar"; + +interface Props { + children: React.ReactNode; +} + +const ContextProviders = ({ children }: Props) => { + return ( + + + {children} + + + ); +}; + +export { ContextProviders, useCardano, useModal, useSnackbar }; diff --git a/src/vva-fe/src/context/modal.tsx b/src/vva-fe/src/context/modal.tsx new file mode 100644 index 000000000..d8146a167 --- /dev/null +++ b/src/vva-fe/src/context/modal.tsx @@ -0,0 +1,116 @@ +import { createContext, useContext, useMemo, useReducer } from "react"; + +import { Modal, type MuiModalChildren } from "@atoms"; +import { + ChooseWalletModal, + ExternalLinkModal, + StatusModal, + VotingPowerModal, +} from "@organisms"; +import { basicReducer, callAll, BasicReducer } from "@utils"; + +interface ProviderProps { + children: React.ReactNode; +} + +interface ContextModal { + component: null | MuiModalChildren; + variant?: "modal" | "popup"; + preventDismiss?: boolean; + onClose?: () => void; +} + +export type ModalType = + | "none" + | "chooseWallet" + | "statusModal" + | "externalLink" + | "votingPower"; + +const modals: Record = { + none: { + component: null, + }, + chooseWallet: { + component: , + }, + statusModal: { + component: , + }, + externalLink: { + component: , + }, + votingPower: { + component: , + }, +}; + +type Optional = Pick, K> & Omit; + +interface ModalState { + type: ModalType; + state: T | null; +} + +interface ModalContext { + modal: ContextModal; + state: T | null; + openModal: (modal: Optional, "state">) => void; + closeModal: () => void; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ModalContext = createContext>({} as ModalContext); +ModalContext.displayName = "ModalContext"; + +function ModalProvider(props: ProviderProps) { + const [modal, openModal] = useReducer>>( + basicReducer, + { + state: null, + type: "none", + } + ); + + const value = useMemo( + () => ({ + modal: modals[modal.type], + state: modal.state, + openModal, + closeModal: callAll(modals[modal.type]?.onClose, () => + openModal({ type: "none", state: null }) + ), + }), + [modal, openModal] + ); + + return ( + + {modals[modal.type]?.component && ( + + openModal({ type: "none", state: null }) + ) + : undefined + } + > + {modals[modal.type]?.component ?? <>} + + )} + {props.children} + + ); +} + +function useModal() { + const context = useContext>(ModalContext); + if (context === undefined) { + throw new Error("useModal must be used within a ModalProvider"); + } + return context; +} + +export { ModalProvider, useModal }; diff --git a/src/vva-fe/src/context/snackbar.tsx b/src/vva-fe/src/context/snackbar.tsx new file mode 100644 index 000000000..7c2d64cc7 --- /dev/null +++ b/src/vva-fe/src/context/snackbar.tsx @@ -0,0 +1,183 @@ +import type { SnackbarOrigin } from "@mui/material/Snackbar"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Snackbar, Alert } from "@mui/material"; + +import { SnackbarMessage } from "@atoms"; +import { SnackbarSeverity } from "@models"; +import { useScreenDimension } from "@hooks"; + +interface ProviderProps { + children: React.ReactNode; +} + +interface SnackbarContext { + addSuccessAlert: (message: string, autoHideDuration?: number) => void; + addErrorAlert: (message: string, autoHideDuration?: number) => void; + addWarningAlert: (message: string, autoHideDuration?: number) => void; + addChangesSavedAlert: () => void; +} + +interface SnackbarMessage { + key: number; + message: string; + severity: SnackbarSeverity; + autoHideDuration: number; +} + +interface State { + open: boolean; + messageInfo?: SnackbarMessage; +} + +const SnackbarContext = createContext({} as SnackbarContext); +SnackbarContext.displayName = "SnackbarContext"; + +const DEFAULT_AUTO_HIDE_DURATION = 2000; +const defaultState: State = { + open: false, + messageInfo: undefined, +}; +const defaultPosition = { + vertical: "top", + horizontal: "center", +} as SnackbarOrigin; + +function SnackbarProvider({ children }: ProviderProps) { + const [snackPack, setSnackPack] = useState([]); + const [{ messageInfo, open }, setState] = useState(defaultState); + const { isMobile } = useScreenDimension(); + + const addWarningAlert = useCallback( + (message: string, autoHideDuration = DEFAULT_AUTO_HIDE_DURATION) => + setSnackPack((prev) => [ + ...prev, + { + message, + autoHideDuration, + severity: "warning", + key: new Date().getTime(), + }, + ]), + [] + ); + + const addSuccessAlert = useCallback( + (message: string, autoHideDuration = DEFAULT_AUTO_HIDE_DURATION) => + setSnackPack((prev) => [ + ...prev, + { + message, + autoHideDuration, + severity: "success", + key: new Date().getTime(), + }, + ]), + [] + ); + + const addErrorAlert = useCallback( + (message: string, autoHideDuration = DEFAULT_AUTO_HIDE_DURATION) => + setSnackPack((prev) => [ + ...prev, + { + message, + autoHideDuration, + severity: "error", + key: new Date().getTime(), + }, + ]), + [] + ); + + const addChangesSavedAlert = useCallback( + () => addSuccessAlert("Your changes have been saved"), + [addSuccessAlert] + ); + + const value = useMemo( + () => ({ + addSuccessAlert, + addErrorAlert, + addChangesSavedAlert, + addWarningAlert, + }), + [addSuccessAlert, addErrorAlert, addChangesSavedAlert, addWarningAlert] + ); + + useEffect(() => { + if (snackPack.length && !messageInfo) { + // Set a new snack when we don't have an active one + setState({ open: true, messageInfo: snackPack[0] }); + setSnackPack((prev) => prev.slice(1)); + } else if (snackPack.length && messageInfo && open) { + // Close an active snack when a new one is added + // setState((prev) => ({ ...prev, open: false })); + } + }, [snackPack, messageInfo, open]); + + const handleClose = ( + _event: React.SyntheticEvent | Event, + reason?: string + ) => { + if (reason === "clickaway") { + return; + } + setState((prev) => ({ ...prev, open: false })); + }; + + const handleExited = () => { + setState((prev) => ({ ...prev, messageInfo: undefined })); + }; + + return ( + + {children} + {messageInfo && ( + + + {messageInfo.message} + + + )} + + ); +} + +function useSnackbar() { + const context = useContext(SnackbarContext); + if (context === undefined) { + throw new Error("useSnackbar must be used within a SnackbarProvider"); + } + return context; +} + +export { SnackbarProvider, useSnackbar }; diff --git a/src/vva-fe/src/context/wallet.tsx b/src/vva-fe/src/context/wallet.tsx new file mode 100644 index 000000000..46529d0af --- /dev/null +++ b/src/vva-fe/src/context/wallet.tsx @@ -0,0 +1,1275 @@ +import { + Dispatch, + SetStateAction, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { + Address, + Anchor, + AnchorDataHash, + BigNum, + Certificate, + CertificatesBuilder, + Credential, + DRep, + DrepDeregistration, + DrepRegistration, + DrepUpdate, + Ed25519KeyHash, + GovernanceActionId, + LinearFee, + PublicKey, + RewardAddress, + Transaction, + TransactionBuilder, + TransactionBuilderConfigBuilder, + TransactionHash, + TransactionOutput, + TransactionUnspentOutput, + TransactionUnspentOutputs, + TransactionWitnessSet, + URL, + Value, + VoteDelegation, + Voter, + VotingBuilder, + VotingProcedure, + StakeRegistration, +} from "@emurgo/cardano-serialization-lib-asmjs"; +import { Buffer } from "buffer"; +import { useNavigate } from "react-router-dom"; +import { Link } from "@mui/material"; +import * as Sentry from "@sentry/react"; + +import { useModal, useSnackbar } from "."; + +import { PATHS } from "@consts"; +import { CardanoApiWallet, DRepInfo, Protocol } from "@models"; +import type { StatusModalState } from "@organisms"; +import { + getPubDRepId, + WALLET_LS_KEY, + DELEGATE_TRANSACTION_KEY, + REGISTER_TRANSACTION_KEY, + DELEGATE_TO_KEY, + PROTOCOL_PARAMS_KEY, + getItemFromLocalStorage, + setItemToLocalStorage, + removeItemFromLocalStorage, + openInNewTab, + SANCHO_INFO_KEY, + VOTE_TRANSACTION_KEY, + checkIsMaintenanceOn, +} from "@utils"; +import { getDRepInfo, getEpochParams, getTransactionStatus } from "@services"; +import { + setLimitedDelegationInterval, + setLimitedRegistrationInterval, +} from "./walletUtils"; + +interface Props { + children: React.ReactNode; +} + +interface EnableResponse { + status: string; + stakeKey?: boolean; + error?: string; +} +const TIME_TO_EXPIRE_TRANSACTION = 3 * 60 * 1000; // 3 MINUTES +const REFRESH_TIME = 15 * 1000; // 15 SECONDS + +type TransactionHistoryItem = { + transactionHash: string; + time?: Date; +}; + +export type DRepActionType = "retirement" | "registration" | "update" | ""; + +interface CardanoContext { + address?: string; + disconnectWallet: () => Promise; + enable: (walletName: string) => Promise; + error?: string; + dRep: DRepInfo | undefined; + isEnabled: boolean; + pubDRepKey: string; + dRepID: string; + dRepIDBech32: string; + isMainnet: boolean; + stakeKey?: string; + setDRep: (key: undefined | DRepInfo) => void; + setStakeKey: (key: string) => void; + stakeKeys: string[]; + walletApi?: CardanoApiWallet; + delegatedDRepId?: string; + setDelegatedDRepId: (key: string) => void; + buildSignSubmitConwayCertTx: ({ + certBuilder, + votingBuilder, + type, + registrationType, + }: { + certBuilder?: CertificatesBuilder; + votingBuilder?: VotingBuilder; + type?: "delegation" | "registration" | "vote"; + proposalId?: string; + registrationType?: DRepActionType; + }) => Promise; + buildDRepRegCert: ( + url?: string, + hash?: string + ) => Promise; + buildVoteDelegationCert: (vote: string) => Promise; + buildDRepUpdateCert: ( + url?: string, + hash?: string + ) => Promise; + buildDRepRetirementCert: () => Promise; + buildVote: ( + voteChoice: string, + txHash: string, + index: number, + cip95MetadataURL?: string, + cip95MetadataHash?: string + ) => Promise; + delegateTransaction: TransactionHistoryItem; + registerTransaction: TransactionHistoryItem & { type: DRepActionType }; + delegateTo: string; + voteTransaction: TransactionHistoryItem & { proposalId: string }; + isPendingTransaction: () => boolean; + isDrepLoading: boolean; + setIsDrepLoading: Dispatch>; +} + +type Utxos = { + txid: any; + txindx: number; + amount: string; + str: string; + multiAssetStr: string; + TransactionUnspentOutput: TransactionUnspentOutput; +}[]; + +const NETWORK = import.meta.env.VITE_NETWORK_FLAG; + +const CardanoContext = createContext({} as CardanoContext); +CardanoContext.displayName = "CardanoContext"; + +function CardanoProvider(props: Props) { + const [isEnabled, setIsEnabled] = useState(false); + const [dRep, setDRep] = useState(undefined); + const [walletApi, setWalletApi] = useState( + undefined + ); + const [address, setAddress] = useState(undefined); + const [pubDRepKey, setPubDRepKey] = useState(""); + const [dRepID, setDRepID] = useState(""); + const [dRepIDBech32, setDRepIDBech32] = useState(""); + const [stakeKey, setStakeKey] = useState(undefined); + const [stakeKeys, setStakeKeys] = useState([]); + const [isMainnet, setIsMainnet] = useState(false); + const { openModal, closeModal } = useModal(); + const [registeredStakeKeysListState, setRegisteredPubStakeKeysState] = + useState([]); + const [error, setError] = useState(undefined); + const [delegatedDRepId, setDelegatedDRepId] = useState( + undefined + ); + const [delegateTo, setDelegateTo] = useState(""); + const [walletState, setWalletState] = useState<{ + changeAddress: undefined | string; + usedAddress: undefined | string; + }>({ + changeAddress: undefined, + usedAddress: undefined, + }); + const [delegateTransaction, setDelegateTransaction] = + useState({ + time: undefined, + transactionHash: "", + }); + const [registerTransaction, setRegisterTransaction] = useState< + TransactionHistoryItem & { type: DRepActionType } + >({ time: undefined, transactionHash: "", type: "" }); + const [voteTransaction, setVoteTransaction] = useState< + { proposalId: string } & TransactionHistoryItem + >({ time: undefined, transactionHash: "", proposalId: "" }); + const [isDrepLoading, setIsDrepLoading] = useState(true); + + const { addSuccessAlert, addWarningAlert, addErrorAlert } = useSnackbar(); + + const isPendingTransaction = useCallback(() => { + if ( + registerTransaction?.transactionHash || + delegateTransaction?.transactionHash || + voteTransaction?.transactionHash + ) { + openModal({ + type: "statusModal", + state: { + status: "info", + title: "Please wait for your previous transaction to be completed.", + message: + "Before performing a new action please wait for the previous action transaction to be completed.", + buttonText: "Ok", + onSubmit: () => { + closeModal(); + }, + dataTestId: "transaction-inprogress-modal", + }, + }); + return true; + } + return false; + }, [ + registerTransaction?.transactionHash, + delegateTransaction?.transactionHash, + voteTransaction?.transactionHash, + ]); + + useEffect(() => { + const delegateTransaction = JSON.parse( + getItemFromLocalStorage(DELEGATE_TRANSACTION_KEY + `_${stakeKey}`) + ); + const registerTransaction = JSON.parse( + getItemFromLocalStorage(REGISTER_TRANSACTION_KEY + `_${stakeKey}`) + ); + const voteTransaction = JSON.parse( + getItemFromLocalStorage(VOTE_TRANSACTION_KEY + `_${stakeKey}`) + ); + const delegateTo = getItemFromLocalStorage( + DELEGATE_TO_KEY + `_${stakeKey}` + ); + if (delegateTransaction?.transactionHash) { + setDelegateTransaction(delegateTransaction); + } + if (registerTransaction?.transactionHash) { + setRegisterTransaction(registerTransaction); + } + if (voteTransaction?.transactionHash) { + setVoteTransaction(voteTransaction); + } + if (delegateTo) { + setDelegateTo(delegateTo); + } + }, [isEnabled, stakeKey]); + + useEffect(() => { + if (delegateTransaction?.transactionHash) { + const checkDelegateTransaction = async () => { + const resetDelegateTransaction = () => { + clearInterval(interval); + removeItemFromLocalStorage(DELEGATE_TRANSACTION_KEY + `_${stakeKey}`); + setDelegateTransaction({ + time: undefined, + transactionHash: "", + }); + }; + const status = await getTransactionStatus( + delegateTransaction.transactionHash + ); + if (status.transactionConfirmed) { + if (isEnabled) { + await setLimitedDelegationInterval( + 3000, + 10, + dRepID, + delegateTo, + stakeKey + ).then((isDelegated) => { + if (isDelegated) { + addSuccessAlert( + "Your voting power has been successfully delegated!" + ); + } else { + addWarningAlert( + "Your voting power has been successfully delegated! Please refresh the page." + ); + } + }); + } + resetDelegateTransaction(); + } + if ( + new Date().getTime() - new Date(delegateTransaction?.time).getTime() > + TIME_TO_EXPIRE_TRANSACTION + ) { + resetDelegateTransaction(); + if (isEnabled) addErrorAlert("Delegation transaction failed"); + } + }; + let interval = setInterval(checkDelegateTransaction, REFRESH_TIME); + checkDelegateTransaction(); + } + if (registerTransaction?.transactionHash) { + const checkRegisterTransaction = async () => { + const resetRegisterTransaction = () => { + clearInterval(interval); + removeItemFromLocalStorage(REGISTER_TRANSACTION_KEY + `_${stakeKey}`); + setRegisterTransaction({ + time: undefined, + transactionHash: "", + type: "", + }); + }; + const status = await getTransactionStatus( + registerTransaction.transactionHash + ); + if (status.transactionConfirmed) { + if (isEnabled) { + if ( + registerTransaction.type === "registration" || + registerTransaction.type === "retirement" + ) { + await setLimitedRegistrationInterval( + 3000, + 10, + dRepID, + registerTransaction.type, + setDRep + ).then((isRegistered) => { + if (registerTransaction.type === "registration") { + if (isRegistered) { + addSuccessAlert( + "You have successfully registered as a DRep!" + ); + } else { + addWarningAlert( + "You have successfully registered as a DRep! Please refresh the page." + ); + } + } else if (registerTransaction.type === "retirement") { + if (!isRegistered) { + addSuccessAlert( + "You have successfully retired from being a DRep!" + ); + } else { + addWarningAlert( + "You have successfully retired from being a DRep! Please refresh the page." + ); + } + } + }); + } else { + addSuccessAlert("You have successfully updated DRep metadata!"); + } + } + resetRegisterTransaction(); + } + if ( + new Date().getTime() - new Date(registerTransaction?.time).getTime() > + TIME_TO_EXPIRE_TRANSACTION + ) { + resetRegisterTransaction(); + if (isEnabled) + addErrorAlert( + registerTransaction.type === "retirement" + ? "Retirement transaction failed" + : registerTransaction.type === "registration" + ? "Registration transaction failed" + : "Update DRep metadata transaction failed" + ); + } + }; + let interval = setInterval(checkRegisterTransaction, REFRESH_TIME); + checkRegisterTransaction(); + } + if (voteTransaction?.transactionHash) { + const checkVoteTransaction = async () => { + const resetVoteTransaction = () => { + clearInterval(interval); + removeItemFromLocalStorage(VOTE_TRANSACTION_KEY + `_${stakeKey}`); + setVoteTransaction({ + time: undefined, + transactionHash: "", + proposalId: "", + }); + }; + const status = await getTransactionStatus( + voteTransaction.transactionHash + ); + if (status.transactionConfirmed) { + resetVoteTransaction(); + if (isEnabled) addSuccessAlert("You have successfully voted!"); + } + if ( + new Date().getTime() - new Date(voteTransaction?.time).getTime() > + TIME_TO_EXPIRE_TRANSACTION + ) { + resetVoteTransaction(); + if (isEnabled) addErrorAlert("Vote transaction failed"); + } + }; + let interval = setInterval(checkVoteTransaction, REFRESH_TIME); + checkVoteTransaction(); + } + if ( + isEnabled && + (voteTransaction?.transactionHash || + registerTransaction?.transactionHash || + delegateTransaction?.transactionHash) + ) { + addWarningAlert("Transaction in progress. Please wait.", 10000); + } + }, [delegateTransaction, registerTransaction, voteTransaction]); + + const getChangeAddress = async (enabledApi: CardanoApiWallet) => { + try { + const raw = await enabledApi.getChangeAddress(); + const changeAddress = Address.from_bytes( + Buffer.from(raw, "hex") + ).to_bech32(); + setWalletState((prev) => ({ ...prev, changeAddress })); + } catch (err) { + Sentry.captureException(err); + console.log(err); + } + }; + + const getUsedAddresses = async (enabledApi: CardanoApiWallet) => { + try { + const raw = await enabledApi.getUsedAddresses(); + const rawFirst = raw[0]; + const usedAddress = Address.from_bytes( + Buffer.from(rawFirst, "hex") + ).to_bech32(); + setWalletState((prev) => ({ ...prev, usedAddress })); + } catch (err) { + Sentry.captureException(err); + console.log(err); + } + }; + + const getUtxos = async ( + enabledApi: CardanoApiWallet + ): Promise => { + let Utxos = []; + + try { + const rawUtxos = await enabledApi.getUtxos(); + + for (const rawUtxo of rawUtxos) { + const utxo = TransactionUnspentOutput.from_bytes( + Buffer.from(rawUtxo, "hex") + ); + const input = utxo.input(); + const txid = Buffer.from( + input.transaction_id().to_bytes(), + "utf8" + ).toString("hex"); + const txindx = input.index(); + const output = utxo.output(); + const amount = output.amount().coin().to_str(); // ADA amount in lovelace + const multiasset = output.amount().multiasset(); + let multiAssetStr = ""; + + if (multiasset) { + const keys = multiasset.keys(); // policy Ids of thee multiasset + const N = keys.len(); + + for (let i = 0; i < N; i++) { + const policyId = keys.get(i); + const policyIdHex = Buffer.from( + policyId.to_bytes(), + "utf8" + ).toString("hex"); + const assets = multiasset.get(policyId); + if (assets) { + const assetNames = assets.keys(); + const K = assetNames.len(); + + for (let j = 0; j < K; j++) { + const assetName = assetNames.get(j); + const assetNameString = Buffer.from( + assetName.name(), + "utf8" + ).toString(); + const assetNameHex = Buffer.from( + assetName.name(), + "utf8" + ).toString("hex"); + const multiassetAmt = multiasset.get_asset(policyId, assetName); + multiAssetStr += `+ ${multiassetAmt.to_str()} + ${policyIdHex}.${assetNameHex} (${assetNameString})`; + } + } + } + } + + const obj = { + txid: txid, + txindx: txindx, + amount: amount, + str: `${txid} #${txindx} = ${amount}`, + multiAssetStr: multiAssetStr, + TransactionUnspentOutput: utxo, + }; + Utxos.push(obj); + } + + return Utxos; + } catch (err) { + Sentry.captureException(err); + console.log(err); + } + }; + + const enable = useCallback( + async (walletName: string) => { + await checkIsMaintenanceOn(); + + // todo: use .getSupportedExtensions() to check if wallet supports CIP-95 + if (!isEnabled && walletName) { + try { + // Check that this wallet supports CIP-95 connection + if (!window.cardano[walletName].supportedExtensions) { + throw new Error("Your wallet does not support CIP-30 extensions."); + } else if ( + !window.cardano[walletName].supportedExtensions.some( + (item) => item.cip === 95 + ) + ) { + throw new Error( + "Your wallet does not support the required CIP-30 extension, CIP-95." + ); + } + // Enable wallet connection + const enabledApi = await window.cardano[walletName] + .enable({ + extensions: [{ cip: 95 }], + }) + .catch((e) => { + Sentry.captureException(e); + throw e.info; + }); + await getChangeAddress(enabledApi); + await getUsedAddresses(enabledApi); + setIsEnabled(true); + setWalletApi(enabledApi); + // Check if wallet has enabled the CIP-95 extension + const enabledExtensions = await enabledApi.getExtensions(); + if (!enabledExtensions.some((item) => item.cip === 95)) { + throw new Error( + "Your wallet did not enable the needed CIP-95 functions during connection." + ); + } + const network = await enabledApi.getNetworkId(); + if (network != NETWORK) { + throw new Error( + `You are trying to connect with a wallet connected to ${ + network == 1 ? "mainnet" : "testnet" + }. Please adjust your wallet settings to connect to ${ + network != 1 ? "mainnet" : "testnet" + } or select a different wallet` + ); + } + setIsMainnet(network == 1); + // Check and set wallet address + const usedAddresses = await enabledApi.getUsedAddresses(); + const unusedAddresses = await enabledApi.getUnusedAddresses(); + if (!usedAddresses.length && !unusedAddresses.length) { + throw new Error("No addresses found."); + } + if (!usedAddresses.length) { + setAddress(unusedAddresses[0]); + } else { + setAddress(usedAddresses[0]); + } + + const registeredStakeKeysList = + await enabledApi.cip95.getRegisteredPubStakeKeys(); + setRegisteredPubStakeKeysState(registeredStakeKeysList); + + const unregisteredStakeKeysList = + await enabledApi.cip95.getUnregisteredPubStakeKeys(); + + let stakeKeysList; + if (registeredStakeKeysList.length > 0) { + stakeKeysList = registeredStakeKeysList.map((stakeKey) => { + const stakeKeyHash = PublicKey.from_hex(stakeKey).hash(); + const stakeCredential = Credential.from_keyhash(stakeKeyHash); + if (network === 1) + return RewardAddress.new(1, stakeCredential) + .to_address() + .to_hex(); + else + return RewardAddress.new(0, stakeCredential) + .to_address() + .to_hex(); + }); + } else { + console.log( + "Warning, no registered stake keys, using unregistered stake keys" + ); + stakeKeysList = unregisteredStakeKeysList.map((stakeKey) => { + const stakeKeyHash = PublicKey.from_hex(stakeKey).hash(); + const stakeCredential = Credential.from_keyhash(stakeKeyHash); + if (network === 1) + return RewardAddress.new(1, stakeCredential) + .to_address() + .to_hex(); + else + return RewardAddress.new(0, stakeCredential) + .to_address() + .to_hex(); + }); + } + + setStakeKeys(stakeKeysList); + + let stakeKeySet = false; + const savedStakeKey = getItemFromLocalStorage( + `${WALLET_LS_KEY}_stake_key` + ); + if (savedStakeKey && stakeKeysList.includes(savedStakeKey)) { + setStakeKey(savedStakeKey); + stakeKeySet = true; + } else if (stakeKeysList.length === 1) { + setStakeKey(stakeKeysList[0]); + + setItemToLocalStorage( + `${WALLET_LS_KEY}_stake_key`, + stakeKeysList[0] + ); + stakeKeySet = true; + } + const dRepIds = await getPubDRepId(enabledApi); + setPubDRepKey(dRepIds?.dRepKey || ""); + setDRepID(dRepIds?.dRepID || ""); + setDRepIDBech32(dRepIds?.dRepIDBech32 || ""); + setItemToLocalStorage(`${WALLET_LS_KEY}_name`, walletName); + + const protocol = await getEpochParams(); + setItemToLocalStorage(PROTOCOL_PARAMS_KEY, protocol); + + return { status: "OK", stakeKey: stakeKeySet }; + } catch (e) { + Sentry.captureException(e); + console.error(e); + setError(`${e}`); + setAddress(undefined); + setWalletApi(undefined); + setPubDRepKey(""); + setStakeKey(undefined); + setIsEnabled(false); + throw { + status: "ERROR", + error: `${e == undefined ? "Something went wrong" : e}`, + }; + } + } + throw { status: "ERROR", error: `Something went wrong` }; + }, + [isEnabled, stakeKeys] + ); + + const disconnectWallet = useCallback(async () => { + removeItemFromLocalStorage(`${WALLET_LS_KEY}_name`); + removeItemFromLocalStorage(`${WALLET_LS_KEY}_stake_key`); + setWalletApi(undefined); + setAddress(undefined); + setStakeKey(undefined); + setIsEnabled(false); + }, []); + + // Create transaction builder + const initTransactionBuilder = useCallback(async () => { + const protocolParams = getItemFromLocalStorage( + PROTOCOL_PARAMS_KEY + ) as Protocol; + + if (protocolParams) { + const txBuilder = TransactionBuilder.new( + TransactionBuilderConfigBuilder.new() + .fee_algo( + LinearFee.new( + BigNum.from_str(String(protocolParams.min_fee_a)), + BigNum.from_str(String(protocolParams.min_fee_b)) + ) + ) + .pool_deposit(BigNum.from_str(String(protocolParams.pool_deposit))) + .key_deposit(BigNum.from_str(String(protocolParams.key_deposit))) + .coins_per_utxo_word( + BigNum.from_str(String(protocolParams.coins_per_utxo_size)) + ) + .max_value_size(protocolParams.max_val_size) + .max_tx_size(protocolParams.max_tx_size) + .prefer_pure_change(true) + .build() + ); + return txBuilder; + } + }, []); + + const getTxUnspentOutputs = useCallback(async (utxos: Utxos) => { + let txOutputs = TransactionUnspentOutputs.new(); + for (const utxo of utxos) { + txOutputs.add(utxo.TransactionUnspentOutput); + } + return txOutputs; + }, []); + + // Build, sign and submit transaction + const buildSignSubmitConwayCertTx = useCallback( + async ({ + certBuilder, + votingBuilder, + type, + proposalId, + registrationType, + }: { + certBuilder?: CertificatesBuilder; + votingBuilder?: VotingBuilder; + type?: "delegation" | "registration" | "vote"; + proposalId?: string; + registrationType?: DRepActionType; + }) => { + await checkIsMaintenanceOn(); + const isPendingTx = isPendingTransaction(); + if (isPendingTx) return; + + console.log(walletState, "walletState"); + console.log(certBuilder, "certBuilder"); + try { + const txBuilder = await initTransactionBuilder(); + + if (!txBuilder) { + throw new Error("Application can not create transaction"); + } + + if (certBuilder) { + txBuilder.set_certs_builder(certBuilder); + } + + if (votingBuilder) { + txBuilder.set_voting_builder(votingBuilder); + } + + if ( + !walletState.changeAddress || + !walletState.usedAddress || + !walletApi + ) + throw new Error("Check the wallet is connected."); + const shelleyOutputAddress = Address.from_bech32( + walletState.usedAddress + ); + const shelleyChangeAddress = Address.from_bech32( + walletState.changeAddress + ); + + // Add output of 1 ADA to the address of our wallet + let outputValue = BigNum.from_str("1000000"); + + if (registrationType === "retirement" && dRep?.deposit) { + outputValue = outputValue.checked_add( + BigNum.from_str(`${dRep?.deposit}`) + ); + } + + txBuilder.add_output( + TransactionOutput.new(shelleyOutputAddress, Value.new(outputValue)) + ); + + const utxos = await getUtxos(walletApi); + + if (!utxos) { + throw new Error("Application can not get utxos"); + } + // Find the available UTXOs in the wallet and use them as Inputs for the transaction + const txUnspentOutputs = await getTxUnspentOutputs(utxos); + + // Use UTxO selection strategy 2 + txBuilder.add_inputs_from(txUnspentOutputs, 2); + + // Set change address, incase too much ADA provided for fee + txBuilder.add_change_if_needed(shelleyChangeAddress); + + // Build transaction body + const txBody = txBuilder.build(); + + // Make a full transaction, passing in empty witness set + const transactionWitnessSet = TransactionWitnessSet.new(); + const tx = Transaction.new( + txBody, + TransactionWitnessSet.from_bytes(transactionWitnessSet.to_bytes()) + ); + // Ask wallet to to provide signature (witnesses) for the transaction + let txVkeyWitnesses; + + txVkeyWitnesses = await walletApi.signTx( + Buffer.from(tx.to_bytes(), "utf8").toString("hex"), + true + ); + + // Create witness set object using the witnesses provided by the wallet + txVkeyWitnesses = TransactionWitnessSet.from_bytes( + Buffer.from(txVkeyWitnesses, "hex") + ); + transactionWitnessSet.set_vkeys(txVkeyWitnesses.vkeys()); + // Build transaction with witnesses + const signedTx = Transaction.new(tx.body(), transactionWitnessSet); + console.log( + Buffer.from(signedTx.to_bytes(), "utf8").toString("hex"), + "signed tx cbor" + ); + + // Submit built signed transaction to chain, via wallet's submit transaction endpoint + const result = await walletApi.submitTx( + Buffer.from(signedTx.to_bytes(), "utf8").toString("hex") + ); + // Set results so they can be rendered + const cip95ResultTx = Buffer.from(signedTx.to_bytes(), "utf8").toString( + "hex" + ); + const resultHash = result; + const cip95ResultWitness = Buffer.from( + txVkeyWitnesses.to_bytes(), + "utf8" + ).toString("hex"); + + if (type === "registration") { + setRegisterTransaction({ + time: new Date(), + transactionHash: resultHash, + type: registrationType ?? "", + }); + setItemToLocalStorage( + REGISTER_TRANSACTION_KEY + `_${stakeKey}`, + JSON.stringify({ + time: new Date(), + transactionHash: resultHash, + type: registrationType, + }) + ); + } + if (type === "delegation") { + setDelegateTransaction({ + time: new Date(), + transactionHash: resultHash, + }); + setItemToLocalStorage( + DELEGATE_TRANSACTION_KEY + `_${stakeKey}`, + JSON.stringify({ + time: new Date(), + transactionHash: resultHash, + }) + ); + } + if (type === "vote") { + setVoteTransaction({ + time: new Date(), + transactionHash: resultHash, + proposalId: proposalId ?? "", + }); + setItemToLocalStorage( + VOTE_TRANSACTION_KEY + `_${stakeKey}`, + JSON.stringify({ + time: new Date(), + transactionHash: resultHash, + proposalId: proposalId ?? "", + }) + ); + } + console.log(cip95ResultTx, "cip95ResultTx"); + console.log(resultHash, "cip95ResultHash"); + console.log(cip95ResultWitness, "cip95ResultWitness"); + return resultHash; + } catch (error) { + const walletName = getItemFromLocalStorage(`${WALLET_LS_KEY}_name`); + const isWalletConnected = await window.cardano[walletName].isEnabled(); + + if (!isWalletConnected) { + disconnectWallet(); + } + + Sentry.captureException(error); + console.log(error, "error"); + throw error?.info ?? error; + } + }, + [ + walletState, + walletApi, + getUtxos, + registerTransaction.transactionHash, + delegateTransaction.transactionHash, + voteTransaction.transactionHash, + stakeKey, + isPendingTransaction, + dRep, + ] + ); + + const buildVoteDelegationCert = useCallback( + async (target: string): Promise => { + try { + // Build Vote Delegation Certificate + const certBuilder = CertificatesBuilder.new(); + let stakeCred; + if (!stakeKey) { + throw new Error("No stake key selected"); + } + // Remove network tag from stake key hash + const stakeKeyHash = Ed25519KeyHash.from_hex(stakeKey.substring(2)); + // if chosen stake key is registered use it, else register it + if (registeredStakeKeysListState.length > 0) { + stakeCred = Credential.from_keyhash(stakeKeyHash); + } else { + console.log("Registering stake key"); + stakeCred = Credential.from_keyhash(stakeKeyHash); + const stakeKeyRegCert = StakeRegistration.new(stakeCred); + certBuilder.add(Certificate.new_stake_registration(stakeKeyRegCert)); + } + // Create correct DRep + let targetDRep; + if (target === "abstain") { + targetDRep = DRep.new_always_abstain(); + } else if (target === "no confidence") { + targetDRep = DRep.new_always_no_confidence(); + } else { + if (target.includes("drep")) { + targetDRep = DRep.new_key_hash(Ed25519KeyHash.from_bech32(target)); + } else { + targetDRep = DRep.new_key_hash(Ed25519KeyHash.from_hex(target)); + } + } + // Create cert object + const voteDelegationCert = VoteDelegation.new(stakeCred, targetDRep); + // add cert to tbuilder + + certBuilder.add(Certificate.new_vote_delegation(voteDelegationCert)); + setDelegateTo(target); + setItemToLocalStorage(DELEGATE_TO_KEY + `_${stakeKey}`, target); + return certBuilder; + } catch (e) { + Sentry.captureException(e); + console.log(e); + throw e; + } + }, + [stakeKey, registeredStakeKeysListState] + ); + + // conway alpha + const buildDRepRegCert = useCallback( + async ( + cip95MetadataURL?: string, + cip95MetadataHash?: string + ): Promise => { + try { + const epochParams = getItemFromLocalStorage(PROTOCOL_PARAMS_KEY); + // Build DRep Registration Certificate + const certBuilder = CertificatesBuilder.new(); + + // Get wallet's DRep key + const dRepKeyHash = Ed25519KeyHash.from_hex(dRepID); + const dRepCred = Credential.from_keyhash(dRepKeyHash); + + let dRepRegCert; + // If there is an anchor + if (cip95MetadataURL && cip95MetadataHash) { + const url = URL.new(cip95MetadataURL); + const hash = AnchorDataHash.from_hex(cip95MetadataHash); + const anchor = Anchor.new(url, hash); + // Create cert object using one Ada as the deposit + dRepRegCert = DrepRegistration.new_with_anchor( + dRepCred, + BigNum.from_str(`${epochParams.drep_deposit}`), + anchor + ); + } else { + console.log("DRep Registration - not using anchor"); + dRepRegCert = DrepRegistration.new( + dRepCred, + BigNum.from_str(`${epochParams.drep_deposit}`) + ); + } + // add cert to tbuilder + certBuilder.add(Certificate.new_drep_registration(dRepRegCert)); + return certBuilder; + } catch (e) { + Sentry.captureException(e); + console.log(e); + throw e; + } + }, + [dRepID] + ); + + // conway alpha + const buildDRepUpdateCert = useCallback( + async ( + cip95MetadataURL?: string, + cip95MetadataHash?: string + ): Promise => { + try { + // Build DRep Registration Certificate + const certBuilder = CertificatesBuilder.new(); + + // Get wallet's DRep key + const dRepKeyHash = Ed25519KeyHash.from_hex(dRepID); + const dRepCred = Credential.from_keyhash(dRepKeyHash); + + let dRepUpdateCert; + // If there is an anchor + if (cip95MetadataURL && cip95MetadataHash) { + const url = URL.new(cip95MetadataURL); + const hash = AnchorDataHash.from_hex(cip95MetadataHash); + const anchor = Anchor.new(url, hash); + // Create cert object using one Ada as the deposit + dRepUpdateCert = DrepUpdate.new_with_anchor(dRepCred, anchor); + } else { + dRepUpdateCert = DrepUpdate.new(dRepCred); + } + // add cert to tbuilder + certBuilder.add(Certificate.new_drep_update(dRepUpdateCert)); + return certBuilder; + } catch (e) { + Sentry.captureException(e); + console.log(e); + throw e; + } + }, + [dRepID] + ); + + // conway alpha + const buildDRepRetirementCert = + useCallback(async (): Promise => { + try { + // Build DRep Registration Certificate + const certBuilder = CertificatesBuilder.new(); + // Get wallet's DRep key + const dRepKeyHash = Ed25519KeyHash.from_hex(dRepID); + const dRepCred = Credential.from_keyhash(dRepKeyHash); + + const dRepRetirementCert = DrepDeregistration.new( + dRepCred, + BigNum.from_str(`${dRep?.deposit}`) + ); + // add cert to tbuilder + certBuilder.add( + Certificate.new_drep_deregistration(dRepRetirementCert) + ); + return certBuilder; + } catch (e) { + Sentry.captureException(e); + console.log(e); + throw e; + } + }, [dRepID, dRep]); + + const buildVote = useCallback( + async ( + voteChoice: string, + txHash: string, + index: number, + cip95MetadataURL?: string, + cip95MetadataHash?: string + ): Promise => { + try { + // Get wallet's DRep key + const dRepKeyHash = Ed25519KeyHash.from_hex(dRepID); + // Vote things + const voter = Voter.new_drep(Credential.from_keyhash(dRepKeyHash)); + const govActionId = GovernanceActionId.new( + // placeholder + TransactionHash.from_hex(txHash), + index + ); + + let votingChoice; + if (voteChoice === "yes") { + votingChoice = 1; + } else if (voteChoice === "no") { + votingChoice = 0; + } else { + votingChoice = 2; + } + + let votingProcedure; + if (cip95MetadataURL && cip95MetadataHash) { + const url = URL.new(cip95MetadataURL); + const hash = AnchorDataHash.from_hex(cip95MetadataHash); + const anchor = Anchor.new(url, hash); + // Create cert object using one Ada as the deposit + votingProcedure = VotingProcedure.new_with_anchor( + votingChoice, + anchor + ); + } else { + votingProcedure = VotingProcedure.new(votingChoice); + } + + const votingBuilder = VotingBuilder.new(); + votingBuilder.add(voter, govActionId, votingProcedure); + + return votingBuilder; + } catch (e) { + Sentry.captureException(e); + console.log(e); + throw e; + } + }, + [dRepID] + ); + + const value = useMemo( + () => ({ + address, + enable, + dRep, + isEnabled, + isMainnet, + disconnectWallet, + dRepID, + dRepIDBech32, + pubDRepKey, + stakeKey, + setDRep, + setStakeKey, + stakeKeys, + walletApi, + error, + delegatedDRepId, + setDelegatedDRepId, + buildSignSubmitConwayCertTx, + buildDRepRegCert, + buildDRepUpdateCert, + buildDRepRetirementCert, + buildVote, + buildVoteDelegationCert, + delegateTransaction, + registerTransaction, + delegateTo, + voteTransaction, + isPendingTransaction, + isDrepLoading, + setIsDrepLoading, + }), + [ + address, + enable, + dRep, + isEnabled, + isMainnet, + disconnectWallet, + dRepID, + dRepIDBech32, + pubDRepKey, + stakeKey, + setDRep, + setStakeKey, + stakeKeys, + walletApi, + error, + delegatedDRepId, + setDelegatedDRepId, + buildSignSubmitConwayCertTx, + buildDRepRegCert, + buildDRepUpdateCert, + buildDRepRetirementCert, + buildVote, + buildVoteDelegationCert, + delegateTransaction, + registerTransaction, + delegateTo, + voteTransaction, + isPendingTransaction, + isDrepLoading, + setIsDrepLoading, + ] + ); + + return ; +} + +function useCardano() { + const context = useContext(CardanoContext); + const { openModal, closeModal } = useModal(); + const { addSuccessAlert } = useSnackbar(); + const navigate = useNavigate(); + + if (context === undefined) { + throw new Error("useCardano must be used within a CardanoProvider"); + } + + const enable = useCallback( + async (walletName: string) => { + try { + const isSanchoInfoShown = getItemFromLocalStorage( + SANCHO_INFO_KEY + `_${walletName}` + ); + const result = await context.enable(walletName); + if (!result.error) { + closeModal(); + if (result.stakeKey) { + addSuccessAlert(`Wallet connected`, 3000); + } + if (!isSanchoInfoShown) { + openModal({ + type: "statusModal", + state: { + status: "info", + dataTestId: "info-about-sancho-net-modal", + message: ( +

+ The SanchoNet GovTool is currently in beta and it connects + to{" "} + openInNewTab("https://sancho.network/")} + sx={{ cursor: "pointer" }} + > + SanchoNet + + . +
+
Please note, this tool uses ‘Test ada’ + NOT real ada. All + governance actions and related terms pertain to SanchoNet." +

+ ), + title: "This tool is connected to SanchoNet", + buttonText: "Ok", + }, + }); + setItemToLocalStorage(SANCHO_INFO_KEY + `_${walletName}`, true); + } + return result; + } + } catch (e: any) { + Sentry.captureException(e); + await context.disconnectWallet(); + navigate(PATHS.home); + openModal({ + type: "statusModal", + state: { + status: "warning", + message: e?.error?.replace("Error: ", ""), + onSubmit: () => { + closeModal(); + }, + title: "Oops!", + dataTestId: "wallet-connection-error-modal", + }, + }); + throw e; + } + }, + [context, openModal, context.isEnabled] + ); + + const disconnectWallet = useCallback(async () => { + await context.disconnectWallet(); + }, [context]); + + return { ...context, enable, disconnectWallet }; +} + +export { CardanoProvider, useCardano }; diff --git a/src/vva-fe/src/context/walletUtils.ts b/src/vva-fe/src/context/walletUtils.ts new file mode 100644 index 000000000..5bf0be32a --- /dev/null +++ b/src/vva-fe/src/context/walletUtils.ts @@ -0,0 +1,80 @@ +import { getAdaHolderCurrentDelegation, getDRepInfo } from "@services"; +import { DRepActionType } from "./wallet"; +import { DRepInfo } from "@models"; + +export const setLimitedRegistrationInterval = ( + intervalTime: number, + attemptsNumber: number, + dRepID: string, + transactionType: DRepActionType, + setDRep: (key: undefined | DRepInfo) => void +): Promise => { + return new Promise(async (resolve) => { + const desiredResult = transactionType === "registration" ? true : false; + let count = 0; + + const interval = setInterval(async () => { + if (count < attemptsNumber) { + count++; + + try { + const data = await getDRepInfo(dRepID); + + if (data.isRegistered === desiredResult) { + setDRep(data); + clearInterval(interval); + resolve(desiredResult); + } + } catch (error) { + clearInterval(interval); + resolve(!desiredResult); + } + } else { + clearInterval(interval); + resolve(!desiredResult); + } + }, intervalTime); + }); +}; + +export const setLimitedDelegationInterval = ( + intervalTime: number, + attemptsNumber: number, + dRepID: string, + delegateTo: string, + stakeKey?: string +): Promise => { + return new Promise(async (resolve) => { + let count = 0; + + const interval = setInterval(async () => { + if (count < attemptsNumber) { + count++; + + try { + const currentDelegation = await getAdaHolderCurrentDelegation({ + stakeKey, + }); + + if ( + (delegateTo === dRepID && currentDelegation === dRepID) || + (delegateTo === "no confidence" && + currentDelegation === "drep_always_no_confidence") || + (delegateTo === "abstain" && + currentDelegation === "drep_always_abstain") || + (delegateTo !== dRepID && delegateTo === currentDelegation) + ) { + clearInterval(interval); + resolve(true); + } + } catch (error) { + clearInterval(interval); + resolve(false); + } + } else { + clearInterval(interval); + resolve(false); + } + }, intervalTime); + }); +}; diff --git a/src/vva-fe/src/hooks/forms/index.ts b/src/vva-fe/src/hooks/forms/index.ts new file mode 100644 index 000000000..2457ac6de --- /dev/null +++ b/src/vva-fe/src/hooks/forms/index.ts @@ -0,0 +1,5 @@ +export * from "./useDelegateTodRepForm"; +export * from "./useRegisterAsdRepFormContext"; +export * from "./useUpdatedRepMetadataForm"; +export * from "./useUrlAndHashFormController"; +export * from "./useVoteActionForm"; diff --git a/src/vva-fe/src/hooks/forms/useDelegateTodRepForm.tsx b/src/vva-fe/src/hooks/forms/useDelegateTodRepForm.tsx new file mode 100644 index 000000000..e4ff1861b --- /dev/null +++ b/src/vva-fe/src/hooks/forms/useDelegateTodRepForm.tsx @@ -0,0 +1,97 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useForm, useWatch } from "react-hook-form"; + +import { PATHS } from "@consts"; +import { useCardano, useModal } from "@context"; +import { useGetDRepListQuery } from "@hooks"; +import { formHexToBech32 } from "@utils"; + +export interface DelegateTodrepFormValues { + dRepId: string; +} + +export const useDelegateTodRepForm = () => { + const { + setDelegatedDRepId, + buildSignSubmitConwayCertTx, + buildVoteDelegationCert, + } = useCardano(); + const { data: drepList } = useGetDRepListQuery(); + const { openModal, closeModal, modal } = useModal(); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const { control, handleSubmit } = useForm(); + + const watch = useWatch({ + control, + name: "dRepId", + }); + + const isDelegateButtonDisabled = !watch; + + const delegate = useCallback( + async ({ dRepId }: DelegateTodrepFormValues) => { + setIsLoading(true); + try { + setDelegatedDRepId(dRepId); + let isValidDrep = false; + if (drepList?.length) { + isValidDrep = drepList.some((i) => { + return i.drepId === dRepId || formHexToBech32(i.drepId) === dRepId; + }); + } + if (!drepList?.length || !isValidDrep) { + throw new Error("DrepId not found"); + } + const certBuilder = await buildVoteDelegationCert(dRepId); + const result = await buildSignSubmitConwayCertTx({ + certBuilder, + type: "delegation", + }); + if (result) + openModal({ + type: "statusModal", + state: { + status: "success", + title: "Delegation Transaction Submitted!", + message: + "The confirmation of your actual delegation might take a bit of time but you can track it using.", + link: "https://adanordic.com/latest_transactions", + buttonText: "Go to dashboard", + onSubmit: () => { + navigate(PATHS.dashboard); + closeModal(); + }, + dataTestId: "delegation-transaction-submitted-modal", + }, + }); + } catch (error) { + openModal({ + type: "statusModal", + state: { + status: "warning", + message: `${error}`.replace("Error: ", ""), + onSubmit: () => { + closeModal(); + }, + title: "Oops!", + dataTestId: "delegation-transaction-error-modal", + }, + }); + } finally { + setIsLoading(false); + } + }, + [buildVoteDelegationCert, buildSignSubmitConwayCertTx, drepList] + ); + + return { + control, + isDelegateButtonDisabled, + delegate: handleSubmit(delegate), + modal, + isLoading, + }; +}; diff --git a/src/vva-fe/src/hooks/forms/useRegisterAsdRepFormContext.tsx b/src/vva-fe/src/hooks/forms/useRegisterAsdRepFormContext.tsx new file mode 100644 index 000000000..a68b89519 --- /dev/null +++ b/src/vva-fe/src/hooks/forms/useRegisterAsdRepFormContext.tsx @@ -0,0 +1,96 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useFormContext, useWatch } from "react-hook-form"; + +import { PATHS } from "@consts"; +import { useCardano, useModal } from "@context"; +import { UrlAndHashFormValues } from "@hooks"; + +export const useRegisterAsdRepFormContext = () => { + const { buildSignSubmitConwayCertTx, buildDRepRegCert } = useCardano(); + const { openModal, closeModal } = useModal(); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const { + control, + handleSubmit, + formState: { errors, isValid }, + } = useFormContext(); + + const watch = useWatch({ + control, + }); + + const isUrlNullOrFilledIn = watch.url !== "" && watch.url !== null; + const isHashNullOrFilledIn = watch.hash !== "" && watch.hash !== null; + const showSubmitButton = isUrlNullOrFilledIn || isHashNullOrFilledIn; + + const onSubmit = useCallback( + async (values: UrlAndHashFormValues) => { + const { url, hash } = values; + + const urlSubmitValue = url ?? ""; + const hashSubmitValue = hash ?? ""; + setIsLoading(true); + + try { + const certBuilder = await buildDRepRegCert( + urlSubmitValue, + hashSubmitValue + ); + const result = await buildSignSubmitConwayCertTx({ + certBuilder, + type: "registration", + registrationType: "registration", + }); + if (result) + openModal({ + type: "statusModal", + state: { + status: "success", + title: "Registration Transaction Submitted!", + message: + "The confirmation of your registration might take a bit of time but you can track it using.", + link: "https://adanordic.com/latest_transactions", + buttonText: "Go to dashboard", + onSubmit: () => { + navigate(PATHS.dashboard); + closeModal(); + }, + dataTestId: "registration-transaction-submitted-modal", + }, + }); + } catch (e: any) { + const errorMessage = e.info ? e.info : e; + + openModal({ + type: "statusModal", + state: { + status: "warning", + title: "Oops!", + message: errorMessage, + buttonText: "Go to dashboard", + onSubmit: () => { + navigate(PATHS.dashboard); + closeModal(); + }, + dataTestId: "registration-transaction-error-modal", + }, + }); + } finally { + setIsLoading(false); + } + }, + [buildSignSubmitConwayCertTx, buildDRepRegCert, openModal] + ); + + return { + isLoading, + control, + errors, + isValid, + showSubmitButton, + submitForm: handleSubmit(onSubmit), + }; +}; diff --git a/src/vva-fe/src/hooks/forms/useUpdatedRepMetadataForm.tsx b/src/vva-fe/src/hooks/forms/useUpdatedRepMetadataForm.tsx new file mode 100644 index 000000000..da5adae9f --- /dev/null +++ b/src/vva-fe/src/hooks/forms/useUpdatedRepMetadataForm.tsx @@ -0,0 +1,58 @@ +import { useCallback, useState } from "react"; +import { + UrlAndHashFormValues, + useUrlAndHashFormController, +} from "./useUrlAndHashFormController"; +import { useNavigate } from "react-router-dom"; + +import { PATHS } from "@consts"; +import { useCardano, useSnackbar } from "@context"; + +export const useUpdatedRepMetadataForm = () => { + const { buildSignSubmitConwayCertTx, buildDRepUpdateCert } = useCardano(); + const { addSuccessAlert, addErrorAlert } = useSnackbar(); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const { + handleSubmit, + control, + formState: { errors, isValid }, + } = useUrlAndHashFormController(); + + const onSubmit = useCallback( + async (values: UrlAndHashFormValues) => { + const { url, hash } = values; + + const urlSubmitValue = url ?? ""; + const hashSubmitValue = hash ?? ""; + setIsLoading(true); + try { + const certBuilder = await buildDRepUpdateCert( + urlSubmitValue, + hashSubmitValue + ); + const result = await buildSignSubmitConwayCertTx({ + certBuilder, + type: "registration", + registrationType: "update", + }); + if (result) addSuccessAlert("Metadata update submitted"); + navigate(PATHS.dashboard); + } catch (e) { + addErrorAlert("Something went wrong while updating metadata"); + } finally { + setIsLoading(false); + } + }, + [buildDRepUpdateCert, buildSignSubmitConwayCertTx] + ); + + return { + submitForm: handleSubmit(onSubmit), + control, + errors, + isValid, + isLoading, + }; +}; diff --git a/src/vva-fe/src/hooks/forms/useUrlAndHashFormController.tsx b/src/vva-fe/src/hooks/forms/useUrlAndHashFormController.tsx new file mode 100644 index 000000000..437354e8a --- /dev/null +++ b/src/vva-fe/src/hooks/forms/useUrlAndHashFormController.tsx @@ -0,0 +1,43 @@ +import { useMemo } from "react"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useForm } from "react-hook-form"; +import * as Yup from "yup"; +import { HASH_REGEX, URL_REGEX } from "@utils"; + +export interface UrlAndHashFormValues { + url?: string; + hash?: string; +} + +export const useUrlAndHashFormController = () => { + const validationSchema = useMemo( + () => + Yup.object().shape({ + url: Yup.string() + .trim() + .max(64, "Url must be less than 65 characters") + .test("url-validation", "Invalid URL format", (value) => { + return !value || URL_REGEX.test(value); + }), + hash: Yup.string() + .trim() + .test( + "hash-length-validation", + "Hash must be exactly 64 characters long", + (value) => { + return !value || value.length === 64; + } + ) + .test("hash-format-validation", "Invalid hash format", (value) => { + return !value || HASH_REGEX.test(value); + }), + }), + [] + ); + + return useForm({ + defaultValues: { url: "", hash: "" }, + mode: "onChange", + resolver: yupResolver(validationSchema), + }); +}; diff --git a/src/vva-fe/src/hooks/forms/useVoteActionForm.tsx b/src/vva-fe/src/hooks/forms/useVoteActionForm.tsx new file mode 100644 index 000000000..0dc5f768f --- /dev/null +++ b/src/vva-fe/src/hooks/forms/useVoteActionForm.tsx @@ -0,0 +1,129 @@ +import { useCallback, useMemo, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import * as Yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { UrlAndHashFormValues } from "./useUrlAndHashFormController"; + +import { PATHS } from "@consts"; +import { useCardano, useSnackbar } from "@context"; +import { HASH_REGEX, URL_REGEX } from "@utils"; + +export interface VoteActionFormValues extends UrlAndHashFormValues { + vote: string; +} + +export const useVoteActionFormController = () => { + const validationSchema = useMemo( + () => + Yup.object().shape({ + vote: Yup.string().oneOf(["yes", "no", "abstain"]).required(), + url: Yup.string() + .trim() + .max(64, "Url must be less than 65 characters") + .test("url-validation", "Invalid URL format", (value) => { + return !value || URL_REGEX.test(value); + }), + hash: Yup.string() + .trim() + .test( + "hash-length-validation", + "Hash must be exactly 64 characters long", + (value) => { + return !value || value.length === 64; + } + ) + .test("hash-format-validation", "Invalid hash format", (value) => { + return !value || HASH_REGEX.test(value); + }), + }), + [] + ); + + return useForm({ + defaultValues: { url: "", hash: "", vote: "" }, + mode: "onChange", + resolver: yupResolver(validationSchema), + }); +}; + +export const useVoteActionForm = () => { + const [isLoading, setIsLoading] = useState(false); + const { buildSignSubmitConwayCertTx, buildVote, isPendingTransaction } = + useCardano(); + const { addErrorAlert, addSuccessAlert } = useSnackbar(); + const navigate = useNavigate(); + const { state } = useLocation(); + + const { + control, + handleSubmit, + formState: { errors, isDirty }, + setValue, + register: registerInput, + clearErrors, + } = useVoteActionFormController(); + + const watch = useWatch({ + control, + }); + + const areFormErrors = !!errors.vote || !!errors.url || !!errors.hash; + + const vote = watch.vote; + + const confirmVote = useCallback( + async (values: VoteActionFormValues) => { + setIsLoading(true); + + const { url, hash, vote } = values; + + const urlSubmitValue = url ?? ""; + const hashSubmitValue = hash ?? ""; + + try { + const isPendingTx = isPendingTransaction(); + if (isPendingTx) return; + const votingBuilder = await buildVote( + vote, + state.txHash, + state.index, + urlSubmitValue, + hashSubmitValue + ); + const result = await buildSignSubmitConwayCertTx({ + votingBuilder, + type: "vote", + proposalId: state.txHash + state.index, + }); + if (result) { + addSuccessAlert("Vote submitted"); + navigate(PATHS.dashboard_governance_actions, { + state: { + isVotedListOnLoad: state && state.vote ? true : false, + }, + }); + } + } catch (e) { + addErrorAlert("Please try again later"); + } finally { + setIsLoading(false); + } + }, + [state, buildVote, buildSignSubmitConwayCertTx] + ); + + return { + control, + errors, + confirmVote: handleSubmit(confirmVote), + setValue, + vote, + registerInput, + isDirty, + clearErrors, + areFormErrors, + isLoading, + }; +}; diff --git a/src/vva-fe/src/hooks/index.ts b/src/vva-fe/src/hooks/index.ts new file mode 100644 index 000000000..b524db0d5 --- /dev/null +++ b/src/vva-fe/src/hooks/index.ts @@ -0,0 +1,8 @@ +export * from "./useScreenDimension"; +export * from "./useSlider"; +export * from "./useSaveScrollPosition"; +export * from "./useFetchNextPageDetector"; + +export * from "./forms"; +export * from "./mutations"; +export * from "./queries"; diff --git a/src/vva-fe/src/hooks/mutations/index.ts b/src/vva-fe/src/hooks/mutations/index.ts new file mode 100644 index 000000000..7ab26f25b --- /dev/null +++ b/src/vva-fe/src/hooks/mutations/index.ts @@ -0,0 +1,7 @@ +export * from "./useDRepRegisterMutation"; +export * from "./useDRepRemoveVoteMutation"; +export * from "./useDRepRetireMutation"; +export * from "./useAdaHolderDelegateMutation"; +export * from "./useAdaHolderDelegateAbstainMutation"; +export * from "./useAdaHolderDelegateNoMutation"; +export * from "./useAdaHolderRemoveDelegationMutation"; diff --git a/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateAbstainMutation.ts b/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateAbstainMutation.ts new file mode 100644 index 000000000..9d37a5a01 --- /dev/null +++ b/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateAbstainMutation.ts @@ -0,0 +1,10 @@ +import { postAdaHolderDelegateAbstain } from "@services"; +import { useMutation } from "react-query"; + +export const useAdaHolderDelegateAbstainMutation = () => { + const { mutateAsync } = useMutation(postAdaHolderDelegateAbstain, {}); + + return { + delegateAbstainAsAdaHolder: mutateAsync, + }; +}; diff --git a/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateMutation.ts b/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateMutation.ts new file mode 100644 index 000000000..fe915c357 --- /dev/null +++ b/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateMutation.ts @@ -0,0 +1,10 @@ +import { postAdaHolderDelegate } from "@services"; +import { useMutation } from "react-query"; + +export const useAdaHolderDelegateMutation = () => { + const { mutateAsync } = useMutation(postAdaHolderDelegate, {}); + + return { + delegateAsAdaHolder: mutateAsync, + }; +}; diff --git a/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateNoMutation.ts b/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateNoMutation.ts new file mode 100644 index 000000000..8491c4627 --- /dev/null +++ b/src/vva-fe/src/hooks/mutations/useAdaHolderDelegateNoMutation.ts @@ -0,0 +1,10 @@ +import { postAdaHolderDelegateNo } from "@services"; +import { useMutation } from "react-query"; + +export const useAdaHolderDelegateNoMutation = () => { + const { mutateAsync } = useMutation(postAdaHolderDelegateNo, {}); + + return { + delegateNoAsAdaHolder: mutateAsync, + }; +}; diff --git a/src/vva-fe/src/hooks/mutations/useAdaHolderRemoveDelegationMutation.ts b/src/vva-fe/src/hooks/mutations/useAdaHolderRemoveDelegationMutation.ts new file mode 100644 index 000000000..739a6b5e4 --- /dev/null +++ b/src/vva-fe/src/hooks/mutations/useAdaHolderRemoveDelegationMutation.ts @@ -0,0 +1,10 @@ +import { postAdaHolderRemoveDelegation } from "@services"; +import { useMutation } from "react-query"; + +export const useAdaHolderRemoveDelegation = () => { + const { mutateAsync } = useMutation(postAdaHolderRemoveDelegation); + + return { + register: mutateAsync, + }; +}; diff --git a/src/vva-fe/src/hooks/mutations/useDRepRegisterMutation.ts b/src/vva-fe/src/hooks/mutations/useDRepRegisterMutation.ts new file mode 100644 index 000000000..76b79287d --- /dev/null +++ b/src/vva-fe/src/hooks/mutations/useDRepRegisterMutation.ts @@ -0,0 +1,18 @@ +import { useMutation } from "react-query"; +import { useCardano } from "@context"; +import { postDRepRegister } from "@services"; + +export const useDRepRegisterMutation = () => { + const { setDRep } = useCardano(); + + const { mutateAsync, isLoading } = useMutation(postDRepRegister, { + onSuccess: () => { + setDRep({ deposit: 100, isRegistered: true, wasRegistered: false }); + }, + }); + + return { + isLoading: isLoading, + register: mutateAsync, + }; +}; diff --git a/src/vva-fe/src/hooks/mutations/useDRepRemoveVoteMutation.ts b/src/vva-fe/src/hooks/mutations/useDRepRemoveVoteMutation.ts new file mode 100644 index 000000000..7102ad95a --- /dev/null +++ b/src/vva-fe/src/hooks/mutations/useDRepRemoveVoteMutation.ts @@ -0,0 +1,10 @@ +import { postDRepRemoveVote } from "@services"; +import { useMutation } from "react-query"; + +export const useDRepRemoveVoteMutation = () => { + const { mutateAsync } = useMutation(postDRepRemoveVote); + + return { + removeVote: mutateAsync, + }; +}; diff --git a/src/vva-fe/src/hooks/mutations/useDRepRetireMutation.ts b/src/vva-fe/src/hooks/mutations/useDRepRetireMutation.ts new file mode 100644 index 000000000..b3697c714 --- /dev/null +++ b/src/vva-fe/src/hooks/mutations/useDRepRetireMutation.ts @@ -0,0 +1,19 @@ +import { useMutation } from "react-query"; +import { useCardano, useSnackbar } from "@context"; +import { postDRepRetire } from "@services"; + +export const useDRepRetireMutation = () => { + const { setDRep } = useCardano(); + const { addSuccessAlert } = useSnackbar(); + + const { mutateAsync } = useMutation(postDRepRetire, { + onSuccess: () => { + setDRep({ deposit: 100, wasRegistered: true, isRegistered: false }); + addSuccessAlert("DRep retired."); + }, + }); + + return { + retire: mutateAsync, + }; +}; diff --git a/src/vva-fe/src/hooks/queries/index.ts b/src/vva-fe/src/hooks/queries/index.ts new file mode 100644 index 000000000..7733019b0 --- /dev/null +++ b/src/vva-fe/src/hooks/queries/index.ts @@ -0,0 +1,9 @@ +export * from "./useGetAdaHolderCurrentDelegationQuery"; +export * from "./useGetAdaHolderVotingPowerQuery"; +export * from "./useGetDRepInfoQuery"; +export * from "./useGetDRepListQuery"; +export * from "./useGetDRepVotesQuery"; +export * from "./useGetDRepVotingPowerQuery"; +export * from "./useGetProposalQuery"; +export * from "./useGetProposalsQuery"; +export * from "./useGetProposalsInfiniteQuery"; diff --git a/src/vva-fe/src/hooks/queries/useGetAdaHolderCurrentDelegationQuery.ts b/src/vva-fe/src/hooks/queries/useGetAdaHolderCurrentDelegationQuery.ts new file mode 100644 index 000000000..b780796b9 --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetAdaHolderCurrentDelegationQuery.ts @@ -0,0 +1,20 @@ +import { useQuery } from "react-query"; + +import { getAdaHolderCurrentDelegation } from "@services"; +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; + +export const useGetAdaHolderCurrentDelegationQuery = (stakeKey?: string) => { + const { delegateTransaction } = useCardano(); + + const { data, isLoading } = useQuery({ + queryKey: [ + QUERY_KEYS.getAdaHolderCurrentDelegationKey, + delegateTransaction.transactionHash, + ], + queryFn: async () => await getAdaHolderCurrentDelegation({ stakeKey }), + enabled: !!stakeKey, + }); + + return { currentDelegation: data, isCurrentDelegationLoading: isLoading }; +}; diff --git a/src/vva-fe/src/hooks/queries/useGetAdaHolderVotingPowerQuery.ts b/src/vva-fe/src/hooks/queries/useGetAdaHolderVotingPowerQuery.ts new file mode 100644 index 000000000..6bc338cec --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetAdaHolderVotingPowerQuery.ts @@ -0,0 +1,17 @@ +import { useQuery } from "react-query"; + +import { getAdaHolderVotingPower } from "@services"; +import { QUERY_KEYS } from "@consts"; + +const REFRESH_TIME = 20 * 1000; + +export const useGetAdaHolderVotingPowerQuery = (stakeKey?: string) => { + const { data, isLoading } = useQuery({ + queryKey: QUERY_KEYS.getAdaHolderVotingPowerKey, + queryFn: async () => await getAdaHolderVotingPower({ stakeKey }), + enabled: !!stakeKey, + refetchInterval: REFRESH_TIME, + }); + + return { votingPower: data, powerIsLoading: isLoading }; +}; diff --git a/src/vva-fe/src/hooks/queries/useGetDRepInfoQuery.ts b/src/vva-fe/src/hooks/queries/useGetDRepInfoQuery.ts new file mode 100644 index 000000000..2ac0e85a2 --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetDRepInfoQuery.ts @@ -0,0 +1,20 @@ +import { useQuery } from "react-query"; + +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; +import { getDRepInfo } from "@services"; + +export const useGetDRepInfo = () => { + const { registerTransaction, dRepID } = useCardano(); + + const { data, isLoading } = useQuery({ + queryKey: [ + QUERY_KEYS.useGetDRepInfoKey, + registerTransaction?.transactionHash, + ], + enabled: !!dRepID, + queryFn: async () => await getDRepInfo(dRepID), + }); + + return { data, isLoading }; +}; diff --git a/src/vva-fe/src/hooks/queries/useGetDRepListQuery.ts b/src/vva-fe/src/hooks/queries/useGetDRepListQuery.ts new file mode 100644 index 000000000..f1471eab0 --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetDRepListQuery.ts @@ -0,0 +1,19 @@ +import { useQuery } from "react-query"; + +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; +import { getDRepList } from "@services"; + +export const useGetDRepListQuery = () => { + const { registerTransaction } = useCardano(); + + const { data, isLoading } = useQuery({ + queryKey: [ + QUERY_KEYS.useGetDRepListKey, + registerTransaction?.transactionHash, + ], + queryFn: getDRepList, + }); + + return { data, isLoading }; +}; diff --git a/src/vva-fe/src/hooks/queries/useGetDRepVotesQuery.ts b/src/vva-fe/src/hooks/queries/useGetDRepVotesQuery.ts new file mode 100644 index 000000000..24667b2f9 --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetDRepVotesQuery.ts @@ -0,0 +1,48 @@ +import { useQuery } from "react-query"; + +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; +import { getDRepVotes } from "@services"; +import { VotedProposal } from "@/models/api"; + +export const useGetDRepVotesQuery = (filters: string[], sorting: string) => { + const { dRepID: dRepId, voteTransaction } = useCardano(); + + const { data, isLoading, refetch, isRefetching } = useQuery({ + queryKey: [ + QUERY_KEYS.useGetDRepVotesKey, + voteTransaction.transactionHash, + filters, + sorting, + ], + queryFn: async () => { + return await getDRepVotes({ dRepId, filters, sorting }); + }, + enabled: !!dRepId, + }); + + const groupedByType = data?.reduce((groups, item) => { + const itemType = item.proposal.type; + + if (!groups[itemType]) { + groups[itemType] = { + title: itemType, + actions: [], + }; + } + + groups[itemType].actions.push(item); + + return groups; + }, {}); + + return { + data: Object.values(groupedByType ?? []) as { + title: string; + actions: VotedProposal[]; + }[], + dRepVotesAreLoading: isLoading, + refetch, + isRefetching, + }; +}; diff --git a/src/vva-fe/src/hooks/queries/useGetDRepVotingPowerQuery.ts b/src/vva-fe/src/hooks/queries/useGetDRepVotingPowerQuery.ts new file mode 100644 index 000000000..554a81688 --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetDRepVotingPowerQuery.ts @@ -0,0 +1,19 @@ +import { useQuery } from "react-query"; + +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; +import { getDRepVotingPower } from "@services"; + +export const useGetDRepVotingPowerQuery = () => { + const { dRepID: dRepId } = useCardano(); + + const { data, isLoading } = useQuery({ + queryKey: QUERY_KEYS.useGetDRepVotingPowerKey, + queryFn: async () => { + return await getDRepVotingPower({ dRepId }); + }, + enabled: !!dRepId, + }); + + return { data, isLoading }; +}; diff --git a/src/vva-fe/src/hooks/queries/useGetProposalQuery.ts b/src/vva-fe/src/hooks/queries/useGetProposalQuery.ts new file mode 100644 index 000000000..46000e03a --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetProposalQuery.ts @@ -0,0 +1,38 @@ +import { useQuery } from "react-query"; + +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; +import { getProposal } from "@services"; + +export const useGetProposalQuery = (proposalId: string, enabled?: boolean) => { + const { dRepID } = useCardano(); + + const request = useQuery( + [QUERY_KEYS.useGetProposalKey, dRepID], + async () => { + return await getProposal(proposalId, dRepID); + }, + { + staleTime: Infinity, + enabled, + } + ); + + const data = request.data as { + proposal: ActionType; + vote: { + proposalId: string; + drepId: string; + vote: string; + url: string; + metadataHash: string; + }; + }; + + return { + data, + isLoading: request.isLoading, + refetch: request.refetch, + isFetching: request.isRefetching, + }; +}; diff --git a/src/vva-fe/src/hooks/queries/useGetProposalsInfiniteQuery.ts b/src/vva-fe/src/hooks/queries/useGetProposalsInfiniteQuery.ts new file mode 100644 index 000000000..c25b04f29 --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetProposalsInfiniteQuery.ts @@ -0,0 +1,57 @@ +import { useInfiniteQuery } from "react-query"; + +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; +import { getProposals } from "@services"; + +export const useGetProposalsInfiniteQuery = ( + filters: string[], + sorting: string, + pageSize: number = 10 +) => { + const { voteTransaction, isEnabled } = useCardano(); + + const fetchProposals = async ({ pageParam = 0 }) => { + return await getProposals(filters, sorting, pageParam, pageSize); + }; + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + } = useInfiniteQuery( + [ + QUERY_KEYS.useGetProposalsInfiniteKey, + filters, + sorting, + voteTransaction.proposalId, + isEnabled, + ], + fetchProposals, + { + getNextPageParam: (lastPage) => { + if (lastPage.elements.length === 0) { + return undefined; + } + return lastPage.page + 1; + }, + refetchInterval: 20000, + } + ); + + const proposals = data?.pages.flatMap( + (page) => page.elements + ) as ActionType[]; + + return { + proposals, + isLoading, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + }; +}; diff --git a/src/vva-fe/src/hooks/queries/useGetProposalsQuery.ts b/src/vva-fe/src/hooks/queries/useGetProposalsQuery.ts new file mode 100644 index 000000000..8273dc1b3 --- /dev/null +++ b/src/vva-fe/src/hooks/queries/useGetProposalsQuery.ts @@ -0,0 +1,41 @@ +import { useQuery } from "react-query"; + +import { QUERY_KEYS } from "@consts"; +import { useCardano } from "@context"; +import { getProposals } from "@services"; + +export const useGetProposalsQuery = ( + filters: string[], + sorting: string +): { + proposals: ActionType[]; + isLoading: boolean; +} => { + const { voteTransaction, isEnabled } = useCardano(); + + const fetchProposals = async () => { + const allProposals = await Promise.all( + filters.map((filter) => getProposals([filter], sorting)) + ); + + return allProposals.flatMap((proposal) => proposal.elements || []); + }; + + const request = useQuery( + [ + QUERY_KEYS.useGetProposalsKey, + filters, + sorting, + voteTransaction.proposalId, + isEnabled, + ], + fetchProposals + ); + + const proposals = request.data as ActionType[]; + + return { + proposals, + isLoading: request.isLoading, + }; +}; diff --git a/src/vva-fe/src/hooks/useFetchNextPageDetector.ts b/src/vva-fe/src/hooks/useFetchNextPageDetector.ts new file mode 100644 index 000000000..752628cec --- /dev/null +++ b/src/vva-fe/src/hooks/useFetchNextPageDetector.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; + +const windowHeightFetchThreshold = 0.85; + +export const useFetchNextPageDetector = ( + fetchNextPage: () => void, + isLoading: boolean, + hasNextPage?: boolean +) => { + useEffect(() => { + const onScroll = () => { + const scrollTop = document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const fullHeight = document.documentElement.offsetHeight; + + if ( + scrollTop + windowHeight > fullHeight * windowHeightFetchThreshold && + hasNextPage && + !isLoading + ) { + fetchNextPage(); + } + }; + + window.addEventListener("scroll", onScroll); + + return () => { + window.removeEventListener("scroll", onScroll); + }; + }, [fetchNextPage, isLoading, hasNextPage]); +}; diff --git a/src/vva-fe/src/hooks/useSaveScrollPosition.ts b/src/vva-fe/src/hooks/useSaveScrollPosition.ts new file mode 100644 index 000000000..b9aebeb71 --- /dev/null +++ b/src/vva-fe/src/hooks/useSaveScrollPosition.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; + +export const useSaveScrollPosition = ( + isLoading: boolean, + isFetching: boolean +) => { + const saveScrollPosition = () => { + sessionStorage.setItem("scrollPosition", window.scrollY.toString()); + }; + + useEffect(() => { + if (!isLoading && !isFetching) { + const savedPosition = sessionStorage.getItem("scrollPosition"); + + if (savedPosition !== null) { + window.scrollTo(0, parseInt(savedPosition, 10)); + sessionStorage.removeItem("scrollPosition"); + } + } + }, [isLoading, isFetching]); + + return saveScrollPosition; +}; diff --git a/src/vva-fe/src/hooks/useScreenDimension.ts b/src/vva-fe/src/hooks/useScreenDimension.ts new file mode 100644 index 000000000..5e93f2295 --- /dev/null +++ b/src/vva-fe/src/hooks/useScreenDimension.ts @@ -0,0 +1,34 @@ +import { useEffect, useMemo, useState } from "react"; + +export const useScreenDimension = () => { + const [screenWidth, setScreenWidth] = useState(window.innerWidth); + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + const pagePadding = useMemo(() => { + return screenWidth < 768 + ? 2 + : screenWidth < 1024 + ? 6 + : screenWidth < 1440 + ? 8 + : screenWidth < 1920 + ? 10 + : 37; + }, [screenWidth]); + + function handleWindowSizeChange() { + setScreenWidth(window.innerWidth); + setIsMobile(window.innerWidth < 768); + } + useEffect(() => { + window.addEventListener("resize", handleWindowSizeChange); + return () => { + window.removeEventListener("resize", handleWindowSizeChange); + }; + }, []); + + return { + screenWidth, + isMobile, + pagePadding, + }; +}; diff --git a/src/vva-fe/src/hooks/useSlider.ts b/src/vva-fe/src/hooks/useSlider.ts new file mode 100644 index 000000000..d3e422082 --- /dev/null +++ b/src/vva-fe/src/hooks/useSlider.ts @@ -0,0 +1,98 @@ +import { ChangeEvent, useState } from "react"; +import { KeenSliderOptions, useKeenSlider } from "keen-slider/react"; + +const WheelControls = (slider: any) => { + let touchTimeout: NodeJS.Timeout; + let position: { x: number; y: number }; + let wheelActive: boolean = false; + + function dispatch(e: WheelEvent, name: string) { + position.x -= e.deltaX; + position.y -= e.deltaY; + slider.container.dispatchEvent( + new CustomEvent(name, { + detail: { + x: position.x, + y: position.y, + }, + }) + ); + } + + function eventWheel(e: WheelEvent) { + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + e.preventDefault(); + if (!wheelActive) { + position = { + x: e.pageX, + y: e.pageY, + }; + dispatch(e, "ksDragStart"); + wheelActive = true; + } + dispatch(e, "ksDrag"); + clearTimeout(touchTimeout); + touchTimeout = setTimeout(() => { + wheelActive = false; + dispatch(e, "ksDragEnd"); + }, 50); + } + } + + slider.on("created", () => { + slider.container.addEventListener("wheel", eventWheel, { + passive: false, + }); + }); +}; + +export const useSlider = ({ + config, + sliderMaxLength, +}: { + config: KeenSliderOptions; + sliderMaxLength: number; +}) => { + const [currentSlide, setCurrentSlide] = useState(0); + const [currentRange, setCurrentRange] = useState(0); + + const [sliderRef, instanceRef] = useKeenSlider( + { + ...config, + rubberband: false, + detailsChanged: (slider) => { + setCurrentRange(slider.track.details.progress * sliderMaxLength); + setCurrentSlide(slider.track.details.rel); + }, + }, + [WheelControls] + ); + + const DATA_LENGTH = instanceRef?.current?.slides?.length ?? 10; + const ITEMS_PER_VIEW = + DATA_LENGTH - (instanceRef?.current?.track?.details?.maxIdx ?? 2); + + const setPercentageValue = (e: ChangeEvent) => { + const target = e?.target; + const currentIndexOfSlide = Math.floor( + +target?.value / + (sliderMaxLength / (DATA_LENGTH - Math.floor(ITEMS_PER_VIEW))) + ); + + instanceRef.current?.track.add( + (+target.value - currentRange) * + (instanceRef.current.track.details.length / sliderMaxLength) + ); + setCurrentRange(+target.value); + setCurrentSlide(currentIndexOfSlide); + }; + + return { + sliderRef, + instanceRef, + currentSlide, + currentRange, + setCurrentRange, + setPercentageValue, + }; +}; diff --git a/src/vva-fe/src/main.tsx b/src/vva-fe/src/main.tsx new file mode 100644 index 000000000..206c03e80 --- /dev/null +++ b/src/vva-fe/src/main.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import { ThemeProvider } from "@emotion/react"; +import { ContextProviders } from "@context"; +import { theme } from "./theme.ts"; +import { + BrowserRouter, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "react-query"; +import * as Sentry from "@sentry/react"; +import TagManager from "react-gtm-module"; + +const queryClient = new QueryClient(); + +declare global { + interface Window { + dataLayer: SentryEventDataLayer[]; + } +} + +interface SentryEventDataLayer { + event: string; + sentryEventId: string; + sentryErrorMessage?: any; +} + +const tagManagerArgs = { + gtmId: import.meta.env.VITE_GTM_ID, +}; + +TagManager.initialize(tagManagerArgs); + +Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes + ), + }), + new Sentry.Replay(), + ], + + tracesSampleRate: 1.0, + + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, +}); + +Sentry.addGlobalEventProcessor((event) => { + window.dataLayer = window.dataLayer || []; + + const errorMessage = + (event.exception && + event.exception.values && + event.exception.values[0] && + event.exception.values[0].value) || + "Unknown Error"; + + window.dataLayer.push({ + event: "sentryEvent", + sentryEventId: event.event_id || "default_event_id", + sentryErrorMessage: errorMessage, + }); + + return event; +}); + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + + + + + +); diff --git a/src/vva-fe/src/mock/index.ts b/src/vva-fe/src/mock/index.ts new file mode 100644 index 000000000..30dd7145f --- /dev/null +++ b/src/vva-fe/src/mock/index.ts @@ -0,0 +1,2 @@ +export * from "./toVote"; +export * from "./voteOn"; diff --git a/src/vva-fe/src/mock/toVote.ts b/src/vva-fe/src/mock/toVote.ts new file mode 100644 index 000000000..c58f7b3ab --- /dev/null +++ b/src/vva-fe/src/mock/toVote.ts @@ -0,0 +1,43 @@ +export const TO_VOTE_DATA = [ + { + title: "No Confidence", + actions: [ + { actionId: "123456", actionType: "for example", expiryDate: new Date() }, + { actionId: "65432", actionType: "gov action", expiryDate: new Date() }, + { actionId: "123456", actionType: "for example", expiryDate: new Date() }, + { actionId: "65432", actionType: "gov action", expiryDate: new Date() }, + { actionId: "123456", actionType: "for example", expiryDate: new Date() }, + { actionId: "65432", actionType: "gov action", expiryDate: new Date() }, + ], + }, + { + title: "New constitutional committee or quorum size", + actions: [ + { actionId: "abc123", actionType: "action gov", expiryDate: new Date() }, + { + actionId: "123cba", + actionType: "voting action", + expiryDate: new Date(), + }, + { actionId: "123456", actionType: "for example", expiryDate: new Date() }, + { actionId: "65432", actionType: "gov action", expiryDate: new Date() }, + { actionId: "123456", actionType: "for example", expiryDate: new Date() }, + { actionId: "65432", actionType: "gov action", expiryDate: new Date() }, + ], + }, + { + title: "New constitutional committee or quorum size", + actions: [ + { actionId: "abc123", actionType: "action gov", expiryDate: new Date() }, + { + actionId: "123cba", + actionType: "voting action", + expiryDate: new Date(), + }, + { actionId: "123456", actionType: "for example", expiryDate: new Date() }, + { actionId: "65432", actionType: "gov action", expiryDate: new Date() }, + { actionId: "123456", actionType: "for example", expiryDate: new Date() }, + { actionId: "65432", actionType: "gov action", expiryDate: new Date() }, + ], + }, +] as ToVoteDataType; diff --git a/src/vva-fe/src/mock/voteOn.ts b/src/vva-fe/src/mock/voteOn.ts new file mode 100644 index 000000000..20f49a257 --- /dev/null +++ b/src/vva-fe/src/mock/voteOn.ts @@ -0,0 +1,36 @@ +export const VOTE_ON_DATA = [ + { + title: "No Confidence", + actions: [ + { + id: "123456", + type: "for example", + vote: "yes", + expiryDate: "1970-01-01", + }, + { + id: "65432", + type: "gov action", + vote: "no", + expiryDate: "1970-01-01", + }, + ], + }, + { + title: "New constitutional committee or quorum size", + actions: [ + { + id: "abc123", + type: "action gov", + vote: "abstain", + expiryDate: "1970-01-01", + }, + { + id: "123cba", + type: "voting action", + vote: "yes", + expiryDate: "1970-01-01", + }, + ], + }, +] as VotedOnDataType; diff --git a/src/vva-fe/src/models/api.ts b/src/vva-fe/src/models/api.ts new file mode 100644 index 000000000..03fec9e2b --- /dev/null +++ b/src/vva-fe/src/models/api.ts @@ -0,0 +1,38 @@ +export interface DRepInfo { + isRegistered: boolean; + wasRegistered: boolean; + deposit: number; +} + +export interface DRepData { + drepId: string; + url: string; + metadataHash: string; + deposit: number; +} + +export type Vote = "yes" | "no" | "abstain"; + +export interface VotedProposal { + vote: { + proposalId: string; + drepId: string; + vote: Vote; + url: string; + metadataHash: string; + }; + proposal: { + id: string; + type: string; + details: string; + expiryDate: string; + createdDate: string; + url: string; + metadataHash: string; + yesVotes: number; + noVotes: number; + abstainVotes: number; + txHash: string; + index: number; + }; +} diff --git a/src/vva-fe/src/models/index.ts b/src/vva-fe/src/models/index.ts new file mode 100644 index 000000000..331bf6b43 --- /dev/null +++ b/src/vva-fe/src/models/index.ts @@ -0,0 +1,3 @@ +export * from "./api"; +export * from "./snackbar"; +export * from "./wallet"; diff --git a/src/vva-fe/src/models/snackbar.ts b/src/vva-fe/src/models/snackbar.ts new file mode 100644 index 000000000..cbf190bcb --- /dev/null +++ b/src/vva-fe/src/models/snackbar.ts @@ -0,0 +1 @@ +export type SnackbarSeverity = "success" | "error" | "warning"; diff --git a/src/vva-fe/src/models/wallet.ts b/src/vva-fe/src/models/wallet.ts new file mode 100644 index 000000000..ebba3d0c1 --- /dev/null +++ b/src/vva-fe/src/models/wallet.ts @@ -0,0 +1,106 @@ +declare global { + interface Window { + cardano: { + [key: string]: CardanoBrowserWallet; + }; + } +} + +interface Extension { + cip: number; +} + +export interface EnableExtensionPayload { + extensions: Extension[]; +} + +export interface CardanoBrowserWallet { + apiVersion: string; + enable(extensions?: EnableExtensionPayload): Promise; + icon: string; + isEnabled(): Promise; + name: string; + supportedExtensions: Extension[]; +} + +export interface Protocol { + block_id: number; + coins_per_utxo_size: number; + collateral_percent: number; + committee_max_term_length: number; + committee_min_size: number; + cost_model_id: number; + decentralisation: number; + drep_activity: number; + drep_deposit: number; + dvt_committee_no_confidence: number; + dvt_committee_normal: number; + dvt_hard_fork_initiation: number; + dvt_motion_no_confidence: number; + dvt_p_p_economic_group: number; + dvt_p_p_gov_group: number; + dvt_p_p_network_group: number; + dvt_p_p_technical_group: number; + dvt_treasury_withdrawal: number; + dvt_update_to_constitution: number; + epoch_no: number; + extra_entropy: any; + gov_action_deposit: number; + gov_action_lifetime: number; + id: number; + influence: number; + key_deposit: number; + max_bh_size: number; + max_block_ex_mem: number; + max_block_ex_steps: number; + max_block_size: number; + max_collateral_inputs: number; + max_epoch: number; + max_tx_ex_mem: number; + max_tx_ex_steps: number; + max_tx_size: number; + max_val_size: number; + min_fee_a: number; + min_fee_b: number; + min_pool_cost: number; + min_utxo_value: number; + monetary_expand_rate: number; + nonce: string; + optimal_pool_count: number; + pool_deposit: number; + price_mem: number; + price_step: number; + protocol_major: number; + protocol_minor: number; + pvt_committee_no_confidence: number; + pvt_committee_normal: number; + pvt_hard_fork_initiation: number; + pvt_motion_no_confidence: number; + treasury_growth_rate: number; +} + +export interface CardanoApiWallet { + experimental: any; + cip95: { + getPubDRepKey(): Promise; + getRegisteredPubStakeKeys(): Promise; + getUnregisteredPubStakeKeys(): Promise; + signData(): Promise; + }; + isEnabled(): Promise; + getBalance(): Promise; + getUtxos(): Promise; + getCollateral?(): Promise; + getUsedAddresses(): Promise; + getUnusedAddresses(): Promise; + getChangeAddress(): Promise; + getRewardAddress(): Promise; + getNetworkId(): Promise; + signData(arg0: any, arg1?: any): Promise; + signTx(arg0: any, arg1?: any): Promise; + submitTx(arg0: any): Promise; + onAccountChange(arg0: (addresses: string) => void): Promise; + onNetworkChange(arg0: (network: number) => void): Promise; + getActivePubStakeKeys(): Promise; + getExtensions(): Promise; +} diff --git a/src/vva-fe/src/pages/ChooseStakeKey.tsx b/src/vva-fe/src/pages/ChooseStakeKey.tsx new file mode 100644 index 000000000..254e267f5 --- /dev/null +++ b/src/vva-fe/src/pages/ChooseStakeKey.tsx @@ -0,0 +1,22 @@ +import { Box } from "@mui/material"; + +import { useScreenDimension } from "@/hooks"; +import { Background } from "@atoms"; + +import { TopNav, ChooseStakeKeyPanel, Footer } from "@organisms"; + +export const ChooseStakeKey = () => { + const { isMobile } = useScreenDimension(); + + return ( + + + + + + + {isMobile &&