diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 57691c8cde1..e27314214c2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -11,7 +11,6 @@ env: USES_DOCKER_PGSQL: true PORT: 8080 REDIS_URL: redis://localhost:6379 - ENTITIES_URL: ${{ secrets.ENTITIES_URL }} GITHUB_BASE_REF: ${{ github.base_ref }} FEDERATION_POSTGRES_DB_URL: postgresql://commonwealth:edgeware@localhost/common_test ALCHEMY_PRIVATE_APP_KEY: ${{ secrets.ALCHEMY_PRIVATE_APP_KEY }} @@ -19,8 +18,12 @@ env: on: workflow_dispatch: + inputs: + pr_number: + description: "PR number to test (optional)" + required: false + type: number pull_request: - merge_group: push: branches: - master @@ -59,6 +62,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} @@ -119,6 +124,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} @@ -176,6 +182,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} @@ -210,6 +218,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} @@ -230,6 +239,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} @@ -273,6 +283,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} @@ -328,6 +339,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} @@ -376,6 +389,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} @@ -428,6 +443,8 @@ jobs: - 6379:6379 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number) || github.ref }} - uses: ./.github/actions/setup with: node-version: ${{ matrix.node }} diff --git a/libs/chains/package.json b/libs/chains/package.json index 580f74bcb0f..35403ce3f92 100644 --- a/libs/chains/package.json +++ b/libs/chains/package.json @@ -31,6 +31,7 @@ "protobufjs": "^6.1.13" }, "devDependencies": { + "@atomone/atomone-types-long": "^1.0.3", "tsx": "^4.7.2" } } diff --git a/libs/chains/src/index.ts b/libs/chains/src/index.ts index 760d4e7e524..f60cde6f5cb 100644 --- a/libs/chains/src/index.ts +++ b/libs/chains/src/index.ts @@ -17,6 +17,9 @@ export { type QueryVotesResponseSDKType, } from './cosmos-ts/src/codegen/cosmos/gov/v1/query'; export { LCDQueryClient as GovV1Client } from './cosmos-ts/src/codegen/cosmos/gov/v1/query.lcd'; + +export { LCDQueryClient as GovV1AtomOneClient } from '@atomone/atomone-types-long/atomone/gov/v1/query.lcd'; +export { createLCDClient as createAtomOneLCDClient } from '@atomone/atomone-types-long/atomone/lcd'; export { createLCDClient } from './cosmos-ts/src/codegen/cosmos/lcd'; export * from './cosmos-ts/src/codegen/google/protobuf/any'; export * from './cosmos-ts/src/codegen/helpers'; diff --git a/libs/evm-protocols/src/abis/communityStakesAbi.ts b/libs/evm-protocols/src/abis/communityStakesAbi.ts index f6b04bfd227..fe37f1feb54 100644 --- a/libs/evm-protocols/src/abis/communityStakesAbi.ts +++ b/libs/evm-protocols/src/abis/communityStakesAbi.ts @@ -3,28 +3,149 @@ export const communityStakesAbi = [ inputs: [ { internalType: 'address', - name: 'sharesSubject', + name: '_feeDestination', type: 'address', }, + { internalType: 'address', name: '_factory', type: 'address' }, { internalType: 'uint256', - name: 'id', + name: '_protocolFee', type: 'uint256', }, + { internalType: 'uint256', name: '_namespaceFee', type: 'uint256' }, { + internalType: 'address', + name: '_curveManager', + type: 'address', + }, + { internalType: 'uint256', name: '_supplyCap', type: 'uint256' }, + { + internalType: 'address', + name: '_supplyCapGuardian', + type: 'address', + }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [{ internalType: 'address', name: 'target', type: 'address' }], + name: 'AddressEmptyCode', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'AddressInsufficientBalance', + type: 'error', + }, + { inputs: [], name: 'FailedInnerCall', type: 'error' }, + { + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + ], + name: 'OwnableInvalidOwner', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'OwnableUnauthorizedAccount', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'token', type: 'address' }], + name: 'SafeERC20FailedOperation', + type: 'error', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'previousOwner', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'newOwner', + type: 'address', + }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: 'address', + name: 'trader', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'namespace', + type: 'address', + }, + { indexed: false, internalType: 'bool', name: 'isBuy', type: 'bool' }, + { + indexed: false, internalType: 'uint256', - name: 'amount', + name: 'communityTokenAmount', type: 'uint256', }, - ], - name: 'getBuyPrice', - outputs: [ { + indexed: false, internalType: 'uint256', - name: '', + name: 'ethAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'protocolEthAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'nameSpaceEthAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'supply', type: 'uint256', }, + { + indexed: false, + internalType: 'address', + name: 'exchangeToken', + type: 'address', + }, ], + name: 'Trade', + type: 'event', + }, + { + inputs: [{ internalType: 'address[]', name: 'tokens', type: 'address[]' }], + name: 'blacklistTokens', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'bondingCurveAddress', + outputs: [{ internalType: 'address', name: '', type: 'address' }], stateMutability: 'view', type: 'function', }, @@ -32,28 +153,39 @@ export const communityStakesAbi = [ inputs: [ { internalType: 'address', - name: 'sharesSubject', + name: 'namespaceAddress', type: 'address', }, - { - internalType: 'uint256', - name: 'id', - type: 'uint256', - }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, { internalType: 'uint256', name: 'amount', type: 'uint256', }, ], - name: 'getBuyPriceAfterFee', - outputs: [ + name: 'buyStake', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, { internalType: 'uint256', name: '', type: 'uint256', }, ], + name: 'curveId', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'factory', + outputs: [{ internalType: 'address', name: '', type: 'address' }], stateMutability: 'view', type: 'function', }, @@ -61,102 +193,309 @@ export const communityStakesAbi = [ inputs: [ { internalType: 'address', - name: 'sharesSubject', + name: 'namespaceAddress', type: 'address', }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, { internalType: 'uint256', - name: 'id', + name: 'amount', type: 'uint256', }, + ], + name: 'getBuyPrice', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'namespaceAddress', + type: 'address', + }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, { internalType: 'uint256', name: 'amount', type: 'uint256', }, ], - name: 'getSellPrice', - outputs: [ + name: 'getBuyPriceAfterFee', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ { - internalType: 'uint256', - name: '', - type: 'uint256', + internalType: 'address', + name: 'namespaceAddress', + type: 'address', }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, ], + name: 'getDecimals', + outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], stateMutability: 'view', type: 'function', }, { inputs: [ + { internalType: 'uint256', name: '_supply', type: 'uint256' }, { internalType: 'address', - name: 'sharesSubject', + name: 'namespaceAddress', type: 'address', }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, { internalType: 'uint256', - name: 'id', + name: 'amount', type: 'uint256', }, + ], + name: 'getPrice', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'namespaceAddress', + type: 'address', + }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, { internalType: 'uint256', name: 'amount', type: 'uint256', }, ], - name: 'getSellPriceAfterFee', - outputs: [ + name: 'getSellPrice', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'namespaceAddress', + type: 'address', + }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, { internalType: 'uint256', - name: '', + name: 'amount', type: 'uint256', }, ], + name: 'getSellPriceAfterFee', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'namespaceFeePercent', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], stateMutability: 'view', type: 'function', }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'protocolFeeDestination', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'protocolFeePercent', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'renounceOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, { inputs: [ { internalType: 'address', - name: 'sharesSubject', + name: 'namespaceAddress', type: 'address', }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, { internalType: 'uint256', - name: 'id', + name: 'amount', type: 'uint256', }, + ], + name: 'sellStake', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '_bondingCurveAddress', + type: 'address', + }, + ], + name: 'setBondingCurveAddress', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '_factory', type: 'address' }], + name: 'setFactory', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '_feeDestination', type: 'address' }, + ], + name: 'setFeeDestination', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'supplyCapGuardian', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '', type: 'address' }], + name: 'tokenBlacklist', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'totalSupply', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'totalSupplyCap', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newGuardian', type: 'address' }], + name: 'transferGuardian', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'newCap', type: 'uint256' }], + name: 'updateSupplyCap', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, { internalType: 'uint256', - name: 'amount', + name: '', type: 'uint256', }, ], - stateMutability: 'payable', + name: 'whitelist', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', type: 'function', - name: 'buyStake', }, { inputs: [ { internalType: 'address', - name: 'sharesSubject', + name: 'namespaceAddress', + type: 'address', + }, + { internalType: 'uint256', name: 'id', type: 'uint256' }, + { + internalType: 'address', + name: 'exchangeToken', type: 'address', }, + { internalType: 'uint256', name: 'scalar', type: 'uint256' }, { internalType: 'uint256', - name: 'id', + name: 'curve', type: 'uint256', }, + ], + name: 'whitelistId', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, { internalType: 'uint256', - name: 'amount', + name: '', type: 'uint256', }, ], - stateMutability: 'payable', + name: 'whitelistedExchangeToken', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: '', type: 'address' }, + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + name: 'whitelistedScaler', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', type: 'function', - name: 'sellStake', }, ]; diff --git a/libs/evm-protocols/src/abis/contestAbi.ts b/libs/evm-protocols/src/abis/contestAbi.ts index 35a068802f3..0185db2df03 100644 --- a/libs/evm-protocols/src/abis/contestAbi.ts +++ b/libs/evm-protocols/src/abis/contestAbi.ts @@ -1,3 +1,4 @@ +// TODO: delete or remove unnecessary items export const contestAbi = [ { inputs: [ @@ -446,4 +447,129 @@ export const contestAbi = [ type: 'function', }, { stateMutability: 'payable', type: 'receive' }, + { + type: 'function', + name: 'contestEnded', + inputs: [], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contestStarted', + inputs: [], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'owner', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'protocolFee', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'renounceOwnership', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'sweepTokens', + inputs: [ + { name: 'tokenAddress', type: 'address', internalType: 'address' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'totalPrize', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'transferOwnership', + inputs: [{ name: 'newOwner', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'event', + name: 'NewSingleContestStarted', + inputs: [ + { + name: 'startTime', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'endTime', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipTransferred', + inputs: [ + { + name: 'previousOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'newOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'TokenSwept', + inputs: [ + { + name: 'token', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'OwnableInvalidOwner', + inputs: [{ name: 'owner', type: 'address', internalType: 'address' }], + }, + { + type: 'error', + name: 'OwnableUnauthorizedAccount', + inputs: [{ name: 'account', type: 'address', internalType: 'address' }], + }, ]; diff --git a/libs/evm-protocols/src/abis/index.ts b/libs/evm-protocols/src/abis/index.ts new file mode 100644 index 00000000000..f5dcb7901a8 --- /dev/null +++ b/libs/evm-protocols/src/abis/index.ts @@ -0,0 +1,11 @@ +export * from './communityStakesAbi'; +export * from './contestAbi'; +export * from './erc20Abi'; +export * from './feeManagerAbi'; +export * from './launchpadFactoryAbi'; +export * from './lpBondingCurveAbi'; +export * from './namespaceAbi'; +export * from './namespaceFactoryAbi'; +export * from './reservationHookAbi'; +export * from './singleContestAbi'; +export * from './tokenCommunityManagerAbi'; diff --git a/libs/evm-protocols/src/abis/launchpadFactoryAbi.ts b/libs/evm-protocols/src/abis/launchpadFactoryAbi.ts index d0336bbfb42..a25eee66df3 100644 --- a/libs/evm-protocols/src/abis/launchpadFactoryAbi.ts +++ b/libs/evm-protocols/src/abis/launchpadFactoryAbi.ts @@ -1,4 +1,55 @@ export const launchpadFactoryAbi = [ + { + type: 'constructor', + inputs: [ + { name: '_defaultLPHook', type: 'address', internalType: 'address' }, + { + name: '_protocolVault', + type: 'address', + internalType: 'address', + }, + { name: '_protocolFee', type: 'uint256', internalType: 'uint256' }, + { + name: '_curveManager', + type: 'address', + internalType: 'address', + }, + { name: '_curveActionHook', type: 'address', internalType: 'address' }, + ], + stateMutability: 'nonpayable', + }, + { + name: 'bondingCurve', + type: 'function', + inputs: [], + outputs: [ + { name: '', type: 'address', internalType: 'contract LPBondingCurve' }, + ], + stateMutability: 'view', + }, + { + name: 'bondingCurveAddress', + type: 'function', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + name: 'curveActionHook', + type: 'function', + inputs: [], + outputs: [ + { name: '', type: 'address', internalType: 'contract ICurveActionHook' }, + ], + stateMutability: 'view', + }, + { + name: 'defaultLPHook', + type: 'function', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, { type: 'function', name: 'launchTokenWithLiquidity', @@ -17,4 +68,81 @@ export const launchpadFactoryAbi = [ outputs: [], stateMutability: 'payable', }, + { + name: 'protocolFee', + type: 'function', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + name: 'protocolVault', + type: 'function', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + name: 'LaunchpadCreated', + type: 'event', + inputs: [ + { + name: 'launchpad', + type: 'address', + indexed: false, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + name: 'NewTokenCreated', + type: 'event', + inputs: [ + { + name: 'token', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'totalSupply', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'name', + type: 'string', + indexed: false, + internalType: 'string', + }, + { + name: 'symbol', + type: 'string', + indexed: false, + internalType: 'string', + }, + ], + anonymous: false, + }, + { + name: 'TokenRegistered', + type: 'event', + inputs: [ + { + name: 'token', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'curveId', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, ]; diff --git a/libs/evm-protocols/src/abis/recurringContestAbi.ts b/libs/evm-protocols/src/abis/recurringContestAbi.ts new file mode 100644 index 00000000000..9b7467e519d --- /dev/null +++ b/libs/evm-protocols/src/abis/recurringContestAbi.ts @@ -0,0 +1,426 @@ +export const recurringContestAbi = [ + { + type: 'constructor', + inputs: [ + { + name: 'initParams', + type: 'tuple', + internalType: 'struct ContestGovernor.InitializationParams', + components: [ + { name: '_startTime', type: 'uint256', internalType: 'uint256' }, + { + name: '_contestInterval', + type: 'uint256', + internalType: 'uint256', + }, + { name: '_votingStrategy', type: 'address', internalType: 'address' }, + { + name: '_claimHookAddress', + type: 'address', + internalType: 'address', + }, + { + name: '_contentHookAddress', + type: 'address', + internalType: 'address', + }, + { + name: '_winnerShares', + type: 'uint256[]', + internalType: 'uint256[]', + }, + { name: '_contestToken', type: 'address', internalType: 'address' }, + { + name: '_prizeShare', + type: 'uint256', + internalType: 'uint256', + }, + { name: '_voterShare', type: 'uint256', internalType: 'uint256' }, + { + name: '_namespace', + type: 'address', + internalType: 'address', + }, + { + name: '_protocolFeePercentage', + type: 'uint256', + internalType: 'uint256', + }, + { + name: '_protocolFeeDestination', + type: 'address', + internalType: 'address', + }, + ], + }, + ], + stateMutability: 'nonpayable', + }, + { type: 'receive', stateMutability: 'payable' }, + { + type: 'function', + name: 'FeeMangerAddress', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'addContent', + inputs: [ + { name: 'creator', type: 'address', internalType: 'address' }, + { + name: 'url', + type: 'string', + internalType: 'string', + }, + { name: 'data', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'claimHook', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IContestGovernorClaimHook', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'claimVoterRewards', + inputs: [{ name: 'voter', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'completedContests', + inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + outputs: [ + { name: 'claimed', type: 'bool', internalType: 'bool' }, + { + name: 'totalPrize', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'content', + inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + outputs: [ + { name: 'url', type: 'string', internalType: 'string' }, + { + name: 'cumulativeVotes', + type: 'uint256', + internalType: 'uint256', + }, + { name: 'creator', type: 'address', internalType: 'address' }, + { + name: 'isWinner', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contentHook', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IContestGovernorContentHook', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contestId', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contestInterval', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contestToken', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'currMinWinVotes', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'currentContentId', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'deposit', + inputs: [{ name: 'amount', type: 'uint256', internalType: 'uint256' }], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'endTime', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getPastWinners', + inputs: [{ name: 'contestId', type: 'uint256', internalType: 'uint256' }], + outputs: [{ name: '', type: 'uint256[]', internalType: 'uint256[]' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getWinnerIds', + inputs: [], + outputs: [{ name: '', type: 'uint256[]', internalType: 'uint256[]' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'namespace', + inputs: [], + outputs: [ + { name: '', type: 'address', internalType: 'contract INamespace' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'newContest', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'nextPrizeShare', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'prizeShare', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'protocolFeeDestination', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'protocolFeePercentage', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'startTime', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'updatePrizeShare', + inputs: [{ name: 'newShare', type: 'uint256', internalType: 'uint256' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'voteContent', + inputs: [ + { name: 'voter', type: 'address', internalType: 'address' }, + { + name: 'id', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'voterShare', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'votingStrategy', + inputs: [], + outputs: [ + { name: '', type: 'address', internalType: 'contract IVotingStrategy' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'winnerIds', + inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'event', + name: 'ContentAdded', + inputs: [ + { + name: 'contentId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'creator', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'url', + type: 'string', + indexed: false, + internalType: 'string', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'NewRecurringContestStarted', + inputs: [ + { + name: 'contestId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'startTime', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'endTime', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'PrizeShareUpdated', + inputs: [ + { + name: 'newPrizeShare', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'TransferFailed', + inputs: [ + { + name: 'token', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'to', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'VoterVoted', + inputs: [ + { + name: 'voter', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'contentId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'contestId', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'votingPower', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, +] as const; diff --git a/libs/evm-protocols/src/abis/singleContestAbi.ts b/libs/evm-protocols/src/abis/singleContestAbi.ts new file mode 100644 index 00000000000..0a9d0851148 --- /dev/null +++ b/libs/evm-protocols/src/abis/singleContestAbi.ts @@ -0,0 +1,427 @@ +export const singleContestAbi = [ + { + type: 'constructor', + inputs: [ + { name: '_contestLength', type: 'uint256', internalType: 'uint256' }, + { + name: '_votingStrategy', + type: 'address', + internalType: 'address', + }, + { name: '_claimHookAddress', type: 'address', internalType: 'address' }, + { + name: '_contentHookAddress', + type: 'address', + internalType: 'address', + }, + { name: '_winnerShares', type: 'uint256[]', internalType: 'uint256[]' }, + { + name: '_contestToken', + type: 'address', + internalType: 'address', + }, + { name: '_voterShare', type: 'uint256', internalType: 'uint256' }, + { + name: '_namespace', + type: 'address', + internalType: 'address', + }, + { + name: '_protocolFeePercentage', + type: 'uint256', + internalType: 'uint256', + }, + { + name: '_protocolFeeDestination', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'nonpayable', + }, + { type: 'receive', stateMutability: 'payable' }, + { + type: 'function', + name: 'addContent', + inputs: [ + { name: 'creator', type: 'address', internalType: 'address' }, + { + name: 'url', + type: 'string', + internalType: 'string', + }, + { name: 'data', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'claimHook', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IContestGovernorClaimHook', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'claimVoterRewards', + inputs: [{ name: 'voter', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'content', + inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + outputs: [ + { name: 'url', type: 'string', internalType: 'string' }, + { + name: 'cumulativeVotes', + type: 'uint256', + internalType: 'uint256', + }, + { name: 'creator', type: 'address', internalType: 'address' }, + { + name: 'completed', + type: 'bool', + internalType: 'bool', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contentHook', + inputs: [], + outputs: [ + { + name: '', + type: 'address', + internalType: 'contract IContestGovernorContentHook', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contestEnded', + inputs: [], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contestLength', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contestStarted', + inputs: [], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'contestToken', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'currMinWinVotes', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'currentContentId', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'deposit', + inputs: [{ name: 'amount', type: 'uint256', internalType: 'uint256' }], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'endContest', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'endTime', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getWinnerIds', + inputs: [], + outputs: [{ name: '', type: 'uint256[]', internalType: 'uint256[]' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'namespace', + inputs: [], + outputs: [ + { name: '', type: 'address', internalType: 'contract INamespace' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'owner', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'protocolFee', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'protocolFeeDestination', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'protocolFeePercentage', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'renounceOwnership', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'startTime', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'sweepTokens', + inputs: [ + { name: 'tokenAddress', type: 'address', internalType: 'address' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'totalPrize', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'transferOwnership', + inputs: [{ name: 'newOwner', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'voteContent', + inputs: [ + { name: 'voter', type: 'address', internalType: 'address' }, + { + name: 'id', + type: 'uint256', + internalType: 'uint256', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'voterShare', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'votingStrategy', + inputs: [], + outputs: [ + { name: '', type: 'address', internalType: 'contract IVotingStrategy' }, + ], + stateMutability: 'view', + }, + { + type: 'event', + name: 'ContentAdded', + inputs: [ + { + name: 'contentId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'creator', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'url', + type: 'string', + indexed: false, + internalType: 'string', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'NewSingleContestStarted', + inputs: [ + { + name: 'startTime', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + { + name: 'endTime', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipTransferred', + inputs: [ + { + name: 'previousOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'newOwner', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'TokenSwept', + inputs: [ + { + name: 'token', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'TransferFailed', + inputs: [ + { + name: 'token', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'to', + type: 'address', + indexed: false, + internalType: 'address', + }, + { + name: 'amount', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'VoterVoted', + inputs: [ + { + name: 'voter', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'contentId', + type: 'uint256', + indexed: true, + internalType: 'uint256', + }, + { + name: 'votingPower', + type: 'uint256', + indexed: false, + internalType: 'uint256', + }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'OwnableInvalidOwner', + inputs: [{ name: 'owner', type: 'address', internalType: 'address' }], + }, + { + type: 'error', + name: 'OwnableUnauthorizedAccount', + inputs: [{ name: 'account', type: 'address', internalType: 'address' }], + }, +] as const; diff --git a/libs/evm-protocols/src/common-protocol/chainConfig.ts b/libs/evm-protocols/src/common-protocol/chainConfig.ts index 5759bc5bbb0..638e6dc2908 100644 --- a/libs/evm-protocols/src/common-protocol/chainConfig.ts +++ b/libs/evm-protocols/src/common-protocol/chainConfig.ts @@ -12,6 +12,15 @@ export enum ValidChains { SKALE_TEST = 974399131, } +/** + * Type guard to verify if a given number is a value in the ValidChains enum. + * @param chainId - The number to verify. + * @returns boolean - true if the number is a valid chain ID. + */ +export function isValidChain(chainId: number): chainId is ValidChains { + return Object.values(ValidChains).includes(chainId); +} + export const STAKE_ID = 2; export const CONTEST_VOTER_SHARE = 0; export const CONTEST_FEE_SHARE = 100; @@ -28,7 +37,7 @@ type factoryContractsType = { }; // Requires a live contract for each enum chain. Add address of factory here on new deploy. -// WARNING: ADD THE CONTRACT IN EvmEventSources TABLE VIA MIGRATION IF ADDING HERE! +// WARNING: UPDATE THE EvmEventSources.parent_contract_address IN THE DB IF THE FACTORY ADDRESS IS UPDATED export const factoryContracts = { [ValidChains.Sepolia]: { factory: '0xEAB6373E6a722EeC8A65Fd38b014d8B81d5Bc1d4', @@ -54,18 +63,18 @@ export const factoryContracts = { chainId: 8453, }, [ValidChains.Linea]: { - factory: '0xe3ae9569f4523161742414480f87967e991741bd', - communityStake: '0xcc752fd15a7dd0d5301b6a626316e7211352cf62', + factory: '0xE3AE9569f4523161742414480f87967e991741bd', + communityStake: '0xcc752fd15A7Dd0d5301b6A626316E7211352Cf62', chainId: 59144, }, [ValidChains.Optimism]: { - factory: '0xe3ae9569f4523161742414480f87967e991741bd', - communityStake: '0xcc752fd15a7dd0d5301b6a626316e7211352cf62', + factory: '0xE3AE9569f4523161742414480f87967e991741bd', + communityStake: '0xcc752fd15A7Dd0d5301b6A626316E7211352Cf62', chainId: 10, }, [ValidChains.Mainnet]: { - factory: '0x90aa47bf6e754f69ee53f05b5187b320e3118b0f', - communityStake: '0x9ed281e62db1b1d98af90106974891a4c1ca3a47', + factory: '0x90aa47bf6e754f69ee53F05b5187B320E3118B0f', + communityStake: '0x9ed281E62dB1b1d98aF90106974891a4c1cA3a47', chainId: 1, }, [ValidChains.Arbitrum]: { @@ -74,13 +83,13 @@ export const factoryContracts = { chainId: 42161, }, [ValidChains.BSC]: { - factory: '0xe3ae9569f4523161742414480f87967e991741bd', - communityStake: '0xcc752fd15a7dd0d5301b6a626316e7211352cf62', + factory: '0xE3AE9569f4523161742414480f87967e991741bd', + communityStake: '0xcc752fd15A7Dd0d5301b6A626316E7211352Cf62', chainId: 56, }, [ValidChains.SKALE_TEST]: { - factory: '0x16da329328d9816b5e68d96ec5944d939ed9727e', - communityStake: '0xc49eecf7af055c4dfa3e918662d9bbac45544bd6', + factory: '0x16da329328d9816b5e68D96Ec5944D939ed9727E', + communityStake: '0xC49eEcf7af055c4dfA3E918662D9BbAC45544BD6', chainId: 974399131, }, } as const satisfies factoryContractsType; diff --git a/libs/evm-protocols/src/common-protocol/utils.ts b/libs/evm-protocols/src/common-protocol/utils.ts index ec04f2ee965..2761c874c87 100644 --- a/libs/evm-protocols/src/common-protocol/utils.ts +++ b/libs/evm-protocols/src/common-protocol/utils.ts @@ -1,15 +1,12 @@ -import { BigNumber } from '@ethersproject/bignumber'; - export const calculateVoteWeight = ( balance: string, // should be in wei voteWeight: number = 0, -): BigNumber | null => { + precision: number = 10 ** 18, // precision factor for multiplying +): bigint | null => { if (!balance || voteWeight <= 0) return null; - const bigBalance = BigNumber.from(balance); - const precision = 1e6; - const scaledVoteWeight = Math.floor(voteWeight * precision); - const result = bigBalance.mul(scaledVoteWeight).div(precision); - return result; + // solution to multiply BigInt with fractional vote weight + const scaledVoteWeight = BigInt(Math.round(voteWeight * precision)); + return (BigInt(balance) * scaledVoteWeight) / BigInt(precision); }; export enum Denominations { diff --git a/libs/evm-protocols/src/event-registry/eventRegistry.ts b/libs/evm-protocols/src/event-registry/eventRegistry.ts index ff0a5488aef..320aaf56539 100644 --- a/libs/evm-protocols/src/event-registry/eventRegistry.ts +++ b/libs/evm-protocols/src/event-registry/eventRegistry.ts @@ -1,14 +1,14 @@ import { communityStakesAbi, - contestAbi, - EvmEventSignature, - EvmEventSignatures, launchpadFactoryAbi, lpBondingCurveAbi, namespaceFactoryAbi, + singleContestAbi, tokenCommunityManagerAbi, -} from '@hicommonwealth/evm-protocols'; -import { factoryContracts, ValidChains } from '../common-protocol'; +} from '../abis'; +import { recurringContestAbi } from '../abis/recurringContestAbi'; +import { ValidChains, factoryContracts } from '../common-protocol'; +import { EvmEventSignature, EvmEventSignatures } from './eventSignatures'; type ContractAddresses = { [key in ValidChains]: @@ -37,7 +37,7 @@ export enum ChildContractNames { RecurringContest = 'RecurringContest', } -type ContractSource = { +export type ContractSource = { abi: Readonly>; eventSignatures: Array; // Runtime/user deployed contract sources @@ -65,7 +65,7 @@ const namespaceFactorySource = { ], childContracts: { [ChildContractNames.RecurringContest]: { - abi: contestAbi, + abi: recurringContestAbi, eventSignatures: [ EvmEventSignatures.Contests.ContentAdded, EvmEventSignatures.Contests.RecurringContestStarted, @@ -73,7 +73,7 @@ const namespaceFactorySource = { ], }, [ChildContractNames.SingleContest]: { - abi: contestAbi, + abi: singleContestAbi, eventSignatures: [ EvmEventSignatures.Contests.ContentAdded, EvmEventSignatures.Contests.SingleContestStarted, diff --git a/libs/evm-protocols/src/event-registry/eventSignatures.ts b/libs/evm-protocols/src/event-registry/eventSignatures.ts index 708b1ee4068..0e0d01bcf14 100644 --- a/libs/evm-protocols/src/event-registry/eventSignatures.ts +++ b/libs/evm-protocols/src/event-registry/eventSignatures.ts @@ -1,3 +1,4 @@ +// TODO: remove when we remove Contest mappers export const ChainEventSigs = { NewContest: 'address contest, address namespace, uint256 interval, bool oneOff' as const, diff --git a/libs/evm-protocols/src/index.ts b/libs/evm-protocols/src/index.ts index 273a70e8acc..d079562f179 100644 --- a/libs/evm-protocols/src/index.ts +++ b/libs/evm-protocols/src/index.ts @@ -1,12 +1,4 @@ -export * from './abis/communityStakesAbi'; -export * from './abis/contestAbi'; -export * from './abis/erc20Abi'; -export * from './abis/feeManagerAbi'; -export * from './abis/launchpadFactoryAbi'; -export * from './abis/lpBondingCurveAbi'; -export * from './abis/namespaceAbi'; -export * from './abis/namespaceFactoryAbi'; -export * from './abis/reservationHookAbi'; -export * from './abis/tokenCommunityManagerAbi'; +export * from './abis'; export * as commonProtocol from './common-protocol'; +export * from './event-registry/eventRegistry'; export * from './event-registry/eventSignatures'; diff --git a/libs/evm-protocols/test/commonProtocol/commonProtocol.spec.ts b/libs/evm-protocols/test/commonProtocol/commonProtocol.spec.ts index de0700e94db..aef5e54ebbe 100644 --- a/libs/evm-protocols/test/commonProtocol/commonProtocol.spec.ts +++ b/libs/evm-protocols/test/commonProtocol/commonProtocol.spec.ts @@ -6,44 +6,44 @@ describe('commonProtocol', () => { it('should calculate voting weight for balance with 1 multiplier', () => { { const result = calculateVoteWeight('1', 1); - expect(result!.toString()).eq('1'); + expect(result!).eq(BigInt('1')); } { const result = calculateVoteWeight('1000', 1); - expect(result!.toString()).eq('1000'); + expect(result!).eq(BigInt('1000')); } }); it('should calculate voting weight for balance with > 1 multiplier', () => { { const result = calculateVoteWeight('1', 3); - expect(result!.toString()).eq('3'); + expect(result!).eq(BigInt('3')); } { const result = calculateVoteWeight('1000', 3); - expect(result!.toString()).eq('3000'); + expect(result!).eq(BigInt('3000')); } { const result = calculateVoteWeight('1000000000000000000000000000', 7); - expect(result!.toString()).eq('7000000000000000000000000000'); + expect(result!).eq(BigInt('7000000000000000000000000000')); } { const result = calculateVoteWeight('10', 1.5); - expect(result!.toString()).eq('15'); + expect(result!).eq(BigInt('15')); } { const result = calculateVoteWeight('1000', 1.234); - expect(result!.toString()).eq('1234'); + expect(result!).eq(BigInt('1234')); } { const result = calculateVoteWeight('1000', 0.5); - expect(result!.toString()).eq('500'); + expect(result!).eq(BigInt('500')); } }); }); diff --git a/libs/model/src/community/CreateTopic.command.ts b/libs/model/src/community/CreateTopic.command.ts index ef371105ddd..2b3e12934df 100644 --- a/libs/model/src/community/CreateTopic.command.ts +++ b/libs/model/src/community/CreateTopic.command.ts @@ -1,5 +1,4 @@ import { InvalidInput, InvalidState, type Command } from '@hicommonwealth/core'; - import * as schemas from '@hicommonwealth/schemas'; import { models } from '../database'; import { authRoles } from '../middleware'; diff --git a/libs/model/src/community/GetCommunities.query.ts b/libs/model/src/community/GetCommunities.query.ts index a94dfa02e6e..3c1e1c8eb05 100644 --- a/libs/model/src/community/GetCommunities.query.ts +++ b/libs/model/src/community/GetCommunities.query.ts @@ -127,8 +127,8 @@ export function GetCommunities(): Query { ? ` AND ( SELECT "community_id" - FROM "Tokens" AS "Tokens" - WHERE ( "Tokens"."community_id" = "Community"."id" ) + FROM "LaunchpadTokens" AS "LaunchpadTokens" + WHERE ( "LaunchpadTokens"."community_id" = "Community"."id" ) LIMIT 1 ) IS ${community_type === CommunityType.Launchpad ? 'NOT' : ''} NULL ` diff --git a/libs/model/src/community/GetPinnedTokens.query.ts b/libs/model/src/community/GetPinnedTokens.query.ts new file mode 100644 index 00000000000..db6bfdfcc06 --- /dev/null +++ b/libs/model/src/community/GetPinnedTokens.query.ts @@ -0,0 +1,35 @@ +import { type Query } from '@hicommonwealth/core'; +import * as schemas from '@hicommonwealth/schemas'; +import { Includeable } from 'sequelize'; +import { models } from '../database'; + +export function GetPinnedTokens(): Query { + return { + ...schemas.GetPinnedTokens, + auth: [], + secure: false, + body: async ({ payload }) => { + const { community_ids, with_chain_node } = payload; + if (community_ids.length === 0) return []; + const parsedIds = community_ids.split(',').filter((v) => v !== ''); + if (parsedIds.length === 0) return []; + + const include: Includeable[] = []; + if (with_chain_node) { + include.push({ + model: models.ChainNode, + required: true, + }); + } + + return ( + await models.PinnedToken.findAll({ + where: { + community_id: parsedIds, + }, + include, + }) + ).map((t) => t.get({ plain: true })); + }, + }; +} diff --git a/libs/model/src/community/GetTransactions.query.ts b/libs/model/src/community/GetTransactions.query.ts index b623e9c67c2..316e47ada2f 100644 --- a/libs/model/src/community/GetTransactions.query.ts +++ b/libs/model/src/community/GetTransactions.query.ts @@ -64,7 +64,7 @@ export function GetTransactions(): Query { 'chain_node_name', cn.name ) AS community FROM "LaunchpadTrades" lts - LEFT JOIN "Tokens" AS tkns ON tkns.token_address = lts.token_address + LEFT JOIN "LaunchpadTokens" AS tkns ON tkns.token_address = lts.token_address LEFT JOIN "Communities" AS c ON c.namespace = tkns.namespace LEFT JOIN "ChainNodes" AS cn ON cn.id = c.chain_node_id ${addressesList.length > 0 ? 'WHERE lts.trader_address IN (:addresses)' : ''} diff --git a/libs/model/src/community/PinToken.command.ts b/libs/model/src/community/PinToken.command.ts new file mode 100644 index 00000000000..062dce740de --- /dev/null +++ b/libs/model/src/community/PinToken.command.ts @@ -0,0 +1,110 @@ +import { InvalidState, logger, type Command } from '@hicommonwealth/core'; +import { commonProtocol as cp } from '@hicommonwealth/evm-protocols'; +import { config } from '@hicommonwealth/model'; +import * as schemas from '@hicommonwealth/schemas'; +import { alchemyGetTokenPrices } from '@hicommonwealth/shared'; +import { models } from '../database'; +import { authRoles } from '../middleware'; +import { mustExist } from '../middleware/guards'; + +const log = logger(import.meta); + +export const PinTokenErrors = { + NotSupported: 'Pinned tokens only supported on Alchemy supported chains', + FailedToFetchPrice: 'Failed to fetch token price', + OnlyBaseSupport: 'Only Base (ETH) chain supported', + LaunchpadTokenFound: (communityId: string) => + `Community ${communityId} has an attached launchpad token`, +}; + +export function PinToken(): Command { + return { + ...schemas.PinToken, + auth: [authRoles('admin')], + body: async ({ payload }) => { + const { community_id, contract_address, chain_node_id } = payload; + + const chainNode = await models.ChainNode.scope('withPrivateData').findOne( + { + where: { + id: chain_node_id, + }, + }, + ); + mustExist('ChainNode', chainNode); + + if (chainNode.eth_chain_id !== cp.ValidChains.Base) + throw new InvalidState(PinTokenErrors.OnlyBaseSupport); + + const community = await models.Community.findOne({ + where: { + id: community_id, + }, + }); + mustExist('Community', community); + + if (community.namespace) { + const launchpadToken = await models.LaunchpadToken.findOne({ + where: { + namespace: community.namespace, + }, + }); + + if (launchpadToken) + throw new InvalidState( + PinTokenErrors.LaunchpadTokenFound(community_id), + ); + } + + if ( + !chainNode.url.includes('alchemy') || + !chainNode.private_url?.includes('alchemy') || + !chainNode.alchemy_metadata?.price_api_supported + ) { + throw new InvalidState(PinTokenErrors.NotSupported); + } + + let price: Awaited> | undefined; + try { + price = await alchemyGetTokenPrices({ + alchemyApiKey: config.ALCHEMY.APP_KEYS.PRIVATE, + tokenSources: [ + { + contractAddress: contract_address, + alchemyNetworkId: chainNode.alchemy_metadata.network_id, + }, + ], + }); + } catch (e: unknown) { + if (e instanceof Error) + log.error(e.message, e, { + contractAddress: contract_address, + alchemyNetworkId: chainNode.alchemy_metadata.network_id, + }); + else { + log.error(JSON.stringify(e), undefined, { + contractAddress: contract_address, + alchemyNetworkId: chainNode.alchemy_metadata.network_id, + }); + } + } + + if ( + !Array.isArray(price?.data) || + price.data.length !== 1 || + price.data[0].error + ) { + log.error(PinTokenErrors.FailedToFetchPrice, undefined, { + price, + }); + throw new InvalidState(PinTokenErrors.FailedToFetchPrice); + } + + return await models.PinnedToken.create({ + community_id, + chain_node_id, + contract_address, + }); + }, + }; +} diff --git a/libs/model/src/community/UnpinToken.command.ts b/libs/model/src/community/UnpinToken.command.ts new file mode 100644 index 00000000000..38203dc6c73 --- /dev/null +++ b/libs/model/src/community/UnpinToken.command.ts @@ -0,0 +1,28 @@ +import { InvalidState, type Command } from '@hicommonwealth/core'; +import * as schemas from '@hicommonwealth/schemas'; +import { models } from '../database'; +import { authRoles } from '../middleware'; + +export const UnpinTokenErrors = { + NotFound: 'Token not found', +}; + +export function UnpinToken(): Command { + return { + ...schemas.UnpinToken, + auth: [authRoles('admin')], + body: async ({ payload }) => { + const { community_id } = payload; + const pinnedToken = await models.PinnedToken.findOne({ + where: { + community_id, + }, + }); + + if (!pinnedToken) throw new InvalidState(UnpinTokenErrors.NotFound); + + await pinnedToken.destroy(); + return {}; + }, + }; +} diff --git a/libs/model/src/community/index.ts b/libs/model/src/community/index.ts index 4a915c2f30b..e69e7ede409 100644 --- a/libs/model/src/community/index.ts +++ b/libs/model/src/community/index.ts @@ -10,14 +10,17 @@ export * from './GetCommunities.query'; export * from './GetCommunity.query'; export * from './GetCommunityStake.query'; export * from './GetMembers.query'; +export * from './GetPinnedTokens.query'; export * from './GetStakeHistoricalPrice.query'; export * from './GetTopics.query'; export * from './GetTransactions.query'; export * from './JoinCommunity.command'; +export * from './PinToken.command'; export * from './RefreshCommunityMemberships.command'; export * from './RefreshCustomDomain.query'; export * from './SetCommunityStake.command'; export * from './ToggleArchiveTopic.command'; +export * from './UnpinToken.command'; export * from './UpdateCommunity.command'; export * from './UpdateCustomDomain.command'; export * from './UpdateGroup.command'; diff --git a/libs/model/src/config.ts b/libs/model/src/config.ts index a2b64f89d77..d8ab5f4f578 100644 --- a/libs/model/src/config.ts +++ b/libs/model/src/config.ts @@ -33,6 +33,7 @@ const { ALCHEMY_PUBLIC_APP_KEY, MEMBERSHIP_REFRESH_BATCH_SIZE, MEMBERSHIP_REFRESH_TTL_SECONDS, + NEYNAR_BOT_UUID, NEYNAR_API_KEY, NEYNAR_CAST_CREATED_WEBHOOK_SECRET, NEYNAR_REPLY_WEBHOOK_URL, @@ -91,6 +92,7 @@ export const config = configure( : 5, FLAG_FARCASTER_CONTEST: FLAG_FARCASTER_CONTEST === 'true', NEYNAR_API_KEY: NEYNAR_API_KEY, + NEYNAR_BOT_UUID: NEYNAR_BOT_UUID, NEYNAR_CAST_CREATED_WEBHOOK_SECRET: NEYNAR_CAST_CREATED_WEBHOOK_SECRET, NEYNAR_REPLY_WEBHOOK_URL: NEYNAR_REPLY_WEBHOOK_URL, FARCASTER_ACTION_URL: FARCASTER_ACTION_URL, @@ -195,10 +197,41 @@ export const config = configure( MIN_USER_ETH: z.number(), MAX_USER_POSTS_PER_CONTEST: z.number().int(), FLAG_FARCASTER_CONTEST: z.boolean().nullish(), - NEYNAR_API_KEY: z.string().nullish(), - NEYNAR_CAST_CREATED_WEBHOOK_SECRET: z.string().nullish(), - NEYNAR_REPLY_WEBHOOK_URL: z.string().nullish(), - FARCASTER_ACTION_URL: z.string().nullish(), + NEYNAR_BOT_UUID: z + .string() + .optional() + .refine( + (data) => !(target.APP_ENV === 'production' && !data), + 'NEYNAR_BOT_UUID must be set to a non-default value in production.', + ), + NEYNAR_API_KEY: z + .string() + .optional() + .refine( + (data) => !(target.APP_ENV === 'production' && !data), + 'NEYNAR_API_KEY must be set to a non-default value in production.', + ), + NEYNAR_CAST_CREATED_WEBHOOK_SECRET: z + .string() + .optional() + .refine( + (data) => !(target.APP_ENV === 'production' && !data), + 'NEYNAR_CAST_CREATED_WEBHOOK_SECRET must be set to a non-default value in production.', + ), + NEYNAR_REPLY_WEBHOOK_URL: z + .string() + .optional() + .refine( + (data) => !(target.APP_ENV === 'production' && !data), + 'NEYNAR_REPLY_WEBHOOK_URL must be set to a non-default value in production.', + ), + FARCASTER_ACTION_URL: z + .string() + .optional() + .refine( + (data) => !(target.APP_ENV === 'production' && !data), + 'FARCASTER_ACTION_URL must be set to a non-default value in production.', + ), }), AUTH: z .object({ diff --git a/libs/model/src/contest/Contests.projection.ts b/libs/model/src/contest/Contests.projection.ts index 1a8fc3e24b8..cb5bb70a767 100644 --- a/libs/model/src/contest/Contests.projection.ts +++ b/libs/model/src/contest/Contests.projection.ts @@ -1,17 +1,26 @@ import { BigNumber } from '@ethersproject/bignumber'; import { InvalidState, Projection, logger } from '@hicommonwealth/core'; -import { EvmEventSignatures } from '@hicommonwealth/evm-protocols'; +import { + ChildContractNames, + EvmEventSignatures, + commonProtocol as cp, +} from '@hicommonwealth/evm-protocols'; +import { config } from '@hicommonwealth/model'; import { ContestScore, events } from '@hicommonwealth/schemas'; +import { buildContestLeaderboardUrl, getBaseUrl } from '@hicommonwealth/shared'; import { QueryTypes } from 'sequelize'; import { z } from 'zod'; import { models } from '../database'; import { mustExist } from '../middleware/guards'; import { EvmEventSourceAttributes } from '../models'; import * as protocol from '../services/commonProtocol'; +import { getWeightedNumTokens } from '../services/stakeHelper'; import { decodeThreadContentUrl, getChainNodeUrl, getDefaultContestImage, + parseFarcasterContentUrl, + publishCast, } from '../utils'; const log = logger(import.meta); @@ -35,15 +44,6 @@ const inputs = { ContestContentUpvoted: events.ContestContentUpvoted, }; -// TODO: remove kind column from EvmEventSources -const signatureToKind = { - [EvmEventSignatures.Contests.ContentAdded]: 'ContentAdded', - [EvmEventSignatures.Contests.RecurringContestStarted]: 'ContestStarted', - [EvmEventSignatures.Contests.RecurringContestVoterVoted]: 'VoterVoted', - [EvmEventSignatures.Contests.SingleContestStarted]: 'ContestStarted', - [EvmEventSignatures.Contests.SingleContestVoterVoted]: 'VoterVoted', -}; - /** * Makes sure contest manager (off-chain metadata) record exists * - Alerts when not found and inserts default record to patch distributed transaction @@ -67,6 +67,14 @@ async function updateOrCreateWithAlert( return; } + const ethChainId = community!.ChainNode!.eth_chain_id!; + if (!cp.isValidChain(ethChainId)) { + log.error( + `Unsupported eth chain id: ${ethChainId} for namespace: ${namespace}`, + ); + return; + } + const { ticker, decimals } = await protocol.contractHelpers.getTokenAttributes( contest_address, @@ -127,13 +135,9 @@ async function updateOrCreateWithAlert( { transaction }, ); - // TODO: move EVM concerns out of projection - // create EVM event sources so chain listener will listen to events on new contest contract - const abiNickname = isOneOff ? 'SingleContest' : 'RecurringContest'; - const contestAbi = await models.ContractAbi.findOne({ - where: { nickname: abiNickname }, - }); - mustExist(`Contest ABI with nickname "${abiNickname}"`, contestAbi); + const childContractName = isOneOff + ? ChildContractNames.SingleContest + : ChildContractNames.RecurringContest; const sigs = isOneOff ? [ @@ -149,11 +153,12 @@ async function updateOrCreateWithAlert( const sourcesToCreate: EvmEventSourceAttributes[] = sigs.map( (eventSignature) => { return { - chain_node_id: community!.ChainNode!.id!, + eth_chain_id: ethChainId, contract_address: contest_address, event_signature: eventSignature, - kind: signatureToKind[eventSignature], - abi_id: contestAbi.id!, + contract_name: childContractName, + parent_contract_address: cp.factoryContracts[ethChainId].factory, + // TODO: add created_at_block so EVM CE runs the migrateEvents func }; }, ); @@ -294,7 +299,9 @@ export function Contests(): Projection { }, ContestContentAdded: async ({ payload }) => { - const { threadId } = decodeThreadContentUrl(payload.content_url); + const { threadId, isFarcaster } = decodeThreadContentUrl( + payload.content_url, + ); await models.ContestAction.create({ ...payload, contest_id: payload.contest_id || 0, @@ -305,6 +312,26 @@ export function Contests(): Projection { voting_power: '0', created_at: new Date(), }); + + // post confirmation via FC bot + if (isFarcaster) { + const contestManager = await models.ContestManager.findByPk( + payload.contest_address, + ); + const leaderboardUrl = buildContestLeaderboardUrl( + getBaseUrl(config.APP_ENV), + contestManager!.community_id, + contestManager!.contest_address, + ); + const { replyCastHash } = parseFarcasterContentUrl( + payload.content_url, + ); + await publishCast( + replyCastHash, + ({ username }) => + `Hey @${username}, your entry has been submitted to the contest: ${leaderboardUrl}`, + ); + } }, ContestContentUpvoted: async ({ payload }) => { @@ -315,8 +342,43 @@ export function Contests(): Projection { content_id: payload.content_id, action: 'added', }, - raw: true, + include: [ + { + model: models.ContestManager, + include: [ + { + model: models.Community, + include: [ + { + model: models.ChainNode.scope('withPrivateData'), + }, + ], + }, + ], + }, + ], }); + + let calculated_voting_weight: string | undefined; + + if ( + BigInt(payload.voting_power || 0) > BigInt(0) && + add_action?.ContestManager?.vote_weight_multiplier + ) { + const { eth_chain_id, url, private_url } = + add_action!.ContestManager!.Community!.ChainNode!; + const { funding_token_address, vote_weight_multiplier } = + add_action!.ContestManager!; + const numTokens = await getWeightedNumTokens( + payload.voter_address, + funding_token_address!, + eth_chain_id!, + getChainNodeUrl({ url, private_url }), + vote_weight_multiplier!, + ); + calculated_voting_weight = numTokens.toString(); + } + await models.ContestAction.upsert({ ...payload, contest_id, @@ -325,6 +387,7 @@ export function Contests(): Projection { thread_id: add_action!.thread_id, content_url: add_action!.content_url, created_at: new Date(), + calculated_voting_weight, }); // eslint-disable-next-line @typescript-eslint/no-misused-promises diff --git a/libs/model/src/contest/FarcasterReplyCastCreatedWebhook.command.ts b/libs/model/src/contest/FarcasterReplyCastCreatedWebhook.command.ts index 77a81fc132d..006a8657bc7 100644 --- a/libs/model/src/contest/FarcasterReplyCastCreatedWebhook.command.ts +++ b/libs/model/src/contest/FarcasterReplyCastCreatedWebhook.command.ts @@ -1,7 +1,12 @@ -import { InvalidInput, type Command } from '@hicommonwealth/core'; +import { logger, type Command } from '@hicommonwealth/core'; import * as schemas from '@hicommonwealth/schemas'; +import { NeynarAPIClient } from '@neynar/nodejs-sdk'; +import { config } from '../config'; import { models } from '../database'; -import { emitEvent } from '../utils'; +import { mustExist } from '../middleware/guards'; +import { emitEvent, publishCast } from '../utils'; + +const log = logger(import.meta); // This webhook processes the "cast.created" event // from a programmatic Neynar webhook for REPLIES to a cast @@ -12,8 +17,24 @@ export function FarcasterReplyCastCreatedWebhook(): Command< ...schemas.FarcasterCastCreatedWebhook, auth: [], body: async ({ payload }) => { - if (!payload.data.parent_hash) { - throw new InvalidInput('parent hash must exist'); + mustExist('Farcaster Cast Parent Hash', payload.data.parent_hash); + mustExist('Farcaster Cast Author FID', payload.data.author?.fid); + + const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!); + + // get user verified address + const { users } = await client.fetchBulkUsers([payload.data.author.fid]); + const verified_address = users[0].verified_addresses.eth_addresses.at(0); + if (!verified_address) { + log.warn( + 'Farcaster verified address not found for reply cast created event- content will be ignored.', + ); + await publishCast( + payload.data.hash, + ({ username }) => + `Hey @${username}, you need a verified address to participate in the contest.`, + ); + return; } await emitEvent( @@ -21,7 +42,10 @@ export function FarcasterReplyCastCreatedWebhook(): Command< [ { event_name: schemas.EventNames.FarcasterReplyCastCreated, - event_payload: payload.data, + event_payload: { + ...payload.data, + verified_address, + }, }, ], null, diff --git a/libs/model/src/contest/FarcasterUpvoteAction.command.ts b/libs/model/src/contest/FarcasterUpvoteAction.command.ts index df801190c5e..c12b7d174e1 100644 --- a/libs/model/src/contest/FarcasterUpvoteAction.command.ts +++ b/libs/model/src/contest/FarcasterUpvoteAction.command.ts @@ -1,11 +1,11 @@ -import { type Command } from '@hicommonwealth/core'; +import { logger, type Command } from '@hicommonwealth/core'; import * as schemas from '@hicommonwealth/schemas'; -import { NeynarAPIClient } from '@neynar/nodejs-sdk'; -import { config } from '../config'; import { models } from '../database'; import { mustExist } from '../middleware/guards'; import { buildFarcasterContentUrl, emitEvent } from '../utils'; +const log = logger(import.meta); + // This webhook processes the cast action event export function FarcasterUpvoteAction(): Command< typeof schemas.FarcasterUpvoteAction @@ -14,13 +14,19 @@ export function FarcasterUpvoteAction(): Command< ...schemas.FarcasterUpvoteAction, auth: [], body: async ({ payload }) => { - // find contest manager from parent cast hash - const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!); - const castsResponse = await client.fetchBulkCasts([ - payload.untrustedData.castId.hash, - ]); - const { parent_hash, hash } = castsResponse.result.casts.at(0)!; + const verified_address = + payload.interactor.verified_addresses?.eth_addresses.at(0); + + if (!verified_address) { + log.warn( + 'Farcaster verified address not found for upvote action- upvote will be ignored.', + ); + return; + } + const { parent_hash, hash } = payload.cast; const content_url = buildFarcasterContentUrl(parent_hash!, hash); + + // find content from farcaster hash const addAction = await models.ContestAction.findOne({ where: { action: 'added', @@ -37,6 +43,7 @@ export function FarcasterUpvoteAction(): Command< event_payload: { ...payload, contest_address: addAction.contest_address, + verified_address, }, }, ], diff --git a/libs/model/src/contest/GetFarcasterContestCasts.query.ts b/libs/model/src/contest/GetFarcasterContestCasts.query.ts new file mode 100644 index 00000000000..65289ef6515 --- /dev/null +++ b/libs/model/src/contest/GetFarcasterContestCasts.query.ts @@ -0,0 +1,118 @@ +import { Query } from '@hicommonwealth/core'; +import * as schemas from '@hicommonwealth/schemas'; +import { NeynarAPIClient } from '@neynar/nodejs-sdk'; +import { QueryTypes } from 'sequelize'; +import { config } from '../config'; +import { models } from '../database'; +import { mustExist } from '../middleware/guards'; + +export function GetFarcasterContestCasts(): Query< + typeof schemas.GetFarcasterContestCasts +> { + return { + ...schemas.GetFarcasterContestCasts, + auth: [], + secure: false, + body: async ({ payload }) => { + const contestManager = await models.ContestManager.findOne({ + where: { + contest_address: payload.contest_address, + }, + }); + mustExist('Contest Manager', contestManager); + if (!contestManager.farcaster_frame_hashes?.length) { + return []; + } + const contents = await models.sequelize.query<{ + contest_address: string; + contest_id: number; + content_id: number; + actor_address: string; + added_action: 'added'; + content_url: string; + voting_weights_sum: number; + }>( + ` + SELECT + ca1.contest_address, + ca1.contest_id, + ca1.content_id, + ca1.actor_address, + ca1.action AS added_action, + ca1.content_url, + SUM(ca2.calculated_voting_weight) AS voting_weights_sum + FROM + "ContestActions" ca1 + LEFT JOIN + "ContestActions" ca2 + ON ca1.contest_address = ca2.contest_address + AND ca1.contest_id = ca2.contest_id + AND ca1.content_id = ca2.content_id + AND ca2.action = 'upvoted' + WHERE + ca1.action = 'added' + AND ca1.contest_address = :contest_address + AND ca1.contest_id = :contest_id + GROUP BY + ca1.contest_address, + ca1.contest_id, + ca1.content_id, + ca1.actor_address, + ca1.action + ORDER BY + ca1.contest_address, + ca1.contest_id, + ca1.content_id; + `, + { + replacements: { + contest_address: payload.contest_address, + contest_id: 0, // only support one-off contests for now + }, + type: QueryTypes.SELECT, + }, + ); + if (!contents.length) { + return []; + } + + const replyCastHashes = contents.map((action) => { + /* + /farcaster/0x123/0x234 + */ + const [, , , replyCastHash] = action.content_url!.split('/'); + return replyCastHash; + }); + const frameHashesToFetch = [...replyCastHashes]; + const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!); + const castsResponse = await client.fetchBulkCasts(frameHashesToFetch); + + const { casts } = castsResponse.result; + + const replyVoteSums = contents.reduce( + (acc, content) => { + const [, , , replyCastHash] = content.content_url!.split('/'); + return { + ...acc, + [replyCastHash]: content.voting_weights_sum || 0, + }; + }, + {} as Record, + ); + + return casts + .map((cast) => ({ + ...cast, + calculated_vote_weight: replyVoteSums[cast.hash], + })) + .sort((a, b) => { + if (payload.sort_by === 'upvotes') { + return b.calculated_vote_weight - a.calculated_vote_weight; + } + return ( + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + }); + }, + }; +} diff --git a/libs/model/src/contest/GetFarcasterContestCasts.ts b/libs/model/src/contest/GetFarcasterContestCasts.ts deleted file mode 100644 index 62f39a3829a..00000000000 --- a/libs/model/src/contest/GetFarcasterContestCasts.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Query } from '@hicommonwealth/core'; -import * as schemas from '@hicommonwealth/schemas'; -import { BulkCastsSortType, NeynarAPIClient } from '@neynar/nodejs-sdk'; -import lo from 'lodash'; -import { config } from '../config'; -import { models } from '../database'; -import { mustExist } from '../middleware/guards'; - -export function GetFarcasterContestCasts(): Query< - typeof schemas.GetFarcasterContestCasts -> { - return { - ...schemas.GetFarcasterContestCasts, - auth: [], - secure: false, - body: async ({ payload }) => { - const contestManager = await models.ContestManager.findOne({ - where: { - contest_address: payload.contest_address, - }, - }); - mustExist('Contest Manager', contestManager); - if (!contestManager.farcaster_frame_hashes?.length) { - return []; - } - const parentCastHashes = contestManager.farcaster_frame_hashes || []; - const actions = await models.ContestAction.findAll({ - where: { - contest_address: payload.contest_address, - action: 'added', - }, - }); - const replyCastHashes = actions.map((action) => { - /* - /farcaster/0x123/0x234 - */ - const [, , , replyCastHash] = action.content_url!.split('/'); - return replyCastHash; - }); - const frameHashesToFetch = [...parentCastHashes, ...replyCastHashes]; - const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!); - const castsResponse = await client.fetchBulkCasts(frameHashesToFetch, { - sortType: - payload.sort_by === 'likes' - ? BulkCastsSortType.LIKES - : BulkCastsSortType.RECENT, - }); - - const { casts } = castsResponse.result; - - const replyCasts = lo.groupBy(casts, (cast) => { - return cast.parent_hash; - }); - - const parentCasts = casts - .filter((cast) => parentCastHashes.includes(cast.hash)) - .map((cast) => ({ - ...cast, - replies: replyCasts[cast.hash], - })); - - return parentCasts; - }, - }; -} diff --git a/libs/model/src/contest/index.ts b/libs/model/src/contest/index.ts index 10c0ec7082f..bc0f81751b1 100644 --- a/libs/model/src/contest/index.ts +++ b/libs/model/src/contest/index.ts @@ -8,6 +8,6 @@ export * from './GetActiveContestManagers.query'; export * from './GetAllContests.query'; export * from './GetContest.query'; export * from './GetContestLog.query'; -export * from './GetFarcasterContestCasts'; +export * from './GetFarcasterContestCasts.query'; export * from './GetFarcasterUpvoteActionMetadata.query'; export * from './UpdateContestManagerMetadata.command'; diff --git a/libs/model/src/models/associations.ts b/libs/model/src/models/associations.ts index 56e82c0d818..50f8538305d 100644 --- a/libs/model/src/models/associations.ts +++ b/libs/model/src/models/associations.ts @@ -71,15 +71,15 @@ export const buildAssociations = (db: DB) => { }); db.ChainNode.withMany(db.Community) - .withMany(db.EvmEventSource) .withOne(db.LastProcessedEvmBlock) .withMany(db.Topic, { onUpdate: 'CASCADE', onDelete: 'SET NULL', + }) + .withMany(db.PinnedToken, { + onDelete: 'CASCADE', }); - db.ContractAbi.withMany(db.EvmEventSource, { foreignKey: 'abi_id' }); - db.Community.withMany(db.Group, { asMany: 'groups' }) .withMany(db.Topic, { asOne: 'community', @@ -113,7 +113,10 @@ export const buildAssociations = (db: DB) => { as: 'selectedCommunity', }) .withMany(db.Quest, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - .withMany(db.ContestManager, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }); + .withMany(db.ContestManager, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + .withMany(db.PinnedToken, { + onDelete: 'CASCADE', + }); db.Tags.withMany(db.ProfileTags, { foreignKey: 'tag_id', @@ -154,6 +157,10 @@ export const buildAssociations = (db: DB) => { foreignKey: 'contest_address', asMany: 'contests', onDelete: 'CASCADE', + }).withMany(db.ContestAction, { + foreignKey: 'contest_address', + onUpdate: 'CASCADE', + onDelete: 'CASCADE', }); db.Contest.withMany(db.ContestAction, { @@ -243,7 +250,7 @@ export const buildAssociations = (db: DB) => { }, ); - db.Token.withMany(db.LaunchpadTrade, { + db.LaunchpadToken.withMany(db.LaunchpadTrade, { foreignKey: 'token_address', }); }; diff --git a/libs/model/src/models/chain_node.ts b/libs/model/src/models/chain_node.ts index 18190d61aae..43d7eff5e23 100644 --- a/libs/model/src/models/chain_node.ts +++ b/libs/model/src/models/chain_node.ts @@ -61,6 +61,7 @@ export default ( block_explorer: { type: Sequelize.STRING, allowNull: true }, slip44: { type: Sequelize.INTEGER, allowNull: true }, max_ce_block_range: { type: Sequelize.INTEGER, allowNull: true }, + alchemy_metadata: { type: Sequelize.JSONB, allowNull: true }, created_at: { type: Sequelize.DATE, allowNull: false }, updated_at: { type: Sequelize.DATE, allowNull: false }, }, diff --git a/libs/model/src/models/comment_subscriptions.ts b/libs/model/src/models/comment_subscriptions.ts index 0deee2973c0..22fa7177a97 100644 --- a/libs/model/src/models/comment_subscriptions.ts +++ b/libs/model/src/models/comment_subscriptions.ts @@ -19,12 +19,12 @@ export default ( created_at: { type: Sequelize.DATE, allowNull: false, - defaultValue: sequelize.literal('CURRENT_TIMESTAMP'), + defaultValue: new Date(), }, updated_at: { type: Sequelize.DATE, allowNull: false, - defaultValue: sequelize.literal('CURRENT_TIMESTAMP'), + defaultValue: new Date(), }, }, { diff --git a/libs/model/src/models/community_alerts.ts b/libs/model/src/models/community_alerts.ts index dab5c9d8394..f1b4dfceba0 100644 --- a/libs/model/src/models/community_alerts.ts +++ b/libs/model/src/models/community_alerts.ts @@ -24,7 +24,7 @@ export default ( created_at: { type: Sequelize.DATE, allowNull: false, - defaultValue: sequelize.literal('CURRENT_TIMESTAMP'), + defaultValue: new Date(), get() { return (this.getDataValue( 'created_at', @@ -34,7 +34,7 @@ export default ( updated_at: { type: Sequelize.DATE, allowNull: false, - defaultValue: sequelize.literal('CURRENT_TIMESTAMP'), + defaultValue: new Date(), get() { return (this.getDataValue( 'updated_at', diff --git a/libs/model/src/models/contest_action.ts b/libs/model/src/models/contest_action.ts index 5559abf7890..7b1aa311846 100644 --- a/libs/model/src/models/contest_action.ts +++ b/libs/model/src/models/contest_action.ts @@ -1,9 +1,15 @@ import { CONTEST_ACTIONS, ContestAction } from '@hicommonwealth/schemas'; import Sequelize from 'sequelize'; import { z } from 'zod'; +import { ContestManagerAttributes } from './contest_manager'; import type { ModelInstance } from './types'; -type ContestAction = ModelInstance>; +export type ContestActionAttributes = z.infer & { + // associations + ContestManager?: ContestManagerAttributes; +}; + +type ContestAction = ModelInstance; export default ( sequelize: Sequelize.Sequelize, @@ -39,6 +45,10 @@ export default ( type: Sequelize.DECIMAL(78, 0), allowNull: false, }, + calculated_voting_weight: { + type: Sequelize.DECIMAL(78, 0), + allowNull: true, + }, created_at: { type: Sequelize.DATE, allowNull: false }, }, { diff --git a/libs/model/src/models/contest_manager.ts b/libs/model/src/models/contest_manager.ts index 49490234d23..9319ef2ecaf 100644 --- a/libs/model/src/models/contest_manager.ts +++ b/libs/model/src/models/contest_manager.ts @@ -59,6 +59,7 @@ export default ( neynar_webhook_secret: { type: Sequelize.STRING, allowNull: true }, topic_id: { type: Sequelize.INTEGER, allowNull: true }, is_farcaster_contest: { type: Sequelize.BOOLEAN, allowNull: false }, + vote_weight_multiplier: { type: Sequelize.FLOAT, allowNull: true }, }, { tableName: 'ContestManagers', diff --git a/libs/model/src/models/contract_abi.ts b/libs/model/src/models/contract_abi.ts deleted file mode 100644 index cac1d346606..00000000000 --- a/libs/model/src/models/contract_abi.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ContractAbi } from '@hicommonwealth/schemas'; -import Sequelize from 'sequelize'; // must use "* as" to avoid scope errors -import { z } from 'zod'; -import { hashAbi } from '../utils/utils'; -import type { ModelInstance } from './types'; - -export type ContractAbiInstance = ModelInstance>; - -export default ( - sequelize: Sequelize.Sequelize, -): Sequelize.ModelStatic => - sequelize.define( - 'ContractAbi', - { - id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, - nickname: { type: Sequelize.STRING, allowNull: true, unique: true }, - abi: { type: Sequelize.JSONB, allowNull: false }, - abi_hash: { type: Sequelize.TEXT, allowNull: false, unique: true }, - verified: { - type: Sequelize.BOOLEAN, - allowNull: false, - defaultValue: false, - }, - created_at: { type: Sequelize.DATE, allowNull: false }, - updated_at: { type: Sequelize.DATE, allowNull: false }, - }, - { - tableName: 'ContractAbis', - timestamps: true, - underscored: true, - createdAt: 'created_at', - updatedAt: 'updated_at', - validate: { - // this validation function is replicated in the database as the 'chk_contract_abi_array' CHECK constraint - // see 20230913003500-contract-abis-fixes.js for the constraint - validAbi() { - if (!Array.isArray(this.abi)) { - throw new Error( - `Invalid ABI. The given ABI of type ${typeof this - .abi} is not a valid array.`, - ); - } - }, - }, - hooks: { - beforeValidate(instance: ContractAbiInstance) { - if (!instance.abi_hash) { - instance.abi_hash = hashAbi(instance.abi); - } - }, - }, - }, - ); diff --git a/libs/model/src/models/evmEventSource.ts b/libs/model/src/models/evmEventSource.ts index 8a5d7c252ba..f0f0741e052 100644 --- a/libs/model/src/models/evmEventSource.ts +++ b/libs/model/src/models/evmEventSource.ts @@ -1,23 +1,9 @@ -import { ContractAbi } from '@hicommonwealth/schemas'; +import { EvmEventSource } from '@hicommonwealth/schemas'; import Sequelize from 'sequelize'; import { z } from 'zod'; -import { ChainNodeAttributes } from './chain_node'; import { ModelInstance } from './types'; -export type EvmEventSourceAttributes = { - id?: number; - chain_node_id: number; - contract_address: string; - event_signature: string; - kind: string; - created_at_block?: number; - events_migrated?: boolean; - active?: boolean; - abi_id: number; - - ContractAbi?: z.infer; - ChainNode?: ChainNodeAttributes; -}; +export type EvmEventSourceAttributes = z.infer; export type EvmEventSourceInstance = ModelInstance; @@ -27,35 +13,28 @@ export default ( sequelize.define( 'EvmEventSource', { - id: { + eth_chain_id: { type: Sequelize.INTEGER, - autoIncrement: true, primaryKey: true, }, - chain_node_id: { - type: Sequelize.INTEGER, - allowNull: false, - unique: 'unique_event_source', - }, contract_address: { type: Sequelize.STRING, - allowNull: false, - unique: 'unique_event_source', + primaryKey: true, }, event_signature: { + type: Sequelize.STRING, + primaryKey: true, + }, + contract_name: { type: Sequelize.STRING, allowNull: false, - unique: 'unique_event_source', }, - kind: { type: Sequelize.STRING, allowNull: false }, - created_at_block: { type: Sequelize.INTEGER, allowNull: true }, - events_migrated: { type: Sequelize.BOOLEAN, allowNull: true }, - active: { - type: Sequelize.BOOLEAN, + parent_contract_address: { + type: Sequelize.STRING, allowNull: false, - defaultValue: true, }, - abi_id: { type: Sequelize.INTEGER, allowNull: false }, + created_at_block: { type: Sequelize.INTEGER, allowNull: true }, + events_migrated: { type: Sequelize.BOOLEAN, allowNull: true }, }, { tableName: 'EvmEventSources', diff --git a/libs/model/src/models/factories.ts b/libs/model/src/models/factories.ts index 88f07d1ad67..dc1f1e32713 100644 --- a/libs/model/src/models/factories.ts +++ b/libs/model/src/models/factories.ts @@ -15,7 +15,6 @@ import CommunityTags from './community_tags'; import Contest from './contest'; import ContestAction from './contest_action'; import ContestManager from './contest_manager'; -import ContractAbi from './contract_abi'; import DiscordBotConfig from './discord_bot_config'; import EmailUpdateToken from './email_update_token'; import EvmEventSource from './evmEventSource'; @@ -25,6 +24,7 @@ import LastProcessedEvmBlock from './lastProcessedEvmBlock'; import LaunchpadTrade from './launchpad_trade'; import Membership from './membership'; import Outbox from './outbox'; +import PinnedToken from './pinned_token'; import Poll from './poll'; import ProfileTags from './profile_tags'; import { Quest, QuestAction, QuestActionMeta } from './quest'; @@ -38,7 +38,7 @@ import Tags from './tags'; import Thread from './thread'; import ThreadSubscription from './thread_subscriptions'; import ThreadVersionHistory from './thread_version_history'; -import Token from './token'; +import LaunchpadToken from './token'; import Topic from './topic'; import User from './user'; import Vote from './vote'; @@ -61,7 +61,6 @@ export const Factories = { Contest, ContestAction, ContestManager, - ContractAbi, DiscordBotConfig, EmailUpdateToken, EvmEventSource, @@ -71,6 +70,7 @@ export const Factories = { LaunchpadTrade, Membership, Outbox, + PinnedToken, Poll, ProfileTags, Quest, @@ -91,7 +91,7 @@ export const Factories = { Vote, Webhook, Wallets, - Token, + LaunchpadToken, XpLog, }; diff --git a/libs/model/src/models/index.ts b/libs/model/src/models/index.ts index e428fc56948..998295baa4e 100644 --- a/libs/model/src/models/index.ts +++ b/libs/model/src/models/index.ts @@ -49,7 +49,6 @@ export * from './community'; export * from './community_role'; export * from './community_stake'; export * from './community_tags'; -export * from './contract_abi'; export * from './discord_bot_config'; export * from './email_update_token'; export * from './evmEventSource'; diff --git a/libs/model/src/models/outbox.ts b/libs/model/src/models/outbox.ts index 5ce49511519..f4f1fef9d18 100644 --- a/libs/model/src/models/outbox.ts +++ b/libs/model/src/models/outbox.ts @@ -1,15 +1,10 @@ -import { EventContext, Outbox } from '@hicommonwealth/core'; -import { Events } from '@hicommonwealth/schemas'; +import { Outbox } from '@hicommonwealth/core'; import Sequelize from 'sequelize'; // must use "* as" to avoid scope errors import { z } from 'zod'; import { ModelInstance } from './types'; export type OutboxAttributes = z.infer; -export type InsertOutboxEvent = EventContext & { - created_at?: Date; -}; - export type OutboxInstance = ModelInstance; export default ( diff --git a/libs/model/src/models/pinned_token.ts b/libs/model/src/models/pinned_token.ts new file mode 100644 index 00000000000..369844ac406 --- /dev/null +++ b/libs/model/src/models/pinned_token.ts @@ -0,0 +1,44 @@ +import { PinnedToken } from '@hicommonwealth/schemas'; +import Sequelize from 'sequelize'; +import { z } from 'zod'; +import type { ModelInstance } from './types'; + +export type PinnedTokenAttributes = z.infer; + +export type PinnedTokenInstance = ModelInstance; + +export default ( + sequelize: Sequelize.Sequelize, +): Sequelize.ModelStatic => + sequelize.define( + 'PinnedToken', + { + community_id: { + type: Sequelize.STRING, + primaryKey: true, + }, + contract_address: { + type: Sequelize.STRING, + allowNull: false, + }, + chain_node_id: { + type: Sequelize.INTEGER, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + }, + }, + { + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + tableName: 'PinnedTokens', + }, + ); diff --git a/libs/model/src/models/subscription_preference.ts b/libs/model/src/models/subscription_preference.ts index c24bcd9a802..359e83d705e 100644 --- a/libs/model/src/models/subscription_preference.ts +++ b/libs/model/src/models/subscription_preference.ts @@ -40,12 +40,12 @@ export default ( created_at: { type: Sequelize.DATE, allowNull: false, - defaultValue: sequelize.literal('CURRENT_TIMESTAMP'), + defaultValue: new Date(), }, updated_at: { type: Sequelize.DATE, allowNull: false, - defaultValue: sequelize.literal('CURRENT_TIMESTAMP'), + defaultValue: new Date(), }, }, { diff --git a/libs/model/src/models/thread_subscriptions.ts b/libs/model/src/models/thread_subscriptions.ts index 2ba30afe984..989c4c50f22 100644 --- a/libs/model/src/models/thread_subscriptions.ts +++ b/libs/model/src/models/thread_subscriptions.ts @@ -33,12 +33,12 @@ export default ( created_at: { type: Sequelize.DATE, allowNull: false, - defaultValue: sequelize.literal('CURRENT_TIMESTAMP'), + defaultValue: new Date(), }, updated_at: { type: Sequelize.DATE, allowNull: false, - defaultValue: sequelize.literal('CURRENT_TIMESTAMP'), + defaultValue: new Date(), }, }, { diff --git a/libs/model/src/models/token.ts b/libs/model/src/models/token.ts index 15e35b9b166..ea34d01d3d3 100644 --- a/libs/model/src/models/token.ts +++ b/libs/model/src/models/token.ts @@ -1,24 +1,24 @@ -import { Token } from '@hicommonwealth/schemas'; +import { LaunchpadToken } from '@hicommonwealth/schemas'; import Sequelize from 'sequelize'; // must use "* as" to avoid scope errors import { z } from 'zod'; import type { ChainNodeAttributes, ChainNodeInstance } from './chain_node'; import type { ModelInstance } from './types'; -export type TokenAttributes = z.infer & { +export type LaunchpadTokenAttributes = z.infer & { // associations ChainNode?: ChainNodeAttributes; }; -export type TokenInstance = ModelInstance & { +export type LaunchpadTokenInstance = ModelInstance & { // add mixins as needed getChainNode: Sequelize.BelongsToGetAssociationMixin; }; export default ( sequelize: Sequelize.Sequelize, -): Sequelize.ModelStatic => - sequelize.define( - 'Token', +): Sequelize.ModelStatic => + sequelize.define( + 'LaunchpadToken', { // derivable when event received token_address: { type: Sequelize.STRING, primaryKey: true }, @@ -44,7 +44,7 @@ export default ( icon_url: { type: Sequelize.STRING, allowNull: true }, }, { - tableName: 'Tokens', + tableName: 'LaunchpadTokens', timestamps: true, createdAt: 'created_at', updatedAt: 'updated_at', diff --git a/libs/model/src/policies/FarcasterWorker.policy.ts b/libs/model/src/policies/FarcasterWorker.policy.ts index ff6fb120638..8d2d6cc33b3 100644 --- a/libs/model/src/policies/FarcasterWorker.policy.ts +++ b/libs/model/src/policies/FarcasterWorker.policy.ts @@ -135,10 +135,6 @@ export function FarcasterWorker(): Policy { ]; // create onchain content from reply cast - mustExist( - 'Farcaster Author Custody Address', - payload.author?.custody_address, - ); const content_url = buildFarcasterContentUrl( payload.parent_hash!, payload.hash, @@ -146,16 +142,12 @@ export function FarcasterWorker(): Policy { await createOnchainContestContent({ contestManagers, bypass_quota: true, - author_address: payload.author.custody_address, + author_address: payload.verified_address, content_url, }); }, FarcasterVoteCreated: async ({ payload }) => { - const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!); - const castsResponse = await client.fetchBulkCasts([ - payload.untrustedData.castId.hash, - ]); - const { parent_hash, hash } = castsResponse.result.casts.at(0)!; + const { parent_hash, hash } = payload.cast; const content_url = buildFarcasterContentUrl(parent_hash!, hash); const contestManager = await models.ContestManager.findOne({ @@ -180,11 +172,6 @@ export function FarcasterWorker(): Policy { }, }); - const { users } = await client.fetchBulkUsers([ - payload.untrustedData.fid, - ]); - mustExist('Farcaster User', users[0]); - const community = await models.Community.findByPk( contestManager.community_id, { @@ -206,7 +193,7 @@ export function FarcasterWorker(): Policy { await createOnchainContestVote({ contestManagers, - author_address: users[0].custody_address, + author_address: payload.verified_address, content_url, }); }, diff --git a/libs/model/src/policies/chainEventCreatedPolicy.ts b/libs/model/src/policies/chainEventCreatedPolicy.ts index f421b5321ee..328ca04eeeb 100644 --- a/libs/model/src/policies/chainEventCreatedPolicy.ts +++ b/libs/model/src/policies/chainEventCreatedPolicy.ts @@ -4,7 +4,7 @@ import { events } from '@hicommonwealth/schemas'; import { ZodUndefined } from 'zod'; import { models } from '../database'; import { systemActor } from '../middleware'; -import { CreateToken } from '../token/CreateToken.command'; +import { CreateLaunchpadToken } from '../token/CreateToken.command'; import { handleCommunityStakeTrades } from './handleCommunityStakeTrades'; import { handleLaunchpadTrade } from './handleLaunchpadTrade'; @@ -23,10 +23,15 @@ export const processChainEventCreated: EventHandler< payload.eventSource.eventSignature === EvmEventSignatures.Launchpad.TokenLaunched ) { - await command(CreateToken(), { + const chainNode = await models.ChainNode.findOne({ + where: { + eth_chain_id: payload.eventSource.ethChainId, + }, + }); + await command(CreateLaunchpadToken(), { actor: systemActor({}), payload: { - chain_node_id: payload.eventSource.chainNodeId, + chain_node_id: chainNode!.id!, community_id: '', // not required for system actors transaction_hash: payload.rawLog.transactionHash, }, diff --git a/libs/model/src/policies/handleCommunityStakeTrades.ts b/libs/model/src/policies/handleCommunityStakeTrades.ts index 11a9dfd0678..4b300993fff 100644 --- a/libs/model/src/policies/handleCommunityStakeTrades.ts +++ b/libs/model/src/policies/handleCommunityStakeTrades.ts @@ -43,7 +43,7 @@ export async function handleCommunityStakeTrades( const chainNode = await models.ChainNode.scope('withPrivateData').findOne({ where: { - id: event.eventSource.chainNodeId, + eth_chain_id: event.eventSource.ethChainId, }, }); if (!chainNode) { diff --git a/libs/model/src/policies/handleLaunchpadTrade.ts b/libs/model/src/policies/handleLaunchpadTrade.ts index 31f70fe96fd..dd5bb553748 100644 --- a/libs/model/src/policies/handleLaunchpadTrade.ts +++ b/libs/model/src/policies/handleLaunchpadTrade.ts @@ -22,7 +22,7 @@ export async function handleLaunchpadTrade( 6: floatingSupply, } = event.parsedArgs as z.infer; - const token = await models.Token.findOne({ + const token = await models.LaunchpadToken.findOne({ where: { token_address: tokenAddress, }, @@ -34,7 +34,7 @@ export async function handleLaunchpadTrade( const chainNode = await models.ChainNode.scope('withPrivateData').findOne({ where: { - id: event.eventSource.chainNodeId, + eth_chain_id: event.eventSource.ethChainId, }, }); diff --git a/libs/model/src/services/openai/generateTokenIdea.ts b/libs/model/src/services/openai/generateTokenIdea.ts index a68bb8fb68e..5da2d146440 100644 --- a/libs/model/src/services/openai/generateTokenIdea.ts +++ b/libs/model/src/services/openai/generateTokenIdea.ts @@ -8,7 +8,7 @@ import { import { v4 as uuidv4 } from 'uuid'; import { config } from '../../config'; import { models } from '../../database'; -import { TokenInstance } from '../../models/token'; +import { LaunchpadTokenInstance } from '../../models/token'; type TokenIdea = { name: string; @@ -104,14 +104,14 @@ const generateTokenIdea = async function* ({ try { // generate a unique token name - let foundToken: TokenInstance | boolean | null = true; + let foundToken: LaunchpadTokenInstance | boolean | null = true; while (foundToken) { tokenIdea.name = await chatWithOpenAI( TOKEN_AI_PROMPTS_CONFIG.name(ideaPrompt), openai, ); - foundToken = await models.Token.findOne({ + foundToken = await models.LaunchpadToken.findOne({ where: { name: tokenIdea.name, }, diff --git a/libs/model/src/services/stakeHelper.ts b/libs/model/src/services/stakeHelper.ts index 3d0164593bb..b29ad311828 100644 --- a/libs/model/src/services/stakeHelper.ts +++ b/libs/model/src/services/stakeHelper.ts @@ -2,7 +2,6 @@ import { InvalidState } from '@hicommonwealth/core'; import { commonProtocol } from '@hicommonwealth/evm-protocols'; import { TopicWeightedVoting } from '@hicommonwealth/schemas'; import { BalanceSourceType, ZERO_ADDRESS } from '@hicommonwealth/shared'; -import { BigNumber } from 'ethers'; import { GetBalancesOptions, tokenBalanceCache } from '.'; import { config } from '../config'; import { models } from '../database'; @@ -19,9 +18,9 @@ import { getTokenAttributes } from './commonProtocol/contractHelpers'; export async function getVotingWeight( topic_id: number, address: string, -): Promise { +): Promise { if (config.STAKE.REACTION_WEIGHT_OVERRIDE) - return BigNumber.from(config.STAKE.REACTION_WEIGHT_OVERRIDE); + return BigInt(config.STAKE.REACTION_WEIGHT_OVERRIDE); const topic = await models.Topic.findByPk(topic_id, { include: [ @@ -67,58 +66,28 @@ export async function getVotingWeight( [address], ); const stakeBalance = stakeBalances[address]; - if (BigNumber.from(stakeBalance).lte(0)) + if (BigInt(stakeBalance) === BigInt(0)) { throw new InvalidState('Must have stake to upvote'); + } return commonProtocol.calculateVoteWeight(stakeBalance, stake.vote_weight); } else if (topic.weighted_voting === TopicWeightedVoting.ERC20) { - const { eth_chain_id, private_url, url } = topic.ChainNode!; + // if topic chain node is missing, fallback on community chain node + const chainNode = topic.ChainNode || community.ChainNode!; + const { eth_chain_id, private_url, url } = chainNode; mustExist('Chain Node Eth Chain Id', eth_chain_id); const chainNodeUrl = private_url! || url!; mustExist('Chain Node URL', chainNodeUrl); mustExist('Topic Token Address', topic.token_address); - const balanceOptions: GetBalancesOptions = - topic.token_address == ZERO_ADDRESS - ? { - balanceSourceType: BalanceSourceType.ETHNative, - addresses: [address], - sourceOptions: { - evmChainId: eth_chain_id, - }, - } - : { - balanceSourceType: BalanceSourceType.ERC20, - addresses: [address], - sourceOptions: { - evmChainId: eth_chain_id, - contractAddress: topic.token_address, - }, - }; - - balanceOptions.cacheRefresh = true; - - const balances = await tokenBalanceCache.getBalances(balanceOptions); - - const tokenBalance = balances[address]; - - if (BigNumber.from(tokenBalance || 0).lte(0)) - throw new InvalidState('Insufficient token balance'); - - const result = commonProtocol.calculateVoteWeight( - tokenBalance, - topic.vote_weight_multiplier!, - ); - - const { decimals } = await getTokenAttributes( - topic.token_address!, + const numFullTokens = await getWeightedNumTokens( + address, + topic.token_address, + eth_chain_id, chainNodeUrl, - false, + topic.vote_weight_multiplier!, ); - - // only count full ERC20 tokens - const numFullTokens = result?.div(BigNumber.from(10).pow(decimals)) || null; - if (!numFullTokens || numFullTokens.isZero()) { + if (numFullTokens === BigInt(0)) { // if the weighted value is not at least a full token, reject the action throw new InvalidState('Insufficient token balance'); } @@ -128,3 +97,54 @@ export async function getVotingWeight( // no weighted voting return null; } + +export async function getWeightedNumTokens( + address: string, + tokenAddress: string, + ethChainId: number, + chainNodeUrl: string, + voteWeightMultiplier: number, +): Promise { + const balanceOptions: GetBalancesOptions = + tokenAddress == ZERO_ADDRESS + ? { + balanceSourceType: BalanceSourceType.ETHNative, + addresses: [address], + sourceOptions: { + evmChainId: ethChainId, + }, + } + : { + balanceSourceType: BalanceSourceType.ERC20, + addresses: [address], + sourceOptions: { + evmChainId: ethChainId, + contractAddress: tokenAddress, + }, + }; + + balanceOptions.cacheRefresh = true; + + const balances = await tokenBalanceCache.getBalances(balanceOptions); + + const tokenBalance = balances[address]; + + if (BigInt(tokenBalance || 0) <= BigInt(0)) { + throw new InvalidState('Insufficient token balance'); + } + const result = commonProtocol.calculateVoteWeight( + tokenBalance, + voteWeightMultiplier, + ); + const { decimals } = await getTokenAttributes( + tokenAddress, + chainNodeUrl, + false, + ); + // only count full ERC20 tokens + const numFullTokens = result ? result / BigInt(10 ** decimals) : null; + if (!numFullTokens || numFullTokens === BigInt(0)) { + return BigInt(0); + } + return numFullTokens; +} diff --git a/libs/model/src/thread/GetThreads.query.ts b/libs/model/src/thread/GetThreads.query.ts index 2af1f64b49d..ac2ba283b88 100644 --- a/libs/model/src/thread/GetThreads.query.ts +++ b/libs/model/src/thread/GetThreads.query.ts @@ -66,16 +66,6 @@ export function GetThreads(): Query { pastWinners: ' AND CON.end_time <= NOW()', all: '', }; - const baseWhereClause = ` - community_id = :community_id AND - deleted_at IS NULL AND - archived_at IS ${archived ? 'NOT' : ''} NULL - ${topic_id ? ' AND topic_id = :topic_id' : ''} - ${stage ? ' AND stage = :stage' : ''} - ${from_date ? ' AND T.created_at > :from_date' : ''} - ${to_date ? ' AND T.created_at < :to_date' : ''} - ${contestAddress ? ' AND id IN (SELECT * FROM "contest_ids")' : ''} - `; const responseThreadsQuery = models.sequelize.query< z.infer >( @@ -96,10 +86,18 @@ export function GetThreads(): Query { pinned, community_id, T.created_at, updated_at, locked_at as thread_locked, links, has_poll, last_commented_on, comment_count as "numberOfComments", marked_as_spam_at, archived_at, topic_id, reaction_weights_sum, canvas_signed_data, - canvas_msg_id, last_edited, address_id, reaction_count + canvas_msg_id, last_edited, address_id, reaction_count, + (COUNT(id) OVER())::INTEGER AS total_num_thread_results FROM "Threads" T - WHERE ${baseWhereClause} - + WHERE + community_id = :community_id AND + deleted_at IS NULL AND + archived_at IS ${archived ? 'NOT' : ''} NULL + ${topic_id ? ' AND topic_id = :topic_id' : ''} + ${stage ? ' AND stage = :stage' : ''} + ${from_date ? ' AND T.created_at > :from_date' : ''} + ${to_date ? ' AND T.created_at < :to_date' : ''} + ${contestAddress ? ' AND id IN (SELECT * FROM "contest_ids")' : ''} ORDER BY pinned DESC, ${orderByQueries[order_by ?? 'newest']} LIMIT :limit OFFSET :offset ), thread_metadata AS ( @@ -252,18 +250,6 @@ export function GetThreads(): Query { }, ); - const countThreadsQuery = models.sequelize.query<{ count: number }>( - ` - SELECT COUNT(*) AS count - FROM "Threads" T - WHERE ${baseWhereClause} - `, - { - replacements, - type: QueryTypes.SELECT, - }, - ); - const numVotingThreadsQuery = models.Thread.count({ where: { community_id, @@ -271,10 +257,9 @@ export function GetThreads(): Query { }, }); - const [threads, numVotingThreads, countResult] = await Promise.all([ + const [threads, numVotingThreads] = await Promise.all([ responseThreadsQuery, numVotingThreadsQuery, - countThreadsQuery, ]); return { @@ -282,7 +267,7 @@ export function GetThreads(): Query { page: replacements.page, threads, numVotingThreads, - threadCount: Number(countResult[0]?.count) || 0, + threadCount: threads.at(0)?.total_num_thread_results || 0, }; }, }; diff --git a/libs/model/src/thread/UpdateThread.command.ts b/libs/model/src/thread/UpdateThread.command.ts index 0fd6871ade0..70a2b38cfb5 100644 --- a/libs/model/src/thread/UpdateThread.command.ts +++ b/libs/model/src/thread/UpdateThread.command.ts @@ -5,7 +5,7 @@ import { type Command, } from '@hicommonwealth/core'; import * as schemas from '@hicommonwealth/schemas'; -import { Op, Sequelize } from 'sequelize'; +import { Op } from 'sequelize'; import { z } from 'zod'; import { models } from '../database'; import { authThread } from '../middleware'; @@ -230,7 +230,7 @@ export function UpdateThread(): Command { ...content, ...adminPatch, ...ownerPatch, - last_edited: Sequelize.literal('CURRENT_TIMESTAMP'), + last_edited: new Date(), ...searchUpdate, content_url: contentUrl, }, diff --git a/libs/model/src/token/CreateToken.command.ts b/libs/model/src/token/CreateToken.command.ts index deb9a4f5f07..07c79e39888 100644 --- a/libs/model/src/token/CreateToken.command.ts +++ b/libs/model/src/token/CreateToken.command.ts @@ -11,7 +11,7 @@ import { getTokenCreatedTransaction, } from '../services/commonProtocol/launchpadHelpers'; -export function CreateToken(): Command { +export function CreateLaunchpadToken(): Command { return { ...schemas.CreateToken, auth: [authRoles('admin')], @@ -47,7 +47,7 @@ export function CreateToken(): Command { ); } - const token = await models.Token.create({ + const token = await models.LaunchpadToken.create({ token_address: tokenData.parsedArgs.tokenAddress.toLowerCase(), namespace: tokenData.parsedArgs.namespace, name: tokenInfo.name, diff --git a/libs/model/src/token/GetToken.query.ts b/libs/model/src/token/GetToken.query.ts index ea66a52ee28..56d48eaf86d 100644 --- a/libs/model/src/token/GetToken.query.ts +++ b/libs/model/src/token/GetToken.query.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { models } from '../database'; import { mustExist } from '../middleware/guards'; -export function GetToken(): Query { +export function GetLaunchpadToken(): Query { return { ...schemas.GetToken, auth: [], @@ -21,7 +21,7 @@ export function GetToken(): Query { mustExist('Community', community); if (!community.namespace) { - return; + return null; } const sql = ` @@ -44,7 +44,7 @@ export function GetToken(): Query { : '' } SELECT T.*${with_stats ? ', trades.latest_price, trades.old_price' : ''} - FROM "Tokens" as T + FROM "LaunchpadTokens" as T ${with_stats ? 'LEFT JOIN trades ON trades.token_address = T.token_address' : ''} WHERE T.namespace = :namespace; `; @@ -57,7 +57,7 @@ export function GetToken(): Query { }, type: QueryTypes.SELECT, }); - if (!token || !Array.isArray(token) || token.length !== 1) return; + if (!token || !Array.isArray(token) || token.length !== 1) return null; return token[0]; }, diff --git a/libs/model/src/token/GetTokens.query.ts b/libs/model/src/token/GetTokens.query.ts index 99f6353bdac..b7f9d22e1ff 100644 --- a/libs/model/src/token/GetTokens.query.ts +++ b/libs/model/src/token/GetTokens.query.ts @@ -4,7 +4,7 @@ import { QueryTypes } from 'sequelize'; import { z } from 'zod'; import { models } from '../database'; -export function GetTokens(): Query { +export function GetLaunchpadTokens(): Query { return { ...schemas.GetTokens, auth: [], @@ -66,7 +66,7 @@ export function GetTokens(): Query { C.id as community_id, ${includeStats ? 'trades.latest_price, trades.old_price,' : ''} count(*) OVER () AS total - FROM "Tokens" as T + FROM "LaunchpadTokens" as T JOIN "Communities" as C ON T.namespace = C.namespace ${includeStats ? 'LEFT JOIN trades ON trades.token_address = T.token_address' : ''} diff --git a/libs/model/src/user/GetUserProfile.query.ts b/libs/model/src/user/GetUserProfile.query.ts index 7552335cb4a..4a4a27003ef 100644 --- a/libs/model/src/user/GetUserProfile.query.ts +++ b/libs/model/src/user/GetUserProfile.query.ts @@ -3,20 +3,23 @@ import * as schemas from '@hicommonwealth/schemas'; import { Op } from 'sequelize'; import { z } from 'zod'; import { models } from '../database'; +import { mustExist } from '../middleware/guards'; export function GetUserProfile(): Query { return { ...schemas.GetUserProfile, auth: [], - secure: true, + secure: false, body: async ({ actor, payload }) => { - const user_id = payload.userId ?? actor.user.id; + const user_id = payload.userId ?? actor.user?.id; const user = await models.User.findOne({ where: { id: user_id }, attributes: ['profile', 'xp_points', 'referral_link'], }); + mustExist('User', user); + const addresses = await models.Address.findAll({ where: { user_id }, include: [ @@ -98,7 +101,7 @@ export function GetUserProfile(): Query { commentThreads: commentThreads.map( (c) => c.toJSON() as z.infer, ), - isOwner: actor.user.id === user_id, + isOwner: actor.user?.id === user_id, // ensure Tag is present in typed response tags: profileTags.map((t) => ({ id: t.Tag!.id!, name: t.Tag!.name })), xp_points: user!.xp_points ?? 0, diff --git a/libs/model/src/utils/buildFarcasterContentUrl.ts b/libs/model/src/utils/buildFarcasterContentUrl.ts deleted file mode 100644 index 143f9f81deb..00000000000 --- a/libs/model/src/utils/buildFarcasterContentUrl.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function buildFarcasterContentUrl( - parentCastHash: string, - replyCashHash: string, -) { - return `/farcaster/${parentCastHash}/${replyCashHash}`; -} diff --git a/libs/model/src/utils/farcasterUtils.ts b/libs/model/src/utils/farcasterUtils.ts new file mode 100644 index 00000000000..6c8704d231f --- /dev/null +++ b/libs/model/src/utils/farcasterUtils.ts @@ -0,0 +1,14 @@ +export function buildFarcasterContentUrl( + parentCastHash: string, + replyCashHash: string, +) { + return `/farcaster/${parentCastHash}/${replyCashHash}`; +} + +export function parseFarcasterContentUrl(url: string) { + const [, , parentCastHash, replyCastHash] = url.split('/'); + return { + parentCastHash, + replyCastHash, + }; +} diff --git a/libs/model/src/utils/index.ts b/libs/model/src/utils/index.ts index 281f6a18509..12861f43c7c 100644 --- a/libs/model/src/utils/index.ts +++ b/libs/model/src/utils/index.ts @@ -1,14 +1,15 @@ -export * from './buildFarcasterContentUrl'; export * from './buildFarcasterWebhookName'; export * from './decodeContent'; export * from './defaultAvatar'; export * from './denormalizedCountUtils'; +export * from './farcasterUtils'; export * from './getDefaultContestImage'; export * from './getDelta'; export * from './makeGetBalancesOptions'; export * from './parseUserMentions'; export * from './sanitizeDeletedComment'; export * from './sanitizeQuillText'; +export * from './testChainNodeUtils'; export * from './updateTags'; export * from './utils'; export * from './validateGroupMembership'; diff --git a/libs/model/src/utils/testChainNodeUtils.ts b/libs/model/src/utils/testChainNodeUtils.ts new file mode 100644 index 00000000000..537aaf42812 --- /dev/null +++ b/libs/model/src/utils/testChainNodeUtils.ts @@ -0,0 +1,64 @@ +import { commonProtocol as cp } from '@hicommonwealth/evm-protocols'; +import { BalanceType } from '@hicommonwealth/shared'; +import { models } from '../database'; +import { ChainNodeInstance } from '../models'; +import { buildChainNodeUrl } from './utils'; + +export function createTestRpc( + ethChainId: cp.ValidChains, + scope: 'private' | 'public' = 'public', +): string { + switch (ethChainId) { + case cp.ValidChains.Arbitrum: + return buildChainNodeUrl('https://arb-mainnet.g.alchemy.com/v2/', scope); + case cp.ValidChains.Mainnet: + return buildChainNodeUrl('https://eth-mainnet.g.alchemy.com/v2/', scope); + case cp.ValidChains.Optimism: + return buildChainNodeUrl('https://opt-mainnet.g.alchemy.com/v2/', scope); + case cp.ValidChains.Linea: + return buildChainNodeUrl( + 'https://linea-mainnet.g.alchemy.com/v2/', + scope, + ); + case cp.ValidChains.Blast: + return buildChainNodeUrl( + 'https://blast-mainnet.g.alchemy.com/v2/', + scope, + ); + case cp.ValidChains.Sepolia: + return buildChainNodeUrl('https://eth-sepolia.g.alchemy.com/v2/', scope); + case cp.ValidChains.SepoliaBase: + return buildChainNodeUrl('https://base-sepolia.g.alchemy.com/v2/', scope); + case cp.ValidChains.Base: + return buildChainNodeUrl('https://base-mainnet.g.alchemy.com/v2/', scope); + case cp.ValidChains.BSC: + return buildChainNodeUrl('https://bnb-mainnet.g.alchemy.com/v2/', scope); + case cp.ValidChains.SKALE_TEST: + return 'https://testnet.skalenodes.com/v1/giant-half-dual-testnet'; + default: + throw new Error(`Eth chain id ${ethChainId} not supported`); + } +} + +export async function createEventRegistryChainNodes() { + const promises: Array> = []; + for (const ethChainId of Object.values(cp.ValidChains)) { + if (typeof ethChainId === 'number') { + promises.push( + models.ChainNode.findOrCreate({ + where: { + eth_chain_id: ethChainId, + }, + defaults: { + url: createTestRpc(ethChainId), + private_url: createTestRpc(ethChainId, 'private'), + balance_type: BalanceType.Ethereum, + name: `${ethChainId} Node`, + }, + }), + ); + } + } + const chainNodes = await Promise.all(promises); + return chainNodes.map((c) => c[0]); +} diff --git a/libs/model/src/utils/utils.ts b/libs/model/src/utils/utils.ts index f5e052e0267..5ecff95c905 100644 --- a/libs/model/src/utils/utils.ts +++ b/libs/model/src/utils/utils.ts @@ -5,6 +5,7 @@ import { safeTruncateBody, type AbiType, } from '@hicommonwealth/shared'; +import { NeynarAPIClient } from '@neynar/nodejs-sdk'; import { createHash } from 'crypto'; import { hasher } from 'node-object-hash'; import { @@ -51,7 +52,7 @@ export async function emitEvent( } else { log.warn( `Event not inserted into outbox! ` + - `The event "${event.event_name}" is blacklisted. + `The event "${event.event_name}" is blacklisted. Remove it from BLACKLISTED_EVENTS env in order to allow emitting this event.`, { event_name: event.event_name, @@ -79,11 +80,13 @@ export function buildThreadContentUrl(communityId: string, threadId: number) { export function decodeThreadContentUrl(contentUrl: string): { communityId: string | null; threadId: number | null; + isFarcaster: boolean; } { if (contentUrl.startsWith('/farcaster/')) { return { communityId: null, threadId: null, + isFarcaster: true, }; } if (!contentUrl.includes('/discussion/')) { @@ -95,6 +98,7 @@ export function decodeThreadContentUrl(contentUrl: string): { return { communityId, threadId: parseInt(threadId, 10), + isFarcaster: false, }; } @@ -206,7 +210,7 @@ export function getChainNodeUrl({ private_url, }: { url: string; - private_url?: string; + private_url?: string | null | undefined; }) { if (!private_url || private_url === '') return buildChainNodeUrl(url, 'public'); @@ -256,3 +260,26 @@ export function getSaltedApiKeyHash(apiKey: string, salt: string): string { export function buildApiKeySaltCacheKey(address: string) { return `salt_${address.toLowerCase()}`; } + +export async function publishCast( + replyCastHash: string, + messageBuilder: ({ username }: { username: string }) => string, +) { + const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!); + try { + const { + result: { casts }, + } = await client.fetchBulkCasts([replyCastHash]); + const username = casts[0].author.username!; + await client.publishCast( + config.CONTESTS.NEYNAR_BOT_UUID!, + messageBuilder({ username }), + { + replyTo: replyCastHash, + }, + ); + log.info(`FC bot published reply to ${replyCastHash}`); + } catch (err) { + log.error(`Failed to post as FC bot`, err as Error); + } +} diff --git a/libs/model/test/community/community-lifecycle.spec.ts b/libs/model/test/community/community-lifecycle.spec.ts index 835f5fb24b4..8db5aae7934 100644 --- a/libs/model/test/community/community-lifecycle.spec.ts +++ b/libs/model/test/community/community-lifecycle.spec.ts @@ -51,6 +51,7 @@ function buildCreateGroupPayload( metadata: { name: chance.name(), description: chance.sentence(), + groupImageUrl: chance.url(), required_requirements: 1, membership_ttl: 100, }, diff --git a/libs/model/test/community/pinned-token-lifecycle.spec.ts b/libs/model/test/community/pinned-token-lifecycle.spec.ts new file mode 100644 index 00000000000..a67c3f10cc5 --- /dev/null +++ b/libs/model/test/community/pinned-token-lifecycle.spec.ts @@ -0,0 +1,479 @@ +import { Actor, command, dispose, query } from '@hicommonwealth/core'; +import { models } from '@hicommonwealth/model'; +import * as shared from '@hicommonwealth/shared'; +import { + MockInstance, + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest'; +import { + GetPinnedTokens, + PinToken, + PinTokenErrors, + UnpinToken, + UnpinTokenErrors, +} from '../../src/community'; +import { seed } from '../../src/tester'; + +const adminAddress = '0x0b84092914abaA89dDCb9C788Ace0B1fD6Ea7d90'; +const ethMainnetUSDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; +const ethMainnetUSDT = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + +describe('Pinned token lifecycle', () => { + let community_id: string | undefined; + let second_community_id: string | undefined; + let third_community_id: string | undefined; + let chain_node_id: number | undefined; + let unsupported_chain_node_id: number | undefined; + let adminActor: Actor; + let userActor: Actor; + let topSpy: MockInstance; + + beforeAll(async () => { + const [ethNode] = await seed('ChainNode', { + url: 'https://base-mainnet.g.alchemy.com/v2/', + private_url: 'https://base-mainnet.g.alchemy.com/v2/', + eth_chain_id: 8453, + alchemy_metadata: { + network_id: 'base-mainnet', + price_api_supported: true, + transfer_api_supported: true, + }, + }); + const [randomNode] = await seed('ChainNode', {}); + const [admin] = await seed('User', { isAdmin: false }); + const [user] = await seed('User', { isAdmin: false }); + const [community] = await seed('Community', { + chain_node_id: randomNode!.id!, + base: shared.ChainBase.Ethereum, + active: true, + profile_count: 2, + lifetime_thread_count: 0, + Addresses: [ + { + role: 'admin', + user_id: admin!.id, + verified: new Date(), + address: adminAddress, + }, + { + role: 'member', + user_id: user!.id, + verified: new Date(), + }, + ], + namespace: null, + }); + const [secondCommunity] = await seed('Community', { + chain_node_id: randomNode!.id!, + base: shared.ChainBase.Ethereum, + active: true, + profile_count: 2, + lifetime_thread_count: 0, + Addresses: [ + { + role: 'admin', + user_id: admin!.id, + verified: new Date(), + address: adminAddress, + }, + { + role: 'member', + user_id: user!.id, + verified: new Date(), + }, + ], + namespace: 'namespaceOne', + }); + const [thirdCommunity] = await seed('Community', { + chain_node_id: randomNode!.id!, + base: shared.ChainBase.Ethereum, + active: true, + profile_count: 2, + lifetime_thread_count: 0, + Addresses: [ + { + role: 'admin', + user_id: admin!.id, + verified: new Date(), + address: adminAddress, + }, + { + role: 'member', + user_id: user!.id, + verified: new Date(), + }, + ], + namespace: 'namespaceTwo', + }); + await seed('LaunchpadToken', { + namespace: 'namespaceTwo', + launchpad_liquidity: BigInt(1), + eth_market_cap_target: 1, + initial_supply: 1, + }); + third_community_id = thirdCommunity!.id!; + second_community_id = secondCommunity!.id!; + community_id = community!.id!; + chain_node_id = ethNode!.id!; + unsupported_chain_node_id = randomNode!.id!; + adminActor = { + user: { + id: admin!.id!, + email: admin!.email!, + isAdmin: admin!.isAdmin!, + }, + address: adminAddress, + }; + userActor = { + user: { + id: user!.id!, + email: user!.email!, + isAdmin: user!.isAdmin!, + }, + address: community!.Addresses!.at(1)!.address!, + }; + }); + + afterAll(async () => { + await dispose()(); + }); + + beforeEach(() => { + topSpy = vi + .spyOn(shared, 'alchemyGetTokenPrices') + .mockImplementation(() => { + console.log('alchemyGetTokenPrices mock'); + return Promise.resolve({ + data: [ + { + network: 'eth-mainnet', + address: '0x123', + prices: [ + { + currency: 'USDC', + value: '1', + lastUpdatedAt: new Date().toISOString(), + }, + ], + error: null, + }, + ], + }); + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('should to pin token if not admin', async () => { + await expect(() => + command(PinToken(), { + actor: userActor, + payload: { + community_id: community_id!, + chain_node_id: unsupported_chain_node_id!, + contract_address: ethMainnetUSDC, + }, + }), + ).rejects.toThrow('User is not admin in the community'); + expect(topSpy).toBeCalledTimes(0); + }); + + test('should fail to create a pinned token for an unsupported node', async () => { + await expect(() => + command(PinToken(), { + actor: adminActor, + payload: { + community_id: community_id!, + chain_node_id: unsupported_chain_node_id!, + contract_address: ethMainnetUSDC, + }, + }), + ).rejects.toThrow(PinTokenErrors.OnlyBaseSupport); + + await models.ChainNode.update( + { + alchemy_metadata: { + network_id: 'base-mainnet', + price_api_supported: false, + transfer_api_supported: false, + }, + }, + { + where: { + id: chain_node_id!, + }, + }, + ); + + await expect(() => + command(PinToken(), { + actor: adminActor, + payload: { + community_id: community_id!, + chain_node_id: chain_node_id!, + contract_address: ethMainnetUSDC, + }, + }), + ).rejects.toThrow(PinTokenErrors.NotSupported); + + await models.ChainNode.update( + { + alchemy_metadata: { + network_id: 'base-mainnet', + price_api_supported: true, + transfer_api_supported: true, + }, + }, + { + where: { + id: chain_node_id!, + }, + }, + ); + expect(topSpy).toBeCalledTimes(0); + }); + + test('should fail to create a pinned token for an invalid token', async () => { + let spy = vi + .spyOn(shared, 'alchemyGetTokenPrices') + .mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await expect(() => + command(PinToken(), { + actor: adminActor, + payload: { + community_id: community_id!, + chain_node_id: chain_node_id!, + // random address + contract_address: '0x0b84092914abaA89dDCb9C788Ace0B1fD6Ea7d91', + }, + }), + ).rejects.toThrow(PinTokenErrors.FailedToFetchPrice); + expect(spy).toBeCalledTimes(1); + + spy = vi.spyOn(shared, 'alchemyGetTokenPrices').mockImplementation(() => { + return Promise.resolve({ + data: [ + { + network: 'eth-mainnet', + address: '0x123', + prices: [ + { + currency: 'USDC', + value: '1', + lastUpdatedAt: new Date().toISOString(), + }, + ], + error: 'Something failed', + }, + ], + }); + }); + + await expect(() => + command(PinToken(), { + actor: adminActor, + payload: { + community_id: community_id!, + chain_node_id: chain_node_id!, + // random address + contract_address: '0x0b84092914abaA89dDCb9C788Ace0B1fD6Ea7d91', + }, + }), + ).rejects.toThrow(PinTokenErrors.FailedToFetchPrice); + expect(spy).toBeCalledTimes(1); + }); + + test('should fail to pin a token if the community has a launchpad token', async () => { + await expect(() => + command(PinToken(), { + actor: adminActor, + payload: { + community_id: third_community_id!, + chain_node_id: chain_node_id!, + contract_address: ethMainnetUSDC, + }, + }), + ).rejects.toThrow(PinTokenErrors.LaunchpadTokenFound(third_community_id!)); + expect(topSpy).toBeCalledTimes(0); + }); + + test('should pin a token', async () => { + let res = await command(PinToken(), { + actor: adminActor, + payload: { + community_id: community_id!, + chain_node_id: chain_node_id!, + contract_address: ethMainnetUSDC, + }, + }); + expect(res?.community_id).to.equal(community_id); + expect(res?.chain_node_id).to.equal(chain_node_id); + expect(res?.contract_address).to.equal(ethMainnetUSDC); + + res = await command(PinToken(), { + actor: adminActor, + payload: { + community_id: second_community_id!, + chain_node_id: chain_node_id!, + contract_address: ethMainnetUSDT, + }, + }); + expect(res?.community_id).to.equal(second_community_id); + expect(res?.chain_node_id).to.equal(chain_node_id); + expect(res?.contract_address).to.equal(ethMainnetUSDT); + + expect(topSpy).toBeCalledTimes(2); + }); + + test('should fail to pin more than 1 token on the same community', async () => { + await expect(() => + command(PinToken(), { + actor: adminActor, + payload: { + community_id: community_id!, + chain_node_id: chain_node_id!, + contract_address: ethMainnetUSDC, + }, + }), + ).rejects.toThrow(); + + await expect(() => + command(PinToken(), { + actor: adminActor, + payload: { + community_id: community_id!, + chain_node_id: chain_node_id!, + contract_address: ethMainnetUSDT, + }, + }), + ).rejects.toThrow(); + + expect(topSpy).toBeCalledTimes(2); + }); + + test('should return empty array if no pinned token', async () => { + let res = await query(GetPinnedTokens(), { + actor: adminActor, + payload: { + community_ids: 'random_community_id', + with_chain_node: true, + }, + }); + expect(res).to.deep.equal([]); + + res = await query(GetPinnedTokens(), { + actor: userActor, + payload: { + community_ids: 'random_community_id', + with_chain_node: true, + }, + }); + expect(res).to.deep.equal([]); + }); + + test('should get a pinned token', async () => { + let res = await query(GetPinnedTokens(), { + actor: adminActor, + payload: { + community_ids: `${community_id}`, + with_chain_node: true, + }, + }); + if (!res) expect.fail('Result is null'); + expect(res[0].community_id).to.equal(community_id); + expect(res[0].chain_node_id).to.equal(chain_node_id); + expect(res[0].contract_address).to.equal(ethMainnetUSDC); + + res = await query(GetPinnedTokens(), { + actor: userActor, + payload: { + community_ids: `${community_id}`, + with_chain_node: true, + }, + }); + if (!res) expect.fail('Result is null'); + expect(res[0].community_id).to.equal(community_id); + expect(res[0].chain_node_id).to.equal(chain_node_id); + expect(res[0].contract_address).to.equal(ethMainnetUSDC); + + res = await query(GetPinnedTokens(), { + actor: userActor, + payload: { + community_ids: `${community_id},${second_community_id}`, + with_chain_node: true, + }, + }); + if (!res) expect.fail('Result is null'); + const pinnedToken1 = res.find((x) => x.community_id === community_id); + const pinnedToken2 = res.find( + (x) => x.community_id === second_community_id, + ); + expect(pinnedToken1).to.exist; + expect(pinnedToken2).to.exist; + expect(pinnedToken1).toEqual( + expect.objectContaining({ + community_id, + chain_node_id, + contract_address: ethMainnetUSDC, + }), + ); + expect(pinnedToken2).toEqual( + expect.objectContaining({ + community_id: second_community_id, + chain_node_id, + contract_address: ethMainnetUSDT, + }), + ); + }); + + test('should fail to unpin a token if not admin', async () => { + await expect(() => + command(UnpinToken(), { + actor: userActor, + payload: { + community_id: community_id!, + }, + }), + ).rejects.toThrow('User is not admin in the community'); + }); + + test('should fail to unpin a token if not pinned', async () => { + await expect(() => + command(UnpinToken(), { + actor: adminActor, + payload: { + community_id: third_community_id!, + }, + }), + ).rejects.toThrow(UnpinTokenErrors.NotFound); + }); + + test('should unpin a token', async () => { + const res = await query(UnpinToken(), { + actor: adminActor, + payload: { + community_id: community_id!, + }, + }); + expect(res).to.deep.equal({}); + + const pinnedToken = await models.PinnedToken.findOne({ + where: { + community_id: community_id!, + }, + }); + expect(pinnedToken).toBeFalsy(); + }); +}); diff --git a/libs/model/test/contest/check-contests.spec.ts b/libs/model/test/contest/check-contests.spec.ts index 37f5cfdd70e..50039520bf5 100644 --- a/libs/model/test/contest/check-contests.spec.ts +++ b/libs/model/test/contest/check-contests.spec.ts @@ -12,7 +12,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import { seed } from '../../src/tester'; import { drainOutbox } from '../utils'; -describe('Check Contests', () => { +describe.skip('Check Contests', () => { const addressId = 444; const address = '0x0'; const communityId = 'ethhh'; @@ -23,7 +23,7 @@ describe('Check Contests', () => { const topicId: number = 0; beforeAll(async () => { - const [chainNode] = await seed('ChainNode', { contracts: [] }); + const [chainNode] = await seed('ChainNode'); const [user] = await seed( 'User', { diff --git a/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts index 0f2f8c6e7b7..a82dcc3cc3d 100644 --- a/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts +++ b/libs/model/test/contest/contest-worker-policy-lifecycle.spec.ts @@ -19,7 +19,7 @@ describe('Contest Worker Policy Lifecycle', () => { const topicId: number = 0; beforeAll(async () => { - const [chainNode] = await seed('ChainNode', { contracts: [] }); + const [chainNode] = await seed('ChainNode'); const [user] = await seed( 'User', { diff --git a/libs/model/test/contest/contests-projection-lifecycle.spec.ts b/libs/model/test/contest/contests-projection-lifecycle.spec.ts index 9eb392c0fdd..97fd2569f76 100644 --- a/libs/model/test/contest/contests-projection-lifecycle.spec.ts +++ b/libs/model/test/contest/contests-projection-lifecycle.spec.ts @@ -7,9 +7,9 @@ import { query, } from '@hicommonwealth/core'; import { commonProtocol } from '@hicommonwealth/evm-protocols'; -import { models } from '@hicommonwealth/model'; +import { createEventRegistryChainNodes, models } from '@hicommonwealth/model'; import { ContestResults, EventNames } from '@hicommonwealth/schemas'; -import { AbiType, delay } from '@hicommonwealth/shared'; +import { delay } from '@hicommonwealth/shared'; import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { @@ -71,28 +71,7 @@ describe('Contests projection lifecycle', () => { beforeAll(async () => { try { - const [recurringContestAbi] = await seed('ContractAbi', { - id: 700, - abi: [] as AbiType, - nickname: 'RecurringContest', - abi_hash: 'hash1', - verified: true, - }); - const [singleContestAbi] = await seed('ContractAbi', { - id: 701, - abi: [] as AbiType, - nickname: 'SingleContest', - abi_hash: 'hash2', - verified: true, - }); - const [chain] = await seed('ChainNode', { - contracts: [ - { abi_id: recurringContestAbi!.id }, - { abi_id: singleContestAbi!.id }, - ], - url: 'https://test', - private_url: 'https://test', - }); + const chainNodes = await createEventRegistryChainNodes(); const [user] = await seed( 'User', { @@ -106,7 +85,7 @@ describe('Contests projection lifecycle', () => { { id: community_id, namespace_address: namespace, - chain_node_id: chain!.id, + chain_node_id: chainNodes[0]!.id, discord_config_id: undefined, lifetime_thread_count: 0, profile_count: 1, diff --git a/libs/model/test/launchpad/launchpad.spec.ts b/libs/model/test/launchpad/launchpad.spec.ts index 00262dd3999..9bbf6e3ca15 100644 --- a/libs/model/test/launchpad/launchpad.spec.ts +++ b/libs/model/test/launchpad/launchpad.spec.ts @@ -7,7 +7,7 @@ import chaiAsPromised from 'chai-as-promised'; import { seed } from 'model/src/tester'; import { afterAll, beforeAll, describe, test } from 'vitest'; import { ChainNodeAttributes } from '../../src'; -import { CreateLaunchpadTrade, CreateToken } from '../../src/token'; +import { CreateLaunchpadToken, CreateLaunchpadTrade } from '../../src/token'; chai.use(chaiAsPromised); @@ -78,7 +78,7 @@ describe('Launchpad Lifecycle', () => { community_id: community_id!, }; - const results = await command(CreateToken(), { + const results = await command(CreateLaunchpadToken(), { actor, payload, }); diff --git a/libs/model/test/reaction/reaction-lifecycle.spec.ts b/libs/model/test/reaction/reaction-lifecycle.spec.ts index 15ba0c20194..1872033463c 100644 --- a/libs/model/test/reaction/reaction-lifecycle.spec.ts +++ b/libs/model/test/reaction/reaction-lifecycle.spec.ts @@ -10,7 +10,7 @@ describe('Reactions lifecycle', () => { const threadId = 999; beforeAll(async () => { - const [chain] = await seed('ChainNode', { contracts: [] }); + const [chain] = await seed('ChainNode'); const [user] = await seed( 'User', { diff --git a/libs/model/test/seed/seed.spec.ts b/libs/model/test/seed/seed.spec.ts index 04e0e4c2068..aa55924d615 100644 --- a/libs/model/test/seed/seed.spec.ts +++ b/libs/model/test/seed/seed.spec.ts @@ -79,8 +79,8 @@ describe('Seed functions', () => { test('Should seed with defaults', async () => { expect(shouldExit).to.be.false; shouldExit = true; - await testSeed('ChainNode', { contracts: undefined }); - await testSeed('ChainNode', { contracts: undefined }); + await testSeed('ChainNode'); + await testSeed('ChainNode'); shouldExit = false; }); @@ -91,16 +91,6 @@ describe('Seed functions', () => { url: 'mainnet1.edgewa.re', name: 'Edgeware Mainnet', balance_type: BalanceType.Substrate, - contracts: [ - { - address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', - token_name: 'sushi', - symbol: 'SUSHI', - type: ChainNetwork.ERC20, - chain_node_id: 1, - abi_id: undefined, - }, - ], }); shouldExit = false; }); @@ -110,7 +100,7 @@ describe('Seed functions', () => { test('Should seed with overrides', async () => { expect(shouldExit).to.be.false; shouldExit = true; - const node = await testSeed('ChainNode', { contracts: undefined }); + const node = await testSeed('ChainNode'); const user = await testSeed('User', { selected_community_id: null }); await testSeed('Community', { id: 'ethereum', diff --git a/libs/model/test/snapshot/createSnapshotProposal.spec.ts b/libs/model/test/snapshot/createSnapshotProposal.spec.ts index 2f4d1f3681c..a52013e9a48 100644 --- a/libs/model/test/snapshot/createSnapshotProposal.spec.ts +++ b/libs/model/test/snapshot/createSnapshotProposal.spec.ts @@ -15,7 +15,6 @@ describe('Snapshot Listener API', { timeout: 5_000 }, () => { name: 'Sepolia Testnet', eth_chain_id: 11155111, balance_type: BalanceType.Ethereum, - contracts: [], }, { mock: false }, ); diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index d99e58137d5..151e73ff032 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -14,6 +14,7 @@ import { Community, Group, PermissionEnum, + PinnedToken, Requirement, StakeTransaction, Topic, @@ -229,6 +230,7 @@ export const ToggleArchiveTopic = { const GroupMetadata = z.object({ name: z.string(), description: z.string(), + groupImageUrl: z.string().nullish(), required_requirements: PG_INT.nullish(), membership_ttl: PG_INT.optional(), }); @@ -328,3 +330,21 @@ export const BanAddress = { output: z.object({}), context: AuthContext, }; + +export const PinToken = { + input: z.object({ + community_id: z.string(), + contract_address: z.string(), + chain_node_id: z.number(), + }), + output: PinnedToken, + context: AuthContext, +}; + +export const UnpinToken = { + input: z.object({ + community_id: z.string(), + }), + output: z.object({}), + context: AuthContext, +}; diff --git a/libs/schemas/src/commands/contest.schemas.ts b/libs/schemas/src/commands/contest.schemas.ts index 40ae2289f04..6af1fce3fac 100644 --- a/libs/schemas/src/commands/contest.schemas.ts +++ b/libs/schemas/src/commands/contest.schemas.ts @@ -2,6 +2,7 @@ import { commonProtocol } from '@hicommonwealth/evm-protocols'; import z from 'zod'; import { AuthContext } from '../context'; import { ContestManager } from '../entities/contest-manager.schemas'; +import { FarcasterAction } from '../entities/farcaster.schemas'; import { PG_INT } from '../utils'; export const CreateContestManagerMetadata = { @@ -33,7 +34,8 @@ export const CreateContestManagerMetadata = { commonProtocol.WeiDecimals[commonProtocol.Denominations.ETH], ), topic_id: z.number().optional(), - is_farcaster_contest: z.boolean().nullish(), + is_farcaster_contest: z.boolean().optional(), + vote_weight_multiplier: z.number().optional().nullish(), }), output: z.object({ contest_managers: z.array(ContestManager), @@ -136,24 +138,6 @@ export const FarcasterCast = z.object({ event_timestamp: z.string(), }); -export const FarcasterAction = z.object({ - untrustedData: z.object({ - fid: z.number(), - url: z.string().url(), - messageHash: z.string(), - timestamp: z.number(), - network: z.number(), - buttonIndex: z.number(), - castId: z.object({ - fid: z.number(), - hash: z.string(), - }), - }), - trustedData: z.object({ - messageBytes: z.string(), - }), -}); - export const FarcasterCastCreatedWebhook = { input: z.object({ created_at: z.number(), @@ -166,23 +150,7 @@ export const FarcasterCastCreatedWebhook = { }; export const FarcasterUpvoteAction = { - input: z.object({ - untrustedData: z.object({ - fid: z.number(), - url: z.string().url(), - messageHash: z.string(), - timestamp: z.number(), - network: z.number(), - buttonIndex: z.number(), - castId: z.object({ - fid: z.number(), - hash: z.string(), - }), - }), - trustedData: z.object({ - messageBytes: z.string(), - }), - }), + input: FarcasterAction, output: z.object({ message: z.string(), }), diff --git a/libs/schemas/src/entities/chain.schemas.ts b/libs/schemas/src/entities/chain.schemas.ts index 8c62eee19b9..7ab5f4f488e 100644 --- a/libs/schemas/src/entities/chain.schemas.ts +++ b/libs/schemas/src/entities/chain.schemas.ts @@ -5,7 +5,6 @@ import { } from '@hicommonwealth/shared'; import z from 'zod'; import { PG_INT } from '../utils'; -import { Contract } from './contract.schemas'; export const ChainNode = z.object({ id: PG_INT.optional().nullish(), @@ -25,9 +24,15 @@ export const ChainNode = z.object({ .nullish(), cosmos_gov_version: z.nativeEnum(CosmosGovernanceVersion).nullish(), health: z.nativeEnum(NodeHealth).default(NodeHealth.Healthy).nullish(), - contracts: z.array(Contract).nullish(), block_explorer: z.string().nullish(), max_ce_block_range: z.number().gte(-1).nullish(), + alchemy_metadata: z + .object({ + network_id: z.string(), + price_api_supported: z.boolean(), + transfer_api_supported: z.boolean(), + }) + .nullish(), created_at: z.coerce.date().optional(), updated_at: z.coerce.date().optional(), diff --git a/libs/schemas/src/entities/community.schemas.ts b/libs/schemas/src/entities/community.schemas.ts index ac821262641..0f288c8f6c8 100644 --- a/libs/schemas/src/entities/community.schemas.ts +++ b/libs/schemas/src/entities/community.schemas.ts @@ -8,10 +8,11 @@ import { import { z } from 'zod'; import { PG_INT } from '../utils'; import { ChainNode } from './chain.schemas'; +import { ContestManager } from './contest-manager.schemas'; import { Group } from './group.schemas'; import { CommunityStake } from './stake.schemas'; import { CommunityTags } from './tag.schemas'; -import { ContestManager, Topic } from './topic.schemas'; +import { Topic } from './topic.schemas'; import { Address } from './user.schemas'; export const Community = z.object({ diff --git a/libs/schemas/src/entities/contest-manager.schemas.ts b/libs/schemas/src/entities/contest-manager.schemas.ts index ca697ddf956..234786782ce 100644 --- a/libs/schemas/src/entities/contest-manager.schemas.ts +++ b/libs/schemas/src/entities/contest-manager.schemas.ts @@ -10,6 +10,7 @@ export const ContestManager = z contest_address: z.string().describe('On-Chain contest manager address'), community_id: z.string(), name: z.string(), + description: z.string().nullish(), image_url: z.string().nullish(), funding_token_address: z .string() @@ -46,9 +47,24 @@ export const ContestManager = z .describe( 'Flags when the one-off contest has ended and rollover was completed', ), - topics: z.array(Topic).nullish(), contests: z.array(Contest).nullish(), farcaster_frame_url: z.string().nullish(), farcaster_frame_hashes: z.array(z.string()).nullish(), + neynar_webhook_id: z + .string() + .nullish() + .describe('Neynar ID of the ReplyCastCreated webhook'), + neynar_webhook_secret: z + .string() + .nullish() + .describe('Neynar secret for the ReplyCastCreated webhook'), + topic_id: PG_INT.nullish(), + topics: z.array(Topic).nullish(), + is_farcaster_contest: z.boolean(), + vote_weight_multiplier: z + .number() + .gt(0) + .nullish() + .describe('Vote weight multiplier'), }) .describe('On-Chain Contest Manager'); diff --git a/libs/schemas/src/entities/contract.schemas.ts b/libs/schemas/src/entities/contract.schemas.ts deleted file mode 100644 index 4c4801c2181..00000000000 --- a/libs/schemas/src/entities/contract.schemas.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod'; -import { PG_INT } from '../utils'; - -export const CommunityContract = z.object({ - id: PG_INT, - community_id: z.string().max(255), - contract_id: PG_INT, - - created_at: z.coerce.date().optional(), - updated_at: z.coerce.date().optional(), -}); - -export const Contract = z.object({ - id: PG_INT, - address: z.string().max(255), - chain_node_id: PG_INT, - abi_id: PG_INT.optional().nullable(), - decimals: PG_INT.optional(), - token_name: z.string().max(255).optional(), - symbol: z.string().max(255).optional(), - type: z.string().max(255), - is_factory: z.boolean().default(false), - nickname: z.string().max(255).optional(), - - created_at: z.coerce.date().optional(), - updated_at: z.coerce.date().optional(), -}); - -export const ContractAbi = z.object({ - id: PG_INT, - abi: z.record(z.string(), z.unknown()).array(), - abi_hash: z.string().max(255).nullish(), - nickname: z.string().max(255).nullish(), - verified: z.boolean().default(false).nullish(), - - created_at: z.coerce.date().optional(), - updated_at: z.coerce.date().optional(), -}); diff --git a/libs/schemas/src/entities/evm-event-source.schemas.ts b/libs/schemas/src/entities/evm-event-source.schemas.ts new file mode 100644 index 00000000000..9f19a841682 --- /dev/null +++ b/libs/schemas/src/entities/evm-event-source.schemas.ts @@ -0,0 +1,15 @@ +import { ChildContractNames } from '@hicommonwealth/evm-protocols'; +import { EVM_ADDRESS } from '@hicommonwealth/schemas'; +import { z } from 'zod'; + +export const EvmEventSource = z.object({ + eth_chain_id: z.number(), + contract_address: EVM_ADDRESS, + event_signature: z.string(), + contract_name: z.nativeEnum(ChildContractNames), + parent_contract_address: EVM_ADDRESS, + + // TODO: this should be required + created_at_block: z.number().optional(), + events_migrated: z.boolean().optional(), +}); diff --git a/libs/schemas/src/entities/farcaster.schemas.ts b/libs/schemas/src/entities/farcaster.schemas.ts new file mode 100644 index 00000000000..7267a8a5c73 --- /dev/null +++ b/libs/schemas/src/entities/farcaster.schemas.ts @@ -0,0 +1,108 @@ +import { z } from 'zod'; + +export const FarcasterAction = z.object({ + url: z.string().url(), + interactor: z.object({ + object: z.literal('user'), + fid: z.number(), + username: z.string(), + display_name: z.string(), + pfp_url: z.string().url(), + custody_address: z.string(), + profile: z.object({ + bio: z.object({ + text: z.string(), + }), + }), + follower_count: z.number(), + following_count: z.number(), + verifications: z.array(z.string()), + verified_addresses: z.object({ + eth_addresses: z.array(z.string()), + sol_addresses: z.array(z.string()), + }), + verified_accounts: z.array(z.unknown()), + power_badge: z.boolean(), + }), + tapped_button: z.object({ + index: z.number(), + }), + state: z.object({ + serialized: z.string(), + }), + cast: z.object({ + object: z.literal('cast'), + hash: z.string(), + author: z.object({ + object: z.literal('user'), + fid: z.number(), + username: z.string(), + display_name: z.string(), + pfp_url: z.string().url(), + custody_address: z.string(), + profile: z.object({ + bio: z.object({ + text: z.string(), + }), + }), + follower_count: z.number(), + following_count: z.number(), + verifications: z.array(z.string()), + verified_addresses: z.object({ + eth_addresses: z.array(z.string()), + sol_addresses: z.array(z.string()), + }), + verified_accounts: z.array(z.unknown()), + power_badge: z.boolean(), + viewer_context: z.object({ + following: z.boolean(), + followed_by: z.boolean(), + blocking: z.boolean(), + blocked_by: z.boolean(), + }), + }), + thread_hash: z.string(), + parent_hash: z.string().nullable(), + parent_url: z.string().nullable(), + root_parent_url: z.string().nullable(), + parent_author: z.object({ + fid: z.number().nullable(), + }), + text: z.string(), + timestamp: z.string(), + embeds: z.array( + z.object({ + url: z.string(), + metadata: z.object({ + content_type: z.string(), + content_length: z.number().nullable(), + _status: z.string(), + html: z.object({ + ogImage: z.array( + z.object({ + url: z.string(), + }), + ), + ogTitle: z.string(), + }), + }), + }), + ), + channel: z.string().nullable(), + reactions: z.object({ + likes_count: z.number(), + recasts_count: z.number(), + likes: z.array(z.unknown()), + recasts: z.array(z.unknown()), + }), + replies: z.object({ + count: z.number(), + }), + mentioned_profiles: z.array(z.unknown()), + viewer_context: z.object({ + liked: z.boolean(), + recasted: z.boolean(), + }), + }), + timestamp: z.string(), +}); diff --git a/libs/schemas/src/entities/index.ts b/libs/schemas/src/entities/index.ts index 046d8f2c356..2eb5dfa71a8 100644 --- a/libs/schemas/src/entities/index.ts +++ b/libs/schemas/src/entities/index.ts @@ -1,12 +1,15 @@ export * from './chain.schemas'; export * from './comment.schemas'; export * from './community.schemas'; -export * from './contract.schemas'; +export * from './contest-manager.schemas'; export * from './discordBotConfig.schemas'; +export * from './evm-event-source.schemas'; export * from './group-permission.schemas'; export * from './group.schemas'; +export * from './launchpad-token.schemas'; export * from './launchpad.schemas'; export * from './notification.schemas'; +export * from './pinned-token.schemas'; export * from './poll.schemas'; export * from './quest.schemas'; export * from './reaction.schemas'; @@ -15,7 +18,6 @@ export * from './snapshot.schemas'; export * from './stake.schemas'; export * from './tag.schemas'; export * from './thread.schemas'; -export * from './token.schemas'; export * from './topic.schemas'; export * from './user.schemas'; export * from './wallets.schemas'; diff --git a/libs/schemas/src/entities/token.schemas.ts b/libs/schemas/src/entities/launchpad-token.schemas.ts similarity index 97% rename from libs/schemas/src/entities/token.schemas.ts rename to libs/schemas/src/entities/launchpad-token.schemas.ts index e2794afd273..55314ce342d 100644 --- a/libs/schemas/src/entities/token.schemas.ts +++ b/libs/schemas/src/entities/launchpad-token.schemas.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { PG_ETH } from '../utils'; -export const Token = z.object({ +export const LaunchpadToken = z.object({ // derivable from creation event token_address: z.string().describe('Address of the token'), namespace: z.string().describe('Namespace associated with the token'), diff --git a/libs/schemas/src/entities/pinned-token.schemas.ts b/libs/schemas/src/entities/pinned-token.schemas.ts new file mode 100644 index 00000000000..ae17270da7f --- /dev/null +++ b/libs/schemas/src/entities/pinned-token.schemas.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { PG_INT } from '../utils'; +import { ChainNode } from './chain.schemas'; + +export const PinnedToken = z.object({ + contract_address: z.string(), + community_id: z.string(), + chain_node_id: PG_INT, + created_at: z.coerce.date().optional(), + updated_at: z.coerce.date().optional(), + ChainNode: ChainNode.optional(), +}); diff --git a/libs/schemas/src/entities/topic.schemas.ts b/libs/schemas/src/entities/topic.schemas.ts index a383d3d55ec..75e54871a85 100644 --- a/libs/schemas/src/entities/topic.schemas.ts +++ b/libs/schemas/src/entities/topic.schemas.ts @@ -1,7 +1,4 @@ -import { commonProtocol } from '@hicommonwealth/evm-protocols'; -import { MAX_SCHEMA_INT } from '@hicommonwealth/shared'; import { z } from 'zod'; -import { Contest } from '../projections'; import { PG_INT } from '../utils'; export enum TopicWeightedVoting { @@ -56,62 +53,3 @@ export const Topic = z.object({ deleted_at: z.coerce.date().nullish(), archived_at: z.coerce.date().nullish(), }); - -export const ContestManager = z - .object({ - contest_address: z.string().describe('On-Chain contest manager address'), - community_id: z.string(), - name: z.string(), - description: z.string().nullish(), - image_url: z.string().nullish(), - funding_token_address: z - .string() - .nullish() - .describe('Provided by admin on creation when stake funds are not used'), - prize_percentage: z - .number() - .int() - .min(0) - .max(100) - .nullish() - .describe('Percentage of pool used for prizes in recurring contests'), - payout_structure: z - .array(z.number().int().min(0).max(100)) - .describe('Sorted array of percentages for prize, from first to last'), - interval: z - .number() - .int() - .min(0) - .max(MAX_SCHEMA_INT) - .describe('Recurring contest interval, 0 when one-off'), - ticker: z.string().default(commonProtocol.Denominations.ETH), - decimals: PG_INT.default( - commonProtocol.WeiDecimals[commonProtocol.Denominations.ETH], - ), - created_at: z.coerce.date(), - cancelled: z - .boolean() - .nullish() - .describe('Flags when contest policy is cancelled by admin'), - ended: z - .boolean() - .nullish() - .describe( - 'Flags when the one-off contest has ended and rollover was completed', - ), - contests: z.array(Contest).nullish(), - farcaster_frame_url: z.string().nullish(), - farcaster_frame_hashes: z.array(z.string()).nullish(), - neynar_webhook_id: z - .string() - .nullish() - .describe('Neynar ID of the ReplyCastCreated webhook'), - neynar_webhook_secret: z - .string() - .nullish() - .describe('Neynar secret for the ReplyCastCreated webhook'), - topic_id: PG_INT.nullish(), - topics: z.array(Topic).nullish(), - is_farcaster_contest: z.boolean(), - }) - .describe('On-Chain Contest Manager'); diff --git a/libs/schemas/src/events/events.schemas.ts b/libs/schemas/src/events/events.schemas.ts index e7d64d1cad9..9fc502b0997 100644 --- a/libs/schemas/src/events/events.schemas.ts +++ b/libs/schemas/src/events/events.schemas.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; -import { FarcasterAction, FarcasterCast } from '../commands/contest.schemas'; +import { FarcasterCast } from '../commands/contest.schemas'; import { Comment } from '../entities/comment.schemas'; +import { FarcasterAction } from '../entities/farcaster.schemas'; import { SubscriptionPreference } from '../entities/notification.schemas'; import { Reaction } from '../entities/reaction.schemas'; import { Thread } from '../entities/thread.schemas'; @@ -156,8 +157,7 @@ export const DiscordThreadDeleted = DiscordEventBase.pick({ const ChainEventCreatedBase = z.object({ eventSource: z.object({ - kind: z.string(), - chainNodeId: z.number(), + ethChainId: z.number(), }), rawLog: z.object({ blockNumber: z.number(), @@ -268,12 +268,13 @@ export const FarcasterCastCreated = FarcasterCast.describe( 'When a farcaster contest cast has been posted', ); -export const FarcasterReplyCastCreated = FarcasterCast.describe( - 'When a reply is posted to a farcaster contest cast', -); +export const FarcasterReplyCastCreated = FarcasterCast.extend({ + verified_address: z.string(), +}).describe('When a reply is posted to a farcaster contest cast'); export const FarcasterVoteCreated = FarcasterAction.extend({ contest_address: z.string(), + verified_address: z.string(), }).describe('When a farcaster action is initiated on a cast reply'); export const SignUpFlowCompleted = z.object({ diff --git a/libs/schemas/src/index.ts b/libs/schemas/src/index.ts index 9e0ab534993..93f7c1cdc38 100644 --- a/libs/schemas/src/index.ts +++ b/libs/schemas/src/index.ts @@ -23,6 +23,7 @@ export type Aggregates = Extract< | 'Tags' | 'CommunityTags' | 'ContractAbi' + | 'LaunchpadToken' >; export * from './commands'; diff --git a/libs/schemas/src/projections/contest.schemas.ts b/libs/schemas/src/projections/contest.schemas.ts index 285f819713b..0abd8d07bc9 100644 --- a/libs/schemas/src/projections/contest.schemas.ts +++ b/libs/schemas/src/projections/contest.schemas.ts @@ -16,6 +16,10 @@ export const ContestAction = z voting_power: z .string() .describe('Voting power of address when action was recorded'), + calculated_voting_weight: z + .string() + .nullish() + .describe('Calculated weight of the vote when action was recorded'), created_at: z.coerce.date().describe('Date-time when action was recorded'), }) .describe('On-Chain content related actions on contest instance'); diff --git a/libs/schemas/src/queries/community.schemas.ts b/libs/schemas/src/queries/community.schemas.ts index 62dc8d2e0ea..cd02ed1c467 100644 --- a/libs/schemas/src/queries/community.schemas.ts +++ b/libs/schemas/src/queries/community.schemas.ts @@ -12,6 +12,7 @@ import { CommunityStake, ContestManager, ExtendedCommunity, + PinnedToken, Topic, } from '../entities'; import * as projections from '../projections'; @@ -202,3 +203,11 @@ export const GetTopics = { }), output: z.array(TopicView), }; + +export const GetPinnedTokens = { + input: z.object({ + community_ids: z.string(), + with_chain_node: z.boolean().optional(), + }), + output: PinnedToken.array(), +}; diff --git a/libs/schemas/src/queries/contests.schemas.ts b/libs/schemas/src/queries/contests.schemas.ts index d53d89a73ec..16fcaa9d864 100644 --- a/libs/schemas/src/queries/contests.schemas.ts +++ b/libs/schemas/src/queries/contests.schemas.ts @@ -104,7 +104,7 @@ export const GetFarcasterUpvoteActionMetadata = { export const GetFarcasterContestCasts = { input: z.object({ contest_address: z.string(), - sort_by: z.enum(['likes', 'recent']).optional().default('likes'), + sort_by: z.enum(['upvotes', 'recent']).optional().default('upvotes'), }), output: z.array(z.any()), }; diff --git a/libs/schemas/src/queries/thread.schemas.ts b/libs/schemas/src/queries/thread.schemas.ts index 16ffb460ca9..ba219f900a1 100644 --- a/libs/schemas/src/queries/thread.schemas.ts +++ b/libs/schemas/src/queries/thread.schemas.ts @@ -152,6 +152,10 @@ export const ThreadView = Thread.extend({ Comments: z.array(CommentView).optional(), ThreadVersionHistories: z.array(ThreadVersionHistoryView).nullish(), search: z.union([z.string(), z.record(z.any())]).nullish(), + total_num_thread_results: z + .number() + .nullish() + .describe('total number of thread results for the query'), }); export const OrderByQueriesKeys = z.enum([ diff --git a/libs/schemas/src/queries/token.schemas.ts b/libs/schemas/src/queries/token.schemas.ts index 8903742ea5f..af69ef328de 100644 --- a/libs/schemas/src/queries/token.schemas.ts +++ b/libs/schemas/src/queries/token.schemas.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; -import { Token } from '../entities'; +import { LaunchpadToken } from '../entities'; import { PaginatedResultSchema, PaginationParamsSchema } from './pagination'; -export const TokenView = Token.extend({ +export const TokenView = LaunchpadToken.extend({ launchpad_liquidity: z.string(), latest_price: z.number().nullish(), old_price: z.number().nullish(), @@ -24,5 +24,5 @@ export const GetToken = { community_id: z.string(), with_stats: z.boolean().optional(), }), - output: z.union([TokenView, z.undefined()]), + output: z.union([TokenView, z.null()]), }; diff --git a/libs/shared/src/types/protocol.ts b/libs/shared/src/types/protocol.ts index 84239a5011a..c5e1a156fc7 100644 --- a/libs/shared/src/types/protocol.ts +++ b/libs/shared/src/types/protocol.ts @@ -129,6 +129,7 @@ export enum ChainNetwork { */ export enum CosmosGovernanceVersion { v1 = 'v1', + v1atomone = 'v1atomone', v1beta1govgen = 'v1beta1govgen', v1beta1 = 'v1beta1', v1beta1Failed = 'v1beta1-attempt-failed', diff --git a/libs/shared/src/utils.ts b/libs/shared/src/utils.ts index 9a9497a1c45..4bac85176e2 100644 --- a/libs/shared/src/utils.ts +++ b/libs/shared/src/utils.ts @@ -44,10 +44,10 @@ export const splitAndDecodeURL = (locationPathname: string) => { //this is to check for malformed urls on a topics page in /discussions const splitURLPath = locationPathname.split('/'); if (splitURLPath[2] === 'discussions') { - return decodeURIComponent(splitURLPath[3]); + return splitURLPath[3] ? decodeURIComponent(splitURLPath[3]) : null; } splitURLPath[1] === 'discussions'; - return decodeURIComponent(splitURLPath[2]); + return splitURLPath[2] ? decodeURIComponent(splitURLPath[2]) : null; }; export const getThreadUrl = ( @@ -386,3 +386,71 @@ export function isWithinPeriod( const end = moment(refDate).endOf(period); return moment(targetDate).isBetween(start, end, null, '[]'); } + +export async function alchemyGetTokenPrices({ + alchemyApiKey, + tokenSources, +}: { + alchemyApiKey: string; + tokenSources: { + contractAddress: string; + alchemyNetworkId: string; + }[]; +}): Promise<{ + data: { + network: string; + address: string; + prices: { currency: string; value: string; lastUpdatedAt: string }[]; + error: string | null; + }[]; +}> { + const options = { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ + addresses: tokenSources.map((x) => ({ + network: x.alchemyNetworkId, + address: x.contractAddress, + })), + }), + }; + + const res = await fetch( + `https://api.g.alchemy.com/prices/v1/${alchemyApiKey}/tokens/by-address`, + options, + ); + + if (res.ok) return res.json(); + else + throw new Error('Failed to fetch token prices', { + cause: { status: res.status, statusText: res.statusText }, + }); +} + +export const getBaseUrl = ( + env: 'local' | 'CI' | 'frick' | 'frack' | 'beta' | 'demo' | 'production', +) => { + switch (env) { + case 'local': + case 'CI': + return 'http://localhost:8080'; + case 'beta': + return 'https://qa.commonwealth.im'; + case 'demo': + return 'https://demo.commonwealth.im'; + case 'frick': + return 'https://frick.commonwealth.im'; + case 'frack': + return 'https://frack.commonwealth.im'; + default: + return `https://${PRODUCTION_DOMAIN}`; + } +}; + +export const buildContestLeaderboardUrl = ( + baseUrl: string, + communityId: string, + contestAddress: string, +) => { + return `${baseUrl}/${communityId}/contests/${contestAddress}`; +}; diff --git a/packages/commonwealth/client/assets/brand_assets/common-social.png b/packages/commonwealth/client/assets/brand_assets/common-social.png new file mode 100644 index 00000000000..e8c7170ac0f Binary files /dev/null and b/packages/commonwealth/client/assets/brand_assets/common-social.png differ diff --git a/packages/commonwealth/client/assets/img/TwitterspaceGrowlImage.png b/packages/commonwealth/client/assets/img/TwitterspaceGrowlImage.png new file mode 100644 index 00000000000..da123670136 Binary files /dev/null and b/packages/commonwealth/client/assets/img/TwitterspaceGrowlImage.png differ diff --git a/packages/commonwealth/client/assets/img/communitySelector/skale.svg b/packages/commonwealth/client/assets/img/communitySelector/skale.svg new file mode 100644 index 00000000000..4dcfcd4c0c5 --- /dev/null +++ b/packages/commonwealth/client/assets/img/communitySelector/skale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/commonwealth/client/public/brand_assets/common-social.png b/packages/commonwealth/client/public/brand_assets/common-social.png new file mode 100644 index 00000000000..e8c7170ac0f Binary files /dev/null and b/packages/commonwealth/client/public/brand_assets/common-social.png differ diff --git a/packages/commonwealth/client/scripts/App.tsx b/packages/commonwealth/client/scripts/App.tsx index 37eb22453ee..70d6ab5ccc9 100644 --- a/packages/commonwealth/client/scripts/App.tsx +++ b/packages/commonwealth/client/scripts/App.tsx @@ -9,6 +9,7 @@ import { HelmetProvider } from 'react-helmet-async'; import { RouterProvider } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; import { queryClient } from 'state/api/config'; +import { ReactNativeBridge } from 'views/components/ReactNativeBridge'; import { Splash } from './Splash'; import { openFeatureProvider } from './helpers/feature-flags'; import useAppStatus from './hooks/useAppStatus'; @@ -37,6 +38,7 @@ const App = () => { ) : ( <> + {isAddedToHomeScreen || isMarketingPage ? null : ( { // desiredChain may be empty if social login was initialized from // a page without a chain, in which case we default to an eth login @@ -476,7 +478,9 @@ export async function handleSocialLoginCallback({ magicAddress = utils.getAddress(metadata.publicAddress); } } else { - const result = await magic.oauth2.getRedirectResult(); + const result = isCustomDomain + ? await magic.oauth.getRedirectResult() + : await magic.oauth2.getRedirectResult(); if (!bearer) { console.log('No bearer token found in magic redirect result'); diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/adapter.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/adapter.ts index 178b12c322d..ff57d499760 100644 --- a/packages/commonwealth/client/scripts/controllers/chain/cosmos/adapter.ts +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/adapter.ts @@ -6,6 +6,7 @@ import IChainAdapter from '../../../models/IChainAdapter'; import type CosmosAccount from './account'; import CosmosAccounts from './accounts'; import CosmosChain from './chain'; +import CosmosGovernanceV1AtomOne from './gov/atomone/governance-v1'; import CosmosGovernanceGovgen from './gov/govgen/governance-v1beta1'; import CosmosGovernanceV1 from './gov/v1/governance-v1'; import CosmosGovernance from './gov/v1beta1/governance-v1beta1'; @@ -17,7 +18,8 @@ class Cosmos extends IChainAdapter { public governance: | CosmosGovernance | CosmosGovernanceV1 - | CosmosGovernanceGovgen; + | CosmosGovernanceGovgen + | CosmosGovernanceV1AtomOne; public readonly base = ChainBase.CosmosSDK; @@ -28,9 +30,11 @@ class Cosmos extends IChainAdapter { this.governance = meta?.ChainNode?.cosmos_gov_version === 'v1beta1govgen' ? new CosmosGovernanceGovgen(this.app) - : meta?.ChainNode?.cosmos_gov_version === 'v1' - ? new CosmosGovernanceV1(this.app) - : new CosmosGovernance(this.app); + : meta?.ChainNode?.cosmos_gov_version === 'v1atomone' + ? new CosmosGovernanceV1AtomOne(this.app) + : meta?.ChainNode?.cosmos_gov_version === 'v1' + ? new CosmosGovernanceV1(this.app) + : new CosmosGovernance(this.app); } public async initApi() { diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/chain.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/chain.ts index 648ed63a131..0326b200c93 100644 --- a/packages/commonwealth/client/scripts/controllers/chain/cosmos/chain.ts +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/chain.ts @@ -17,7 +17,7 @@ import { import BN from 'bn.js'; import { CosmosToken } from 'controllers/chain/cosmos/types'; import moment from 'moment'; -import { LCD } from 'shared/chain/types/cosmos'; +import { AtomOneLCD, LCD } from 'shared/chain/types/cosmos'; import type { IApp } from 'state'; import { ApiStatus } from 'state'; import { SERVER_URL } from 'state/api/config'; @@ -36,12 +36,14 @@ import { getCosmosChains } from '../../app/webWallets/utils'; import WebWalletController from '../../app/web_wallets'; import type CosmosAccount from './account'; import { + getAtomOneLCDClient, getLCDClient, getRPCClient, getSigningClient, getTMClient, } from './chain.utils'; import EthSigningClient from './eth_signing_client'; +import type { AtomOneGovExtension } from './gov/atomone/queries-v1'; import type { GovgenGovExtension } from './gov/govgen/queries-v1beta1'; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -54,16 +56,19 @@ export interface ICosmosTXData extends ITXData { // skip simulating the tx twice by saving the original estimated gas gas: number; } - +export const isAtomoneLCD = (lcd: LCD | AtomOneLCD): lcd is AtomOneLCD => { + return (lcd as AtomOneLCD).atomone !== undefined; +}; export type CosmosApiType = QueryClient & StakingExtension & GovExtension & GovgenGovExtension & + AtomOneGovExtension & BankExtension; class CosmosChain implements IChainModule { private _api: CosmosApiType; - private _lcd: LCD; + private _lcd: LCD | AtomOneLCD; public get api() { return this._api; @@ -136,7 +141,18 @@ class CosmosChain implements IChainModule { console.error('Error starting LCD client: ', e); } } - + if ( + chain?.ChainNode?.cosmos_gov_version === CosmosGovernanceVersion.v1atomone + ) { + try { + const lcdUrl = `${window.location.origin}${SERVER_URL}/cosmosProxy/v1/${chain.id}`; + console.log(`Starting LCD API at ${lcdUrl}...`); + const lcd = await getAtomOneLCDClient(lcdUrl); + this._lcd = lcd; + } catch (e) { + console.error('Error starting LCD client: ', e); + } + } await this.fetchBlock(); // Poll for new block immediately } diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/chain.utils.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/chain.utils.ts index 837cbb47a3f..876b2af55a6 100644 --- a/packages/commonwealth/client/scripts/controllers/chain/cosmos/chain.utils.ts +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/chain.utils.ts @@ -1,16 +1,21 @@ -import { OfflineSigner } from '@cosmjs/proto-signing'; +import { registry as atomoneRegistry } from '@atomone/atomone-types-long/atomone/gov/v1/tx.registry'; +import { registry as govgenRegistry } from '@atomone/govgen-types-long/govgen/gov/v1beta1/tx.registry'; +import { OfflineSigner, Registry } from '@cosmjs/proto-signing'; import { AminoTypes, SigningStargateClient, createDefaultAminoConverters, + defaultRegistryTypes, } from '@cosmjs/stargate'; import { Tendermint34Client } from '@cosmjs/tendermint-rpc'; -import { LCD } from '../../../../../shared/chain/types/cosmos'; +import { AtomOneLCD, LCD } from '../../../../../shared/chain/types/cosmos'; import { CosmosApiType } from './chain'; import { createAltGovAminoConverters, + createAtomoneGovAminoConverters, createGovgenGovAminoConverters, } from './gov/aminomessages'; +import { setupAtomOneExtension } from './gov/atomone/queries-v1'; import { setupGovgenExtension } from './gov/govgen/queries-v1beta1'; export const getTMClient = async ( @@ -29,6 +34,7 @@ export const getRPCClient = async ( cosm.setupGovExtension, cosm.setupStakingExtension, setupGovgenExtension, + setupAtomOneExtension, cosm.setupBankExtension, ); return client; @@ -42,6 +48,15 @@ export const getLCDClient = async (lcdUrl: string): Promise => { }); }; +export const getAtomOneLCDClient = async ( + lcdUrl: string, +): Promise => { + const { createAtomOneLCDClient } = await import('@hicommonwealth/chains'); + + return await createAtomOneLCDClient({ + restEndpoint: lcdUrl, + }); +}; export const getSigningClient = async ( url: string, signer: OfflineSigner, @@ -50,9 +65,15 @@ export const getSigningClient = async ( ...createDefaultAminoConverters(), ...createAltGovAminoConverters(), ...createGovgenGovAminoConverters(), + ...createAtomoneGovAminoConverters(), }); return await SigningStargateClient.connectWithSigner(url, signer, { + registry: new Registry([ + ...defaultRegistryTypes, + ...atomoneRegistry, + ...govgenRegistry, + ]), aminoTypes, }); }; diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/aminomessages.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/aminomessages.ts index 1eba95517d2..1fb4ed2609a 100644 --- a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/aminomessages.ts +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/aminomessages.ts @@ -1,3 +1,4 @@ +import { AminoConverter as AtomOneAminoConverter } from '@atomone/atomone-types-long/atomone/gov/v1beta1/tx.amino'; import { AminoConverter } from '@atomone/govgen-types-long/govgen/gov/v1beta1/tx.amino'; import { AminoMsg } from '@cosmjs/amino'; import { AminoMsgSubmitProposal } from '@cosmjs/stargate'; @@ -17,6 +18,9 @@ export function isAminoMsgSubmitProposal( return msg.type === 'cosmos-sdk/MsgSubmitProposal'; } export function createGovgenGovAminoConverters(): AminoConverters { + return AtomOneAminoConverter; +} +export function createAtomoneGovAminoConverters(): AminoConverters { return AminoConverter; } export function createAltGovAminoConverters(): AminoConverters { diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/governance-v1.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/governance-v1.ts new file mode 100644 index 00000000000..508ecd899f3 --- /dev/null +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/governance-v1.ts @@ -0,0 +1,125 @@ +import { Any, numberToLong } from '@hicommonwealth/chains'; +import type { + CosmosToken, + ICosmosProposal, +} from 'controllers/chain/cosmos/types'; +import ProposalModule from 'models/ProposalModule'; +import { ITXModalData } from 'models/interfaces'; +import type CosmosAccount from '../../account'; +import type CosmosAccounts from '../../accounts'; +import type CosmosChain from '../../chain'; +import { isAtomoneLCD, type CosmosApiType } from '../../chain'; +import { CosmosProposalV1AtomOne } from './proposal-v1'; +import { encodeMsgSubmitProposalAtomOne, propToIProposal } from './utils-v1'; + +/** This file is a copy of controllers/chain/cosmos/governance.ts, modified for + * gov module version v1. This is considered a patch to make sure v1-enabled chains + * load proposals. Eventually we will ideally move back to one governance.ts file. + * Patch state: + * + * - governance.ts uses cosmJS v1beta1 gov + * - governance-v1.ts uses telescope-generated v1 gov */ +class CosmosGovernanceV1AtomOne extends ProposalModule< + CosmosApiType, + ICosmosProposal, + // @ts-expect-error StrictNullChecks + CosmosProposalV1AtomOne +> { + private _minDeposit: CosmosToken; + + public get minDeposit() { + return this._minDeposit; + } + + public setMinDeposit(minDeposit: CosmosToken) { + this._minDeposit = minDeposit; + } + + private _Chain: CosmosChain; + private _Accounts: CosmosAccounts; + + /* eslint-disable-next-line @typescript-eslint/require-await */ + public async init( + ChainInfo: CosmosChain, + Accounts: CosmosAccounts, + ): Promise { + this._Chain = ChainInfo; + this._Accounts = Accounts; + this._initialized = true; + } + + public async getProposal( + proposalId: number, + ): Promise { + const existingProposal = this.store.getByIdentifier(proposalId); + if (existingProposal) { + return existingProposal; + } + return this._initProposal(proposalId); + } + + private async _initProposal( + proposalId: number, + // @ts-expect-error StrictNullChecks + ): Promise { + try { + // @ts-expect-error StrictNullChecks + if (!proposalId) return; + // @ts-expect-error StrictNullChecks + if (!isAtomoneLCD(this._Chain.lcd)) return; + const { proposal } = await this._Chain.lcd.atomone.gov.v1.proposal({ + proposalId: numberToLong(proposalId), + }); + const cosmosProposal = new CosmosProposalV1AtomOne( + this._Chain, + this._Accounts, + this, + // @ts-expect-error StrictNullChecks + propToIProposal(proposal), + ); + await cosmosProposal.init(); + return cosmosProposal; + } catch (error) { + console.error('Error fetching proposal: ', error); + } + } + + public createTx( + sender: CosmosAccount, + title: string, + description: string, + initialDeposit: CosmosToken, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + memo = '', + ): ITXModalData { + throw new Error('unsupported'); + } + + // TODO: support multiple deposit types + public async submitProposalTx( + sender: CosmosAccount, + initialDeposit: CosmosToken, + content: Any, + ): Promise { + const msg = encodeMsgSubmitProposalAtomOne( + sender.address, + initialDeposit, + content, + ); + + // fetch completed proposal from returned events + const events = await this._Chain.sendTx(sender, msg); + console.log(events); + const submitEvent = events?.find((e) => e.type === 'submit_proposal'); + const cosm = await import('@cosmjs/encoding'); + const idAttribute = submitEvent?.attributes.find( + ({ key }) => key && cosm.fromAscii(key) === 'proposal_id', + ); + // @ts-expect-error StrictNullChecks + const id = +cosm.fromAscii(idAttribute.value); + await this._initProposal(id); + return id; + } +} + +export default CosmosGovernanceV1AtomOne; diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/proposal-v1.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/proposal-v1.ts new file mode 100644 index 00000000000..28179cd29d7 --- /dev/null +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/proposal-v1.ts @@ -0,0 +1,351 @@ +import type { + QueryDepositsResponseSDKType, + QueryTallyResultResponseSDKType, + QueryVotesResponseSDKType, +} from '@atomone/atomone-types-long/atomone/gov/v1/query'; +import { EncodeObject } from '@cosmjs/proto-signing'; +import { longify } from '@cosmjs/stargate/build/queryclient'; +import { ProposalType } from '@hicommonwealth/shared'; +import BN from 'bn.js'; +import type { + CosmosProposalState, + CosmosToken, + CosmosVoteChoice, + ICosmosProposal, +} from 'controllers/chain/cosmos/types'; +import Long from 'long'; +import Proposal from 'models/Proposal'; +import { ITXModalData } from 'models/interfaces'; +import { + ProposalEndTime, + ProposalStatus, + VotingType, + VotingUnit, +} from 'models/types'; +import { DepositVote } from 'models/votes'; +import moment from 'moment'; +import CosmosAccount from '../../account'; +import type CosmosAccounts from '../../accounts'; +import type CosmosChain from '../../chain'; +import { isAtomoneLCD, type CosmosApiType } from '../../chain'; +import { CosmosVote } from '../v1beta1/proposal-v1beta1'; +import { encodeMsgVote } from '../v1beta1/utils-v1beta1'; +import CosmosGovernanceV1AtomOne from './governance-v1'; +import { marshalTallyV1 } from './utils-v1'; + +const voteToEnumV1 = (voteOption: number | string): CosmosVoteChoice => { + switch (voteOption) { + case 'VOTE_OPTION_YES': + return 'Yes'; + case 'VOTE_OPTION_NO': + return 'No'; + case 'VOTE_OPTION_ABSTAIN': + return 'Abstain'; + case 'VOTE_OPTION_NO_WITH_VETO': + return 'NoWithVeto'; + default: + // @ts-expect-error StrictNullChecks + return null; + } +}; + +export class CosmosProposalV1AtomOne extends Proposal< + CosmosApiType, + CosmosToken, + ICosmosProposal, + CosmosVote +> { + public get shortIdentifier() { + return `#${this.identifier.toString()}`; + } + + public get title(): string { + return this.data.title || this._metadata?.title; + } + + public get description() { + return ( + this.data.description || + this._metadata?.summary || + this._metadata?.description + ); + } + + // @ts-expect-error StrictNullChecks + public get author() { + return this.data.proposer + ? this._Accounts.fromAddress(this.data.proposer) + : null; + } + + public get votingType() { + if (this.status === 'DepositPeriod') { + return VotingType.SimpleYesApprovalVoting; + } + return VotingType.YesNoAbstainVeto; + } + + public get votingUnit() { + return VotingUnit.CoinVote; + } + + public canVoteFrom(account) { + // TODO: balance check + return account instanceof CosmosAccount; + } + + public get status(): CosmosProposalState { + return this.data.state.status; + } + + public get depositorsAsVotes(): Array> { + return this.data.state.depositors.map( + ([a, n]) => + new DepositVote(this._Accounts.fromAddress(a), this._Chain.coins(n)), + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _metadata: any; + public get metadata() { + return this._metadata; + } + + private _Chain: CosmosChain; + private _Accounts: CosmosAccounts; + private _Governance: CosmosGovernanceV1AtomOne; + + constructor( + ChainInfo: CosmosChain, + Accounts: CosmosAccounts, + Governance: CosmosGovernanceV1AtomOne, + data: ICosmosProposal, + ) { + super(ProposalType.CosmosProposal, data); + this._Chain = ChainInfo; + this._Accounts = Accounts; + this._Governance = Governance; + this.createdAt = data.submitTime; + this._Governance.store.add(this); + } + + public update() { + throw new Error('unimplemented'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public updateMetadata(metadata: any) { + this._metadata = metadata; + if (!this.data.title) { + this.data.title = metadata.title; + } + if (!this.data.description) { + this.data.description = metadata.description || metadata.summary; + } + this._Governance.store.update(this); + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async init() { + if (!this.initialized) { + this._initialized = true; + } + if (this.data.state.completed) { + // @ts-expect-error StrictNullChecks + super.complete(this._Governance.store); + } + } + + public async fetchDeposits(): Promise { + const proposalId = longify(this.data.identifier) as Long; + // @ts-expect-error StrictNullChecks + if (!isAtomoneLCD(this._Chain.lcd)) return; + const deposits = await this._Chain.lcd.atomone.gov.v1.deposits({ + proposalId, + }); + this.setDeposits(deposits); + return deposits; + } + + public async fetchTally(): Promise { + const proposalId = longify(this.data.identifier) as Long; + // @ts-expect-error StrictNullChecks + if (!isAtomoneLCD(this._Chain.lcd)) return; + const tally = await this._Chain.lcd.atomone.gov.v1.tallyResult({ + proposalId, + }); + this.setTally(tally); + return tally; + } + + public async fetchVotes(): Promise { + const proposalId = longify(this.data.identifier) as Long; + // @ts-expect-error StrictNullChecks + if (!isAtomoneLCD(this._Chain.lcd)) return; + const votes = await this._Chain.lcd.atomone.gov.v1.votes({ + proposalId, + }); + this.setVotes(votes); + return votes; + } + + public setDeposits(depositResp: QueryDepositsResponseSDKType) { + if (depositResp?.deposits) { + for (const deposit of depositResp.deposits) { + if (deposit.amount && deposit.amount[0]) { + this.data.state.depositors.push([ + deposit.depositor, + new BN(deposit.amount[0].amount), + ]); + } + } + } + } + + public setTally(tallyResp: QueryTallyResultResponseSDKType) { + if (tallyResp?.tally) { + this.data.state.tally = marshalTallyV1(tallyResp?.tally); + } + } + + public setVotes(votesResp: QueryVotesResponseSDKType) { + if (votesResp) { + for (const voter of votesResp.votes) { + const vote = voteToEnumV1(voter.options[0].option); + if (vote) { + this.data.state.voters.push([voter.voter, vote]); + this.addOrUpdateVote( + new CosmosVote(this._Accounts.fromAddress(voter.voter), vote), + ); + } else { + console.error( + `voter: ${voter.voter} has invalid vote option: ${voter.options[0].option}`, + ); + } + } + } + } + + // TODO: add getters for various vote features: tally, quorum, threshold, veto + // see: https://blog.chorus.one/an-overview-of-cosmos-hub-governance/ + get support() { + if (this.status === 'DepositPeriod') { + return this._Chain.coins(this.data.state.totalDeposit); + } + if (!this.data.state.tally) return 0; + const nonAbstainingPower = this.data.state.tally.no + .add(this.data.state.tally.noWithVeto) + .add(this.data.state.tally.yes); + if (nonAbstainingPower.eqn(0)) return 0; + const ratioPpm = this.data.state.tally.yes + .muln(1_000_000) + .div(nonAbstainingPower); + return +ratioPpm / 1_000_000; + } + + get turnout() { + if (this.status === 'DepositPeriod') { + if (this.data.state.totalDeposit.eqn(0) || !this._Chain.staked) { + return 0; + } else { + const ratioInPpm = +this.data.state.totalDeposit + .muln(1_000_000) + .div(this._Chain.staked); + return +ratioInPpm / 1_000_000; + } + } + if (!this.data.state.tally) return 0; + // all voters automatically abstain, so we compute turnout as the percent non-abstaining + const totalVotingPower = this.data.state.tally.no + .add(this.data.state.tally.noWithVeto) + .add(this.data.state.tally.yes) + .add(this.data.state.tally.abstain); + if (totalVotingPower.eqn(0)) return 0; + const ratioInPpm = +this.data.state.tally.abstain + .muln(1_000_000) + .div(totalVotingPower); + return 1 - ratioInPpm / 1_000_000; + } + + get veto() { + if (!this.data.state.tally) return 0; + const totalVotingPower = this.data.state.tally.no + .add(this.data.state.tally.noWithVeto) + .add(this.data.state.tally.yes) + .add(this.data.state.tally.abstain); + if (totalVotingPower.eqn(0)) return 0; + const ratioInPpm = +this.data.state.tally.noWithVeto + .muln(1_000_000) + .div(totalVotingPower); + return ratioInPpm / 1_000_000; + } + + get endTime(): ProposalEndTime { + // if in deposit period: at most create time + maxDepositTime + if (this.status === 'DepositPeriod') { + if (!this.data.depositEndTime) return { kind: 'unavailable' }; + return { kind: 'fixed', time: moment(this.data.depositEndTime) }; + } + // if in voting period: exactly voting start time + votingTime + if (!this.data.votingEndTime) return { kind: 'unavailable' }; + return { kind: 'fixed', time: moment(this.data.votingEndTime) }; + } + + get isPassing(): ProposalStatus { + switch (this.status) { + case 'Passed': + return ProposalStatus.Passed; + case 'Rejected': + return ProposalStatus.Failed; + case 'VotingPeriod': + return +this.support > 0.5 && this.veto <= 1 / 3 + ? ProposalStatus.Passing + : ProposalStatus.Failing; + case 'DepositPeriod': + return this._Governance.minDeposit + ? this.data.state.totalDeposit.gte(this._Governance.minDeposit) + ? ProposalStatus.Passing + : ProposalStatus.Failing + : ProposalStatus.None; + default: + return ProposalStatus.None; + } + } + + // TRANSACTIONS + public async submitDepositTx(depositor: CosmosAccount, amount: CosmosToken) { + if (this.status !== 'DepositPeriod') { + throw new Error('proposal not in deposit period'); + } + const cosm = await import('@cosmjs/stargate/build/queryclient'); + const msg: EncodeObject = { + typeUrl: '/atomone.gov.v1beta1.MsgDeposit', + value: { + proposalId: cosm.longify(this.data.identifier), + depositor: depositor.address, + amount: [amount.toCoinObject()], + }, + }; + await this._Chain.sendTx(depositor, msg); + this.data.state.depositors.push([depositor.address, new BN(+amount)]); + } + + public async voteTx(vote: CosmosVote) { + if (this.status !== 'VotingPeriod') { + throw new Error('proposal not in voting period'); + } + const msg = encodeMsgVote( + vote.account.address, + this.data.identifier, + vote.option, + ); + + await this._Chain.sendTx(vote.account, msg); + this.addOrUpdateVote(vote); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public submitVoteTx(vote: CosmosVote, memo = '', cb?): ITXModalData { + throw new Error('unsupported'); + } +} diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/queries-v1.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/queries-v1.ts new file mode 100644 index 00000000000..108ceafa666 --- /dev/null +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/queries-v1.ts @@ -0,0 +1,142 @@ +import { ProposalStatus } from '@atomone/atomone-types-long/atomone/gov/v1beta1/gov'; +import { + QueryClientImpl, + QueryDepositResponse, + QueryDepositsResponse, + QueryParamsResponse, + QueryProposalResponse, + QueryProposalsResponse, + QueryTallyResultResponse, + QueryVoteResponse, + QueryVotesResponse, +} from '@atomone/atomone-types-long/atomone/gov/v1beta1/query'; +import { Uint64 } from '@cosmjs/math'; +import { + QueryClient, + createPagination, + createProtobufRpcClient, + longify, +} from '@cosmjs/stargate/build/queryclient'; +/* +import { + createPagination, + createProtobufRpcClient, + longify, + QueryClient, +} from '../../queryclient'; +*/ +export type GovParamsType = 'deposit' | 'tallying' | 'voting'; + +export type GovProposalId = string | number | Uint64; + +export interface AtomOneGovExtension { + readonly atomone: { + readonly params: ( + parametersType: GovParamsType, + ) => Promise; + readonly proposals: ( + proposalStatus: ProposalStatus, + depositor: string, + voter: string, + paginationKey?: Uint8Array, + ) => Promise; + readonly proposal: ( + proposalId: GovProposalId, + ) => Promise; + readonly deposits: ( + proposalId: GovProposalId, + paginationKey?: Uint8Array, + ) => Promise; + readonly deposit: ( + proposalId: GovProposalId, + depositorAddress: string, + ) => Promise; + readonly tally: ( + proposalId: GovProposalId, + ) => Promise; + readonly votes: ( + proposalId: GovProposalId, + paginationKey?: Uint8Array, + ) => Promise; + readonly vote: ( + proposalId: GovProposalId, + voterAddress: string, + ) => Promise; + }; +} + +export function setupAtomOneExtension(base: QueryClient): AtomOneGovExtension { + const rpc = createProtobufRpcClient(base); + + // Use this service to get easy typed access to query methods + // This cannot be used for proof verification + const queryService = new QueryClientImpl(rpc); + + return { + atomone: { + params: async (parametersType: GovParamsType) => { + const response = await queryService.Params({ + paramsType: parametersType, + }); + return response; + }, + proposals: async ( + proposalStatus: ProposalStatus, + depositorAddress: string, + voterAddress: string, + paginationKey?: Uint8Array, + ) => { + const response = await queryService.Proposals({ + proposalStatus, + depositor: depositorAddress, + voter: voterAddress, + pagination: createPagination(paginationKey), + }); + return response; + }, + proposal: async (proposalId: GovProposalId) => { + const response = await queryService.Proposal({ + proposalId: longify(proposalId), + }); + return response; + }, + deposits: async ( + proposalId: GovProposalId, + paginationKey?: Uint8Array, + ) => { + const response = await queryService.Deposits({ + proposalId: longify(proposalId), + pagination: createPagination(paginationKey), + }); + return response; + }, + deposit: async (proposalId: GovProposalId, depositorAddress: string) => { + const response = await queryService.Deposit({ + proposalId: longify(proposalId), + depositor: depositorAddress, + }); + return response; + }, + tally: async (proposalId: GovProposalId) => { + const response = await queryService.TallyResult({ + proposalId: longify(proposalId), + }); + return response; + }, + votes: async (proposalId: GovProposalId, paginationKey?: Uint8Array) => { + const response = await queryService.Votes({ + proposalId: longify(proposalId), + pagination: createPagination(paginationKey), + }); + return response; + }, + vote: async (proposalId: GovProposalId, voterAddress: string) => { + const response = await queryService.Vote({ + proposalId: longify(proposalId), + voter: voterAddress, + }); + return response; + }, + }, + }; +} diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/utils-v1.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/utils-v1.ts new file mode 100644 index 00000000000..90f4c66346f --- /dev/null +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/atomone/utils-v1.ts @@ -0,0 +1,220 @@ +import { + ProposalSDKType, + ProposalStatus, + TallyResultSDKType, +} from '@atomone/atomone-types-long/atomone/gov/v1/gov'; +import BN from 'bn.js'; +import moment from 'moment'; +import type { AtomOneLCD } from '../../../../../../../shared/chain/types/cosmos'; +import type { + CosmosProposalState, + ICosmosProposal, + ICosmosProposalTally, +} from '../../types'; + +import { EncodeObject } from '@cosmjs/proto-signing'; +import { CosmosToken } from 'controllers/chain/cosmos/types'; +import { Any } from 'cosmjs-types/google/protobuf/any'; +import { isCompleted } from '../v1beta1/utils-v1beta1'; + +/* Governance helper methods for Cosmos chains with gov module v1 (as of Cosmos SDK v0.46.11) */ + +export const fetchProposalsByStatusV1AtomOne = async ( + lcd: AtomOneLCD, + status: ProposalStatus, +): Promise => { + try { + const { proposals: proposalsByStatus, pagination } = + await lcd.atomone.gov.v1.proposals({ + proposalStatus: status, + voter: '', + depositor: '', + }); + + let nextKey = pagination?.next_key; + + // @ts-expect-error StrictNullChecks + while (nextKey?.length > 0) { + // TODO: temp fix to handle chains that return nextKey as a string instead of Uint8Array + // Our v1 API needs to handle this better. To be addressed in #6610 + if (typeof nextKey === 'string') { + nextKey = new Uint8Array(Buffer.from(nextKey, 'base64')); + } + + const { proposals, pagination: nextPage } = + await lcd.atomone.gov.v1.proposals({ + proposalStatus: status, + voter: '', + depositor: '', + pagination: { + // @ts-expect-error StrictNullChecks + key: nextKey, + // @ts-expect-error StrictNullChecks + limit: undefined, + // @ts-expect-error StrictNullChecks + offset: undefined, + countTotal: true, + reverse: true, + }, + }); + proposalsByStatus.push(...proposals); + // @ts-expect-error StrictNullChecks + nextKey = nextPage.next_key; + } + return proposalsByStatus; + } catch (e) { + console.error(`Error fetching proposal by status ${status}`, e); + return []; + } +}; + +export const getActiveProposalsV1AtomOne = async ( + lcd: AtomOneLCD, +): Promise => { + const votingPeriodProposals = await fetchProposalsByStatusV1AtomOne( + lcd, + ProposalStatus.PROPOSAL_STATUS_VOTING_PERIOD, + ); + const depositPeriodProposals = await fetchProposalsByStatusV1AtomOne( + lcd, + ProposalStatus.PROPOSAL_STATUS_DEPOSIT_PERIOD, + ); + return sortProposalsV1AtomOne([ + ...votingPeriodProposals, + ...depositPeriodProposals, + ]); +}; + +export const getCompletedProposalsV1AtomOne = async ( + lcd: AtomOneLCD, +): Promise => { + const passedProposals = await fetchProposalsByStatusV1AtomOne( + lcd, + ProposalStatus.PROPOSAL_STATUS_PASSED, + ); + const failedProposals = await fetchProposalsByStatusV1AtomOne( + lcd, + ProposalStatus.PROPOSAL_STATUS_FAILED, + ); + const rejectedProposals = await fetchProposalsByStatusV1AtomOne( + lcd, + ProposalStatus.PROPOSAL_STATUS_REJECTED, + ); + const combined = [ + ...passedProposals, + ...failedProposals, + ...rejectedProposals, + ]; + return sortProposalsV1AtomOne(combined); +}; + +export const sortProposalsV1AtomOne = ( + proposals: ProposalSDKType[], +): ICosmosProposal[] => { + return proposals + .map((p) => propToIProposal(p)) + .filter((p): p is ICosmosProposal => !!p) + .sort((p1, p2) => +p2!.identifier - +p1!.identifier); +}; + +export const propToIProposal = (p: ProposalSDKType): ICosmosProposal | null => { + const status = stateEnumToStringV1(p.status.toString()); + const identifier = p.id.toString(); + let title = ''; + let description = ''; + let messages = []; + if (p.messages?.length > 0) { + // @ts-expect-error StrictNullChecks + messages = p.messages.map((m) => { + const content = m['content']; + // get title and description from 1st message if no top-level title/desc + if (!title) title = content?.title; + if (!description) description = content?.description; + return m; + }); + } + + return { + identifier, + type: 'text', + title, + description, + messages, + metadata: p.metadata, + // @ts-expect-error StrictNullChecks + submitTime: moment.unix(new Date(p.submit_time).valueOf() / 1000), + // @ts-expect-error StrictNullChecks + depositEndTime: moment.unix(new Date(p.deposit_end_time).valueOf() / 1000), + // @ts-expect-error StrictNullChecks + votingEndTime: moment.unix(new Date(p.voting_end_time).valueOf() / 1000), + votingStartTime: moment.unix( + // @ts-expect-error StrictNullChecks + new Date(p.voting_start_time).valueOf() / 1000, + ), + // @ts-expect-error StrictNullChecks + proposer: null, + state: { + identifier, + completed: isCompleted(status), + status, + // TODO: handle non-default amount + totalDeposit: + p.total_deposit && p.total_deposit[0] + ? new BN(p.total_deposit[0].amount) + : new BN(0), + depositors: [], + voters: [], + // @ts-expect-error StrictNullChecks + tally: p.final_tally_result && marshalTallyV1(p.final_tally_result), + }, + }; +}; + +const stateEnumToStringV1 = (status: string): CosmosProposalState => { + switch (status) { + case 'PROPOSAL_STATUS_UNSPECIFIED': + return 'Unspecified'; + case 'PROPOSAL_STATUS_DEPOSIT_PERIOD': + return 'DepositPeriod'; + case 'PROPOSAL_STATUS_VOTING_PERIOD': + return 'VotingPeriod'; + case 'PROPOSAL_STATUS_PASSED': + return 'Passed'; + case 'PROPOSAL_STATUS_FAILED': + return 'Failed'; + case 'PROPOSAL_STATUS_REJECTED': + return 'Rejected'; + case 'UNRECOGNIZED': + return 'Unrecognized'; + default: + throw new Error(`Invalid proposal state: ${status}`); + } +}; + +export const marshalTallyV1 = ( + tally: TallyResultSDKType, +): ICosmosProposalTally => { + // @ts-expect-error StrictNullChecks + if (!tally) return null; + return { + yes: new BN(tally.yes_count), + abstain: new BN(tally.abstain_count), + no: new BN(tally.no_count), + noWithVeto: new BN(0), + }; +}; + +export const encodeMsgSubmitProposalAtomOne = ( + sender: string, + initialDeposit: CosmosToken, + content: Any, +): EncodeObject => { + return { + typeUrl: '/atomone.gov.v1beta1.MsgSubmitProposal', + value: { + initialDeposit: [initialDeposit.toCoinObject()], + proposer: sender, + content, + }, + }; +}; diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/govgen/utils-v1beta1.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/govgen/utils-v1beta1.ts index ac961301f2d..d93730681f1 100644 --- a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/govgen/utils-v1beta1.ts +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/govgen/utils-v1beta1.ts @@ -251,7 +251,8 @@ export const getDepositParams = async ( ): Promise => { const govController = cosmosChain.governance as CosmosGovernanceGovgen; let minDeposit; - const { depositParams } = await cosmosChain.chain.api.gov.params('deposit'); + const { depositParams } = + await cosmosChain.chain.api.govgen.params('deposit'); // TODO: support off-denom deposits // @ts-expect-error StrictNullChecks diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/utils.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/utils.ts index 56f2a933227..3b34b542c48 100644 --- a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/utils.ts +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/utils.ts @@ -1,5 +1,13 @@ import { CosmosGovernanceVersion } from '@hicommonwealth/shared'; +import { LCD } from 'shared/chain/types/cosmos'; import Cosmos from '../adapter'; +import { isAtomoneLCD } from '../chain'; +import CosmosGovernanceV1AtomOne from './atomone/governance-v1'; +import { CosmosProposalV1AtomOne } from './atomone/proposal-v1'; +import { + getActiveProposalsV1AtomOne, + getCompletedProposalsV1AtomOne, +} from './atomone/utils-v1'; import CosmosGovernanceGovgen from './govgen/governance-v1beta1'; import { CosmosProposalGovgen } from './govgen/proposal-v1beta1'; import { @@ -24,6 +32,8 @@ export const getCompletedProposals = async ( ): Promise => { const { chain, accounts, governance, meta } = cosmosChain; console.log(cosmosChain); + const isAtomone = + meta.ChainNode?.cosmos_gov_version === CosmosGovernanceVersion.v1atomone; const isGovgen = meta.ChainNode?.cosmos_gov_version === CosmosGovernanceVersion.v1beta1govgen; @@ -34,7 +44,19 @@ export const getCompletedProposals = async ( CosmosGovernanceVersion.v1beta1Failed; let cosmosProposals = []; - if (isGovgen) { + if (isAtomone && isAtomoneLCD(chain.lcd)) { + const v1proposals = await getCompletedProposalsV1AtomOne(chain.lcd); + // @ts-expect-error StrictNullChecks + cosmosProposals = v1proposals.map( + (p) => + new CosmosProposalV1AtomOne( + chain, + accounts, + governance as CosmosGovernanceV1AtomOne, + p, + ), + ); + } else if (isGovgen) { const v1Beta1Proposals = await getCompletedProposalsGovgen(chain.api); // @ts-expect-error StrictNullChecks cosmosProposals = v1Beta1Proposals.map( @@ -47,7 +69,7 @@ export const getCompletedProposals = async ( ), ); } else if (!isGovgen && (isV1 || betaAttemptFailed)) { - const v1Proposals = await getCompletedProposalsV1(chain.lcd); + const v1Proposals = await getCompletedProposalsV1(chain.lcd as LCD); // @ts-expect-error StrictNullChecks cosmosProposals = v1Proposals.map( @@ -78,6 +100,8 @@ export const getActiveProposals = async ( cosmosChain: Cosmos, ): Promise => { const { chain, accounts, governance, meta } = cosmosChain; + const isAtomone = + meta.ChainNode?.cosmos_gov_version === CosmosGovernanceVersion.v1atomone; const isGovgen = meta.ChainNode?.cosmos_gov_version === CosmosGovernanceVersion.v1beta1govgen; @@ -87,15 +111,30 @@ export const getActiveProposals = async ( meta.ChainNode?.cosmos_gov_version === CosmosGovernanceVersion.v1beta1Failed; let cosmosProposals = []; - - if (isGovgen) { + if (isAtomone && isAtomoneLCD(chain.lcd)) { + const v1Proposals = await getActiveProposalsV1AtomOne(chain.lcd); + // @ts-expect-error StrictNullChecks + cosmosProposals = v1Proposals.map( + (p) => + new CosmosProposalV1AtomOne( + chain, + accounts, + governance as CosmosGovernanceV1AtomOne, + p, + ), + ); + } else if (isGovgen) { const v1Beta1Proposals = await getActiveProposalsGovgen(chain.api); // @ts-expect-error StrictNullChecks cosmosProposals = v1Beta1Proposals.map( (p) => new CosmosProposal(chain, accounts, governance as CosmosGovernance, p), ); - } else if (!isGovgen && (isV1 || betaAttemptFailed)) { + } else if ( + !isGovgen && + (isV1 || betaAttemptFailed) && + !isAtomoneLCD(chain.lcd) + ) { const v1Proposals = await getActiveProposalsV1(chain.lcd); // @ts-expect-error StrictNullChecks cosmosProposals = v1Proposals.map( diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/v1/governance-v1.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/v1/governance-v1.ts index d2df65635d4..deee76e7b8f 100644 --- a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/v1/governance-v1.ts +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/v1/governance-v1.ts @@ -8,7 +8,7 @@ import { ITXModalData } from 'models/interfaces'; import type CosmosAccount from '../../account'; import type CosmosAccounts from '../../accounts'; import type CosmosChain from '../../chain'; -import type { CosmosApiType } from '../../chain'; +import { isAtomoneLCD, type CosmosApiType } from '../../chain'; import { encodeMsgSubmitProposal } from '../v1beta1/utils-v1beta1'; import { CosmosProposalV1 } from './proposal-v1'; import { propToIProposal } from './utils-v1'; @@ -62,6 +62,8 @@ class CosmosGovernanceV1 extends ProposalModule< try { // @ts-expect-error StrictNullChecks if (!proposalId) return; + // @ts-expect-error StrictNullChecks + if (isAtomoneLCD(this._Chain.lcd)) return; const { proposal } = await this._Chain.lcd.cosmos.gov.v1.proposal({ proposalId: numberToLong(proposalId), }); diff --git a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/v1/proposal-v1.ts b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/v1/proposal-v1.ts index def3c91bcb5..2995d61df01 100644 --- a/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/v1/proposal-v1.ts +++ b/packages/commonwealth/client/scripts/controllers/chain/cosmos/gov/v1/proposal-v1.ts @@ -27,7 +27,7 @@ import moment from 'moment'; import CosmosAccount from '../../account'; import type CosmosAccounts from '../../accounts'; import type CosmosChain from '../../chain'; -import type { CosmosApiType } from '../../chain'; +import { isAtomoneLCD, type CosmosApiType } from '../../chain'; import { CosmosVote } from '../v1beta1/proposal-v1beta1'; import { encodeMsgVote } from '../v1beta1/utils-v1beta1'; import type CosmosGovernanceV1 from './governance-v1'; @@ -155,6 +155,8 @@ export class CosmosProposalV1 extends Proposal< public async fetchDeposits(): Promise { const proposalId = longify(this.data.identifier) as Long; + // @ts-expect-error StrictNullChecks + if (isAtomoneLCD(this._Chain.lcd)) return; const deposits = await this._Chain.lcd.cosmos.gov.v1.deposits({ proposalId, }); @@ -164,6 +166,8 @@ export class CosmosProposalV1 extends Proposal< public async fetchTally(): Promise { const proposalId = longify(this.data.identifier) as Long; + // @ts-expect-error StrictNullChecks + if (isAtomoneLCD(this._Chain.lcd)) return; const tally = await this._Chain.lcd.cosmos.gov.v1.tallyResult({ proposalId, }); @@ -173,6 +177,8 @@ export class CosmosProposalV1 extends Proposal< public async fetchVotes(): Promise { const proposalId = longify(this.data.identifier) as Long; + // @ts-expect-error StrictNullChecks + if (isAtomoneLCD(this._Chain.lcd)) return; const votes = await this._Chain.lcd.cosmos.gov.v1.votes({ proposalId }); this.setVotes(votes); return votes; diff --git a/packages/commonwealth/client/scripts/helpers/launchpad.ts b/packages/commonwealth/client/scripts/helpers/launchpad.ts index 394b832e726..7868642563d 100644 --- a/packages/commonwealth/client/scripts/helpers/launchpad.ts +++ b/packages/commonwealth/client/scripts/helpers/launchpad.ts @@ -18,7 +18,7 @@ export const calculateTokenPricing = ( ); const marketCapCurrent = currentPrice * token.initial_supply; const marketCapGoal = token.eth_market_cap_target * ethToUsdRate; - const isMarketCapGoalReached = false; // TODO: https://github.com/hicommonwealth/commonwealth/issues/9887 + const isMarketCapGoalReached = marketCapCurrent >= marketCapGoal; return { currentPrice: parseFloat(`${currentPrice.toFixed(8)}`), diff --git a/packages/commonwealth/client/scripts/helpers/localStorage.ts b/packages/commonwealth/client/scripts/helpers/localStorage.ts new file mode 100644 index 00000000000..b4fb36d77e0 --- /dev/null +++ b/packages/commonwealth/client/scripts/helpers/localStorage.ts @@ -0,0 +1,45 @@ +export const REFCODE_EXPIRATION_MS = 14 * 24 * 60 * 60 * 1000; // 14 days + +export enum LocalStorageKeys { + ReferralCode = 'common-refcode', +} + +export const getLocalStorageItem = (key: LocalStorageKeys) => { + const stored = localStorage.getItem(key); + + if (!stored) { + return null; + } + + const item = JSON.parse(stored); + + if (new Date().getTime() > item.expires) { + localStorage.removeItem(key); + return null; + } + + return item.value; +}; + +export const setLocalStorageItem = ( + key: LocalStorageKeys, + value: string, + expirationMs?: number, +) => { + const stored = getLocalStorageItem(key); + + if (key === LocalStorageKeys.ReferralCode && stored) { + console.log('Reflink already stored'); + return; + } + + const item: { value: string; expires?: number } = { value }; + + if (expirationMs) { + const expirationDate = new Date(); + expirationDate.setTime(expirationDate.getTime() + expirationMs); + item.expires = expirationDate.getTime(); + } + + localStorage.setItem(key, JSON.stringify(item)); +}; diff --git a/packages/commonwealth/client/scripts/hooks/useHandleInviteLink.ts b/packages/commonwealth/client/scripts/hooks/useHandleInviteLink.ts new file mode 100644 index 00000000000..fe1063ebc63 --- /dev/null +++ b/packages/commonwealth/client/scripts/hooks/useHandleInviteLink.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect } from 'react'; +import { matchRoutes, useSearchParams } from 'react-router-dom'; +import { + LocalStorageKeys, + REFCODE_EXPIRATION_MS, + setLocalStorageItem, +} from '../helpers/localStorage'; +import app from '../state'; +import { useAuthModalStore } from '../state/ui/modals'; +import { useUserStore } from '../state/ui/user/user'; +import { AuthModalType } from '../views/modals/AuthModal'; + +export const useHandleInviteLink = ({ + isInsideCommunity, + handleJoinCommunity, +}: { + isInsideCommunity?: boolean; + handleJoinCommunity: () => Promise; +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const refcode = searchParams.get('refcode'); + + const { setAuthModalType, authModalType } = useAuthModalStore(); + const user = useUserStore(); + const activeChainId = app.activeChainId(); + + const generalInviteRoute = matchRoutes( + [{ path: '/dashboard/global' }, { path: '/profile/id/*' }], + location, + ); + + const communityInviteRoute = + matchRoutes( + [ + { path: '/:scope' }, + { path: '/:scope/discussions/*' }, + { path: '/:scope/discussion/*' }, + ], + location, + ) && isInsideCommunity; + + const removeRefcodeFromUrl = useCallback(() => { + searchParams.delete('refcode'); + setSearchParams(searchParams); + }, [searchParams, setSearchParams]); + + useEffect(() => { + if (!refcode) { + return; + } + + if (user.isLoggedIn) { + if (generalInviteRoute) { + // do nothing + removeRefcodeFromUrl(); + } else if (communityInviteRoute) { + if (!activeChainId) { + return; + } + + setLocalStorageItem( + LocalStorageKeys.ReferralCode, + refcode, + REFCODE_EXPIRATION_MS, + ); + removeRefcodeFromUrl(); + handleJoinCommunity().catch(console.error); + } + } else { + if (generalInviteRoute) { + setLocalStorageItem( + LocalStorageKeys.ReferralCode, + refcode, + REFCODE_EXPIRATION_MS, + ); + } else if (communityInviteRoute) { + if (!activeChainId) { + return; + } + + setLocalStorageItem( + LocalStorageKeys.ReferralCode, + refcode, + REFCODE_EXPIRATION_MS, + ); + } + + removeRefcodeFromUrl(); + setAuthModalType(AuthModalType.CreateAccount); + } + }, [ + handleJoinCommunity, + authModalType, + searchParams, + user.isLoggedIn, + setAuthModalType, + generalInviteRoute, + communityInviteRoute, + activeChainId, + refcode, + setSearchParams, + removeRefcodeFromUrl, + ]); +}; diff --git a/packages/commonwealth/client/scripts/models/Group.ts b/packages/commonwealth/client/scripts/models/Group.ts index f434a024d9b..b71ac8c2752 100644 --- a/packages/commonwealth/client/scripts/models/Group.ts +++ b/packages/commonwealth/client/scripts/models/Group.ts @@ -4,6 +4,7 @@ interface APIResponseFormat { metadata: { name: string; description?: string; + groupImageUrl?: string; required_requirements?: number; }; requirements: { @@ -37,6 +38,7 @@ class Group { public updatedAt: string; // ISO string public name: string; public description?: string; + public groupImageUrl?: string; public requirements: any[]; public topics: any[]; public members: any[]; @@ -58,6 +60,7 @@ class Group { this.updatedAt = updated_at; this.name = metadata.name; this.description = metadata.description; + this.groupImageUrl = metadata.groupImageUrl; this.requirements = requirements; this.topics = topics; this.members = memberships; diff --git a/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx b/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx index 17058d1d61d..55bab393a6b 100644 --- a/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx +++ b/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx @@ -78,6 +78,9 @@ const CommunityIntegrations = lazy( const CommunityStakeIntegration = lazy( () => import('views/pages/CommunityManagement/StakeIntegration'), ); +const CommunityTokenIntegration = lazy( + () => import('views/pages/CommunityManagement/TokenIntegration'), +); const CommunityTopics = lazy( () => import('views/pages/CommunityManagement/Topics'), @@ -407,6 +410,14 @@ const CommonDomainRoutes = ({ scoped: true, })} />, + + , import('views/pages/CommunityManagement/StakeIntegration'), ); +const CommunityTokenIntegration = lazy( + () => import('views/pages/CommunityManagement/TokenIntegration'), +); const CommunityTopics = lazy( () => import('views/pages/CommunityManagement/Topics'), ); @@ -298,6 +301,13 @@ const CustomDomainRoutes = ({ scoped: true, })} />, + , => { - return getDepositParams(app.chain as Cosmos, stakingDenom); + return isGovgen + ? getGovgenDepositParams(app.chain as Cosmos, stakingDenom) + : getDepositParams(app.chain as Cosmos, stakingDenom); }; -const useDepositParamsQuery = (stakingDenom: string) => { +const useDepositParamsQuery = ( + stakingDenom: string, + isGovgen: boolean = false, +) => { const communityId = app.activeChainId(); return useQuery({ // fetchDepositParams depends on stakingDenom being defined - queryKey: ['depositParams', communityId, stakingDenom], - queryFn: () => fetchDepositParams(stakingDenom), + queryKey: ['depositParams', communityId, stakingDenom, isGovgen], + queryFn: () => fetchDepositParams(stakingDenom, isGovgen), enabled: app.chain?.base === ChainBase.CosmosSDK && !!stakingDenom, cacheTime: DEPOSIT_PARAMS_CACHE_TIME, staleTime: DEPOSIT_PARAMS_STALE_TIME, diff --git a/packages/commonwealth/client/scripts/state/api/communities/getPinnedTokenByCommunityId.ts b/packages/commonwealth/client/scripts/state/api/communities/getPinnedTokenByCommunityId.ts new file mode 100644 index 00000000000..26783740797 --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/communities/getPinnedTokenByCommunityId.ts @@ -0,0 +1,32 @@ +import { GetPinnedTokens } from '@hicommonwealth/schemas'; +import { trpc } from 'utils/trpcClient'; +import { z } from 'zod'; + +const FETCH_PINNED_TOKEN_STALE_TIME = 60 * 3_000; // 3 mins + +type UseGetPinnedTokensByCommunityIdProps = Omit< + z.infer, + 'community_ids' +> & { + community_ids: string[]; + enabled?: boolean; +}; + +const useGetPinnedTokensByCommunityId = ({ + community_ids, + with_chain_node, + enabled, +}: UseGetPinnedTokensByCommunityIdProps) => { + return trpc.community.getPinnedTokens.useQuery( + { + community_ids: community_ids.join(','), + with_chain_node, + }, + { + cacheTime: FETCH_PINNED_TOKEN_STALE_TIME, + enabled, + }, + ); +}; + +export default useGetPinnedTokensByCommunityId; diff --git a/packages/commonwealth/client/scripts/state/api/communities/index.ts b/packages/commonwealth/client/scripts/state/api/communities/index.ts index 25d41ec3cea..3bd884ed328 100644 --- a/packages/commonwealth/client/scripts/state/api/communities/index.ts +++ b/packages/commonwealth/client/scripts/state/api/communities/index.ts @@ -3,8 +3,11 @@ import useEditCommunityTagsMutation from './editCommunityTags'; import useFetchCommunitiesQuery from './fetchCommunities'; import useFetchRelatedCommunitiesQuery from './fetchRelatedCommunities'; import useGetCommunityByIdQuery from './getCommuityById'; +import useGetPinnedTokenByCommunityId from './getPinnedTokenByCommunityId'; +import usePinTokenToCommunityMutation from './pinTokenToCommunity'; import useRefreshCustomDomainQuery from './refreshCustomDomain'; import useToggleCommunityStarMutation from './toggleCommunityStar'; +import useUnpinTokenFromCommunityMutation from './unpinTokenFromCommunity'; import useUpdateCommunityMutation from './updateCommunity'; import useUpdateCustomDomainMutation from './updateCustomDomain'; @@ -14,8 +17,11 @@ export { useFetchCommunitiesQuery, useFetchRelatedCommunitiesQuery, useGetCommunityByIdQuery, + useGetPinnedTokenByCommunityId, + usePinTokenToCommunityMutation, useRefreshCustomDomainQuery, useToggleCommunityStarMutation, + useUnpinTokenFromCommunityMutation, useUpdateCommunityMutation, useUpdateCustomDomainMutation, }; diff --git a/packages/commonwealth/client/scripts/state/api/communities/pinTokenToCommunity.ts b/packages/commonwealth/client/scripts/state/api/communities/pinTokenToCommunity.ts new file mode 100644 index 00000000000..e52448132f1 --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/communities/pinTokenToCommunity.ts @@ -0,0 +1,13 @@ +import { trpc } from 'utils/trpcClient'; + +const usePinTokenToCommunityMutation = () => { + const utils = trpc.useUtils(); + + return trpc.community.pinToken.useMutation({ + onSuccess: () => { + utils.community.getPinnedTokens.invalidate().catch(console.error); + }, + }); +}; + +export default usePinTokenToCommunityMutation; diff --git a/packages/commonwealth/client/scripts/state/api/communities/unpinTokenFromCommunity.ts b/packages/commonwealth/client/scripts/state/api/communities/unpinTokenFromCommunity.ts new file mode 100644 index 00000000000..b23ea78824e --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/communities/unpinTokenFromCommunity.ts @@ -0,0 +1,20 @@ +import { trpc } from 'utils/trpcClient'; + +type UseUnpinTokenFromCommunityMutation = { + resetCacheOnSuccess?: boolean; +}; + +const useUnpinTokenFromCommunityMutation = ({ + resetCacheOnSuccess = true, +}: UseUnpinTokenFromCommunityMutation = {}) => { + const utils = trpc.useUtils(); + + return trpc.community.unpinToken.useMutation({ + onSuccess: () => { + resetCacheOnSuccess && + utils.community.getPinnedTokens.invalidate().catch(console.error); + }, + }); +}; + +export default useUnpinTokenFromCommunityMutation; diff --git a/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts b/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts index fd31b1b774d..5993307c2fc 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts @@ -8,6 +8,7 @@ interface CreateGroupProps { groupName: string; topics: GroupFormTopicSubmitValues[]; groupDescription?: string; + groupImageUrl?: string; requirementsToFulfill: number | undefined; requirements?: any[]; } @@ -16,6 +17,7 @@ export const buildCreateGroupInput = ({ communityId, groupName, groupDescription, + groupImageUrl, topics, requirementsToFulfill, requirements = [], @@ -27,6 +29,7 @@ export const buildCreateGroupInput = ({ metadata: { name: groupName, description: groupDescription ?? '', + groupImageUrl: groupImageUrl ?? '', ...(finalRequirementsToFulfill && { required_requirements: finalRequirementsToFulfill, }), diff --git a/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts b/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts index 1e516d6fa9d..7138a9cb379 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts @@ -9,6 +9,7 @@ interface EditGroupProps { address: string; groupName: string; groupDescription?: string; + groupImageUrl?: string; topics: GroupFormTopicSubmitValues[]; requirementsToFulfill: number | undefined; requirements?: any[]; @@ -20,6 +21,7 @@ export const buildUpdateGroupInput = ({ address, groupName, groupDescription, + groupImageUrl, topics, requirementsToFulfill, requirements, @@ -33,6 +35,7 @@ export const buildUpdateGroupInput = ({ metadata: { name: groupName, description: groupDescription ?? '', + groupImageUrl: groupImageUrl ?? '', ...(requirementsToFulfill && { required_requirements: requirementsToFulfill, }), diff --git a/packages/commonwealth/client/scripts/state/api/proposals/cosmos/fetchCosmosProposal.ts b/packages/commonwealth/client/scripts/state/api/proposals/cosmos/fetchCosmosProposal.ts index 90cdffbf1b8..005e848b4ad 100644 --- a/packages/commonwealth/client/scripts/state/api/proposals/cosmos/fetchCosmosProposal.ts +++ b/packages/commonwealth/client/scripts/state/api/proposals/cosmos/fetchCosmosProposal.ts @@ -1,5 +1,6 @@ import { ChainBase } from '@hicommonwealth/shared'; import { useQuery } from '@tanstack/react-query'; +import { CosmosProposalV1AtomOne } from 'client/scripts/controllers/chain/cosmos/gov/atomone/proposal-v1'; import { CosmosProposalGovgen } from 'client/scripts/controllers/chain/cosmos/gov/govgen/proposal-v1beta1'; import Cosmos from 'controllers/chain/cosmos/adapter'; import { CosmosProposalV1 } from 'controllers/chain/cosmos/gov/v1/proposal-v1'; @@ -11,7 +12,12 @@ const PROPOSAL_STALE_TIME = 1000 * 10; const fetchCosmosProposal = async ( proposalId: string, -): Promise => { +): Promise< + | CosmosProposal + | CosmosProposalV1 + | CosmosProposalGovgen + | CosmosProposalV1AtomOne +> => { const { governance } = app.chain as Cosmos; return governance.getProposal(+proposalId); }; diff --git a/packages/commonwealth/client/scripts/state/api/tokens/createToken.ts b/packages/commonwealth/client/scripts/state/api/tokens/createToken.ts index cce3ff07bd2..21d4af6d75c 100644 --- a/packages/commonwealth/client/scripts/state/api/tokens/createToken.ts +++ b/packages/commonwealth/client/scripts/state/api/tokens/createToken.ts @@ -5,7 +5,7 @@ import { queryClient } from '../config'; const useCreateTokenMutation = () => { const user = useUserStore(); - return trpc.token.createToken.useMutation({ + return trpc.launchpadToken.createToken.useMutation({ onSuccess: () => { user.setData({ addressSelectorSelectedAddress: undefined }); diff --git a/packages/commonwealth/client/scripts/state/api/tokens/createTokenTrade.ts b/packages/commonwealth/client/scripts/state/api/tokens/createTokenTrade.ts index d7a6b90a218..d19e00adcd6 100644 --- a/packages/commonwealth/client/scripts/state/api/tokens/createTokenTrade.ts +++ b/packages/commonwealth/client/scripts/state/api/tokens/createTokenTrade.ts @@ -1,7 +1,7 @@ import { trpc } from 'utils/trpcClient'; const useCreateTokenTradeMutation = () => { - return trpc.token.createLaunchpadTrade.useMutation(); + return trpc.launchpadToken.createTrade.useMutation(); }; export default useCreateTokenTradeMutation; diff --git a/packages/commonwealth/client/scripts/state/api/tokens/fetchTokens.ts b/packages/commonwealth/client/scripts/state/api/tokens/fetchTokens.ts index 99c1ae6942e..eecb2d17374 100644 --- a/packages/commonwealth/client/scripts/state/api/tokens/fetchTokens.ts +++ b/packages/commonwealth/client/scripts/state/api/tokens/fetchTokens.ts @@ -16,7 +16,7 @@ const useFetchTokensQuery = ({ with_stats = false, enabled = true, }: UseFetchTokensProps) => { - return trpc.token.getTokens.useInfiniteQuery( + return trpc.launchpadToken.getTokens.useInfiniteQuery( { limit, order_by, diff --git a/packages/commonwealth/client/scripts/state/api/tokens/getTokenByCommunityId.ts b/packages/commonwealth/client/scripts/state/api/tokens/getTokenByCommunityId.ts index fb7d26e5b69..022203199db 100644 --- a/packages/commonwealth/client/scripts/state/api/tokens/getTokenByCommunityId.ts +++ b/packages/commonwealth/client/scripts/state/api/tokens/getTokenByCommunityId.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; const FETCH_TOKEN_STALE_TIME = 60 * 3_000; // 3 mins -type UseFetchTokensProps = z.infer & { +type UseGetTokenByCommunityIdProps = z.infer & { enabled?: boolean; }; @@ -12,8 +12,8 @@ const useGetTokenByCommunityId = ({ community_id, with_stats = true, enabled, -}: UseFetchTokensProps) => { - return trpc.token.getToken.useQuery( +}: UseGetTokenByCommunityIdProps) => { + return trpc.launchpadToken.getToken.useQuery( { community_id, with_stats, diff --git a/packages/commonwealth/client/scripts/state/api/user/createReferralLink.ts b/packages/commonwealth/client/scripts/state/api/user/createReferralLink.ts new file mode 100644 index 00000000000..571436aed2e --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/user/createReferralLink.ts @@ -0,0 +1,11 @@ +import { trpc } from 'utils/trpcClient'; + +export const useCreateReferralLinkMutation = () => { + const utils = trpc.useUtils(); + + return trpc.user.createReferralLink.useMutation({ + onSuccess: async () => { + await utils.user.getReferralLink.invalidate(); + }, + }); +}; diff --git a/packages/commonwealth/client/scripts/state/api/user/getReferralLink.ts b/packages/commonwealth/client/scripts/state/api/user/getReferralLink.ts new file mode 100644 index 00000000000..3eab91ff227 --- /dev/null +++ b/packages/commonwealth/client/scripts/state/api/user/getReferralLink.ts @@ -0,0 +1,17 @@ +import { useFlag } from 'hooks/useFlag'; +import { trpc } from 'utils/trpcClient'; +import useUserStore from '../../ui/user'; + +export const useGetReferralLinkQuery = () => { + const user = useUserStore(); + const referralsEnabled = useFlag('referrals'); + + return trpc.user.getReferralLink.useQuery( + {}, + { + enabled: user?.isLoggedIn && referralsEnabled, + staleTime: Infinity, + cacheTime: Infinity, + }, + ); +}; diff --git a/packages/commonwealth/client/scripts/state/api/user/index.ts b/packages/commonwealth/client/scripts/state/api/user/index.ts index 756589986e8..6ea19a59418 100644 --- a/packages/commonwealth/client/scripts/state/api/user/index.ts +++ b/packages/commonwealth/client/scripts/state/api/user/index.ts @@ -1,7 +1,9 @@ import { useCreateApiKeyMutation } from './createApiKey'; +import { useCreateReferralLinkMutation } from './createReferralLink'; import { useDeleteApiKeyMutation } from './deleteApiKey'; import { useGetApiKeyQuery } from './getApiKey'; import useGetNewContent from './getNewContent'; +import { useGetReferralLinkQuery } from './getReferralLink'; import useUpdateUserActiveCommunityMutation from './updateActiveCommunity'; import useUpdateUserEmailMutation from './updateEmail'; import useUpdateUserEmailSettingsMutation from './updateEmailSettings'; @@ -9,9 +11,11 @@ import useUpdateUserMutation from './updateUser'; export { useCreateApiKeyMutation, + useCreateReferralLinkMutation, useDeleteApiKeyMutation, useGetApiKeyQuery, useGetNewContent, + useGetReferralLinkQuery, useUpdateUserActiveCommunityMutation, useUpdateUserEmailMutation, useUpdateUserEmailSettingsMutation, diff --git a/packages/commonwealth/client/scripts/state/ui/modals/authModal.ts b/packages/commonwealth/client/scripts/state/ui/modals/authModal.ts index d5f60e01f81..3b297ffef2a 100644 --- a/packages/commonwealth/client/scripts/state/ui/modals/authModal.ts +++ b/packages/commonwealth/client/scripts/state/ui/modals/authModal.ts @@ -27,14 +27,13 @@ export const authModal = createStore()( error instanceof SessionKeyError && error; if (sessionKeyValidationError) { - console.log('session key validation error'); - // set((state) => { - // return { - // ...state, - // sessionKeyValidationError: sessionKeyValidationError, - // authModalType: AuthModalType.RevalidateSession, - // }; - // }); + set((state) => { + return { + ...state, + sessionKeyValidationError: sessionKeyValidationError, + authModalType: AuthModalType.RevalidateSession, + }; + }); } }, })), diff --git a/packages/commonwealth/client/scripts/views/Sublayout.tsx b/packages/commonwealth/client/scripts/views/Sublayout.tsx index c72108d43bc..39609a318fd 100644 --- a/packages/commonwealth/client/scripts/views/Sublayout.tsx +++ b/packages/commonwealth/client/scripts/views/Sublayout.tsx @@ -7,6 +7,8 @@ import app from 'state'; import useSidebarStore from 'state/ui/sidebar'; import { SublayoutHeader } from 'views/components/SublayoutHeader'; import { Sidebar } from 'views/components/sidebar'; +import twitterspaceGrowlImage from '../../assets/img/TwitterspaceGrowlImage.png'; +import { useHandleInviteLink } from '../hooks/useHandleInviteLink'; import useNecessaryEffect from '../hooks/useNecessaryEffect'; import useStickyHeader from '../hooks/useStickyHeader'; import { @@ -21,6 +23,8 @@ import { AdminOnboardingSlider } from './components/AdminOnboardingSlider'; import { Breadcrumbs } from './components/Breadcrumbs'; import MobileNavigation from './components/MobileNavigation'; import AuthButtons from './components/SublayoutHeader/AuthButtons'; +import { CWGrowlTemplate } from './components/SublayoutHeader/GrowlTemplate/CWGrowlTemplate'; +import useJoinCommunity from './components/SublayoutHeader/useJoinCommunity'; import { UserTrainingSlider } from './components/UserTrainingSlider'; import { CWModal } from './components/component_kit/new_designs/CWModal'; import CollapsableSidebarButton from './components/sidebar/CollapsableSidebarButton'; @@ -36,12 +40,16 @@ type SublayoutProps = { const Sublayout = ({ children, isInsideCommunity }: SublayoutProps) => { const { menuVisible, setMenu, menuName } = useSidebarStore(); const [resizing, setResizing] = useState(false); + const { JoinCommunityModals, handleJoinCommunity } = useJoinCommunity(); + + const location = useLocation(); useStickyHeader({ elementId: 'mobile-auth-buttons', stickyBehaviourEnabled: true, zIndex: 70, }); + const { isWindowSmallInclusive, isWindowExtraSmall, isWindowSmallToMedium } = useBrowserWindow({ onResize: () => setResizing(true), @@ -76,7 +84,7 @@ const Sublayout = ({ children, isInsideCommunity }: SublayoutProps) => { user.isLoggedIn, ]); - const location = useLocation(); + useHandleInviteLink({ isInsideCommunity, handleJoinCommunity }); useWindowResize({ setMenu, @@ -188,6 +196,16 @@ const Sublayout = ({ children, isInsideCommunity }: SublayoutProps) => { )} {children} + { content={ setIsInviteLinkModalOpen(false)} - isInsideCommunity={!!isInsideCommunity} /> } open={!isWindowExtraSmall && isInviteLinkModalOpen} onClose={() => setIsInviteLinkModalOpen(false)} /> + {JoinCommunityModals} {isWindowExtraSmall && } diff --git a/packages/commonwealth/client/scripts/views/components/CommunityInformationForm/CommunityInformationForm.tsx b/packages/commonwealth/client/scripts/views/components/CommunityInformationForm/CommunityInformationForm.tsx index bcc0a4b20d3..45c9e64906d 100644 --- a/packages/commonwealth/client/scripts/views/components/CommunityInformationForm/CommunityInformationForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/CommunityInformationForm/CommunityInformationForm.tsx @@ -21,6 +21,7 @@ import { ETHEREUM_MAINNET_ID, OSMOSIS_ID, POLYGON_ETH_CHAIN_ID, + SKALE_ID, alphabeticallyStakeWiseSortedChains as sortedChains, } from './constants'; import { @@ -116,6 +117,8 @@ const CommunityInformationForm = ({ return options?.find((o) => o.value === OSMOSIS_ID); case CommunityType.Blast: return options?.find((o) => o.value === BLAST_ID); + case CommunityType.Skale: + return options?.find((o) => o.value === SKALE_ID); case CommunityType.Polygon: case CommunityType.Solana: return options?.[0]; diff --git a/packages/commonwealth/client/scripts/views/components/CommunityInformationForm/constants.ts b/packages/commonwealth/client/scripts/views/components/CommunityInformationForm/constants.ts index c298df1d805..bf0dc40dee5 100644 --- a/packages/commonwealth/client/scripts/views/components/CommunityInformationForm/constants.ts +++ b/packages/commonwealth/client/scripts/views/components/CommunityInformationForm/constants.ts @@ -9,6 +9,7 @@ export const ETHEREUM_MAINNET_ID = '1'; export const BASE_ID = '8453'; export const OSMOSIS_ID = 'osmosis'; export const BLAST_ID = '81457'; +export const SKALE_ID = '974399131'; const removeTestCosmosNodes = (nodeInfo: NodeInfo): boolean => { return !( diff --git a/packages/commonwealth/client/scripts/views/components/MetaTags/MetaTags.tsx b/packages/commonwealth/client/scripts/views/components/MetaTags/MetaTags.tsx index d81f28390ac..6db99d5a294 100644 --- a/packages/commonwealth/client/scripts/views/components/MetaTags/MetaTags.tsx +++ b/packages/commonwealth/client/scripts/views/components/MetaTags/MetaTags.tsx @@ -82,7 +82,7 @@ const defaultMeta = { }, 'twitter:image': { name: 'twitter:image', - content: `https://${PRODUCTION_DOMAIN}/img/brand_assets/common.png`, + content: `https://${PRODUCTION_DOMAIN}/img/brand_assets/common-social.png`, }, 'og:type': { property: 'og:type', @@ -106,7 +106,7 @@ const defaultMeta = { }, 'og:image': { property: 'og:image', - content: `https://${PRODUCTION_DOMAIN}/img/brand_assets/common.png`, + content: `https://${PRODUCTION_DOMAIN}/img/brand_assets/common-social.png`, }, } as const; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx index 1697e43802b..107abf75157 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx @@ -38,7 +38,8 @@ import { CustomAddressOption, CustomAddressOptionElement, } from '../../modals/ManageCommunityStakeModal/StakeExchangeForm/CustomAddressOption'; -import { convertAddressToDropdownOption } from '../../modals/TradeTokenModel/TradeTokenForm/helpers'; +// eslint-disable-next-line max-len +import { convertAddressToDropdownOption } from '../../modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/helpers'; import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; import { CWGatedTopicPermissionLevelBanner } from '../component_kit/CWGatedTopicPermissionLevelBanner'; import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; diff --git a/packages/commonwealth/client/scripts/views/components/PageCounter/PageCounter.tsx b/packages/commonwealth/client/scripts/views/components/PageCounter/PageCounter.tsx index e38bc790cad..252896330dd 100644 --- a/packages/commonwealth/client/scripts/views/components/PageCounter/PageCounter.tsx +++ b/packages/commonwealth/client/scripts/views/components/PageCounter/PageCounter.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import { CWText } from '../component_kit/cw_text'; import CWIconButton from '../component_kit/new_designs/CWIconButton'; @@ -8,6 +9,7 @@ type PageCounterProps = { activePage?: number; onPageChange?: (pageNumber: number) => void; disabled?: boolean; + className?: string; }; const PageCounter = ({ @@ -15,9 +17,10 @@ const PageCounter = ({ totalPages, onPageChange, disabled, + className, }: PageCounterProps) => { return ( -
+
{totalPages > 1 && ( void; +} + +declare global { + interface Window { + ReactNativeWebView?: ReactNativeWebView; + } +} + +/** + * Typed message so that the react-native client knows how to handel this message. + * + * This is teh standard pattern of how to handle postMessage with multiple uses. + */ +type TypedData = { + type: string; + data: Data; +}; + +/** + * The actual user info that the client needs. + */ +type UserInfo = { + userId: number; + knockJWT: string; + // darkMode: 'dark' | 'light'; +}; + +/** + * This acts as a bridge between the react-native client (mobile app) and our + * webapp. Notifications only work with a userId and the react-native client + * doesn't do any auth or even know about auth. + * + * This way the webapp will send a message to the mobile app via the + * window.ReactNativeWebView.postMessage client. + * + * Note that NOTHING will happen in our normal app otherwise. It will track + * the userInfo but not send it. + */ +export const ReactNativeBridge = () => { + const user = useUserStore(); + + const [userInfo, setUserInfo] = useState(null); + + useEffect(() => { + if (user.id !== userInfo?.userId) { + setUserInfo({ + userId: user.id, + knockJWT: user.knockJWT, + }); + } + }, [user.id, user.knockJWT, userInfo?.userId]); + + useEffect(() => { + const message: TypedData = { + type: 'user', + data: userInfo, + }; + + if (window.ReactNativeWebView && userInfo) { + // send the user information to react native now. + window.ReactNativeWebView.postMessage(JSON.stringify(message)); + } + }, [userInfo]); + + return null; +}; diff --git a/packages/commonwealth/client/scripts/views/components/ReactNativeBridge/index.tsx b/packages/commonwealth/client/scripts/views/components/ReactNativeBridge/index.tsx new file mode 100644 index 00000000000..1274c14a45c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/ReactNativeBridge/index.tsx @@ -0,0 +1 @@ +export * from './ReactNativeBridge'; diff --git a/packages/commonwealth/client/scripts/views/components/SharePopover/SharePopover.tsx b/packages/commonwealth/client/scripts/views/components/SharePopover/SharePopover.tsx index 3be4792ba97..2f95e7e6d65 100644 --- a/packages/commonwealth/client/scripts/views/components/SharePopover/SharePopover.tsx +++ b/packages/commonwealth/client/scripts/views/components/SharePopover/SharePopover.tsx @@ -1,8 +1,10 @@ -import { notifySuccess } from 'client/scripts/controllers/app/notifications'; +import { useFlag } from 'hooks/useFlag'; import React from 'react'; +import { saveToClipboard } from 'utils/clipboard'; import { PopoverMenu } from 'views/components/component_kit/CWPopoverMenu'; import { PopoverTriggerProps } from 'views/components/component_kit/new_designs/CWPopover'; import { CWThreadAction } from 'views/components/component_kit/new_designs/cw_thread_action'; +import useReferralLink from '../../modals/InviteLinkModal/useReferralLink'; const TWITTER_SHARE_LINK_PREFIX = 'https://twitter.com/intent/tweet?text='; @@ -16,6 +18,10 @@ export const SharePopover = ({ linkToShare, buttonLabel, }: SharePopoverProps) => { + const referralsEnabled = useFlag('referrals'); + + const { getReferralLink } = useReferralLink(); + const defaultRenderTrigger = ( onClick: (e: React.MouseEvent) => void, ) => ( @@ -30,6 +36,17 @@ export const SharePopover = ({ /> ); + const handleCopy = async () => { + if (referralsEnabled) { + const referralLink = await getReferralLink(); + const refLink = + linkToShare + (referralLink ? `?refcode=${referralLink}` : ''); + await saveToClipboard(refLink, true); + } else { + await saveToClipboard(linkToShare, true); + } + }; + return ( { - navigator.clipboard - .writeText(linkToShare) - .then(() => { - notifySuccess('Successfully copied! '); - }) - .catch(console.error); + handleCopy().catch(console.error); }, }, { diff --git a/packages/commonwealth/client/scripts/views/components/StickEditorContainer/DesktopStickyInput.scss b/packages/commonwealth/client/scripts/views/components/StickEditorContainer/DesktopStickyInput.scss index 2d9981ae0e8..92bfdde5f3c 100644 --- a/packages/commonwealth/client/scripts/views/components/StickEditorContainer/DesktopStickyInput.scss +++ b/packages/commonwealth/client/scripts/views/components/StickEditorContainer/DesktopStickyInput.scss @@ -4,8 +4,7 @@ position: sticky; bottom: 0; display: flex; - flex-grow: 1; - z-index: 100; + z-index: 70; background: $white; .DesktopStickyInputPending { diff --git a/packages/commonwealth/client/scripts/views/components/StickEditorContainer/MobileStickyInput.scss b/packages/commonwealth/client/scripts/views/components/StickEditorContainer/MobileStickyInput.scss index c114412b44b..3be2dd64698 100644 --- a/packages/commonwealth/client/scripts/views/components/StickEditorContainer/MobileStickyInput.scss +++ b/packages/commonwealth/client/scripts/views/components/StickEditorContainer/MobileStickyInput.scss @@ -13,7 +13,7 @@ .MobileStickyInputFocused { background: white; - z-index: 1000; + z-index: 70; position: absolute; top: 0; left: 0; diff --git a/packages/commonwealth/client/scripts/views/components/SublayoutHeader/DesktopHeader/DesktopHeader.tsx b/packages/commonwealth/client/scripts/views/components/SublayoutHeader/DesktopHeader/DesktopHeader.tsx index a4fcd07802c..c5f1478c132 100644 --- a/packages/commonwealth/client/scripts/views/components/SublayoutHeader/DesktopHeader/DesktopHeader.tsx +++ b/packages/commonwealth/client/scripts/views/components/SublayoutHeader/DesktopHeader/DesktopHeader.tsx @@ -7,6 +7,7 @@ import KnockNotifications from 'views/components/KnockNotifications'; import { CWDivider } from 'views/components/component_kit/cw_divider'; import { CWIconButton } from 'views/components/component_kit/cw_icon_button'; import { isWindowSmallInclusive } from 'views/components/component_kit/helpers'; +import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; import { CWSearchBar } from 'views/components/component_kit/new_designs/CWSearchBar'; import { CWTooltip } from 'views/components/component_kit/new_designs/CWTooltip'; import { CreateContentPopover } from 'views/menus/CreateContentMenu'; @@ -83,6 +84,24 @@ const DesktopHeader = ({ onMobile, onAuthModalOpen }: DesktopHeaderProps) => { })} > + {!isWindowSmallInclusive(window.innerWidth) && ( + ( + + window.open('https://landing.common.xyz', '_blank') + } + onMouseEnter={handleInteraction} + onMouseLeave={handleInteraction} + /> + )} + /> + )} { setIsInviteLinkModalOpen(false); }} diff --git a/packages/commonwealth/client/scripts/views/components/TokenCard/MarketCapProgress.tsx b/packages/commonwealth/client/scripts/views/components/TokenCard/MarketCapProgress.tsx index 244123fcaea..4facb3bfdba 100644 --- a/packages/commonwealth/client/scripts/views/components/TokenCard/MarketCapProgress.tsx +++ b/packages/commonwealth/client/scripts/views/components/TokenCard/MarketCapProgress.tsx @@ -8,7 +8,7 @@ import './MarketCapProgress.scss'; interface MarketCapProgressProps { currency?: SupportedCurrencies; - marketCap: { current: number; goal: number }; + marketCap: { current: number; goal: number; isCapped: boolean }; onBodyClick?: (e: React.MouseEvent) => void; } @@ -18,7 +18,6 @@ const MarketCapProgress = ({ onBodyClick, }: MarketCapProgressProps) => { const currencySymbol = currencyNameToSymbolMap[currency]; - const isCapped = marketCap.current === marketCap.goal; const progressPercentage = Math.floor( (marketCap.current / marketCap.goal) * 100, ); @@ -26,7 +25,7 @@ const MarketCapProgress = ({ return (
@@ -36,7 +35,7 @@ const MarketCapProgress = ({ {numeral(marketCap.current).format('0.0a')} | Goal {currencySymbol} {numeral(marketCap.goal).format('0.0a')} - {isCapped && ( + {marketCap.isCapped && ( )}
diff --git a/packages/commonwealth/client/scripts/views/components/TokenCard/TokenCard.tsx b/packages/commonwealth/client/scripts/views/components/TokenCard/TokenCard.tsx index 5bcea28d1e9..1a0a8493cae 100644 --- a/packages/commonwealth/client/scripts/views/components/TokenCard/TokenCard.tsx +++ b/packages/commonwealth/client/scripts/views/components/TokenCard/TokenCard.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import { currencyNameToSymbolMap, SupportedCurrencies } from 'helpers/currency'; import React, { ReactNode } from 'react'; +import { TradingMode } from '../../modals/TradeTokenModel'; import { CWText } from '../component_kit/cw_text'; import { CWButton } from '../component_kit/new_designs/CWButton'; import { CWTooltip } from '../component_kit/new_designs/CWTooltip'; @@ -14,12 +15,12 @@ interface TokenCardProps { symbol: string; iconURL: string; currency?: SupportedCurrencies; - marketCap: { current: number; goal: number }; + marketCap: { current: number; goal: number; isCapped: boolean }; price: number; pricePercentage24HourChange: number; - mode: 'buy' | 'swap'; + mode: TradingMode.Buy | TradingMode.Swap; className?: string; - onCTAClick?: () => void; + onCTAClick?: (mode: TradingMode) => void; onCardBodyClick?: () => void; } @@ -129,7 +130,7 @@ const TokenCard = ({ buttonWidth="full" buttonType="secondary" buttonAlt="green" - onClick={onCTAClick} + onClick={() => onCTAClick?.(mode)} />
); diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/cw_growl.scss b/packages/commonwealth/client/scripts/views/components/component_kit/cw_growl.scss index e6d33bd418d..15878c7434f 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/cw_growl.scss +++ b/packages/commonwealth/client/scripts/views/components/component_kit/cw_growl.scss @@ -1,4 +1,4 @@ -@import '../../styles/shared.scss'; +@import '../../../styles/shared.scss'; $growl-outside-bottom-padding: 16px; $growl-outside-left-padding: 16px; diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWCommunitySelector/CWCommunitySelector.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWCommunitySelector/CWCommunitySelector.tsx index 152012d1bb2..f5005ec000d 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWCommunitySelector/CWCommunitySelector.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWCommunitySelector/CWCommunitySelector.tsx @@ -17,6 +17,7 @@ export enum CommunityType { Cosmos = 'cosmos', Polygon = 'polygon', Solana = 'solana', + Skale = 'skale', } export type SelectedCommunity = { diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWTooltip/CWTooltip.scss b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWTooltip/CWTooltip.scss index 95d7835432f..dd3e304dc87 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWTooltip/CWTooltip.scss +++ b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWTooltip/CWTooltip.scss @@ -69,14 +69,6 @@ &.tipBottom { @include tip(-4px, auto, auto, calc(50% - 8px)); - - @include medium { - @include tip(-4px, auto, auto, calc(20% - 8px)); - } - } - - &.tipBottomSidebarHidden { - @include tip(-4px, auto, auto, calc(20% - 8px / 2)); } &.tipLeft { diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWTooltip/TooltipContainer.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWTooltip/TooltipContainer.tsx index 17d122859b3..3987d9a2c1b 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWTooltip/TooltipContainer.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWTooltip/TooltipContainer.tsx @@ -1,6 +1,5 @@ import { Placement } from '@popperjs/core/lib'; import React, { FC } from 'react'; -import useSidebarStore from 'state/ui/sidebar'; import { getClasses } from 'views/components/component_kit/helpers'; import { ComponentType } from 'views/components/component_kit/types'; @@ -13,8 +12,6 @@ export const TooltipContainer: FC = ({ placement, children, }) => { - const { menuVisible } = useSidebarStore(); - return (
{children} @@ -24,7 +21,6 @@ export const TooltipContainer: FC = ({ placement, Arrow: true, tipTop: placement === 'top', - tipBottomSidebarHidden: placement === 'bottom' && !menuVisible, tipRight: placement === 'right', tipBottom: placement === 'bottom', tipLeft: placement === 'left', diff --git a/packages/commonwealth/client/scripts/views/components/linked_addresses.tsx b/packages/commonwealth/client/scripts/views/components/linked_addresses.tsx index ef96c4f0ff7..7a5adf9ebc9 100644 --- a/packages/commonwealth/client/scripts/views/components/linked_addresses.tsx +++ b/packages/commonwealth/client/scripts/views/components/linked_addresses.tsx @@ -23,7 +23,12 @@ type AddressProps = { type AddressDetailsProps = { profile: NewProfile; addressInfo: AddressInfo; - toggleRemoveModal: (val: boolean, address: AddressInfo) => void; + toggleRemoveModal: ( + val: boolean, + address: AddressInfo, + isBulkDelete: boolean, + communityName: string, + ) => void; }; type LinkedAddressesProps = { @@ -54,6 +59,8 @@ const AddressDetails = (props: AddressDetailsProps) => { enabled: !!community.id, }); + if (!fetchedCommunity) return null; + return (
{ menuItems={[ { label: `Disconnect ${formatAddressShort(address)}`, - onClick: () => toggleRemoveModal(true, addressInfo), + onClick: () => + toggleRemoveModal( + true, + addressInfo, + false, + fetchedCommunity.name, + ), + }, + { + label: 'Delete All Addresses', + onClick: () => + toggleRemoveModal(true, addressInfo, true, fetchedCommunity.name), }, ]} renderTrigger={(onclick) => ( @@ -98,6 +116,10 @@ export const LinkedAddresses = (props: LinkedAddressesProps) => { const [currentAddress, setCurrentAddress] = useState( null, ); + const [isBulkDeleteState, setIsBulkDeleteState] = useState(false); + const [selectedCommuinty, setSelectedCommunity] = useState( + null, + ); const { profile, addresses, refreshProfiles } = props; @@ -123,9 +145,13 @@ export const LinkedAddresses = (props: LinkedAddressesProps) => { toggleRemoveModal={( val: boolean, selectedAddress: AddressInfo, + isBulkDelete: boolean = false, + community, ) => { setIsRemoveModalOpen(val); setCurrentAddress(selectedAddress); + setIsBulkDeleteState(isBulkDelete); + setSelectedCommunity(community); }} /> ); @@ -147,7 +173,8 @@ export const LinkedAddresses = (props: LinkedAddressesProps) => { { setIsRemoveModalOpen(false); refreshProfiles(currentAddress); }} + isBulkDelete={isBulkDeleteState} + communityName={selectedCommuinty} /> ) } onClose={() => { setIsRemoveModalOpen(false); setCurrentAddress(null); + setSelectedCommunity(null); }} open={isRemoveModalOpen} /> diff --git a/packages/commonwealth/client/scripts/views/components/proposals/proposal_extensions.tsx b/packages/commonwealth/client/scripts/views/components/proposals/proposal_extensions.tsx index b71f345a5b2..2405da6a335 100644 --- a/packages/commonwealth/client/scripts/views/components/proposals/proposal_extensions.tsx +++ b/packages/commonwealth/client/scripts/views/components/proposals/proposal_extensions.tsx @@ -5,6 +5,8 @@ import './proposal_extensions.scss'; import app from 'state'; +import { CosmosProposalV1AtomOne } from 'client/scripts/controllers/chain/cosmos/gov/atomone/proposal-v1'; +import { CosmosProposalGovgen } from 'client/scripts/controllers/chain/cosmos/gov/govgen/proposal-v1beta1'; import Cosmos from 'controllers/chain/cosmos/adapter'; import { CosmosProposalV1 } from 'controllers/chain/cosmos/gov/v1/proposal-v1'; import { CosmosProposal } from 'controllers/chain/cosmos/gov/v1beta1/proposal-v1beta1'; @@ -26,8 +28,17 @@ type ProposalExtensionsProps = { export const ProposalExtensions = (props: ProposalExtensionsProps) => { const { setCosmosDepositAmount, setDemocracyVoteAmount, proposal } = props; const { data: stakingDenom } = useStakingParamsQuery(); - // @ts-expect-error - const { data: cosmosDepositParams } = useDepositParamsQuery(stakingDenom); + + let isGovgen = false; + if (proposal instanceof CosmosProposalGovgen) { + isGovgen = true; + } + + const { data: cosmosDepositParams } = useDepositParamsQuery( + // @ts-expect-error + stakingDenom, + isGovgen, + ); useEffect(() => { if (setDemocracyVoteAmount) setDemocracyVoteAmount(0); @@ -39,7 +50,9 @@ export const ProposalExtensions = (props: ProposalExtensionsProps) => { if ( (proposal instanceof CosmosProposal || - proposal instanceof CosmosProposalV1) && + proposal instanceof CosmosProposalV1 || + proposal instanceof CosmosProposalGovgen || + proposal instanceof CosmosProposalV1AtomOne) && proposal.status === 'DepositPeriod' ) { const cosmos = app.chain as Cosmos; diff --git a/packages/commonwealth/client/scripts/views/components/proposals/voting_actions.tsx b/packages/commonwealth/client/scripts/views/components/proposals/voting_actions.tsx index b2760abe653..3951448d006 100644 --- a/packages/commonwealth/client/scripts/views/components/proposals/voting_actions.tsx +++ b/packages/commonwealth/client/scripts/views/components/proposals/voting_actions.tsx @@ -16,6 +16,7 @@ import './voting_actions.scss'; import app from 'state'; import { getChainDecimals } from 'client/scripts/controllers/app/webWallets/utils'; +import { CosmosProposalV1AtomOne } from 'client/scripts/controllers/chain/cosmos/gov/atomone/proposal-v1'; import { CosmosProposalGovgen } from 'client/scripts/controllers/chain/cosmos/gov/govgen/proposal-v1beta1'; import useUserStore from 'state/ui/user'; import { naturalDenomToMinimal } from '../../../../../shared/utils'; @@ -62,7 +63,8 @@ export const VotingActions = ({ if ( proposal instanceof CosmosProposal || proposal instanceof CosmosProposalV1 || - proposal instanceof CosmosProposalGovgen + proposal instanceof CosmosProposalGovgen || + proposal instanceof CosmosProposalV1AtomOne ) { user = userData.activeAccount as CosmosAccount; } else { @@ -79,7 +81,9 @@ export const VotingActions = ({ if ( proposal instanceof CosmosProposal || - proposal instanceof CosmosProposalV1 + proposal instanceof CosmosProposalV1 || + proposal instanceof CosmosProposalGovgen || + proposal instanceof CosmosProposalV1AtomOne ) { if (proposal.status === 'DepositPeriod') { const chain = app.chain as Cosmos; @@ -119,7 +123,9 @@ export const VotingActions = ({ if ( proposal instanceof CosmosProposal || - proposal instanceof CosmosProposalV1 + proposal instanceof CosmosProposalV1 || + proposal instanceof CosmosProposalGovgen || + proposal instanceof CosmosProposalV1AtomOne ) { try { await proposal.voteTx(new CosmosVote(user, 'No')); @@ -143,7 +149,9 @@ export const VotingActions = ({ if ( proposal instanceof CosmosProposal || - proposal instanceof CosmosProposalV1 + proposal instanceof CosmosProposalV1 || + proposal instanceof CosmosProposalGovgen || + proposal instanceof CosmosProposalV1AtomOne ) { proposal .voteTx(new CosmosVote(user, 'Abstain')) @@ -161,7 +169,9 @@ export const VotingActions = ({ if ( proposal instanceof CosmosProposal || - proposal instanceof CosmosProposalV1 + proposal instanceof CosmosProposalV1 || + proposal instanceof CosmosProposalGovgen || + proposal instanceof CosmosProposalV1AtomOne ) { proposal .voteTx(new CosmosVote(user, 'NoWithVeto')) diff --git a/packages/commonwealth/client/scripts/views/components/react_quill_editor/markdown_formatted_text.scss b/packages/commonwealth/client/scripts/views/components/react_quill_editor/markdown_formatted_text.scss index 4b0b94b05e4..f94c65f5805 100644 --- a/packages/commonwealth/client/scripts/views/components/react_quill_editor/markdown_formatted_text.scss +++ b/packages/commonwealth/client/scripts/views/components/react_quill_editor/markdown_formatted_text.scss @@ -7,7 +7,6 @@ position: relative; word-break: break-word; - white-space: pre-wrap; @include formatted-text(); @include collapsible(); @include hidden-formatting(); diff --git a/packages/commonwealth/client/scripts/views/components/sidebar/AccountConnectionIndicator/AccountConnectionIndicator.tsx b/packages/commonwealth/client/scripts/views/components/sidebar/AccountConnectionIndicator/AccountConnectionIndicator.tsx index 6a95e9e464b..26b19990a32 100644 --- a/packages/commonwealth/client/scripts/views/components/sidebar/AccountConnectionIndicator/AccountConnectionIndicator.tsx +++ b/packages/commonwealth/client/scripts/views/components/sidebar/AccountConnectionIndicator/AccountConnectionIndicator.tsx @@ -3,6 +3,7 @@ import { useFlag } from 'client/scripts/hooks/useFlag'; import { saveToClipboard } from 'client/scripts/utils/clipboard'; import clsx from 'clsx'; import React from 'react'; +import app from 'state'; import { useInviteLinkModal } from 'state/ui/modals'; import useUserStore from 'state/ui/user'; import useJoinCommunity from 'views/components/SublayoutHeader/useJoinCommunity'; @@ -14,6 +15,7 @@ import useAuthentication from '../../../modals/AuthModal/useAuthentication'; import { SharePopover } from '../../SharePopover'; import CWIconButton from '../../component_kit/new_designs/CWIconButton'; import { CWTooltip } from '../../component_kit/new_designs/CWTooltip'; + import './AccountConnectionIndicator.scss'; interface AccountConnectionIndicatorProps { @@ -121,7 +123,14 @@ const AccountConnectionIndicator = ({ disabled={connected} onClick={handleJoinCommunity} /> - +
{JoinCommunityModals} diff --git a/packages/commonwealth/client/scripts/views/components/sidebar/CommunitySection/TokenTradeWidget/TokenTradeWidget.scss b/packages/commonwealth/client/scripts/views/components/sidebar/CommunitySection/TokenTradeWidget/TokenTradeWidget.scss index 49b2dd1e357..d840a84c8db 100644 --- a/packages/commonwealth/client/scripts/views/components/sidebar/CommunitySection/TokenTradeWidget/TokenTradeWidget.scss +++ b/packages/commonwealth/client/scripts/views/components/sidebar/CommunitySection/TokenTradeWidget/TokenTradeWidget.scss @@ -25,11 +25,18 @@ .action-btns { display: grid; - grid-template-columns: 1fr 1fr; gap: 8px; width: 100%; padding: 8px; + &.cols-1 { + grid-template-columns: 1fr; + } + + &.cols-2 { + grid-template-columns: 1fr 1fr; + } + button { text-transform: capitalize; } diff --git a/packages/commonwealth/client/scripts/views/components/sidebar/CommunitySection/TokenTradeWidget/TokenTradeWidget.tsx b/packages/commonwealth/client/scripts/views/components/sidebar/CommunitySection/TokenTradeWidget/TokenTradeWidget.tsx index bb88f2bddb6..60e876763e6 100644 --- a/packages/commonwealth/client/scripts/views/components/sidebar/CommunitySection/TokenTradeWidget/TokenTradeWidget.tsx +++ b/packages/commonwealth/client/scripts/views/components/sidebar/CommunitySection/TokenTradeWidget/TokenTradeWidget.tsx @@ -1,15 +1,18 @@ import { TokenView } from '@hicommonwealth/schemas'; import { ChainBase } from '@hicommonwealth/shared'; +import clsx from 'clsx'; import { currencyNameToSymbolMap, SupportedCurrencies } from 'helpers/currency'; import { calculateTokenPricing } from 'helpers/launchpad'; +import useDeferredConditionTriggerCallback from 'hooks/useDeferredConditionTriggerCallback'; import React, { useState } from 'react'; import app from 'state'; import { useFetchTokenUsdRateQuery } from 'state/api/communityStake'; -import TradeTokenModal from 'views/modals/TradeTokenModel'; -import { +import useUserStore from 'state/ui/user'; +import { AuthModal } from 'views/modals/AuthModal'; +import TradeTokenModal, { TokenWithCommunity, TradingMode, -} from 'views/modals/TradeTokenModel/TradeTokenForm'; +} from 'views/modals/TradeTokenModel'; import { z } from 'zod'; import { CWDivider } from '../../../component_kit/cw_divider'; import { CWIconButton } from '../../../component_kit/cw_icon_button'; @@ -32,6 +35,7 @@ export const TokenTradeWidget = ({ token, currency = SupportedCurrencies.USD, }: TokenTradeWidgetProps) => { + const user = useUserStore(); const currencySymbol = currencyNameToSymbolMap[currency]; const [isWidgetExpanded, setIsWidgetExpanded] = useState(true); @@ -44,6 +48,11 @@ export const TokenTradeWidget = ({ }; }>({ isOpen: false, tradeConfig: undefined }); + const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); + const { register, trigger } = useDeferredConditionTriggerCallback({ + shouldRunTrigger: user.isLoggedIn, + }); + const { data: ethToCurrencyRateData, isLoading: isLoadingETHToCurrencyRate } = useFetchTokenUsdRateQuery({ tokenSymbol: 'ETH', @@ -53,7 +62,19 @@ export const TokenTradeWidget = ({ ); const tokenPricing = calculateTokenPricing(token, ethToUsdRate); + const openAuthModalOrTriggerCallback = () => { + if (user.isLoggedIn) { + trigger(); + } else { + setIsAuthModalOpen(!user.isLoggedIn); + } + }; + const handleCTAClick = (mode: TradingMode) => { + if (!user.isLoggedIn) { + setIsAuthModalOpen(true); + } + setTokenLaunchModalConfig({ isOpen: true, tradeConfig: { @@ -108,23 +129,57 @@ export const TokenTradeWidget = ({ marketCap={{ current: tokenPricing.marketCapCurrent, goal: tokenPricing.marketCapGoal, + isCapped: tokenPricing.isMarketCapGoalReached, }} /> -
- {[TradingMode.Buy, TradingMode.Sell].map((mode) => ( +
+ {!tokenPricing.isMarketCapGoalReached ? ( + [TradingMode.Buy, TradingMode.Sell].map((mode) => ( + { + register({ + cb: () => { + handleCTAClick(mode); + }, + }); + openAuthModalOrTriggerCallback(); + }} + /> + )) + ) : ( handleCTAClick(mode)} + onClick={() => { + register({ + cb: () => { + handleCTAClick(TradingMode.Swap); + }, + }); + openAuthModalOrTriggerCallback(); + }} /> - ))} + )}
)} + setIsAuthModalOpen(false)} + /> {tokenLaunchModalConfig.tradeConfig && ( + {isWindowSmallInclusive(window.innerWidth) && ( + ( + + window.open('https://landing.common.xyz', '_blank') + } + onMouseEnter={handleInteraction} + onMouseLeave={handleInteraction} + /> + )} + /> + )}
{user.communities.filter((x) => x.isStarred).length !== 0 && ( diff --git a/packages/commonwealth/client/scripts/views/modals/AuthModal/types.ts b/packages/commonwealth/client/scripts/views/modals/AuthModal/types.ts index cbd5cf5edd8..c168e0fabb3 100644 --- a/packages/commonwealth/client/scripts/views/modals/AuthModal/types.ts +++ b/packages/commonwealth/client/scripts/views/modals/AuthModal/types.ts @@ -21,6 +21,7 @@ export type ModalVariantProps = { | ChainBase.CosmosSDK | ChainBase.Solana | ChainBase.Substrate; + showAuthOptionFor?: AuthWallets | AuthSSOs; onSignInClick?: () => void; }; diff --git a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/InviteLinkModal.tsx b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/InviteLinkModal.tsx index 491358870ab..f9d92acfc7d 100644 --- a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/InviteLinkModal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/InviteLinkModal.tsx @@ -9,72 +9,92 @@ import { CWModalHeader, } from '../../components/component_kit/new_designs/CWModal'; import { CWTextInput } from '../../components/component_kit/new_designs/CWTextInput'; +import { ShareSkeleton } from './ShareSkeleton'; import { getShareOptions } from './utils'; -import { PRODUCTION_DOMAIN } from '@hicommonwealth/shared'; +import app from 'state'; +import useUserStore from 'state/ui/user'; + import './InviteLinkModal.scss'; +import useReferralLink from './useReferralLink'; interface InviteLinkModalProps { onModalClose: () => void; - isInsideCommunity: boolean; } -const InviteLinkModal = ({ - onModalClose, - isInsideCommunity, -}: InviteLinkModalProps) => { - // TODO: replace with actual invite link from backend in upcoming PR - const inviteLink = `https://${PRODUCTION_DOMAIN}/~/invite/774037=89defcb8`; +const InviteLinkModal = ({ onModalClose }: InviteLinkModalProps) => { + const user = useUserStore(); + const hasJoinedCommunity = !!user.activeAccount; + const communityId = hasJoinedCommunity ? app.activeChainId() : ''; + + const { referralLink, isLoadingReferralLink, isLoadingCreateReferralLink } = + useReferralLink({ autorun: true }); + + const currentUrl = window.location.origin; + + const inviteLink = referralLink + ? `${currentUrl}${communityId ? `/${communityId}/discussions` : '/dashboard'}?refcode=${referralLink}` + : ''; const handleCopy = () => { - saveToClipboard(inviteLink, true).catch(console.error); + if (referralLink) { + saveToClipboard(inviteLink, true).catch(console.error); + } }; - const shareOptions = getShareOptions(isInsideCommunity, inviteLink); + const shareOptions = getShareOptions(!!communityId, inviteLink); return (
- {isInsideCommunity + {communityId ? 'Get more voting power in your communities when people join with your referral link.' : `When you refer your friends to Common, you'll get a portion of any fees they pay to Common over their lifetime engaging with web 3 native forums.`} - } - /> + {isLoadingReferralLink || isLoadingCreateReferralLink ? ( + + ) : ( + <> + } + /> -
- Share to -
- {shareOptions.map((option) => ( -
- {option.name} - {option.name} +
+ Share to +
+ {shareOptions.map((option) => ( +
+ {option.name} + {option.name} +
+ ))}
- ))} -
-
+
+ + )}
diff --git a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/ShareSkeleton.scss b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/ShareSkeleton.scss new file mode 100644 index 00000000000..e950ed7358a --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/ShareSkeleton.scss @@ -0,0 +1,11 @@ +.ShareSkeleton { + display: flex; + flex-direction: column; + gap: 16px; + + .share-options { + display: flex; + gap: 16px; + justify-content: space-between; + } +} diff --git a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/ShareSkeleton.tsx b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/ShareSkeleton.tsx new file mode 100644 index 00000000000..4df4ee7ff57 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/ShareSkeleton.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { Skeleton } from 'views/components/Skeleton'; + +import './ShareSkeleton.scss'; + +export const ShareSkeleton = () => { + return ( +
+ + + +
+ + + + + + +
+
+ ); +}; diff --git a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/index.ts b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/index.ts new file mode 100644 index 00000000000..63950bb59eb --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/ShareSkeleton/index.ts @@ -0,0 +1,3 @@ +import { ShareSkeleton } from './ShareSkeleton'; + +export { ShareSkeleton }; diff --git a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/useReferralLink.ts b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/useReferralLink.ts new file mode 100644 index 00000000000..cce7b15921d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/useReferralLink.ts @@ -0,0 +1,46 @@ +import { useFlag } from 'hooks/useFlag'; +import useRunOnceOnCondition from 'hooks/useRunOnceOnCondition'; +import { + useCreateReferralLinkMutation, + useGetReferralLinkQuery, +} from 'state/api/user'; + +const useReferralLink = ({ autorun = false }: { autorun?: boolean } = {}) => { + const referralsEnabled = useFlag('referrals'); + const { data: refferalLinkData, isLoading: isLoadingReferralLink } = + useGetReferralLinkQuery(); + + const { + mutate: createReferralLink, + mutateAsync: createReferralLinkAsync, + isLoading: isLoadingCreateReferralLink, + } = useCreateReferralLinkMutation(); + + const referralLink = refferalLinkData?.referral_link; + + useRunOnceOnCondition({ + callback: () => createReferralLink({}), + shouldRun: + autorun && referralsEnabled && !isLoadingReferralLink && !referralLink, + }); + + const getReferralLink = async () => { + if (referralLink) { + return referralLink; + } + + if (!isLoadingReferralLink && referralsEnabled) { + const result = await createReferralLinkAsync({}); + return result.referral_link; + } + }; + + return { + referralLink, + getReferralLink, + isLoadingReferralLink, + isLoadingCreateReferralLink, + }; +}; + +export default useReferralLink; diff --git a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/utils.ts b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/utils.ts index b2d6ffc856a..8d416847ce7 100644 --- a/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/utils.ts +++ b/packages/commonwealth/client/scripts/views/modals/InviteLinkModal/utils.ts @@ -23,54 +23,57 @@ interface ShareOption { export const getShareOptions = ( isInsideCommunity: boolean, - inviteLink: string, -): ShareOption[] => [ - { - name: 'Messages', - icon: messagesImg, - onClick: () => - window.open( - `sms:?&body=${encodeURIComponent(generatePermalink(isInsideCommunity, inviteLink))}`, - ), - }, - { - name: 'Telegram', - icon: telegramImg, - onClick: () => - window.open( - `https://t.me/share/url?url=${encodeURIComponent( - generatePermalink(isInsideCommunity, inviteLink), - )}`, - ), - }, - { - name: 'X (Twitter)', - icon: twitterImg, - onClick: () => - window.open( - `https://twitter.com/intent/tweet?url=${encodeURIComponent( - generatePermalink(isInsideCommunity, inviteLink), - )}`, - ), - }, - { - name: 'Warpcast', - icon: warpcastImg, - onClick: () => - window.open( - `https://warpcast.com/~/compose?text=${encodeURIComponent( - generatePermalink(isInsideCommunity, inviteLink), - )}`, - ), - }, - { - name: 'Email', - icon: mailImg, - onClick: () => - window.open( - `mailto:?body=${encodeURIComponent( - generatePermalink(isInsideCommunity, inviteLink), - )}`, - ), - }, -]; + inviteLink?: string | null, +): ShareOption[] => + inviteLink + ? [ + { + name: 'Messages', + icon: messagesImg, + onClick: () => + window.open( + `sms:?&body=${encodeURIComponent(generatePermalink(isInsideCommunity, inviteLink))}`, + ), + }, + { + name: 'Telegram', + icon: telegramImg, + onClick: () => + window.open( + `https://t.me/share/url?url=${encodeURIComponent( + generatePermalink(isInsideCommunity, inviteLink), + )}`, + ), + }, + { + name: 'X (Twitter)', + icon: twitterImg, + onClick: () => + window.open( + `https://twitter.com/intent/tweet?url=${encodeURIComponent( + generatePermalink(isInsideCommunity, inviteLink), + )}`, + ), + }, + { + name: 'Warpcast', + icon: warpcastImg, + onClick: () => + window.open( + `https://warpcast.com/~/compose?text=${encodeURIComponent( + generatePermalink(isInsideCommunity, inviteLink), + )}`, + ), + }, + { + name: 'Email', + icon: mailImg, + onClick: () => + window.open( + `mailto:?body=${encodeURIComponent( + generatePermalink(isInsideCommunity, inviteLink), + )}`, + ), + }, + ] + : []; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenModal.scss b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeModal.scss similarity index 71% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenModal.scss rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeModal.scss index 6be98aba90c..c052ecee4cc 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenModal.scss +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeModal.scss @@ -1,6 +1,6 @@ -@import '../../../styles/shared.scss'; +@import '../../../../styles/shared.scss'; -.TradeTokenModal { +.CommonTradeModal { overflow-y: scroll; padding-left: 8px; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeModal.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeModal.tsx new file mode 100644 index 00000000000..ed2a2d5bd4f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeModal.tsx @@ -0,0 +1,75 @@ +import { SupportedCurrencies } from 'helpers/currency'; +import useBeforeUnload from 'hooks/useBeforeUnload'; +import React from 'react'; +import { CWText } from '../../../components/component_kit/cw_text'; +import { + CWModal, + CWModalBody, + CWModalFooter, + CWModalHeader, +} from '../../../components/component_kit/new_designs/CWModal'; +import TokenIcon from '../TokenIcon'; +import { TradeTokenModalProps } from '../types'; +import './CommonTradeModal.scss'; +import TradeTokenForm, { + useCommonTradeTokenForm, +} from './CommonTradeTokenForm'; + +const TRADING_CURRENCY = SupportedCurrencies.USD; // make configurable when needed + +const CommonTradeModal = ({ + isOpen, + onModalClose, + tradeConfig, +}: TradeTokenModalProps) => { + const { trading, addresses, isActionPending, onCTAClick } = + useCommonTradeTokenForm({ + tradeConfig: { + ...tradeConfig, + currency: TRADING_CURRENCY, + buyTokenPresetAmounts: [100, 300, 1000], + sellTokenPresetAmounts: ['Max'], + }, + addressType: tradeConfig.addressType, + onTradeComplete: () => onModalClose?.(), + }); + + useBeforeUnload(isActionPending); + + return ( + !isActionPending && onModalClose?.()} + size="medium" + className="CommonTradeModal" + content={ + <> + + Trade Token - {tradeConfig.token.symbol}{' '} + {trading.token.icon_url && ( + + )} + + } + onModalClose={() => !isActionPending && onModalClose?.()} + /> + + + + + <> + + + } + /> + ); +}; + +export default CommonTradeModal; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AddressBalance/AddressBalance.scss b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AddressBalance/AddressBalance.scss similarity index 67% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AddressBalance/AddressBalance.scss rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AddressBalance/AddressBalance.scss index 9892dd19ac0..28c9e13ce11 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AddressBalance/AddressBalance.scss +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AddressBalance/AddressBalance.scss @@ -1,4 +1,4 @@ -@import '../../../../../styles/shared.scss'; +@import '../../../../../../styles/shared.scss'; .AddressBalance { display: flex; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AddressBalance/AddressBalance.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AddressBalance/AddressBalance.tsx similarity index 88% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AddressBalance/AddressBalance.tsx rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AddressBalance/AddressBalance.tsx index e104abfb85e..bea39eb71b1 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AddressBalance/AddressBalance.tsx +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AddressBalance/AddressBalance.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { Skeleton } from 'views/components/Skeleton'; import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; import { CWText } from 'views/components/component_kit/cw_text'; -import TokenIcon from '../../TokenIcon'; -import { AddressBalanceProps, TradingMode } from '../types'; +import TokenIcon from '../../../TokenIcon'; +import { TradingMode } from '../../../types'; +import { AddressBalanceProps } from '../types'; import './AddressBalance.scss'; const AddressBalance = ({ trading, addresses }: AddressBalanceProps) => { diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AddressBalance/index.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AddressBalance/index.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AddressBalance/index.ts rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AddressBalance/index.ts diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AmountSelections/AmountSelections.scss b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AmountSelections/AmountSelections.scss similarity index 96% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AmountSelections/AmountSelections.scss rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AmountSelections/AmountSelections.scss index 6bdb2cd5a3d..86b886b242b 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AmountSelections/AmountSelections.scss +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AmountSelections/AmountSelections.scss @@ -1,4 +1,4 @@ -@import '../../../../../styles/shared.scss'; +@import '../../../../../../styles/shared.scss'; .AmountSelections { display: flex; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AmountSelections/BuyAmountSelection.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AmountSelections/BuyAmountSelection.tsx similarity index 98% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AmountSelections/BuyAmountSelection.tsx rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AmountSelections/BuyAmountSelection.tsx index 55c50fd0bce..36eeefd832b 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AmountSelections/BuyAmountSelection.tsx +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AmountSelections/BuyAmountSelection.tsx @@ -8,7 +8,7 @@ import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; import { CWText } from 'views/components/component_kit/cw_text'; import { CWTag } from 'views/components/component_kit/new_designs/CWTag'; import { CWTextInput } from 'views/components/component_kit/new_designs/CWTextInput'; -import TokenIcon from '../../TokenIcon'; +import TokenIcon from '../../../TokenIcon'; import { BuyAmountSelectionProps } from '../types'; import './AmountSelections.scss'; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AmountSelections/SellAmountSelection.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AmountSelections/SellAmountSelection.tsx similarity index 100% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/AmountSelections/SellAmountSelection.tsx rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/AmountSelections/SellAmountSelection.tsx diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/TradeTokenForm.scss b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/CommonTradeTokenForm.scss similarity index 86% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/TradeTokenForm.scss rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/CommonTradeTokenForm.scss index ea56ad1fe1d..0eb51cee2d4 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/TradeTokenForm.scss +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/CommonTradeTokenForm.scss @@ -1,10 +1,16 @@ -@import '../../../../styles/shared.scss'; +@import '../../../../../styles/shared.scss'; -.TradeTokenForm { +.CommonTradeTokenForm { display: flex; flex-direction: column; gap: 12px; + .Tab { + .Text { + text-transform: capitalize; + } + } + .balance-row { display: flex; justify-content: space-between; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/TradeTokenForm.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/CommonTradeTokenForm.tsx similarity index 91% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/TradeTokenForm.tsx rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/CommonTradeTokenForm.tsx index b1cfb80e193..91237c1aeb2 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/TradeTokenForm.tsx +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/CommonTradeTokenForm.tsx @@ -11,22 +11,23 @@ import { CWTooltip } from 'views/components/component_kit/new_designs/CWTooltip' import { CustomAddressOption, CustomAddressOptionElement, -} from '../../ManageCommunityStakeModal/StakeExchangeForm/CustomAddressOption'; +} from '../../../ManageCommunityStakeModal/StakeExchangeForm/CustomAddressOption'; +import { TradingMode } from '../../types'; import AddressBalance from './AddressBalance'; import BuyAmountSelection from './AmountSelections/BuyAmountSelection'; import SellAmountSelection from './AmountSelections/SellAmountSelection'; +import './CommonTradeTokenForm.scss'; import BuyReceipt from './ReceiptDetails/BuyReceipt'; import SellReceipt from './ReceiptDetails/SellReceipt'; -import './TradeTokenForm.scss'; import { convertAddressToDropdownOption } from './helpers'; -import { TradeTokenFormProps, TradingMode } from './types'; +import { CommonTradeTokenFormProps } from './types'; -const TradeTokenForm = ({ +const CommonTradeTokenForm = ({ trading, addresses, onCTAClick, isActionPending, -}: TradeTokenFormProps) => { +}: CommonTradeTokenFormProps) => { const [isReceiptDetailOpen, setIsReceiptDetailOpen] = useState(false); const getCTADisabledTooltipText = () => { @@ -78,14 +79,14 @@ const TradeTokenForm = ({ }; return ( -
+
- {Object.keys(TradingMode).map((mode) => ( + {[TradingMode.Buy, TradingMode.Sell].map((mode) => ( trading.mode.onChange(TradingMode[mode])} - isSelected={trading.mode.value === TradingMode[mode]} + onClick={() => trading.mode.onChange(mode)} + isSelected={trading.mode.value === mode} /> ))} @@ -181,4 +182,4 @@ const TradeTokenForm = ({ ); }; -export default TradeTokenForm; +export default CommonTradeTokenForm; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/ReceiptDetails/BuyReceipt.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/ReceiptDetails/BuyReceipt.tsx similarity index 100% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/ReceiptDetails/BuyReceipt.tsx rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/ReceiptDetails/BuyReceipt.tsx diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/ReceiptDetails/ReceiptDetails.scss b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/ReceiptDetails/ReceiptDetails.scss similarity index 86% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/ReceiptDetails/ReceiptDetails.scss rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/ReceiptDetails/ReceiptDetails.scss index dec9e66de56..ceb2883efe1 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/ReceiptDetails/ReceiptDetails.scss +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/ReceiptDetails/ReceiptDetails.scss @@ -1,4 +1,4 @@ -@import '../../../../../styles/shared.scss'; +@import '../../../../../../styles/shared.scss'; .ReceiptDetails { display: flex; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/ReceiptDetails/SellReceipt.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/ReceiptDetails/SellReceipt.tsx similarity index 100% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/ReceiptDetails/SellReceipt.tsx rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/ReceiptDetails/SellReceipt.tsx diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/helpers.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/helpers.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/helpers.ts rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/helpers.ts diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/index.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/index.ts new file mode 100644 index 00000000000..c2158d3faca --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/index.ts @@ -0,0 +1,6 @@ +import CommonTradeTokenForm from './CommonTradeTokenForm'; +import useCommonTradeTokenForm from './useCommonTradeTokenForm'; +export * from './types'; +export * from './useCommonTradeTokenForm'; +export { useCommonTradeTokenForm }; +export default CommonTradeTokenForm; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/types.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/types.ts similarity index 56% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/types.ts rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/types.ts index dadf7f017e6..651c67c0b58 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/types.ts +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/types.ts @@ -1,28 +1,14 @@ -import { ExtendedCommunity, TokenView } from '@hicommonwealth/schemas'; +import { ExtendedCommunity } from '@hicommonwealth/schemas'; import { ChainBase } from '@hicommonwealth/shared'; import { SupportedCurrencies } from 'helpers/currency'; import NodeInfo from 'models/NodeInfo'; import { z } from 'zod'; -import useTradeTokenForm from './useTradeTokenForm'; - -export enum TradingMode { - Buy = 'buy', - Sell = 'sell', -} - -export const TokenWithCommunity = TokenView.extend({ - community_id: z.string(), -}); - -export type TradingConfig = { - mode: TradingMode; - token: z.infer; - addressType: ChainBase; -}; +import { TradingConfig } from '../../types'; +import useCommonTradeTokenFormProps from './useCommonTradeTokenForm'; export type TokenPresetAmounts = number | 'Max'; -export type UseTradeTokenFormProps = { +export type UseCommonTradeTokenFormProps = { tradeConfig: TradingConfig & { currency: SupportedCurrencies; buyTokenPresetAmounts?: TokenPresetAmounts[]; @@ -32,7 +18,7 @@ export type UseTradeTokenFormProps = { onTradeComplete?: () => void; }; -export type UseBuyTradeProps = UseTradeTokenFormProps & { +export type UseBuyTradeProps = UseCommonTradeTokenFormProps & { enabled: boolean; chainNode: NodeInfo; tokenCommunity?: z.infer; @@ -42,24 +28,26 @@ export type UseBuyTradeProps = UseTradeTokenFormProps & { export type UseSellTradeProps = UseBuyTradeProps; -export type TradeTokenFormProps = ReturnType; +export type CommonTradeTokenFormProps = ReturnType< + typeof useCommonTradeTokenFormProps +>; export type AddressBalanceProps = Pick< - ReturnType, + ReturnType, 'trading' | 'addresses' >; export type BuyAmountSelectionProps = Pick< - ReturnType, + ReturnType, 'trading' >; export type SellAmountSelectionProps = Pick< - ReturnType, + ReturnType, 'trading' >; export type ReceiptDetailsProps = Pick< - ReturnType, + ReturnType, 'trading' >; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/useBuyTrade.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/useBuyTrade.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/useBuyTrade.ts rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/useBuyTrade.ts diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/useTradeTokenForm.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/useCommonTradeTokenForm.ts similarity index 93% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/useTradeTokenForm.ts rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/useCommonTradeTokenForm.ts index f1fe0ee51aa..ef542478f28 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/useTradeTokenForm.ts +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/useCommonTradeTokenForm.ts @@ -7,17 +7,18 @@ import { useGetCommunityByIdQuery } from 'state/api/communities'; import { fetchCachedNodes } from 'state/api/nodes'; import useUserStore from 'state/ui/user'; import { z } from 'zod'; -import { TradingMode, UseTradeTokenFormProps } from './types'; +import { TradingMode } from '../../types'; +import { UseCommonTradeTokenFormProps } from './types'; import useBuyTrade from './useBuyTrade'; import useSellTrade from './useSellTrade'; const COMMON_PLATFORM_FEE_PERCENTAGE = 5; // make configurable when needed -const useTradeTokenForm = ({ +const useCommonTradeTokenForm = ({ tradeConfig, addressType, onTradeComplete, -}: UseTradeTokenFormProps) => { +}: UseCommonTradeTokenFormProps) => { const [tradingMode, setTradingMode] = useState( tradeConfig.mode || TradingMode.Buy, ); @@ -106,7 +107,7 @@ const useTradeTokenForm = ({ handleTokenSell().catch(console.error); break; default: - console.error('Trading mode not selected'); + console.error(`Trading mode:${tradingMode} not implemented.`); break; } }; @@ -145,4 +146,4 @@ const useTradeTokenForm = ({ }; }; -export default useTradeTokenForm; +export default useCommonTradeTokenForm; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/useSellTrade.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/useSellTrade.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/useSellTrade.ts rename to packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/CommonTradeTokenForm/useSellTrade.ts diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/index.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/index.ts new file mode 100644 index 00000000000..7ade23eb06c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/CommonTradeModal/index.ts @@ -0,0 +1,3 @@ +import CommonTradeModal from './CommonTradeModal'; + +export default CommonTradeModal; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/index.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/index.ts deleted file mode 100644 index b0f0d32965c..00000000000 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenForm/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import TradeTokenForm from './TradeTokenForm'; -import useTradeTokenForm from './useTradeTokenForm'; -export * from './types'; -export * from './useTradeTokenForm'; -export { useTradeTokenForm }; -export default TradeTokenForm; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenModal.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenModal.tsx index cf9872d92c6..1bfea2e2340 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenModal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/TradeTokenModal.tsx @@ -1,80 +1,28 @@ -import { SupportedCurrencies } from 'helpers/currency'; -import useBeforeUnload from 'hooks/useBeforeUnload'; import React from 'react'; -import { CWText } from '../../components/component_kit/cw_text'; -import { - CWModal, - CWModalBody, - CWModalFooter, - CWModalHeader, -} from '../../components/component_kit/new_designs/CWModal'; -import TokenIcon from './TokenIcon'; -import TradeTokenForm, { - TradingConfig, - useTradeTokenForm, -} from './TradeTokenForm'; -import './TradeTokenModal.scss'; - -const TRADING_CURRENCY = SupportedCurrencies.USD; // make configurable when needed - -type TradeTokenModalProps = { - isOpen: boolean; - onModalClose?: () => void; - tradeConfig: TradingConfig; -}; +import CommonTradeModal from './CommonTradeModal'; +import UniswapTradeModal from './UniswapTradeModal/UniswapTradeModal'; +import { TradeTokenModalProps, TradingMode } from './types'; const TradeTokenModal = ({ isOpen, onModalClose, tradeConfig, }: TradeTokenModalProps) => { - const { trading, addresses, isActionPending, onCTAClick } = useTradeTokenForm( - { - tradeConfig: { - ...tradeConfig, - currency: TRADING_CURRENCY, - buyTokenPresetAmounts: [100, 300, 1000], - sellTokenPresetAmounts: ['Max'], - }, - addressType: tradeConfig.addressType, - onTradeComplete: () => onModalClose?.(), - }, - ); - - useBeforeUnload(isActionPending); + if (tradeConfig.mode === TradingMode.Swap) { + return ( + + ); + } return ( - !isActionPending && onModalClose?.()} - size="medium" - className="TradeTokenModal" - content={ - <> - - Trade Token - {tradeConfig.token.symbol}{' '} - {trading.token.icon_url && ( - - )} - - } - onModalClose={() => !isActionPending && onModalClose?.()} - /> - - - - - <> - - - } + ); }; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/UniswapTradeModal.scss b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/UniswapTradeModal.scss new file mode 100644 index 00000000000..5720fd21360 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/UniswapTradeModal.scss @@ -0,0 +1,47 @@ +@import '../../../../styles/shared.scss'; + +.UniswapTradeModal { + overflow-y: scroll; + padding-left: 8px; + + .CWModalBody { + max-height: 500px; + } + + .token-info { + display: flex; + align-items: center; + justify-self: flex-end; + gap: 4px; + } + + .Uniswap { + .uniswap-widget-wrapper { + border: none !important; + box-shadow: none !important; + margin: auto; + width: 100%; + + button[color='interactive'] { + color: $white !important; + + svg { + stroke: $white !important; + } + } + + button[color='accent'], + button[color='accentSoft'] { + div { + color: $white !important; + } + } + + [class^='TokenOptions__OnHover'] { + // fixes a css hover bug with uniswap widget where an extra hover div + // appeared before the active hover element + display: none !important; + } + } + } +} diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/UniswapTradeModal.tsx b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/UniswapTradeModal.tsx new file mode 100644 index 00000000000..4ff1b42a54d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/UniswapTradeModal.tsx @@ -0,0 +1,80 @@ +import { SwapWidget } from '@uniswap/widgets'; +import '@uniswap/widgets/fonts.css'; +import { notifySuccess } from 'controllers/app/notifications'; +import React from 'react'; +import { CWText } from 'views/components/component_kit/cw_text'; +import CWCircleMultiplySpinner from 'views/components/component_kit/new_designs/CWCircleMultiplySpinner'; +import { + CWModal, + CWModalBody, + CWModalFooter, + CWModalHeader, +} from 'views/components/component_kit/new_designs/CWModal'; +import TokenIcon from '../TokenIcon'; +import { TradeTokenModalProps } from '../types'; +import './UniswapTradeModal.scss'; +import useUniswapTradeModal from './useUniswapTradeModal'; + +const UniswapTradeModal = ({ + isOpen, + onModalClose, + tradeConfig, +}: TradeTokenModalProps) => { + const { uniswapWidget } = useUniswapTradeModal({ tradeConfig }); + + return ( + onModalClose?.()} + size="medium" + className="UniswapTradeModal" + content={ + <> + + Swap Token - {tradeConfig.token.symbol}{' '} + {tradeConfig.token.icon_url && ( + + )} + + } + onModalClose={() => onModalClose?.()} + /> + +
+ {!uniswapWidget.isReady ? ( + + ) : ( + notifySuccess('Transaction successful!')} + /> + )} +
+
+ + <> + + + } + /> + ); +}; + +export default UniswapTradeModal; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/index.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/index.ts new file mode 100644 index 00000000000..41a8ff371f9 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/index.ts @@ -0,0 +1,3 @@ +import UniswapTradeModal from './UniswapTradeModal'; + +export default UniswapTradeModal; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/types.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/types.ts new file mode 100644 index 00000000000..db4fe03f86c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/types.ts @@ -0,0 +1,14 @@ +import { TradingConfig } from '../types'; + +export type UseUniswapTradeModalProps = { + tradeConfig: TradingConfig; +}; + +export type UniswapToken = { + name: string; + address: string; + symbol: string; + decimals: number; + chainId: number; + logoURI: string; +}; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/useUniswapTradeModal.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/useUniswapTradeModal.ts new file mode 100644 index 00000000000..c67fa38e501 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/UniswapTradeModal/useUniswapTradeModal.ts @@ -0,0 +1,163 @@ +import { commonProtocol } from '@hicommonwealth/evm-protocols'; +import { ChainBase } from '@hicommonwealth/shared'; +import { Theme } from '@uniswap/widgets'; +import WebWalletController from 'controllers/app/web_wallets'; +import { ethers } from 'ethers'; +import useRunOnceOnCondition from 'hooks/useRunOnceOnCondition'; +import NodeInfo from 'models/NodeInfo'; +import { useState } from 'react'; +import { fetchCachedNodes } from 'state/api/nodes'; +import { UniswapToken, UseUniswapTradeModalProps } from './types'; + +// Maintainance Notes: +// - Anywhere a `UNISWAP_WIDGET_HACK` label is applied, its a workaround to get the uniswap widget +// to work with our stack + +// UNISWAP_WIDGET_HACK: Pricing calculation calls fail when adding a token to swap in the uniswap widget. This hack +// method definition hack fixes a bug with a dependent pkg of the uniswap widget package. +// See: https://github.com/Uniswap/widgets/issues/627#issuecomment-1930627298 for more context +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const tempWindow = window as any; +tempWindow.Browser = { + T: () => {}, +}; + +const uniswapTokenListConfig = { + default: { + // UNISWAP_WIDGET_HACK: By default the widget uses https://gateway.ipfs.io/ipns/tokens.uniswap.org for tokens + // list, but it doesn't work (DNS_PROBE_FINISHED_NXDOMAIN) for me (@malik). The original + // url resolved to https://ipfs.io/ipns/tokens.uniswap.org, i am passing this as a param to + // the uniswap widget. See: https://github.com/Uniswap/widgets/issues/580#issuecomment-2086094025 + // for more context. + chains: { 1: { url: 'https://ipfs.io/ipns/tokens.uniswap.org' } }, + }, + custom: { + chains: { + 8453: { + list: [ + { + name: 'Tether USD', + address: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', + symbol: 'USDT', + decimals: 6, + chainId: 8453, + logoURI: + // eslint-disable-next-line max-len + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png', + }, + { + name: 'USD Coin', + address: '0xec267c53f53807c2337c257f8ac3fc3cc07cc0ed', + symbol: 'USDC', + decimals: 6, + chainId: 8453, + logoURI: + // eslint-disable-next-line max-len + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + { + name: 'Wrapped Ether', + address: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + decimals: 18, + chainId: 8453, + logoURI: + // eslint-disable-next-line max-len + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x4200000000000000000000000000000000000006/logo.png', + }, + ], + }, + }, + }, +}; + +const uniswapRouterURLs = { + // UNISWAP_WIDGET_HACK: the widget doesn't call any pricing endpoints if this router url isn't enforced + // see: https://github.com/Uniswap/widgets/issues/637#issuecomment-2253135676 for more context + default: 'https://api.uniswap.org/v1/', +}; + +// custom theme to make the widget match common's style +const uniswapWidgetTheme: Theme = { + container: '#ffffff', + dialog: '#ffffff', + module: '#e7e7e7', + outline: '#e0dfe1', + fontFamily: 'Silka', + accent: '#514e52', // primary actions color + accentSoft: '#514e52', // primary actions color with soft tone + interactive: '#3d3a3e', // secondary actions color + primary: '#282729', // primary text color + secondary: '#666666', // secondary text color +}; + +const useUniswapTradeModal = ({ tradeConfig }: UseUniswapTradeModalProps) => { + const [isLoadingInitialState, setIsLoadingInitialState] = useState(true); + const [uniswapProvider, setUniswapProvider] = + useState(); + const [uniswapTokensList, setUniswapTokensList] = useState(); + + // base chain node info + const nodes = fetchCachedNodes(); + const baseNode = nodes?.find( + (n) => n.ethChainId === commonProtocol.ValidChains.Base, + ) as NodeInfo; // this is expected to exist + + useRunOnceOnCondition({ + callback: () => { + const handleAsync = async () => { + setIsLoadingInitialState(true); + + // adding this to avoid ts issues + if (!baseNode?.ethChainId) return; + + // set tokens list with add our custom token + setUniswapTokensList([ + ...(uniswapTokenListConfig.custom.chains?.[baseNode.ethChainId] + ?.list || []), + { + name: tradeConfig.token.name, + address: tradeConfig.token.token_address, + symbol: tradeConfig.token.symbol, + decimals: 18, + chainId: baseNode.ethChainId, + logoURI: tradeConfig.token.icon_url || '', + }, + ]); + + // switch chain network on wallet + { + const wallet = WebWalletController.Instance.availableWallets( + ChainBase.Ethereum, + ); + const selectedWallet = wallet[0]; + await selectedWallet.enable(`${baseNode.ethChainId}`); + const tempProvider = new ethers.providers.Web3Provider( + selectedWallet.api.givenProvider, + ); + setUniswapProvider(tempProvider); + } + }; + handleAsync() + .catch(console.error) + .finally(() => setIsLoadingInitialState(false)); + }, + shouldRun: !!baseNode.ethChainId, + }); + + return { + uniswapWidget: { + isReady: !isLoadingInitialState, + provider: uniswapProvider, + theme: uniswapWidgetTheme, + tokensList: uniswapTokensList, + defaultTokenAddress: { + input: 'NATIVE', // special address for native token of default chain + output: tradeConfig.token.token_address, + }, + routerURLs: uniswapRouterURLs, + }, + }; +}; + +export default useUniswapTradeModal; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/index.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/index.ts index 5067a2ebc9b..578d3c9d4cb 100644 --- a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/index.ts +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/index.ts @@ -1,3 +1,4 @@ import TradeTokenModal from './TradeTokenModal'; +export * from './types'; export default TradeTokenModal; diff --git a/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/types.ts b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/types.ts new file mode 100644 index 00000000000..3e25a6b6ce0 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/TradeTokenModel/types.ts @@ -0,0 +1,25 @@ +import { TokenView } from '@hicommonwealth/schemas'; +import { ChainBase } from '@hicommonwealth/shared'; +import { z } from 'zod'; + +export enum TradingMode { + Buy = 'buy', // for trade on common + Sell = 'sell', // for trade on common + Swap = 'swap', // for trade via uniswap +} + +export const TokenWithCommunity = TokenView.extend({ + community_id: z.string(), +}); + +export type TradingConfig = { + mode: TradingMode; + token: z.infer; + addressType: ChainBase; +}; + +export type TradeTokenModalProps = { + isOpen: boolean; + onModalClose?: () => void; + tradeConfig: TradingConfig; +}; diff --git a/packages/commonwealth/client/scripts/views/modals/delete_address_modal.tsx b/packages/commonwealth/client/scripts/views/modals/delete_address_modal.tsx index 9c6a9279b35..d952857432a 100644 --- a/packages/commonwealth/client/scripts/views/modals/delete_address_modal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/delete_address_modal.tsx @@ -24,6 +24,8 @@ type DeleteAddressModalAttrs = { address: AddressInfo; chain: string; closeModal: () => void; + isBulkDelete?: boolean; + communityName: string; }; export const DeleteAddressModal = ({ @@ -31,6 +33,8 @@ export const DeleteAddressModal = ({ addresses, chain, closeModal, + isBulkDelete = false, + communityName, }: DeleteAddressModalAttrs) => { const user = useUserStore(); @@ -42,11 +46,13 @@ export const DeleteAddressModal = ({ } try { - const response = await axios.post(`${SERVER_URL}/deleteAddress`, { - address: address.address, - chain, - jwt: user.jwt, - }); + const payload = { address: address?.address, chain, jwt: user.jwt }; + + const endpoint = isBulkDelete + ? `${SERVER_URL}/deleteAllAddresses` + : `${SERVER_URL}/deleteAddress`; + + const response = await axios.post(endpoint, payload); if (response?.data.status === 'Success') { const updatedAddresses = [...user.addresses].filter( @@ -87,15 +93,21 @@ export const DeleteAddressModal = ({ return (
- By removing this address you will be leaving the{' '} - {address.community.id}. Your contributions and comments will remain. - Don't worry, you can rejoin anytime. + {isBulkDelete + ? `By leaving ${communityName} you will disconnect all + linked addresses. Your threads will remain intact.` + : `By removing this address you will be leaving the ${communityName}. + Your contributions and comments will remain. Don't worry, you can rejoin anytime.`} @@ -106,7 +118,7 @@ export const DeleteAddressModal = ({ buttonHeight="sm" /> { + const user = useUserStore(); const navigate = useCommonNavigate(); const tokenizedCommunityEnabled = useFlag('tokenizedCommunity'); + const [tokenLaunchModalConfig, setTokenLaunchModalConfig] = useState<{ isOpen: boolean; tradeConfig?: { @@ -42,6 +46,11 @@ const TokensList = ({ filters }: TokensListProps) => { }; }>({ isOpen: false, tradeConfig: undefined }); + const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); + const { register, trigger } = useDeferredConditionTriggerCallback({ + shouldRunTrigger: user.isLoggedIn, + }); + const { data: tokensList, isInitialLoading, @@ -85,6 +94,28 @@ const TokensList = ({ filters }: TokensListProps) => { } }; + const openAuthModalOrTriggerCallback = () => { + if (user.isLoggedIn) { + trigger(); + } else { + setIsAuthModalOpen(!user.isLoggedIn); + } + }; + + const handleCTAClick = ( + mode: TradingMode, + token: z.infer, + ) => { + setTokenLaunchModalConfig({ + isOpen: true, + tradeConfig: { + mode: mode, + token: token, + addressType: ChainBase.Ethereum, + }, + }); + }; + if (!tokenizedCommunityEnabled) return <>; return ( @@ -124,20 +155,24 @@ const TokensList = ({ filters }: TokensListProps) => { marketCap={{ current: pricing.marketCapCurrent, goal: pricing.marketCapGoal, + isCapped: pricing.isMarketCapGoalReached, }} - mode={pricing.isMarketCapGoalReached ? 'swap' : 'buy'} + mode={ + pricing.isMarketCapGoalReached + ? TradingMode.Swap + : TradingMode.Buy + } iconURL={token.icon_url || ''} - onCTAClick={() => { - if (pricing.isMarketCapGoalReached) return; - - setTokenLaunchModalConfig({ - isOpen: true, - tradeConfig: { - mode: TradingMode.Buy, - token: token as z.infer, - addressType: ChainBase.Ethereum, + onCTAClick={(mode) => { + register({ + cb: () => { + handleCTAClick( + mode, + token as z.infer, + ); }, }); + openAuthModalOrTriggerCallback(); }} onCardBodyClick={() => navigateToCommunity({ @@ -165,6 +200,11 @@ const TokensList = ({ filters }: TokensListProps) => { ) : ( <> )} + setIsAuthModalOpen(false)} + showWalletsFor={ChainBase.Ethereum} + /> {tokenLaunchModalConfig.tradeConfig && ( { initialValues={{ groupName: foundGroup.name, groupDescription: foundGroup.description, + groupImageUrl: foundGroup.groupImageUrl, // @ts-expect-error requirements: foundGroup.requirements .filter((r) => r?.data?.source) // filter erc groups diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx index 2c90236c6e9..d8aa76eeeb7 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx @@ -135,14 +135,24 @@ const Allowlist = ({ avatarUrl: p.avatar_url, name: p.profile_name || DEFAULT_NAME, role: p.addresses[0].role, + // Map group information as objects groups: (p.group_ids || []) - .map( - (groupId) => - (groups || []).find((group) => group.id === groupId)?.name, - ) + .map((groupId) => { + const group = (groups || []).find((g) => g.id === groupId); + return group + ? { + name: group.name, + groupImageUrl: group.groupImageUrl, + } + : null; + }) .filter(Boolean) - // @ts-expect-error StrictNullChecks - .sort((a, b) => a.localeCompare(b)), + .sort((a, b) => { + if (a && b) { + return a.name.localeCompare(b.name); + } + return 0; + }), stakeBalance: p.addresses[0].stake_balance, address: p.addresses[0].address, })) as Member[]) || [] diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx index 634a63ca44f..470a0e3cf39 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx @@ -1,4 +1,8 @@ /* eslint-disable react/no-multi-comp */ +import { + CWImageInput, + ImageBehavior, +} from 'client/scripts/views/components/component_kit/CWImageInput'; import { weightedVotingValueToLabel } from 'helpers'; import { isValidEthAddress } from 'helpers/validateTypes'; import { useCommonNavigate } from 'navigation/helpers'; @@ -178,6 +182,8 @@ const GroupForm = ({ const [topicPermissionsSubForms, setTopicPermissionsSubForms] = useState< TopicPermissionsSubFormsState[] >([]); + const [isProcessingProfileImage, setIsProcessingProfileImage] = + useState(false); useEffect(() => { if (initialValues.requirements) { @@ -388,6 +394,7 @@ const GroupForm = ({ const formValues = { ...values, + groupImageUrl: values.groupImageUrl || '', topics: topicPermissionsSubForms.map((t) => ({ id: t.topic.id, permissions: convertAccumulatedPermissionsToGranularPermissions( @@ -436,6 +443,7 @@ const GroupForm = ({ initialValues={{ groupName: initialValues.groupName || '', groupDescription: initialValues.groupDescription || '', + groupImageUrl: initialValues.groupImageUrl || '', requirementsToFulfill: initialValues.requirementsToFulfill ? initialValues.requirementsToFulfill === REQUIREMENTS_TO_FULFILL.ALL_REQUIREMENTS @@ -493,6 +501,17 @@ const GroupForm = ({ placeholder="Add a description for your group" instructionalMessage="Can be up to 250 characters long" /> + + { + setIsProcessingProfileImage(isGenerating || isUploading); + }} + name="groupImageUrl" + hookToForm + imageBehavior={ImageBehavior.Circle} + withAIImageGeneration + />
@@ -706,6 +725,7 @@ const GroupForm = ({ buttonWidth="wide" disabled={ isNameTaken || + isProcessingProfileImage || (requirementSubForms.length === 0 && allowedAddresses.length === 0) } diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts index 21b5783bed9..4a4f517214a 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts @@ -66,6 +66,7 @@ export type GroupFormTopicSubmitValues = { export type GroupResponseValuesType = { groupName: string; groupDescription?: string; + groupImageUrl?: string; requirementsToFulfill: 'ALL' | number; requirements?: RequirementSubType[]; topics: GroupFormTopicSubmitValues[]; @@ -75,6 +76,7 @@ export type GroupResponseValuesType = { export type GroupInitialValuesTypeWithLabel = { groupName: string; groupDescription?: string; + groupImageUrl?: string; requirements?: RequirementSubTypeWithLabel[]; requirementsToFulfill?: 'ALL' | number; topics: (LabelType & { permission: TopicPermissions })[]; @@ -83,6 +85,7 @@ export type GroupInitialValuesTypeWithLabel = { export type FormSubmitValues = { groupName: string; groupDescription?: string; + groupImageUrl?: string; requirementsToFulfill: 'ALL' | 'N'; topics: LabelType[]; }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/validations.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/validations.ts index 441fe583332..b16d162d641 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/validations.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/validations.ts @@ -4,6 +4,7 @@ export const VALIDATION_MESSAGES = { NO_INPUT: 'No input', MAX_CHAR_LIMIT_REACHED: 'Max character limit reached', INVALID_INPUT: 'Invalid input', + INVALID_URL: 'Invalid Url', }; export const requirementSubFormValidationSchema = z.object({ @@ -49,6 +50,13 @@ export const groupValidationSchema = z.object({ .max(250, { message: VALIDATION_MESSAGES.MAX_CHAR_LIMIT_REACHED }) .optional() .default(''), + groupImageUrl: z + .union([ + z.string().url({ message: VALIDATION_MESSAGES.INVALID_URL }), + z.string().optional().default(''), + z.null(), // Allows null + ]) + .optional(), requirementsToFulfill: z .string({ invalid_type_error: VALIDATION_MESSAGES.NO_INPUT }) .nonempty({ message: VALIDATION_MESSAGES.NO_INPUT }), diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts index c4618a28369..fb463749b8f 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts @@ -22,6 +22,7 @@ export const makeGroupDataBaseAPIPayload = ( address: userStore.getState().activeAccount?.address || '', groupName: formSubmitValues.groupName.trim(), groupDescription: (formSubmitValues.groupDescription || '').trim(), + groupImageUrl: formSubmitValues.groupImageUrl || '', topics: formSubmitValues.topics, requirementsToFulfill: formSubmitValues.requirementsToFulfill === 'ALL' diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx index dafbe3aa9ea..0e5e1ec86dc 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx @@ -240,12 +240,22 @@ const CommunityMembersPage = () => { name: p.profile_name || DEFAULT_NAME, role: p.addresses[0].role, groups: (p.group_ids || []) - .map( - (groupId) => - (groups || []).find((group) => group.id === groupId)?.name, + .map((groupId) => { + const matchedGroup = (groups || []).find((g) => g.id === groupId); + return matchedGroup + ? { + name: matchedGroup.name, + groupImageUrl: matchedGroup.groupImageUrl, + } + : null; + }) + .filter( + ( + group, + ): group is { name: string; groupImageUrl: string | undefined } => + group !== null && group.name !== undefined, ) - .filter(Boolean) - .sort((a, b) => a!.localeCompare(b!)), + .sort((a, b) => a.name.localeCompare(b.name)), stakeBalance: p.addresses[0].stake_balance, lastActive: p.last_active, })); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/MembersSection/MembersSection.scss b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/MembersSection/MembersSection.scss index cba053373cf..c8ef4a7f377 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/MembersSection/MembersSection.scss +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/MembersSection/MembersSection.scss @@ -12,4 +12,21 @@ width: 100%; @include table-cell; } + + .group-item { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + background-color: $neutral-200; + border-radius: 5px; + padding: 2px 8px 2px 8px; + } + + .group-image { + width: 16px; + height: 16px; + border-radius: 50%; + object-fit: cover; + } } diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/MembersSection/MembersSection.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/MembersSection/MembersSection.tsx index 132bc67c6f8..70a146b7dea 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/MembersSection/MembersSection.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/MembersSection/MembersSection.tsx @@ -7,19 +7,29 @@ import { CWCheckbox } from 'views/components/component_kit/cw_checkbox'; import { CWTable } from 'views/components/component_kit/new_designs/CWTable'; import { CWTableState } from 'views/components/component_kit/new_designs/CWTable/useCWTableState'; import { CWTag } from 'views/components/component_kit/new_designs/CWTag'; +import { getFallbackImage } from '../helper'; import './MembersSection.scss'; +export type Group = { + name: string; + groupImageUrl: string; +}; + export type Member = { userId: number; avatarUrl?: string | null; name: string; role: Role; - groups: string[]; + groups: Group[]; stakeBalance?: string; lastActive?: string; address?: string; }; +export type MemberWithGroups = Omit & { + groups: Group[]; +}; + type MembersSectionProps = { filteredMembers: Member[]; onLoadMoreMembers?: () => unknown; @@ -77,13 +87,21 @@ const MembersSection = ({ }, groups: { sortValue: member.groups + .map((group) => group.name) .sort((a, b) => a.localeCompare(b)) .join(' ') .toLowerCase(), customElement: (
{member.groups.map((group, index) => ( - +
+ {group.name} + {group.name} +
))}
), diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/constants.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/constants.ts new file mode 100644 index 00000000000..4a6eae68b2c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/constants.ts @@ -0,0 +1,8 @@ +export const fallbackImages: string[] = [ + 'https://s3.us-east-1.amazonaws.com/local.assets/fb3289b0-38cb-4883-908b-7af0c1626ece.png', + 'https://s3.us-east-1.amazonaws.com/local.assets/794bb7a3-17d7-407a-b52e-2987501221b5.png', + 'https://s3.us-east-1.amazonaws.com/local.assets/181e25ad-ce08-427d-8d3a-d290af3be44b.png', + 'https://s3.us-east-1.amazonaws.com/local.assets/9f40b221-e2c7-4052-a7de-e580222baaa9.png', + 'https://s3.us-east-1.amazonaws.com/local.assets/ef919936-8554-42e5-8590-118e8cb68101.png', + 'https://s3.us-east-1.amazonaws.com/local.assets/0847e7f5-4d96-4406-8f30-c3082fa2f27c.png', +]; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/helper.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/helper.ts new file mode 100644 index 00000000000..a1762b72008 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/helper.ts @@ -0,0 +1,4 @@ +import { fallbackImages } from './constants'; + +export const getFallbackImage = (): string => + fallbackImages[Math.floor(Math.random() * fallbackImages.length)]; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.scss index 4289d8e611c..d8c8cd90526 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.scss +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.scss @@ -70,6 +70,41 @@ margin-bottom: 32px; } + .contest-section-farcaster-token { + margin-bottom: 32px; + + display: flex; + flex-direction: column; + gap: 16px; + max-width: 596px; + + .description { + color: $neutral-500; + margin-top: -8px; + } + + .token-input { + .MessageRow:first-child { + display: none; + } + } + + .input-row { + display: flex; + align-items: baseline; + + gap: 8px; + + .Text:first-child { + white-space: nowrap; + } + + .input-and-icon-container { + width: 64px; + } + } + } + .contest-section-duration { margin-bottom: 64px; display: flex; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.tsx index 892335fc90b..e4916fb2436 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/DetailsFormStep.tsx @@ -25,7 +25,7 @@ import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInp import { openConfirmation } from 'views/modals/confirmation_modal'; import CommunityManagementLayout from 'views/pages/CommunityManagement/common/CommunityManagementLayout'; -import { BLOG_SUBDOMAIN } from '@hicommonwealth/shared'; +import { BLOG_SUBDOMAIN, ZERO_ADDRESS } from '@hicommonwealth/shared'; import { CONTEST_FAQ_URL } from '../../../utils'; import { ContestFeeType, @@ -77,6 +77,7 @@ const DetailsFormStep = ({ const [isProcessingProfileImage, setIsProcessingProfileImage] = useState(false); + const [voteWeightMultiplier, setVoteWeightMultiplier] = useState(1); const { mutateAsync: updateContest } = useUpdateContestMutation(); @@ -107,7 +108,7 @@ const DetailsFormStep = ({ const totalPayoutPercentageError = totalPayoutPercentage !== 100; const weightedTopics = (topicsData || []) - .filter((t) => t?.weighted_voting) + .filter((t) => t?.weighted_voting && t.token_address !== ZERO_ADDRESS) .map((t) => ({ value: t.id, label: t.name, @@ -206,6 +207,7 @@ const DetailsFormStep = ({ payoutStructure, contestDuration, isFarcasterContest, + voteWeightMultiplier, }; if (editMode) { @@ -270,6 +272,7 @@ const DetailsFormStep = ({ validationSchema={schema} onSubmit={handleSubmit} initialValues={getInitialValues()} + onErrors={(err) => console.warn('FORM ERRORS: ', err)} > {({ watch, setValue }) => ( <> @@ -363,8 +366,7 @@ const DetailsFormStep = ({ {weightedTopics.find( (t) => t.value === watch('contestTopic')?.value, - )?.weightedVoting === TopicWeightedVoting.ERC20 || - isFarcasterContest ? ( + )?.weightedVoting === TopicWeightedVoting.ERC20 ? ( <>
Contest Funding @@ -438,6 +440,59 @@ const DetailsFormStep = ({
+ ) : isFarcasterContest ? ( +
+ Primary token + + Enter a token to fund the contest and for weighting upvotes + on the contest. + + + + Vote weight voteWeightMultiplier + +
+ + 1 token is equal to + + + setVoteWeightMultiplier(Number(e.target.value)) + } + /> + + votes. + +
+ + Vote weight per token held by the user will be{' '} + {voteWeightMultiplier || 0}. + +
) : ( <> )} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/validation.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/validation.ts index 83dca6c2680..bbd2b1a615d 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/validation.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/DetailsFormStep/validation.ts @@ -43,7 +43,22 @@ export const detailsFormValidationSchema = (isFarcasterContest: boolean) => { ContestFeeType.DirectDeposit, ]), contestRecurring: z.string(), - fundingTokenAddress: z.string().optional().nullable(), + fundingTokenAddress: z + .string() + .optional() + .nullable() + .refine( + (value) => { + if (farcasterContestEnabled && isFarcasterContest && !value) { + return false; + } + return true; + }, + { + message: 'Must specify funding token address for Farcaster contests', + }, + ), isFarcasterContest: z.boolean().default(false), + voteWeightMultiplier: z.coerce.number().optional().nullish(), }); }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/SignTransactionsStep/SignTransactionsStep.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/SignTransactionsStep/SignTransactionsStep.tsx index 053a9583ba6..63193e6388a 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/SignTransactionsStep/SignTransactionsStep.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/steps/SignTransactionsStep/SignTransactionsStep.tsx @@ -160,6 +160,7 @@ const SignTransactionsStep = ({ ticker: fundingTokenTicker, is_farcaster_contest: contestFormData.isFarcasterContest, decimals: fundingTokenDecimals, + vote_weight_multiplier: contestFormData.voteWeightMultiplier, }); onSetLaunchContestStep('ContestLive'); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/useManageContestForm.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/useManageContestForm.ts index b4f49bc7240..106d1b31867 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/useManageContestForm.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Contests/ManageContest/useManageContestForm.ts @@ -54,6 +54,7 @@ const useManageContestForm = ({ // @ts-expect-error StrictNullChecks prizePercentage: contestData.prize_percentage, payoutStructure: contestData.payout_structure, + voteWeightMultiplier: contestData.vote_weight_multiplier, }); } }, [ diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Integrations.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Integrations.tsx index 33315675e94..1dea20756d6 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Integrations.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Integrations.tsx @@ -1,4 +1,6 @@ +import { ChainBase } from '@hicommonwealth/shared'; import React from 'react'; +import app from 'state'; import CommunityManagementLayout from '../common/CommunityManagementLayout'; import CustomTOS from './CustomTOS'; import CustomURL from './CustomURL'; @@ -7,9 +9,12 @@ import Discord from './Discord'; import './Integrations.scss'; import Snapshots from './Snapshots'; import Stake from './Stake'; +import Token from './Token'; import Webhooks from './Webhooks'; const Integrations = () => { + const showSnapshotIntegration = app.chain.meta.base === ChainBase.Ethereum; + return ( { >
+ - + {showSnapshotIntegration && } diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Snapshots/Snapshots.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Snapshots/Snapshots.tsx index cd705c16b70..40aeaa4461c 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Snapshots/Snapshots.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Snapshots/Snapshots.tsx @@ -1,4 +1,4 @@ -import { notifySuccess } from 'controllers/app/notifications'; +import { notifyError, notifySuccess } from 'controllers/app/notifications'; import useRunOnceOnCondition from 'hooks/useRunOnceOnCondition'; import React from 'react'; import app from 'state'; @@ -87,7 +87,7 @@ const Snapshots = () => { notifySuccess('Snapshot links updated!'); } catch { - notifySuccess('Failed to update snapshot links!'); + notifyError('Failed to update snapshot links!'); } }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/Token.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/Token.scss new file mode 100644 index 00000000000..90cd8f160ea --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/Token.scss @@ -0,0 +1,23 @@ +@import '../../../../../styles/shared'; + +.Token { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + + .flex-row { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + .Icon { + color: $green-500; + } + } + + .w-fit { + width: fit-content; + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/Token.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/Token.tsx new file mode 100644 index 00000000000..02bb59d9b6d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/Token.tsx @@ -0,0 +1,89 @@ +import { ChainBase } from '@hicommonwealth/shared'; +import { useFlag } from 'client/scripts/hooks/useFlag'; +import { useCommonNavigate } from 'navigation/helpers'; +import React from 'react'; +import app from 'state'; +import { useGetPinnedTokenByCommunityId } from 'state/api/communities'; +import { useGetTokenByCommunityId } from 'state/api/tokens'; +import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; +import { CWText } from 'views/components/component_kit/cw_text'; +import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; +import { CWTooltip } from 'views/components/component_kit/new_designs/CWTooltip'; +import './Token.scss'; + +const Token = () => { + const communityId = app.activeChainId() || ''; + const navigate = useCommonNavigate(); + const tokenizedCommunityEnabled = useFlag('tokenizedCommunity'); + + const { data: communityLaunchpadToken, isLoading: isLoadingLaunchpadToken } = + useGetTokenByCommunityId({ + community_id: communityId, + with_stats: true, + enabled: !!communityId, + }); + + const { data: communityTokens } = useGetPinnedTokenByCommunityId({ + community_ids: [communityId], + with_chain_node: true, + enabled: !!communityId, + }); + const communityPinnedToken = communityTokens?.[0]; + const isExternalTokenLinked = communityPinnedToken; + const canAddToken = app?.chain?.base === ChainBase.Ethereum; // only ethereum communities can add a token + + if ( + communityLaunchpadToken || + isLoadingLaunchpadToken || + !tokenizedCommunityEnabled + ) + return <>; + + const actionButton = ( + navigate('/manage/integrations/token')} + /> + ); + + return ( +
+
+
+ + {isExternalTokenLinked + ? 'Manage token' + : 'Connect an existing token'} + + {isExternalTokenLinked && } +
+ + Connect any existing token that is active on Uniswap to your + community. + +
+ + {canAddToken ? ( + actionButton + ) : ( + ( + + {actionButton} + + )} + /> + )} +
+ ); +}; + +export default Token; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/index.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/index.tsx new file mode 100644 index 00000000000..779f527215e --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Token/index.tsx @@ -0,0 +1,3 @@ +import Token from './Token'; + +export default Token; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/StakeIntegration/StakeIntegration.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/StakeIntegration/StakeIntegration.tsx index 4a88bf67534..e19789f0e36 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/StakeIntegration/StakeIntegration.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/StakeIntegration/StakeIntegration.tsx @@ -1,5 +1,6 @@ import { commonProtocol } from '@hicommonwealth/evm-protocols'; import { TopicWeightedVoting } from '@hicommonwealth/schemas'; +import Permissions from 'client/scripts/utils/Permissions'; import clsx from 'clsx'; import { notifyError } from 'controllers/app/notifications'; import AddressInfo from 'models/AddressInfo'; @@ -50,6 +51,13 @@ const StakeIntegration = ({ return ; } + if ( + !user.isLoggedIn || + !(Permissions.isSiteAdmin() || Permissions.isCommunityAdmin()) + ) { + return ; + } + const communityChainId = `${ community?.ChainNode?.eth_chain_id || community?.ChainNode?.cosmos_chain_id }`; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenForm.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenForm.scss new file mode 100644 index 00000000000..0f63d609d84 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenForm.scss @@ -0,0 +1,11 @@ +@import '../../../../../styles/shared'; + +.ConnectTokenForm { + display: grid; + grid-template-columns: 3fr 1fr; + width: 100%; + + @include extraSmall { + grid-template-columns: 1fr; + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenForm.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenForm.tsx new file mode 100644 index 00000000000..72a9e5c2262 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenForm.tsx @@ -0,0 +1,52 @@ +import { useCommonNavigate } from 'navigation/helpers'; +import React, { useState } from 'react'; +import './ConnectTokenForm.scss'; +import ConnectTokenStep from './ConnectTokenStep'; +import InformationalCTAStep from './InformationalCTAStep'; +import { ConnectTokenFormProps, ConnectTokenFormSteps } from './types'; + +const ConnectTokenForm = ({ + onTokenConnect, + existingToken, + onCancel, +}: ConnectTokenFormProps) => { + const navigate = useCommonNavigate(); + const [activeStep, setActiveStep] = useState( + existingToken + ? ConnectTokenFormSteps.ConnectToken + : ConnectTokenFormSteps.InformationalCTA, + ); + + const getActiveStep = () => { + switch (activeStep) { + case ConnectTokenFormSteps.InformationalCTA: { + return ( + navigate(`/manage/integrations`)} + onConnect={() => setActiveStep(ConnectTokenFormSteps.ConnectToken)} + /> + ); + } + case ConnectTokenFormSteps.ConnectToken: { + return ( + + existingToken + ? onCancel?.() + : setActiveStep(ConnectTokenFormSteps.InformationalCTA) + } + onConnect={() => onTokenConnect?.()} + existingToken={existingToken} + /> + ); + } + default: + console.error(`${activeStep}: not implemented for ConnectTokenForm`); + return <>; + } + }; + + return
{getActiveStep()}
; +}; + +export default ConnectTokenForm; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/ConnectTokenStep.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/ConnectTokenStep.scss new file mode 100644 index 00000000000..0be977d3f4f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/ConnectTokenStep.scss @@ -0,0 +1,42 @@ +@import '../../../../../../styles/shared'; + +.ConnectTokenStep { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + border-radius: 6px; + + .chain-selector, + .token-finder-container { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + + .header { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + } + } + + .description { + color: $neutral-600; + } + + .action-buttons { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + margin-top: 24px; + margin-bottom: 24px; + gap: 10px; + + @include smallInclusive { + justify-content: flex-end; + } + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/ConnectTokenStep.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/ConnectTokenStep.tsx new file mode 100644 index 00000000000..f700de96e64 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/ConnectTokenStep.tsx @@ -0,0 +1,167 @@ +import { commonProtocol } from '@hicommonwealth/evm-protocols'; +import { + notifyError, + notifyInfo, + notifySuccess, +} from 'controllers/app/notifications'; +import NodeInfo from 'models/NodeInfo'; +import React from 'react'; +import app from 'state'; +import { + usePinTokenToCommunityMutation, + useUnpinTokenFromCommunityMutation, +} from 'state/api/communities'; +import { fetchCachedNodes } from 'state/api/nodes'; +import TokenFinder, { useTokenFinder } from 'views/components/TokenFinder'; +import { CWDivider } from 'views/components/component_kit/cw_divider'; +import { CWText } from 'views/components/component_kit/cw_text'; +import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; +import CWCircleMultiplySpinner from 'views/components/component_kit/new_designs/CWCircleMultiplySpinner'; +import { CWForm } from 'views/components/component_kit/new_designs/CWForm'; +import { CWRadioButton } from 'views/components/component_kit/new_designs/cw_radio_button'; +import './ConnectTokenStep.scss'; +import { ConnectTokenStepProps, ConnectTokenStepSubmitValues } from './types'; +import { connectTokenFormValidationSchema } from './validation'; + +const ConnectTokenStep = ({ + onConnect, + onCancel, + existingToken, +}: ConnectTokenStepProps) => { + const communityId = app.activeChainId() || ''; + + // base chain node info + const nodes = fetchCachedNodes(); + const baseNode = nodes?.find( + (n) => n.ethChainId === commonProtocol.ValidChains.Base, + ) as NodeInfo; // this is expected to exist + + const { + debouncedTokenValue, + getTokenError, + setTokenValue, + tokenMetadata, + tokenMetadataLoading, + tokenValue, + } = useTokenFinder({ + nodeEthChainId: baseNode.ethChainId || 0, + }); + + const { mutateAsync: pinToken, isLoading: isPinningToken } = + usePinTokenToCommunityMutation(); + + const { mutateAsync: unpinToken, isLoading: isUnpinningToken } = + useUnpinTokenFromCommunityMutation({ + resetCacheOnSuccess: false, + }); + + const isActionPending = + tokenMetadataLoading || isPinningToken || isUnpinningToken; + + const areActionsDisabled = !!getTokenError() || isActionPending; + + const handleSubmit = (values: ConnectTokenStepSubmitValues) => { + if (areActionsDisabled) return; + + // return early if user is trying to pin an existing token again + if (existingToken && existingToken?.name === tokenMetadata?.name) { + notifyInfo('This token is already connected to your community.'); + return; + } + + const handleAsync = async () => { + try { + // unpin existing token if there is any + if (existingToken) { + await unpinToken({ + community_id: communityId, + }); + } + + // pin the new token + await pinToken({ + community_id: communityId, + chain_node_id: parseInt(values.chainNodeId), + contract_address: values.tokenAddress, + }); + + notifySuccess(`${tokenMetadata?.name} connected successfully!`); + + onConnect(); + } catch { + notifyError('Failed to pin token to community!'); + } + }; + handleAsync().catch(console.error); + }; + + return ( + +
+
+ Supported Chains + + The following are the pre-selected chain(s) all token features will + be interacting with. + +
+ +
+
+
+ Primary Token + + Any token features such as voting or tipping require your community + to define a primary token. + +
+ +
+ + {(isPinningToken || isUnpinningToken) && } +
+ + +
+
+ ); +}; + +export default ConnectTokenStep; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/index.ts new file mode 100644 index 00000000000..9ca4c770622 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/index.ts @@ -0,0 +1,3 @@ +import ConnectTokenStep from './ConnectTokenStep'; + +export default ConnectTokenStep; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/types.ts new file mode 100644 index 00000000000..c10d29550ac --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/types.ts @@ -0,0 +1,13 @@ +import { GetTokenMetadataResponse } from 'state/api/tokens/getTokenMetadata'; +import { z } from 'zod'; +import { connectTokenFormValidationSchema } from './validation'; + +export type ConnectTokenStepProps = { + onConnect: () => void; + onCancel: () => void; + existingToken?: GetTokenMetadataResponse; +}; + +export type ConnectTokenStepSubmitValues = z.infer< + typeof connectTokenFormValidationSchema +>; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/validation.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/validation.ts new file mode 100644 index 00000000000..7019903824b --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/ConnectTokenStep/validation.ts @@ -0,0 +1,11 @@ +import { VALIDATION_MESSAGES } from 'helpers/formValidations/messages'; +import { z } from 'zod'; + +export const connectTokenFormValidationSchema = z.object({ + chainNodeId: z + .string({ invalid_type_error: VALIDATION_MESSAGES.INVALID_INPUT }) + .nonempty({ message: VALIDATION_MESSAGES.NO_INPUT }), + tokenAddress: z + .string({ invalid_type_error: VALIDATION_MESSAGES.INVALID_INPUT }) + .nonempty({ message: VALIDATION_MESSAGES.NO_INPUT }), +}); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/InformationalCTAStep.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/InformationalCTAStep.scss new file mode 100644 index 00000000000..99356541c18 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/InformationalCTAStep.scss @@ -0,0 +1,45 @@ +@import '../../../../../../styles/shared'; + +.InformationalCTAStep { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + + .description { + color: $neutral-600; + } + + .cta-link-container { + margin-top: 12px; + color: $neutral-700 !important; + + a { + color: $primary-600; + text-decoration: none; + + &:focus, + &:active, + &:hover, + &:visited { + color: $primary-600; + outline: none; + text-decoration: none; + } + } + } + + .action-buttons { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + margin-top: 24px; + margin-bottom: 24px; + gap: 10px; + + @include smallInclusive { + justify-content: flex-end; + } + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/InformationalCTAStep.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/InformationalCTAStep.tsx new file mode 100644 index 00000000000..b07088ad2d4 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/InformationalCTAStep.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { CWDivider } from 'views/components/component_kit/cw_divider'; +import { CWText } from 'views/components/component_kit/cw_text'; +import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; +import './InformationalCTAStep.scss'; + +type InformationalCTAStepProps = { + onConnect: () => void; + onCancel: () => void; +}; + +const InformationalCTAStep = ({ + onConnect, + onCancel, +}: InformationalCTAStepProps) => { + return ( +
+ Do you want to connect an existing token? + + {/* TODO: https://github.com/hicommonwealth/commonwealth/issues/10231 */} + Something about connecting an existing token and enabling token + features. + + + Not sure?  + + Learn more about token connection. + + + +
+ + +
+
+ ); +}; + +export default InformationalCTAStep; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/index.ts new file mode 100644 index 00000000000..db6c999dc36 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/InformationalCTAStep/index.ts @@ -0,0 +1,3 @@ +import InformationalCTAStep from './InformationalCTAStep'; + +export default InformationalCTAStep; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/index.ts new file mode 100644 index 00000000000..e6a42d84dd5 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/index.ts @@ -0,0 +1,3 @@ +import ConnectTokenForm from './ConnectTokenForm'; + +export default ConnectTokenForm; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/types.ts new file mode 100644 index 00000000000..faa0c62b21f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ConnectTokenForm/types.ts @@ -0,0 +1,12 @@ +import { GetTokenMetadataResponse } from 'state/api/tokens/getTokenMetadata'; + +export enum ConnectTokenFormSteps { + InformationalCTA = 'InformationalCTAStep', + ConnectToken = 'ConnectTokenStep', +} + +export type ConnectTokenFormProps = { + existingToken?: GetTokenMetadataResponse; + onTokenConnect?: () => void; + onCancel?: () => void; +}; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/ManageConnectedToken.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/ManageConnectedToken.scss new file mode 100644 index 00000000000..c2736ac08fb --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/ManageConnectedToken.scss @@ -0,0 +1,26 @@ +@import '../../../../../styles/shared'; + +.ManageConnectedToken { + display: grid; + grid-template-columns: 3fr 1fr; + width: 100%; + + @include extraSmall { + grid-template-columns: 1fr; + } + + .connected-token { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + } + + .action-buttons { + display: flex; + justify-content: flex-end; + align-items: flex-end; + flex-wrap: wrap; + gap: 10px; + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/ManageConnectedToken.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/ManageConnectedToken.tsx new file mode 100644 index 00000000000..9c97f9f2055 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/ManageConnectedToken.tsx @@ -0,0 +1,81 @@ +import { notifyError, notifySuccess } from 'controllers/app/notifications'; +import React, { useState } from 'react'; +import app from 'state'; +import { useUnpinTokenFromCommunityMutation } from 'state/api/communities'; +import TokenBanner from 'views/components/TokenBanner'; +import { CWDivider } from 'views/components/component_kit/cw_divider'; +import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; +import CWCircleMultiplySpinner from 'views/components/component_kit/new_designs/CWCircleMultiplySpinner'; +import ConnectTokenForm from '../ConnectTokenForm'; +import './ManageConnectedToken.scss'; +import { ManageConnectedTokenProps } from './types'; + +const ManageConnectedToken = ({ + isLoadingToken, + tokenInfo, +}: ManageConnectedTokenProps) => { + const [isChangingToken, setIsChangingToken] = useState(false); + + const { mutateAsync: unpinToken, isLoading: isUnpinningToken } = + useUnpinTokenFromCommunityMutation(); + const isActionPending = isUnpinningToken; + + const handleUnpinToken = () => { + if (isActionPending) return; + + const handleAsync = async () => { + try { + await unpinToken({ + community_id: app.activeChainId() || '', + }); + notifySuccess('Token disconnected from community!'); + } catch { + notifyError('Failed to disconnect token from community!'); + } + }; + handleAsync().catch(console.error); + }; + + if (isChangingToken) { + return ( + setIsChangingToken(false)} + /> + ); + } + + return ( +
+
+ + + {isActionPending && } +
+ + setIsChangingToken(true)} + /> +
+
+
+ ); +}; + +export default ManageConnectedToken; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/index.ts new file mode 100644 index 00000000000..2c7626334a3 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/index.ts @@ -0,0 +1,3 @@ +import ManageConnectedToken from './ManageConnectedToken'; + +export default ManageConnectedToken; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/types.ts new file mode 100644 index 00000000000..e342ce6a10d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/ManageConnectedToken/types.ts @@ -0,0 +1,6 @@ +import { GetTokenMetadataResponse } from 'state/api/tokens/getTokenMetadata'; + +export type ManageConnectedTokenProps = { + tokenInfo?: GetTokenMetadataResponse; + isLoadingToken: boolean; +}; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/Status.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/Status.scss new file mode 100644 index 00000000000..ebd37b9e1e0 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/Status.scss @@ -0,0 +1,30 @@ +@import '../../../../../styles/shared'; + +.Status { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + background-color: $neutral-50; + gap: 8px; + width: 100%; + padding: 16px; + border-radius: 6px; + + .b1 { + color: $neutral-800; + } + + .Icon { + color: $green-500; + } + + .Text { + width: fit-content; + } + + .description { + color: $neutral-700; + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/Status.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/Status.tsx new file mode 100644 index 00000000000..a408c78dfeb --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/Status.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; +import { CWText } from 'views/components/component_kit/cw_text'; +import './Status.scss'; + +type StatusProps = { + communityName: string; + isEnabled: boolean; + tokenName?: string; +}; + +const Status = ({ isEnabled, communityName, tokenName }: StatusProps) => { + return ( +
+ {isEnabled && } + + {isEnabled ? 'Token connected' : 'No token connected'} + + + {isEnabled + ? `You have successfully integrated "${tokenName?.trim()}" token in "${communityName.trim()}".` + : `You currently do not have token connected in "${communityName.trim()}"`} + +
+ ); +}; + +export default Status; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/index.ts new file mode 100644 index 00000000000..ba85646772f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/Status/index.ts @@ -0,0 +1,3 @@ +import Status from './Status'; + +export default Status; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/TokenIntegration.scss b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/TokenIntegration.scss new file mode 100644 index 00000000000..adbcb108a04 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/TokenIntegration.scss @@ -0,0 +1,9 @@ +@import '../../../../styles/shared'; + +.TokenIntegration { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + align-items: flex-start; +} diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/TokenIntegration.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/TokenIntegration.tsx new file mode 100644 index 00000000000..d7f71d10201 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/TokenIntegration.tsx @@ -0,0 +1,92 @@ +import { commonProtocol } from '@hicommonwealth/evm-protocols'; +import { useFlag } from 'hooks/useFlag'; +import React from 'react'; +import app from 'state'; +import { useGetPinnedTokenByCommunityId } from 'state/api/communities'; +import { + useGetTokenByCommunityId, + useTokenMetadataQuery, +} from 'state/api/tokens'; +import { CWDivider } from 'views/components/component_kit/cw_divider'; +import { CWText } from 'views/components/component_kit/cw_text'; +import CWCircleMultiplySpinner from 'views/components/component_kit/new_designs/CWCircleMultiplySpinner'; +import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; +import { PageNotFound } from '../../404'; +import ConnectTokenForm from './ConnectTokenForm'; +import ManageConnectedToken from './ManageConnectedToken'; +import Status from './Status'; +import './TokenIntegration.scss'; + +const TokenIntegration = () => { + const communityId = app.activeChainId() || ''; + const tokenizedCommunityEnabled = useFlag('tokenizedCommunity'); + + const { data: communityLaunchpadToken, isLoading: isLoadingLaunchpadToken } = + useGetTokenByCommunityId({ + community_id: communityId, + with_stats: true, + enabled: !!communityId, + }); + + const { data: communityPinnedTokens, isLoading: isLoadingPinnedToken } = + useGetPinnedTokenByCommunityId({ + community_ids: [communityId], + with_chain_node: true, + enabled: !!communityId, + }); + const communityPinnedToken = communityPinnedTokens?.[0]; + + const { data: tokenMetadata, isLoading: isLoadingTokenMetadata } = + useTokenMetadataQuery({ + tokenId: communityPinnedToken?.contract_address || '', + nodeEthChainId: communityPinnedToken?.ChainNode?.eth_chain_id || 0, + apiEnabled: !!(communityPinnedToken?.contract_address || ''), + }); + const isExternalTokenLinked = !!communityPinnedToken; + const isLoading = + isLoadingPinnedToken || (isLoadingTokenMetadata && isExternalTokenLinked); + + const contractInfo = + commonProtocol?.factoryContracts[ + app?.chain?.meta?.ChainNode?.eth_chain_id || 0 + ]; + + if ( + !tokenizedCommunityEnabled || + !contractInfo || + // if a community already has a launchpad token, don't allow pinning + communityLaunchpadToken + ) { + return ; + } + + if (isLoading || isLoadingLaunchpadToken) { + return ; + } + + return ( + +
+ + {isExternalTokenLinked ? 'Manage connected token' : 'Connect token'} + + + + {isExternalTokenLinked ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default TokenIntegration; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/index.ts new file mode 100644 index 00000000000..8583b66577f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/TokenIntegration/index.ts @@ -0,0 +1,3 @@ +import TokenIntegration from './TokenIntegration'; + +export default TokenIntegration; diff --git a/packages/commonwealth/client/scripts/views/pages/ContestPage/ContestPage.scss b/packages/commonwealth/client/scripts/views/pages/ContestPage/ContestPage.scss index 18d2c792561..b98157efb93 100644 --- a/packages/commonwealth/client/scripts/views/pages/ContestPage/ContestPage.scss +++ b/packages/commonwealth/client/scripts/views/pages/ContestPage/ContestPage.scss @@ -1,3 +1,5 @@ +@import '../../../styles/shared'; + .ContestPage { display: flex; flex-direction: column; @@ -7,7 +9,7 @@ max-width: 598px; display: flex; flex-direction: column; - gap: 16px; + gap: 24px; .filter-section { display: flex; @@ -16,3 +18,35 @@ } } } + +.cast-container { + display: flex; + gap: 8px; + + .upvote-small { + display: none; + order: 1; + margin-bottom: 16px; + width: fit-content; + + .MuiPopper-root { + display: none; + } + } + + .farcaster-embed-container { + flex: 1; + } + + @include extraSmall { + flex-direction: column; + + .Upvote { + display: none; + } + + .upvote-small { + display: block; + } + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/ContestPage/ContestPage.tsx b/packages/commonwealth/client/scripts/views/pages/ContestPage/ContestPage.tsx index 40db647af60..b934bb9ef57 100644 --- a/packages/commonwealth/client/scripts/views/pages/ContestPage/ContestPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/ContestPage/ContestPage.tsx @@ -2,12 +2,13 @@ import moment from 'moment'; import React, { useState } from 'react'; import { FarcasterEmbed } from 'react-farcaster-embed/dist/client'; import 'react-farcaster-embed/dist/styles.css'; - import useFetchFarcasterCastsQuery from 'state/api/contests/getFarcasterCasts'; import { Select } from 'views/components/Select'; import { Skeleton } from 'views/components/Skeleton'; import { CWText } from 'views/components/component_kit/cw_text'; import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; +import CWUpvoteSmall from 'views/components/component_kit/new_designs/CWUpvoteSmall'; +import { CWUpvote } from 'views/components/component_kit/new_designs/cw_upvote'; import { PageNotFound } from 'views/pages/404'; import ContestCard from 'views/pages/CommunityManagement/Contests/ContestsList/ContestCard'; import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; @@ -15,14 +16,14 @@ import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCo import './ContestPage.scss'; export enum SortType { - Likes = 'likes', + Upvotes = 'upvotes', Recent = 'recent', } const sortOptions = [ { - value: SortType.Likes, - label: 'Most Liked', + value: SortType.Upvotes, + label: 'Most Upvoted', }, { value: SortType.Recent, @@ -88,7 +89,7 @@ const ContestPage = ({ contestAddress }: ContestPageProps) => { - ) : !farcasterCasts?.[0]?.replies?.length ? ( + ) : !farcasterCasts?.length ? ( No entries for the contest yet ) : ( <> @@ -105,13 +106,33 @@ const ContestPage = ({ contestAddress }: ContestPageProps) => { />
- {farcasterCasts[0].replies?.map((entry) => ( - - ))} + {farcasterCasts.map((entry) => { + return ( +
+ + +
+ undefined} + popoverContent={<>} + tooltipText="Farcaster Upvotes" + /> +
+ + +
+ ); + })} )}
diff --git a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityTypeStep/CommunityTypeStep.tsx b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityTypeStep/CommunityTypeStep.tsx index ed06e671f0c..75c5ff8c617 100644 --- a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityTypeStep/CommunityTypeStep.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityTypeStep/CommunityTypeStep.tsx @@ -83,8 +83,13 @@ const CommunityTypeStep = ({ handleContinue(); }; - const [baseOption, blastOption, ethereumOption, ...advancedOptions] = - communityTypeOptions; + const [ + baseOption, + blastOption, + ethereumOption, + skaleOption, + ...advancedOptions + ] = communityTypeOptions; return (
@@ -136,6 +141,19 @@ const CommunityTypeStep = ({ }) } /> + + handleCommunitySelection({ + type: skaleOption.type, + chainBase: skaleOption.chainBase, + }) + } + />
Advanced Options diff --git a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityTypeStep/helpers.ts b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityTypeStep/helpers.ts index 945cef7d2f2..e8179820a33 100644 --- a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityTypeStep/helpers.ts +++ b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityTypeStep/helpers.ts @@ -4,6 +4,7 @@ import blastImg from 'assets/img/communitySelector/blast.png'; import cosmosImg from 'assets/img/communitySelector/cosmos.svg'; import ethereumImg from 'assets/img/communitySelector/ethereum.svg'; import polygonImg from 'assets/img/communitySelector/polygon.svg'; +import skaleImg from 'assets/img/communitySelector/skale.svg'; import solanaImg from 'assets/img/communitySelector/solana.svg'; import { CommunityType } from 'views/components/component_kit/new_designs/CWCommunitySelector'; @@ -37,7 +38,19 @@ export const communityTypeOptions = [ 'Tokens built on the ERC20 protocol are fungible, meaning they are interchangeable. ' + 'Select this community type if you have minted a token on the Ethereum blockchain.', }, - + { + type: CommunityType.Skale, + img: skaleImg, + chainBase: ChainBase.Ethereum, + title: 'Skale', + isRecommended: false, + isHidden: false, + description: + // eslint-disable-next-line max-len + 'SKALE is an on-demand blockchain network with zero gas fees. ' + + // eslint-disable-next-line max-len + 'Allowing quick deployment of interoperable EVM-compatible chains without compromising security or decentralization', + }, { type: CommunityType.Cosmos, img: cosmosImg, diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/LaunchToken.scss b/packages/commonwealth/client/scripts/views/pages/LaunchToken/LaunchToken.scss index 96c01eed471..789c60b54a0 100644 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/LaunchToken.scss +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/LaunchToken.scss @@ -4,4 +4,5 @@ display: flex; flex-direction: column; gap: 16px; + max-width: 596px; } diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/LaunchToken.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/LaunchToken.tsx index bfc5b530edd..9e8a356e58a 100644 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/LaunchToken.tsx +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/LaunchToken.tsx @@ -1,39 +1,17 @@ -import useBeforeUnload from 'hooks/useBeforeUnload'; import { useCommonNavigate } from 'navigation/helpers'; import React from 'react'; -import CWFormSteps from 'views/components/component_kit/new_designs/CWFormSteps'; import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; import { MixpanelCommunityCreationEvent } from '../../../../../shared/analytics/types'; import useAppStatus from '../../../hooks/useAppStatus'; import { useBrowserAnalyticsTrack } from '../../../hooks/useBrowserAnalyticsTrack'; import './LaunchToken.scss'; -import CommunityInformationStep from './steps/CommunityInformationStep'; -import SignatureStep from './steps/SignatureStep'; -import SuccessStep from './steps/SuccessStep'; -import TokenInformationStep from './steps/TokenInformationStep'; -import useCreateTokenCommunity from './useCreateTokenCommunity'; -import { CreateTokenCommunityStep, getFormSteps } from './utils'; +import QuickTokenLaunchForm from './QuickTokenLaunchForm'; const LaunchToken = () => { const navigate = useCommonNavigate(); - const { - baseNode, - createTokenCommunityStep, - onChangeStep, - draftTokenInfo, - selectedAddress, - setSelectedAddress, - setDraftTokenInfo, - createdCommunityId, - setCreatedCommunityId, - isTokenLaunched, - setIsTokenLaunched, - } = useCreateTokenCommunity(); const { isAddedToHomeScreen } = useAppStatus(); - useBeforeUnload(!!draftTokenInfo && !isTokenLaunched); - useBrowserAnalyticsTrack({ payload: { event: MixpanelCommunityCreationEvent.CREATE_TOKEN_COMMUNITY_VISITED, @@ -41,76 +19,13 @@ const LaunchToken = () => { }, }); - const isSuccessStep = - createTokenCommunityStep === CreateTokenCommunityStep.Success; - - const getCurrentStep = () => { - switch (createTokenCommunityStep) { - case CreateTokenCommunityStep.TokenInformation: - return ( - navigate('/')} // redirect to home - handleContinue={(tokenInfo) => { - setDraftTokenInfo(tokenInfo); - - onChangeStep(true); - }} - onAddressSelected={(address) => setSelectedAddress(address)} - selectedAddress={selectedAddress} - /> - ); - case CreateTokenCommunityStep.CommunityInformation: - return ( - onChangeStep(false)} - handleContinue={(communityId) => { - setCreatedCommunityId(communityId); - - onChangeStep(true); - }} - tokenInfo={draftTokenInfo} - selectedAddress={selectedAddress} - /> - ); - case CreateTokenCommunityStep.SignatureLaunch: - // this condition will never be triggered, adding this to avoid typescript errors - if (!createdCommunityId || !selectedAddress || !draftTokenInfo) - return <>; - - return ( - { - setIsTokenLaunched(isLaunched); - - onChangeStep(true); - }} - selectedAddress={selectedAddress} - /> - ); - case CreateTokenCommunityStep.Success: - // this condition will never be triggered, adding this to avoid typescript errors - if (!createdCommunityId) return <>; - - return ( - - ); - } - }; - return (
- {!isSuccessStep && ( - - )} - - {getCurrentStep()} + navigate('/')} + onCommunityCreated={() => {}} + />
); diff --git a/packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/QuickTokenLaunchForm.scss b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/QuickTokenLaunchForm.scss similarity index 94% rename from packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/QuickTokenLaunchForm.scss rename to packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/QuickTokenLaunchForm.scss index e5d35c534d7..73a36ae3f3e 100644 --- a/packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/QuickTokenLaunchForm.scss +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/QuickTokenLaunchForm.scss @@ -1,4 +1,4 @@ -@import '../../../../../styles/shared.scss'; +@import '../../../../styles/shared.scss'; .QuickTokenLaunchForm { div.h3 { diff --git a/packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/QuickTokenLaunchForm.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/QuickTokenLaunchForm.tsx similarity index 97% rename from packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/QuickTokenLaunchForm.tsx rename to packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/QuickTokenLaunchForm.tsx index 0b9154a6f8f..3845b767579 100644 --- a/packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/QuickTokenLaunchForm.tsx +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/QuickTokenLaunchForm.tsx @@ -23,13 +23,13 @@ import CWCircleMultiplySpinner from 'views/components/component_kit/new_designs/ import { CWTooltip } from 'views/components/component_kit/new_designs/CWTooltip'; import TokenLaunchButton from 'views/components/sidebar/TokenLaunchButton'; import { openConfirmation } from 'views/modals/confirmation_modal'; -import { generateCommunityNameFromToken } from '../../../LaunchToken/steps/CommunityInformationStep/utils'; -import SuccessStep from '../../../LaunchToken/steps/SuccessStep'; -import TokenInformationForm from '../../../LaunchToken/steps/TokenInformationStep/TokenInformationForm'; -import { FormSubmitValues } from '../../../LaunchToken/steps/TokenInformationStep/TokenInformationForm/types'; -import useCreateTokenCommunity from '../../../LaunchToken/useCreateTokenCommunity'; +import useCreateTokenCommunity from '../useCreateTokenCommunity'; import './QuickTokenLaunchForm.scss'; +import SuccessStep from './steps/SuccessStep'; +import TokenInformationFormStep from './steps/TokenInformationFormStep'; +import { FormSubmitValues } from './steps/TokenInformationFormStep/types'; import { useGenerateTokenIdea } from './useGenerateTokenIdea'; +import { generateCommunityNameFromToken } from './utils'; type QuickTokenLaunchFormProps = { onCancel: () => void; @@ -393,7 +393,7 @@ export const QuickTokenLaunchForm = ({ {createdCommunityId ? ( ) : ( - {/* allows to switch b/w generated ideas */} { +}: TokenInformationFormStepProps) => { const user = useUserStore(); const [baseOption] = communityTypeOptions; @@ -180,7 +180,7 @@ const TokenInformationForm = ({ onSubmit={handleSubmit} onWatch={onFormUpdate} validationSchema={tokenInformationFormValidationSchema} - className={clsx('TokenInformationForm', containerClassName)} + className={clsx('TokenInformationFormStep', containerClassName)} >
@@ -294,4 +294,4 @@ const TokenInformationForm = ({ ); }; -export default TokenInformationForm; +export default TokenInformationFormStep; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/helpers.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/helpers.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/helpers.ts rename to packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/helpers.ts diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/index.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/index.ts new file mode 100644 index 00000000000..8491651f82e --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/index.ts @@ -0,0 +1,3 @@ +import TokenInformationFormStep from './TokenInformationFormStep'; + +export default TokenInformationFormStep; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/types.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/types.ts similarity index 96% rename from packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/types.ts rename to packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/types.ts index 8d16673a231..7f09f989f29 100644 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/types.ts +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/types.ts @@ -10,7 +10,7 @@ export type FormSubmitValues = { imageURL: string; }; -export type TokenInformationFormProps = { +export type TokenInformationFormStepProps = { onSubmit: (values: FormSubmitValues) => void; onCancel: () => void; onFormUpdate?: (values: FormSubmitValues) => void; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/validation.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/validation.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/validation.ts rename to packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/steps/TokenInformationFormStep/validation.ts diff --git a/packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/useGenerateTokenIdea.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/useGenerateTokenIdea.tsx similarity index 91% rename from packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/useGenerateTokenIdea.tsx rename to packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/useGenerateTokenIdea.tsx index 359db2a9f57..00f14185a0d 100644 --- a/packages/commonwealth/client/scripts/views/pages/Communities/IdeaLaunchpad/QuickTokenLaunchForm/useGenerateTokenIdea.tsx +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/useGenerateTokenIdea.tsx @@ -167,14 +167,19 @@ export const useGenerateTokenIdea = ({ notifyError(error.message); console.error('Error fetching token idea:', error.message); } finally { - setTokenIdeas((ti) => { - const temp = [...ti]; - temp[ideaIndex] = { - ...(temp[ideaIndex] || {}), - isChunking: false, - }; - return temp; - }); + setTimeout(() => { + setTokenIdeas((ti) => { + const temp = [...ti]; + temp[ideaIndex] = { + ...(temp[ideaIndex] || {}), + isChunking: false, + }; + return temp; + }); + // Note: Sometimes the final image takes time to load and if the form is submitted during that interval + // it sends the full image url (the one we get from chatgpt, which is non-s3) and this breaks the + // db image col validation, which in turn breaks the api. Adding a wait of 1 sec to avoid this secnario + }, 1000); } }; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/utils.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/utils.ts similarity index 72% rename from packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/utils.ts rename to packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/utils.ts index 2a53023b084..406101340be 100644 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/utils.ts +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/QuickTokenLaunchForm/utils.ts @@ -5,5 +5,5 @@ export const generateCommunityNameFromToken = ({ tokenName: string; tokenSymbol: string; }) => { - return `${tokenName} (${tokenSymbol}) Community`; + return `${tokenName} (${tokenSymbol})`; }; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/CommunityInformationStep.scss b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/CommunityInformationStep.scss deleted file mode 100644 index f3062f52c77..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/CommunityInformationStep.scss +++ /dev/null @@ -1,32 +0,0 @@ -@import '../../../../../styles/shared'; - -$form-width: 596px; - -.CommunityInformationStep { - display: flex; - flex-direction: column; - gap: 24px; - position: relative; - - > .header { - margin-top: 8px; - width: 100%; - - .description { - color: $neutral-500; - } - } - - .CWBanner { - max-width: $form-width !important; - - .content-container { - max-width: calc($form-width - 70px) !important; - } - } - - @include smallInclusive { - flex-direction: column; - gap: 16px; - } -} diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/CommunityInformationStep.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/CommunityInformationStep.tsx deleted file mode 100644 index 113b38380a4..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/CommunityInformationStep.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { commonProtocol } from '@hicommonwealth/evm-protocols'; -import { ChainBase } from '@hicommonwealth/shared'; -import { notifyError } from 'controllers/app/notifications'; -import useAppStatus from 'hooks/useAppStatus'; -import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; -import AddressInfo from 'models/AddressInfo'; -import React from 'react'; -import { - BaseMixpanelPayload, - MixpanelCommunityCreationEvent, - MixpanelLoginPayload, -} from 'shared/analytics/types'; -import useCreateCommunityMutation, { - buildCreateCommunityInput, -} from 'state/api/communities/createCommunity'; -import { fetchCachedNodes } from 'state/api/nodes'; -import useUserStore from 'state/ui/user'; -import CommunityInformationForm from 'views/components/CommunityInformationForm/CommunityInformationForm'; -import { CommunityInformationFormSubmitValues } from 'views/components/CommunityInformationForm/types'; -import { CWText } from 'views/components/component_kit/cw_text'; -import CWBanner from 'views/components/component_kit/new_designs/CWBanner'; -import { openConfirmation } from 'views/modals/confirmation_modal'; -import { TokenInfo } from '../../types'; -import './CommunityInformationStep.scss'; -import { generateCommunityNameFromToken } from './utils'; - -interface CommunityInformationStepProps { - handleGoBack: () => void; - handleContinue: (communityId: string) => void; - tokenInfo?: TokenInfo; - selectedAddress?: AddressInfo; -} - -const CommunityInformationStep = ({ - handleGoBack, - handleContinue, - tokenInfo, - selectedAddress, -}: CommunityInformationStepProps) => { - const user = useUserStore(); - const { isAddedToHomeScreen } = useAppStatus(); - - const initialValues = { - communityName: generateCommunityNameFromToken({ - tokenName: tokenInfo?.name || '', - tokenSymbol: tokenInfo?.symbol || '', - }), - communityDescription: tokenInfo?.description || '', - communityProfileImageURL: tokenInfo?.imageURL || '', - }; - - const { trackAnalytics } = useBrowserAnalyticsTrack< - MixpanelLoginPayload | BaseMixpanelPayload - >({ - onAction: true, - }); - - const { - mutateAsync: createCommunityMutation, - isLoading: createCommunityLoading, - } = useCreateCommunityMutation(); - - const handleSubmit = async ( - values: CommunityInformationFormSubmitValues & { communityId: string }, - ) => { - const nodes = fetchCachedNodes(); - const baseNode = nodes?.find( - (n) => n.ethChainId === commonProtocol.ValidChains.SepoliaBase, - ); - - // this condition will never be triggered, adding this to avoid typescript errors - if (!baseNode || !baseNode.ethChainId) { - notifyError('Could not find base chain node'); - return; - } - - try { - if (selectedAddress?.address) { - user.setData({ - addressSelectorSelectedAddress: selectedAddress.address, - }); - } - - const input = buildCreateCommunityInput({ - id: values.communityId, - name: values.communityName, - chainBase: ChainBase.Ethereum, - description: values.communityDescription, - iconUrl: values.communityProfileImageURL, - socialLinks: values.links ?? [], - chainNodeId: baseNode.id, - }); - await createCommunityMutation(input); - handleContinue(values.communityId); - } catch (err) { - notifyError(err.message); - } - }; - - const handleCancel = () => { - trackAnalytics({ - event: MixpanelCommunityCreationEvent.CREATE_TOKEN_COMMUNITY_CANCELLED, - isPWA: isAddedToHomeScreen, - }); - - openConfirmation({ - title: 'Are you sure you want to cancel?', - description: - 'Your details will not be saved. Cancel create token community flow?', - buttons: [ - { - label: 'Yes, cancel', - buttonType: 'destructive', - buttonHeight: 'sm', - onClick: handleGoBack, - }, - { - label: 'No, continue', - buttonType: 'primary', - buttonHeight: 'sm', - }, - ], - }); - }; - - return ( -
-
- Tell us about your community - - Let's start with some basic information about your community - -
- - - - -
- ); -}; - -export default CommunityInformationStep; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/index.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/index.ts deleted file mode 100644 index 4c30a45a445..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/CommunityInformationStep/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CommunityInformationStep from './CommunityInformationStep'; - -export default CommunityInformationStep; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.scss b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.scss deleted file mode 100644 index 6b51f99805c..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.scss +++ /dev/null @@ -1,43 +0,0 @@ -@import '../../../../../../styles/shared'; - -$form-width: 596px; - -.SignTokenTransactions { - display: flex; - - .header { - margin-top: 8px; - width: 100%; - display: flex; - gap: 16px; - flex-direction: column; - max-width: 596px; - margin-right: 110px; - - @include smallInclusive { - margin-right: unset; - } - - .description { - color: $neutral-500; - } - - .Divider { - margin-block: 8px; - } - - .action-buttons { - display: flex; - justify-content: space-between; - } - } - - .CWBanner { - display: flex !important; - max-width: $form-width !important; - - .content-container { - max-width: calc($form-width - 70px) !important; - } - } -} diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.tsx deleted file mode 100644 index c659ce8778e..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/SignTokenTransactions.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React from 'react'; -import { useUpdateCommunityMutation } from 'state/api/communities'; -import { useLaunchTokenMutation } from 'state/api/launchPad'; -import { useCreateTokenMutation } from 'state/api/tokens'; -import useUserStore from 'state/ui/user'; -import { CWDivider } from 'views/components/component_kit/cw_divider'; -import { CWText } from 'views/components/component_kit/cw_text'; -import CWBanner from 'views/components/component_kit/new_designs/CWBanner'; -import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; -import ActionSteps from 'views/pages/CreateCommunity/components/ActionSteps'; -import { ActionStepsProps } from 'views/pages/CreateCommunity/components/ActionSteps/types'; -import { SignTokenTransactionsProps } from '../types'; -import './SignTokenTransactions.scss'; - -const SignTokenTransactions = ({ - createdCommunityId, - selectedAddress, - baseNode, - tokenInfo, - onSuccess, - onCancel, -}: SignTokenTransactionsProps) => { - const user = useUserStore(); - - const { - mutateAsync: launchToken, - isLoading: isCreatingToken, - error: tokenLaunchError, - data: createdToken, - } = useLaunchTokenMutation(); - - const { mutateAsync: createToken } = useCreateTokenMutation(); - - const { mutateAsync: updateCommunity } = useUpdateCommunityMutation({ - communityId: createdCommunityId, - }); - - const handleSign = async () => { - try { - if (selectedAddress?.address) { - user.setData({ - addressSelectorSelectedAddress: selectedAddress.address, - }); - } - - // 1. Attempt Launch token on chain - const payload = { - chainRpc: baseNode.url, - ethChainId: baseNode.ethChainId || 0, // this will always exist, adding 0 to avoid typescript issues - name: tokenInfo.name.trim(), - symbol: tokenInfo.symbol.trim(), - walletAddress: selectedAddress.address, - }; - - const txReceipt = await launchToken(payload); - - // 2. store `tokenInfo` on db - const token = await createToken({ - transaction_hash: txReceipt.transactionHash, - chain_node_id: baseNode.id, - community_id: createdCommunityId, - icon_url: tokenInfo?.imageURL?.trim() || '', - description: tokenInfo?.description?.trim() || '', - }); - - // 3. update community to reference the created token - await updateCommunity({ - community_id: createdCommunityId, - token_name: payload.name, - namespace: token.namespace, - transactionHash: txReceipt.transactionHash, - }); - - onSuccess(); - } catch (e) { - // this will be displayed in the action step as `errorText`, no need to notify here - console.error(e); - } - }; - - const getActionSteps = (): ActionStepsProps['steps'] => { - return [ - { - label: 'Launch token', - state: isCreatingToken - ? 'loading' - : createdToken - ? 'completed' - : 'not-started', - actionButton: { - label: 'Sign', - disabled: false, - onClick: () => { - handleSign().catch(console.error); - }, - }, - errorText: tokenLaunchError - ? 'Something went wrong when creating the token' - : '', - }, - ]; - }; - - return ( -
-
- - Sign transactions to launch token and community - - - In order to launch token and community you will need to sign a - transaction. This transaction has associated gas fees. - - - - - - - - -
- -
-
-
- ); -}; - -export default SignTokenTransactions; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/index.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/index.ts deleted file mode 100644 index 6a8cf9074a2..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignTokenTransactions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import SignTokenTransactions from './SignTokenTransactions'; - -export default SignTokenTransactions; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignatureStep.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignatureStep.tsx deleted file mode 100644 index 9ff77474622..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/SignatureStep.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import SignTokenTransactions from './SignTokenTransactions'; -import { SignatureStepProps } from './types'; - -const SignatureStep = ({ - goToSuccessStep, - createdCommunityId, - selectedAddress, - baseNode, - tokenInfo, -}: SignatureStepProps) => { - return ( -
- goToSuccessStep(true)} - onCancel={() => goToSuccessStep(false)} - selectedAddress={selectedAddress} - createdCommunityId={createdCommunityId} - baseNode={baseNode} - tokenInfo={tokenInfo} - /> -
- ); -}; - -export default SignatureStep; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/index.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/index.ts deleted file mode 100644 index 1b5ab5a2ce8..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import SignatureStep from './SignatureStep'; - -export default SignatureStep; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/types.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/types.ts deleted file mode 100644 index f665baef81c..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/SignatureStep/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import NodeInfo from 'client/scripts/models/NodeInfo'; -import AddressInfo from 'models/AddressInfo'; -import { ActionStepProps } from 'views/pages/CreateCommunity/components/ActionSteps/types'; -import { TokenInfo } from '../../types'; - -export type ActionState = { - state: ActionStepProps['state']; - errorText: string; -}; - -type BaseProps = { - createdCommunityId: string; - selectedAddress: AddressInfo; - tokenInfo: TokenInfo; - baseNode: NodeInfo; -}; - -export type SignatureStepProps = { - goToSuccessStep: (isLaunched: boolean) => void; -} & BaseProps; - -export type SignTokenTransactionsProps = { - onSuccess: () => void; - onCancel: () => void; - selectedAddress: AddressInfo; - createdCommunityId: string; -} & BaseProps; - -export const defaultActionState: ActionState = { - state: 'not-started', - errorText: '', -}; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/index.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/index.ts deleted file mode 100644 index ea9d67d873f..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationForm/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import TokenInformationForm from './TokenInformationForm'; - -export default TokenInformationForm; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationStep.scss b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationStep.scss deleted file mode 100644 index ddb67a65634..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationStep.scss +++ /dev/null @@ -1,24 +0,0 @@ -@import '../../../../../styles/shared'; - -$form-width: 596px; - -.TokenInformationStep { - display: flex; - flex-direction: column; - gap: 24px; - position: relative; - - .header { - margin-top: 8px; - width: 100%; - - .description { - color: $neutral-500; - } - } - - @include smallInclusive { - flex-direction: column; - gap: 16px; - } -} diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationStep.tsx b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationStep.tsx deleted file mode 100644 index c6fa0767e67..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/TokenInformationStep.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import AddressInfo from 'models/AddressInfo'; -import React from 'react'; -import { CWText } from 'views/components/component_kit/cw_text'; -import TokenInformationForm from './TokenInformationForm/TokenInformationForm'; -import { FormSubmitValues } from './TokenInformationForm/types'; -import './TokenInformationStep.scss'; - -interface TokenInformationStepProps { - handleGoBack: () => void; - handleContinue: (values: FormSubmitValues) => void; - selectedAddress?: AddressInfo; - onAddressSelected: (address: AddressInfo) => void; -} - -const TokenInformationStep = ({ - handleGoBack, - handleContinue, - selectedAddress, - onAddressSelected, -}: TokenInformationStepProps) => { - return ( -
-
- Launch Token - - Something about launching a token - -
- - -
- ); -}; - -export default TokenInformationStep; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/index.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/index.ts deleted file mode 100644 index ead76717620..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/steps/TokenInformationStep/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import TokenInformationStep from './TokenInformationStep'; - -export default TokenInformationStep; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/useCreateTokenCommunity.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/useCreateTokenCommunity.ts index 3ba6786140e..2fa71108238 100644 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/useCreateTokenCommunity.ts +++ b/packages/commonwealth/client/scripts/views/pages/LaunchToken/useCreateTokenCommunity.ts @@ -3,8 +3,6 @@ import AddressInfo from 'models/AddressInfo'; import NodeInfo from 'models/NodeInfo'; import { useState } from 'react'; import { fetchCachedNodes } from 'state/api/nodes'; -import { TokenInfo } from './types'; -import { CreateTokenCommunityStep, handleChangeStep } from './utils'; const useCreateTokenCommunity = () => { // get base chain node info @@ -13,35 +11,15 @@ const useCreateTokenCommunity = () => { (n) => n.ethChainId === commonProtocol.ValidChains.SepoliaBase, ) as NodeInfo; // this is expected to exist - const [createTokenCommunityStep, setCreateTokenCommunityStep] = - useState( - CreateTokenCommunityStep.TokenInformation, - ); const [selectedAddress, setSelectedAddress] = useState(); - const [draftTokenInfo, setDraftTokenInfo] = useState(); const [createdCommunityId, setCreatedCommunityId] = useState(); - const [isTokenLaunched, setIsTokenLaunched] = useState(false); - - const onChangeStep = (forward: boolean) => { - handleChangeStep( - forward, - createTokenCommunityStep, - setCreateTokenCommunityStep, - ); - }; return { baseNode, - createTokenCommunityStep, - onChangeStep, selectedAddress, setSelectedAddress, - draftTokenInfo, - setDraftTokenInfo, createdCommunityId, setCreatedCommunityId, - isTokenLaunched, - setIsTokenLaunched, }; }; diff --git a/packages/commonwealth/client/scripts/views/pages/LaunchToken/utils.ts b/packages/commonwealth/client/scripts/views/pages/LaunchToken/utils.ts deleted file mode 100644 index 8cc705fc89d..00000000000 --- a/packages/commonwealth/client/scripts/views/pages/LaunchToken/utils.ts +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { CWFormStepsProps } from 'views/components/component_kit/new_designs/CWFormSteps/CWFormSteps'; - -export enum CreateTokenCommunityStep { - TokenInformation = 'TokenInformation', - CommunityInformation = 'CommunityInformation', - SignatureLaunch = 'SignatureLaunch', - Success = 'Success', -} - -export const getFormSteps = ( - activeStep: CreateTokenCommunityStep, -): CWFormStepsProps['steps'] => { - return [ - { - label: 'Token Details', - state: - activeStep === CreateTokenCommunityStep.TokenInformation - ? 'active' - : 'completed', - }, - { - label: 'Community', - state: - activeStep < CreateTokenCommunityStep.CommunityInformation - ? 'inactive' - : activeStep === CreateTokenCommunityStep.CommunityInformation - ? 'active' - : 'completed', - }, - { - label: 'Sign and Launch', - state: - activeStep < CreateTokenCommunityStep.SignatureLaunch - ? 'inactive' - : activeStep === CreateTokenCommunityStep.SignatureLaunch - ? 'active' - : 'completed', - }, - ]; -}; - -export const handleChangeStep = ( - forward: boolean, - activeStep: CreateTokenCommunityStep, - setActiveStep: React.Dispatch>, -) => { - switch (activeStep) { - case CreateTokenCommunityStep.TokenInformation: - setActiveStep(CreateTokenCommunityStep.CommunityInformation); - return; - case CreateTokenCommunityStep.CommunityInformation: - setActiveStep( - forward - ? CreateTokenCommunityStep.SignatureLaunch - : CreateTokenCommunityStep.TokenInformation, - ); - return; - case CreateTokenCommunityStep.SignatureLaunch: - setActiveStep( - forward - ? CreateTokenCommunityStep.Success - : CreateTokenCommunityStep.CommunityInformation, - ); - return; - } -}; diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/CommentSubscriptionEntry.tsx b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/CommentSubscriptionEntry.tsx index 0a28b0a549e..a55a4ae5e1a 100644 --- a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/CommentSubscriptionEntry.tsx +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/CommentSubscriptionEntry.tsx @@ -113,7 +113,7 @@ export const CommentSubscriptionEntry = (
} /> diff --git a/packages/commonwealth/client/scripts/views/pages/discussions_redirect.tsx b/packages/commonwealth/client/scripts/views/pages/discussions_redirect.tsx index 581af2cb481..4ed070efdba 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions_redirect.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions_redirect.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/function-component-definition */ import React, { useEffect } from 'react'; -import { NavigateOptions } from 'react-router-dom'; +import { NavigateOptions, useLocation } from 'react-router-dom'; import { DefaultPage } from '@hicommonwealth/shared'; import { useCommonNavigate } from 'navigation/helpers'; @@ -9,10 +9,17 @@ import { PageLoading } from './loading'; export default function DiscussionsRedirect() { const navigate = useCommonNavigate(); + const location = useLocation(); useEffect(() => { if (!app.chain) return; + const searchParams = new URLSearchParams(location.search); + // Remove 'tab' if it exists as we'll be setting it explicitly + searchParams.delete('tab'); + const existingParams = searchParams.toString(); + const additionalParams = existingParams ? `&${existingParams}` : ''; + const view = app.chain.meta?.default_summary_view ? DefaultPage.Overview : DefaultPage.Discussions; @@ -22,15 +29,15 @@ export default function DiscussionsRedirect() { const dontAddHistory: NavigateOptions = { replace: true }; switch (view) { case DefaultPage.Overview: - navigate('/overview?tab=overview', dontAddHistory); + navigate(`/overview?tab=overview${additionalParams}`, dontAddHistory); break; case DefaultPage.Discussions: - navigate('/discussions?tab=all', dontAddHistory); + navigate(`/discussions?tab=all${additionalParams}`, dontAddHistory); break; default: - navigate('/discussions?tab=all', dontAddHistory); + navigate(`/discussions?tab=all${additionalParams}`, dontAddHistory); } - }, [navigate]); + }, [navigate, location.search]); return ; } diff --git a/packages/commonwealth/client/scripts/views/pages/finish_social_login.tsx b/packages/commonwealth/client/scripts/views/pages/finish_social_login.tsx index 8ecff759eda..895b6e68e3e 100644 --- a/packages/commonwealth/client/scripts/views/pages/finish_social_login.tsx +++ b/packages/commonwealth/client/scripts/views/pages/finish_social_login.tsx @@ -42,6 +42,7 @@ const validate = async ( // @ts-expect-error walletSsoSource, isLoggedIn, + isCustomDomain, }); if (isMagicV1) { diff --git a/packages/commonwealth/client/scripts/views/pages/stats.tsx b/packages/commonwealth/client/scripts/views/pages/stats.tsx index 1436ee30a07..692e631d527 100644 --- a/packages/commonwealth/client/scripts/views/pages/stats.tsx +++ b/packages/commonwealth/client/scripts/views/pages/stats.tsx @@ -1,13 +1,14 @@ import axios from 'axios'; -import useNecessaryEffect from 'hooks/useNecessaryEffect'; -import React, { useState } from 'react'; +import Permissions from 'client/scripts/utils/Permissions'; +import React, { useEffect, useState } from 'react'; import app from 'state'; import { SERVER_URL } from 'state/api/config'; -import { userStore } from 'state/ui/user'; +import useUserStore, { userStore } from 'state/ui/user'; import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; import ErrorPage from 'views/pages/error'; import { PageLoading } from 'views/pages/loading'; import { CWText } from '../components/component_kit/cw_text'; +import { PageNotFound } from './404'; import './stats.scss'; type Batchable = { @@ -94,7 +95,9 @@ const StatsPage = () => { const [totalData, setTotalData] = useState(); const [error, setError] = useState(''); - useNecessaryEffect(() => { + const user = useUserStore(); + + useEffect(() => { const fetch = async () => { try { const response = await axios.get(`${SERVER_URL}/communityStats`, { @@ -144,6 +147,13 @@ const StatsPage = () => { } }, []); + if ( + !user.isLoggedIn || + !(Permissions.isSiteAdmin() || Permissions.isCommunityAdmin()) + ) { + return ; + } + if (!batchedData) { return ; } else if (error) { diff --git a/packages/commonwealth/client/scripts/views/pages/user_dashboard/index.tsx b/packages/commonwealth/client/scripts/views/pages/user_dashboard/index.tsx index cd1cff6d5ee..e26100a8283 100644 --- a/packages/commonwealth/client/scripts/views/pages/user_dashboard/index.tsx +++ b/packages/commonwealth/client/scripts/views/pages/user_dashboard/index.tsx @@ -8,6 +8,7 @@ import useBrowserWindow from 'hooks/useBrowserWindow'; import { useFlag } from 'hooks/useFlag'; import { useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; import useUserStore from 'state/ui/user'; import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; import { @@ -36,6 +37,7 @@ type UserDashboardProps = { const UserDashboard = ({ type }: UserDashboardProps) => { const user = useUserStore(); const { isWindowExtraSmall } = useBrowserWindow({}); + const location = useLocation(); const [activePage, setActivePage] = React.useState( DashboardViews.Global, @@ -62,13 +64,20 @@ const UserDashboard = ({ type }: UserDashboardProps) => { isPWA: isAddedToHomeScreen, }, }); + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const existingParams = searchParams.toString(); + const additionalParams = existingParams ? `?${existingParams}` : ''; + if (!type) { - navigate(`/dashboard/${user.isLoggedIn ? 'for-you' : 'global'}`); + navigate( + `/dashboard/${user.isLoggedIn ? 'for-you' : 'global'}${additionalParams}`, + ); } else if (type === 'for-you' && !user.isLoggedIn) { - navigate('/dashboard/global'); + navigate(`/dashboard/global${additionalParams}`); } - }, [user.isLoggedIn, navigate, type]); + }, [user.isLoggedIn, navigate, type, location.search]); const subpage: DashboardViews = user.isLoggedIn && type !== 'global' diff --git a/packages/commonwealth/client/vite.config.ts b/packages/commonwealth/client/vite.config.ts index aeb221e9c3d..01594e63d8b 100644 --- a/packages/commonwealth/client/vite.config.ts +++ b/packages/commonwealth/client/vite.config.ts @@ -138,6 +138,10 @@ export default defineConfig(({ mode }) => { }, build: { outDir: '../build', + // UNISWAP_WIDGET_HACK: this is needed by @uniswap to resolved multiple dependencies issues with peer-deps + commonjsOptions: { + transformMixedEsModules: true, + }, }, server: { port: 8080, @@ -152,6 +156,31 @@ export default defineConfig(({ mode }) => { }, resolve: { alias: [ + { + // UNISWAP_WIDGET_HACK: 'jsbi' is needed by @uniswap pkg for pricing calculations, this is + // not documented by the uniswap pkg or atleast i couldn't find it. + // adding this here for internal uniswap widget import resolution + // see: https://github.com/Uniswap/sdk-core/issues/20 and + // https://github.com/Uniswap/widgets/issues/586#issuecomment-1777323003 + // for more details + find: 'jsbi', + replacement: path.resolve( + __dirname, + '../node_modules/jsbi/dist/jsbi-cjs.js', + ), + }, + { + // UNISWAP_WIDGET_HACK: needed by @uniswap pkg for path resolution + // see: https://github.com/Uniswap/widgets/issues/593#issuecomment-1777415001 for more details + find: '~@fontsource/ibm-plex-mono', + replacement: '@fontsource/ibm-plex-mono', + }, + { + // UNISWAP_WIDGET_HACK: needed by @uniswap pkg for path resolution + // see: https://github.com/Uniswap/widgets/issues/593#issuecomment-1777415001 for more details + find: '~@fontsource/inter', + replacement: '@fontsource/inter', + }, { // matches only non-relative paths that end with .scss find: /^([^.].*)\.scss$/, diff --git a/packages/commonwealth/package.json b/packages/commonwealth/package.json index ce4d33d53f8..2be9db26fdc 100644 --- a/packages/commonwealth/package.json +++ b/packages/commonwealth/package.json @@ -157,6 +157,7 @@ "@trpc/client": "^10.45.1", "@trpc/react-query": "^10.45.1", "@types/react-helmet-async": "^1.0.3", + "@uniswap/widgets": "^2.59.0", "@viem/anvil": "^0.0.10", "@walletconnect/ethereum-provider": "^2.10.1", "@walletconnect/modal": "^2.4.6", @@ -203,6 +204,7 @@ "is-ipfs": "^8.0.1", "is-my-json-valid": "^2.20.6", "jdenticon": "^2.1.1", + "jsbi": "^4.3.0", "jsdom-global": "^3.0.2", "jsonwebtoken": "^9.0.0", "lexical": "^0.17.0", @@ -252,6 +254,7 @@ "react-loading-skeleton": "^3.3.1", "react-modern-drawer": "^1.2.2", "react-quill": "^2.0.0", + "react-redux": "^9.1.2", "react-router": "^6.9.0", "react-router-dom": "^6.9.0", "react-select": "^5.7.0", @@ -285,6 +288,7 @@ "zustand": "^4.3.8" }, "devDependencies": { + "@atomone/atomone-types-long": "^1.0.3", "@ethersproject/keccak256": "5.7.0", "@types/express": "^4.17.21", "@types/passport": "^1.0.16", diff --git a/packages/commonwealth/scripts/emit-event.ts b/packages/commonwealth/scripts/emit-event.ts index 1371b834441..86e16e538e3 100644 --- a/packages/commonwealth/scripts/emit-event.ts +++ b/packages/commonwealth/scripts/emit-event.ts @@ -26,47 +26,7 @@ async function main() { }, ]); } else if (process.argv[2] === 'chain-event') { - log.info('Emitting a chain event'); - await emitEvent(models.Outbox, [ - { - event_name: EventNames.ChainEventCreated, - event_payload: { - eventSource: { - kind: 'Trade', - chainNodeId: 1399, - eventSignature: - '0xfc13c9a8a9a619ac78b803aecb26abdd009182411d51a986090f82519d88a89e', - }, - rawLog: { - blockNumber: 12778141, - blockHash: - '0xca61ffd61d5230444ea82700da798b58861787b77cd79d91dc1c17cdca95f475', - transactionIndex: 1, - removed: false, - address: '0xd097926d8765a7717206559e7d19eeccbba68c18', - // eslint-disable-next-line max-len - data: '0x0000000000000000000000008bd1207d8305cf176c1544d1fe8caa12b1b76fdf000000000000000000000000d7f82204b1f47bfde583a8d360986b31f22d3dae000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000007fba274cf70000000000000000000000000000000000000000000000000000000662e85d72c000000000000000000000000000000000000000000000000000000662e85d72c0000000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000', - topics: [ - '0xfc13c9a8a9a619ac78b803aecb26abdd009182411d51a986090f82519d88a89e', - ], - transactionHash: - '0x2ed9d64010f1ddbcaf40fa7547d525bda91a7e3aaa27d1aa78a9b9273c2cbb0f', - logIndex: 1, - }, - parsedArgs: [ - '0x8bD1207d8305CF176c1544d1fe8CAA12b1B76FDf', // trader - '0xD7f82204b1F47BFdE583a8d360986b31F22D3DAe', // namespace address - true, - { type: 'BigNumber', hex: '0x04' }, // community token amount - { type: 'BigNumber', hex: '0x07fba274cf7000' }, // eth amount - { type: 'BigNumber', hex: '0x662e85d72c00' }, // protocol eth amount - { type: 'BigNumber', hex: '0x662e85d72c00' }, // namespace eth amount - { type: 'BigNumber', hex: '0x24' }, // supply - '0x0000000000000000000000000000000000000000', // exchange token - ], - }, - }, - ]); + log.error('Not implemented'); } else { throw new Error('Unsupported event type'); } diff --git a/packages/commonwealth/scripts/sitemap-runner.ts b/packages/commonwealth/scripts/sitemap-runner.ts index c50ea77199e..c8cceb8a8ea 100644 --- a/packages/commonwealth/scripts/sitemap-runner.ts +++ b/packages/commonwealth/scripts/sitemap-runner.ts @@ -5,6 +5,7 @@ import { createDatabasePaginatorDefault, createSitemapGenerator, } from '@hicommonwealth/sitemaps'; +import { config } from '../server/config'; const log = logger(import.meta); blobStorage({ @@ -15,17 +16,14 @@ stats({ }); async function doExec() { - if (process.env.SITEMAP_ENV !== 'production') { - throw new Error( - // eslint-disable-next-line max-len - 'Define SITEMAP_ENV to signify you understand that this should only run in production to avoid breaking sitemaps.', - ); + if (!['production', 'local'].includes(config.APP_ENV)) { + throw new Error('Must be in production or local environment'); } - if (process.env.NODE_ENV !== 'production') { - // we have to enforce production because if we don't we will get localhost - // URLs and that might be very destructive to our SEO - throw new Error('Must run with NODE_ENV=production'); + if (config.APP_ENV === 'local' && config.NODE_ENV === 'production') { + throw new Error( + 'Cannot execute sitemap-runner locally with NODE_ENV=production', + ); } stats().increment('cw.scheduler.email-digest'); diff --git a/packages/commonwealth/server/api/community.ts b/packages/commonwealth/server/api/community.ts index 89b79841775..6626718db2e 100644 --- a/packages/commonwealth/server/api/community.ts +++ b/packages/commonwealth/server/api/community.ts @@ -128,4 +128,7 @@ export const trpcRouter = trpc.router({ }), ]), banAddress: trpc.command(Community.BanAddress, trpc.Tag.Community), + getPinnedTokens: trpc.query(Community.GetPinnedTokens, trpc.Tag.Community), + pinToken: trpc.command(Community.PinToken, trpc.Tag.Community), + unpinToken: trpc.command(Community.UnpinToken, trpc.Tag.Community), }); diff --git a/packages/commonwealth/server/api/integration-router.ts b/packages/commonwealth/server/api/integration-router.ts index 72cdab8f23c..a89326a5b99 100644 --- a/packages/commonwealth/server/api/integration-router.ts +++ b/packages/commonwealth/server/api/integration-router.ts @@ -3,6 +3,7 @@ import { AppError } from '@hicommonwealth/core'; import { ChainEvents, Contest, Snapshot, config } from '@hicommonwealth/model'; import { Router, raw } from 'express'; import farcasterRouter from 'server/farcaster/router'; +import { validateFarcasterAction } from 'server/middleware/validateFarcasterAction'; import { validateNeynarWebhook } from 'server/middleware/validateNeynarWebhook'; import { config as serverConfig } from '../config'; @@ -59,7 +60,9 @@ function build() { router.post( '/farcaster/CastUpvoteAction', - // TODO: create new validation middleware for actions + (req, _, next) => { + validateFarcasterAction()(req, _, next).catch(next); + }, express.command(Contest.FarcasterUpvoteAction()), ); } diff --git a/packages/commonwealth/server/api/internal-router.ts b/packages/commonwealth/server/api/internal-router.ts index b901477c594..0ee39c02c9f 100644 --- a/packages/commonwealth/server/api/internal-router.ts +++ b/packages/commonwealth/server/api/internal-router.ts @@ -9,12 +9,12 @@ import * as discordBot from './discordBot'; import * as email from './emails'; import * as feed from './feed'; import * as integrations from './integrations'; +import * as launchpadToken from './launchpadToken'; import * as loadTest from './load-test'; import * as poll from './poll'; import * as subscription from './subscription'; import * as superAdmin from './super-admin'; import * as thread from './thread'; -import * as token from './token'; import * as user from './user'; import * as wallet from './wallet'; import * as webhook from './webhook'; @@ -32,7 +32,7 @@ const api = { webhook: webhook.trpcRouter, superAdmin: superAdmin.trpcRouter, discordBot: discordBot.trpcRouter, - token: token.trpcRouter, + launchpadToken: launchpadToken.trpcRouter, poll: poll.trpcRouter, }; diff --git a/packages/commonwealth/server/api/launchpadToken.ts b/packages/commonwealth/server/api/launchpadToken.ts new file mode 100644 index 00000000000..9679985dfc5 --- /dev/null +++ b/packages/commonwealth/server/api/launchpadToken.ts @@ -0,0 +1,9 @@ +import { trpc } from '@hicommonwealth/adapters'; +import { Token } from '@hicommonwealth/model'; + +export const trpcRouter = trpc.router({ + createTrade: trpc.command(Token.CreateLaunchpadTrade, trpc.Tag.Token), + createToken: trpc.command(Token.CreateLaunchpadToken, trpc.Tag.Token), + getTokens: trpc.query(Token.GetLaunchpadTokens, trpc.Tag.Token), + getToken: trpc.query(Token.GetLaunchpadToken, trpc.Tag.Token), +}); diff --git a/packages/commonwealth/server/api/token.ts b/packages/commonwealth/server/api/token.ts deleted file mode 100644 index c99abc0a29d..00000000000 --- a/packages/commonwealth/server/api/token.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { trpc } from '@hicommonwealth/adapters'; -import { Token } from '@hicommonwealth/model'; - -export const trpcRouter = trpc.router({ - createLaunchpadTrade: trpc.command( - Token.CreateLaunchpadTrade, - trpc.Tag.Token, - ), - createToken: trpc.command(Token.CreateToken, trpc.Tag.Token), - getTokens: trpc.query(Token.GetTokens, trpc.Tag.Token), - getToken: trpc.query(Token.GetToken, trpc.Tag.Token), -}); diff --git a/packages/commonwealth/server/farcaster/frames/contest/contestCard.tsx b/packages/commonwealth/server/farcaster/frames/contest/contestCard.tsx index 3bb5f4ad437..42de9b5296f 100644 --- a/packages/commonwealth/server/farcaster/frames/contest/contestCard.tsx +++ b/packages/commonwealth/server/farcaster/frames/contest/contestCard.tsx @@ -3,7 +3,7 @@ import { Contest, config as modelConfig } from '@hicommonwealth/model'; import { Button } from 'frames.js/express'; import React from 'react'; -import { PRODUCTION_DOMAIN } from '@hicommonwealth/shared'; +import { buildContestLeaderboardUrl, getBaseUrl } from '@hicommonwealth/shared'; import { frames } from '../../config'; export const contestCard = frames(async (ctx) => { @@ -16,7 +16,7 @@ export const contestCard = frames(async (ctx) => { if (!contestManager) { return { - title: 'N/A', + title: 'Contest not found', image: (
{ fontSize: '56px', }} > - Not Found + Contest not found.

), }; } + if (contestManager.ended || contestManager.cancelled) { + return { + title: 'Contest Ended', + image: ( +
+

+ Contest ended. New entries will not be accepted. +

+
+ ), + }; + } + + const leaderboardUrl = buildContestLeaderboardUrl( + getBaseUrl(config.APP_ENV), + contestManager.community_id, + contestManager.contest_address, + ); + return { title: contestManager.name, image: ( @@ -81,11 +115,7 @@ export const contestCard = frames(async (ctx) => {
), buttons: [ - ,