diff --git a/.changeset/safe-account-read-view.md b/.changeset/safe-account-read-view.md new file mode 100644 index 0000000000..a36132b899 --- /dev/null +++ b/.changeset/safe-account-read-view.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Add a read-only Safe account view at `/safe/{network}/{address}` showing owners, threshold, version, nonce, the live pending queue and balances diff --git a/.changeset/safe-account-writes.md b/.changeset/safe-account-writes.md new file mode 100644 index 0000000000..dca386ad24 --- /dev/null +++ b/.changeset/safe-account-writes.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Let Safe owner wallets submit gasless approval or veto signatures from SPP proposal body cards and execute the report once the Safe threshold is reached diff --git a/.changeset/safe-address-checksum.md b/.changeset/safe-address-checksum.md new file mode 100644 index 0000000000..ee4cf316ab --- /dev/null +++ b/.changeset/safe-address-checksum.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +Normalise Safe addresses to their checksummed form at the Safe transaction service boundary, fixing the owners, pending transaction and asset sections failing to load when a Safe address is not already checksummed diff --git a/.changeset/safe-backend-reads.md b/.changeset/safe-backend-reads.md new file mode 100644 index 0000000000..18d1de0540 --- /dev/null +++ b/.changeset/safe-backend-reads.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Read Safe governance body state from Aragon's backend instead of calling the Safe transaction service directly, so concurrent viewers of a Safe share one cached upstream read, owners and threshold cost no Safe API quota at all, and a payload served from the backend's stale window is shown as such diff --git a/.changeset/safe-body-card.md b/.changeset/safe-body-card.md new file mode 100644 index 0000000000..f78c6681ab --- /dev/null +++ b/.changeset/safe-body-card.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Show live Safe owners, signature progress, nonces and superseded result reports inside SPP proposal body cards diff --git a/.changeset/safe-nonce-allocation.md b/.changeset/safe-nonce-allocation.md new file mode 100644 index 0000000000..f9d15d67ba --- /dev/null +++ b/.changeset/safe-nonce-allocation.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +Sign Safe proposal reports against the next free Safe nonce instead of the current one, so a new report no longer competes with an already queued transaction, let an owner re-queue a report whose nonce was consumed, and hold the action while an executed report is still being indexed diff --git a/.changeset/safe-read-cost.md b/.changeset/safe-read-cost.md new file mode 100644 index 0000000000..04cfa427e3 --- /dev/null +++ b/.changeset/safe-read-cost.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +Cut Safe transaction service usage by sharing one cached upstream read across concurrent viewers of a Safe body, halving the poll cadence, keeping Safe reads in the query cache longer, and backing the poll off instead of hammering an exhausted quota — and surface an exhausted quota as a temporary state rather than a generic failure diff --git a/.changeset/safe-tx-service-data-layer.md b/.changeset/safe-tx-service-data-layer.md new file mode 100644 index 0000000000..ffc61a5920 --- /dev/null +++ b/.changeset/safe-tx-service-data-layer.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": none +--- + +Add the Safe transaction service data layer: a keyed `/api/safe` proxy route and the `safeService` queries for Safe info, live queue and balances diff --git a/apps/app/.env.example b/apps/app/.env.example index 28a7915652..e8c7f0f397 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -13,6 +13,10 @@ NEXT_SECRET_DRPC_RPC_KEY=YOUR_VALUE_HERE # Api key for connecting to the Aragon backend service NEXT_SECRET_ARAGON_BACKEND_API_KEY=YOUR_VALUE_HERE +# Api key for the Safe transaction service, sent as an Authorization Bearer header by the +# /api/safe proxy route (see https://docs.safe.global/core-api/how-to-use-api-keys) +NEXT_SECRET_SAFE_API_KEY=YOUR_VALUE_HERE + # Envio API key and endpoint NEXT_SECRET_ENVIO_GRAPHQL_ENDPOINT=YOUR_VALUE_HERE NEXT_SECRET_ENVIO_API_TOKEN=YOUR_VALUE_HERE diff --git a/apps/app/package.json b/apps/app/package.json index f73b35ac3d..86c2e692a3 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -56,6 +56,7 @@ "@reown/appkit": "^1.8.23", "@reown/appkit-adapter-wagmi": "^1.8.23", "@reown/walletkit": "^1.5.6", + "@safe-global/protocol-kit": "^8.0.6", "@sentry/core": "^10.71.0", "@sentry/nextjs": "^10.71.0", "@tanstack/react-query": "catalog:", diff --git a/apps/app/src/app/api/safe/[chainId]/[...path]/route.ts b/apps/app/src/app/api/safe/[chainId]/[...path]/route.ts new file mode 100644 index 0000000000..20a47f8cf7 --- /dev/null +++ b/apps/app/src/app/api/safe/[chainId]/[...path]/route.ts @@ -0,0 +1,4 @@ +import { proxySafeUtils } from '@/modules/application/utils/proxySafeUtils'; + +export const GET = proxySafeUtils.request; +export const POST = proxySafeUtils.request; diff --git a/apps/app/src/app/safe/[network]/[address]/page.tsx b/apps/app/src/app/safe/[network]/[address]/page.tsx new file mode 100644 index 0000000000..56a25b8703 --- /dev/null +++ b/apps/app/src/app/safe/[network]/[address]/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from 'next/navigation-original'; +import { SafeAccountPage } from '@/modules/safe/pages/safeAccountPage'; +import type { ISafeAccountPageParams } from '@/modules/safe/types'; +import { networkUtils } from '@/shared/utils/networkUtils'; + +interface ISafePageProps { + /** + * Safe account route parameters. + */ + params: Promise; +} + +// `next/navigation` is aliased to the app's client-side wrapper, so the server helpers are +// imported from `next/navigation-original`. +const SafePage = async (props: ISafePageProps) => { + const { network, address } = await props.params; + + // A malformed network is a missing page, never a server error. The address is normalised to + // its checksum in the client page, matching how the DAO page resolves its parameters. + if (!networkUtils.isValidNetwork(network)) { + notFound(); + } + + return ; +}; + +export default SafePage; diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index e0a7ac4351..2d3894b937 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3161,6 +3161,104 @@ "entity": "Votes" } }, + "safeMultisig": { + "safeMultisigConfirmSignatureDialog": { + "approval": { + "action": "Approve", + "description": "Your confirmation is added to the Safe transaction that approves this proposal for this body. It takes effect once enough owners have confirmed.", + "title": "Approve proposal" + }, + "bundledExecution": "Your confirmation reaches the Safe's threshold, so the Safe transaction is executed straight after it. Your wallet will ask twice: first to confirm, which is free, then to execute onchain, which costs gas.", + "cancel": "Cancel", + "details": { + "network": "Network", + "nonce": "Safe nonce", + "proposal": "Proposal", + "safe": "Safe", + "signingAs": "Signing as" + }, + "gasless": "Signing costs no gas. Aragon submits your signature to the Safe for you.", + "veto": { + "action": "Veto", + "description": "Your confirmation is added to the Safe transaction that vetoes this proposal for this body. It takes effect once enough owners have confirmed.", + "title": "Veto proposal" + } + }, + "safeMultisigGovernanceSettings": { + "currentNonce": "Current Safe nonce", + "execution": "Execution", + "executionValue": "Any owner, once threshold is met", + "safe": "Safe", + "strategy": "Strategy", + "strategyValue": "Safe multisig", + "threshold": "Threshold", + "thresholdValue": "{{min}} of {{max}} owners", + "unknownVersion": "Unknown", + "version": "Safe version" + }, + "safeMultisigProposalVotingBreakdown": { + "error": "Safe state could not be loaded. Try again later.", + "executed": "Safe transaction executed", + "loading": "Loading Safe state\u2026", + "rateLimited": "Safe state is temporarily unavailable \u2014 the shared Safe API limit was reached. This retries on its own.", + "rateLimitedRetry": "Safe state is temporarily unavailable \u2014 the shared Safe API limit was reached. Retrying in about {{seconds}} seconds." + }, + "safeMultisigProposalVotingSummary": { + "approvalLabel": "approvals", + "approved": "approved", + "notApproved": "did not approve", + "notVetoed": "did not veto", + "ownerCount": "of {{count}} owners", + "replaced": "replaced", + "vetoLabel": "veto", + "vetoed": "vetoed" + }, + "safeMultisigSubmitVote": { + "approve": "Approve proposal", + "approveAndExecute": "Approve and execute", + "approveOnly": "Approve only", + "approved": "Approved", + "awaitingExecution": "The Safe's threshold is met. This body's vote only counts once the Safe transaction is executed, and Safe transactions have no deadline - so that can still happen at any time.", + "awaitingIndexing": "Executed onchain. Waiting for indexing to complete.", + "error": "The Safe action could not be completed. Try again.", + "executeSafeTransaction": "Execute Safe transaction", + "finalizing": "Finalizing", + "indexingDelayed": "The Safe transaction executed onchain but is still not indexed. The result will appear once indexing catches up.", + "moreActions": "More approval options", + "nonceQueued": "Waiting on {{count}} earlier transaction(s) in the Safe queue. A Safe executes in nonce order and each confirmation is tied to its transaction's place in that order, so this one cannot be moved ahead. Owners can review the queue in Safe.", + "ownerRequired": "Connect an owner wallet for this Safe to continue.", + "replaced": "Another Safe transaction executed at this nonce, so this one can never execute. Its confirmations no longer apply.", + "requeueSafeTransaction": "Re-queue Safe transaction", + "retry": "Retry", + "stageExpired": "This stage was not advanced in time, so the proposal has expired. A vote can no longer change its outcome.", + "stageExpiredQueued": "This stage expired before the Safe transaction was executed. Safe transactions have no deadline, so it can still be executed, but it will have no bearing on the proposal's outcome.", + "unknownVersion": "unknown", + "unreachable": "Safe{Wallet} is unreachable. Confirmations shown may be incomplete.", + "versionUnsupported": "Contract-owner signatures require Safe v1.4.1 or newer. This Safe is {{version}}.", + "veto": "Veto proposal", + "vetoAndExecute": "Veto and execute", + "vetoOnly": "Veto only", + "vetoed": "Vetoed", + "waitingForOwners": "Your confirmation is submitted. Waiting for the remaining owners.", + "windowClosed": "This shows as rejected because the voting window closed before approval was reached, but the stage has not expired and can still advance. Safe transactions have no deadline either, so confirming and executing still records this body's vote and still counts." + }, + "safeMultisigVoteList": { + "empty": { + "description": "Owner confirmations appear here as they are collected.", + "heading": "No confirmations yet" + }, + "entity": "Confirmations", + "error": { + "description": "The Safe confirmations could not be loaded. Try again later.", + "heading": "Confirmations unavailable" + }, + "settled": { + "action": "View in Safe", + "description": "The Safe serves confirmations for queued transactions only. This one has executed, so its confirmations live in the Safe's own history.", + "heading": "Confirmations no longer listed" + } + } + }, "spp": { "advanceStageDialog": { "button": { @@ -3235,7 +3333,6 @@ "approve": "Approve proposal", "approved": "Approved", "helpText": "If you are an owner of this address you can connect with WalletConnect to vote on this proposal.", - "helpTextSafe": "To approve this proposal from the Safe{Wallet} UI, add Aragon as a custom Safe App or connect directly with a WalletConnect QR code.", "veto": "Veto proposal", "vetoed": "Vetoed" }, @@ -3623,6 +3720,85 @@ } } }, + "safe": { + "safeAccountPage": { + "aside": { + "details": { + "address": "Address", + "chain": "Chain", + "nonce": "Nonce", + "threshold": "Threshold", + "thresholdValue": "{{threshold}} of {{owners}}", + "title": "Details", + "unknown": "Unknown", + "version": "Version" + } + }, + "header": { + "description": "A read-only view of this Safe: its owners, signing threshold, contract version, nonce, queued transactions and balances." + }, + "main": { + "assets": { + "title": "Assets" + }, + "owners": { + "title": "Owners" + }, + "pending": { + "description": "Transactions waiting to be executed. Transactions below the current nonce can never execute and are not shown.", + "title": "Pending transactions" + } + }, + "stats": { + "nonce": "Nonce", + "owners": "Owners", + "threshold": "Threshold", + "thresholdSuffix": "of {{owners}}" + }, + "unsupportedNetwork": { + "description": "Safe does not run a transaction service on {{network}}, so this Safe cannot be read here.", + "heading": "Not supported on this network" + } + }, + "safeBalanceList": { + "empty": { + "description": "This Safe does not hold any assets yet.", + "heading": "No assets" + }, + "entity": "Assets", + "error": { + "description": "The balances of this Safe could not be loaded. Please try again later.", + "heading": "Unable to load assets" + } + }, + "safeOwnerList": { + "empty": { + "description": "No owners were reported for this Safe.", + "heading": "No owners" + }, + "entity": "Owners", + "error": { + "description": "The owners of this Safe could not be loaded. Please try again later.", + "heading": "Unable to load owners" + } + }, + "safePendingTransactionList": { + "empty": { + "description": "This Safe has no transactions waiting to be executed.", + "heading": "No pending transactions" + }, + "entity": "Transactions", + "error": { + "description": "The pending transactions of this Safe could not be loaded. Please try again later.", + "heading": "Unable to load pending transactions" + }, + "item": { + "confirmations": "{{count}} of {{required}} confirmations", + "nonce": "Nonce {{nonce}}", + "submittedOn": "Submitted {{date}}" + } + } + }, "settings": { "daoPluginInfo": { "bodyName": "Body name", diff --git a/apps/app/src/modules/application/utils/proxySafeUtils/index.ts b/apps/app/src/modules/application/utils/proxySafeUtils/index.ts new file mode 100644 index 0000000000..bdb4df8056 --- /dev/null +++ b/apps/app/src/modules/application/utils/proxySafeUtils/index.ts @@ -0,0 +1,6 @@ +import { ProxySafeUtils } from './proxySafeUtils'; + +// The singleton asserts the server-side Safe key at boot, so this barrel is server-only. Client +// code that needs the chain coverage table imports `./safeTxServiceNetworks` directly. +export const proxySafeUtils = new ProxySafeUtils(); +export type { ISafeRequestParams } from './proxySafeUtils'; diff --git a/apps/app/src/modules/application/utils/proxySafeUtils/proxySafeUtils.test.ts b/apps/app/src/modules/application/utils/proxySafeUtils/proxySafeUtils.test.ts new file mode 100644 index 0000000000..30d4e4eddf --- /dev/null +++ b/apps/app/src/modules/application/utils/proxySafeUtils/proxySafeUtils.test.ts @@ -0,0 +1,474 @@ +/** + * @jest-environment node + */ + +import { revalidateTag } from 'next/cache'; +import type { NextURL } from 'next/dist/server/web/next-url'; +import { NextResponse } from 'next/server'; +import { SafeServiceErrorCode } from '@/shared/api/safeService/domain'; +import { generateNextRequest, generateResponse } from '@/shared/testUtils'; +import { testLogger } from '@/test/utils'; +import { type ISafeRequestOptions, ProxySafeUtils } from './proxySafeUtils'; + +jest.mock('next/cache', () => ({ revalidateTag: jest.fn() })); + +describe('proxySafe utils', () => { + const originalProcessEnv = process.env; + + const fetchSpy = jest.spyOn(global, 'fetch'); + const nextResponseJsonSpy = jest.spyOn(NextResponse, 'json'); + const revalidateTagSpy = jest.mocked(revalidateTag); + + beforeEach(() => { + process.env.NEXT_SECRET_SAFE_API_KEY = 'test-safe-key'; + process.env.NEXT_RUNTIME = 'nodejs'; + process.env.CI = 'false'; + }); + + afterEach(() => { + process.env = { ...originalProcessEnv }; + fetchSpy.mockReset(); + nextResponseJsonSpy.mockReset(); + revalidateTagSpy.mockReset(); + }); + + const safeAddress = `0x${'a'.repeat(40)}`; + + const createTestOptions = ( + chainId: string, + path: string[] = ['v1', 'safes', safeAddress, 'balances'], + ): ISafeRequestOptions => ({ + params: Promise.resolve({ chainId, path }), + }); + + const createTestRequest = (search = '', method = 'GET', body?: unknown) => + generateNextRequest({ + method, + nextUrl: { search } as NextURL, + json: jest.fn().mockResolvedValue(body), + }); + + describe('constructor', () => { + it('throws error when the safe api key is not defined on non CI context', () => { + testLogger.suppressErrors(); + delete process.env.NEXT_SECRET_SAFE_API_KEY; + process.env.CI = 'false'; + expect(() => new ProxySafeUtils()).toThrow( + /NEXT_SECRET_SAFE_API_KEY/, + ); + }); + + it('does not throw error when the safe api key is not defined on CI context', () => { + delete process.env.NEXT_SECRET_SAFE_API_KEY; + process.env.CI = 'true'; + expect(() => new ProxySafeUtils()).not.toThrow(); + }); + }); + + describe('request', () => { + it('forwards the request to the safe transaction service with the bearer authorization header', async () => { + const testClass = new ProxySafeUtils(); + const parsedResponse = [{ tokenAddress: null }]; + const fetchReturn = generateResponse({ + json: jest.fn(() => Promise.resolve(parsedResponse)), + }); + fetchSpy.mockResolvedValue(fetchReturn); + + await testClass.request( + createTestRequest('?trusted=true'), + createTestOptions('1'), + ); + + expect(fetchSpy).toHaveBeenCalledWith( + `https://api.safe.global/tx-service/eth/api/v1/safes/${safeAddress}/balances/?trusted=true`, + expect.objectContaining({ + method: 'GET', + credentials: 'omit', + headers: expect.objectContaining({ + Authorization: 'Bearer test-safe-key', + }) as unknown, + }), + ); + expect(nextResponseJsonSpy).toHaveBeenCalledWith(parsedResponse); + }); + + it('rejects a read outside the allowlisted surface', async () => { + // The route is unauthenticated and spends a shared API key. Governance-body reads moved + // to the Aragon backend, so an open GET here would only serve an abuser. + const testClass = new ProxySafeUtils(); + + await testClass.request( + createTestRequest(), + createTestOptions('1', ['v1', 'safes', safeAddress]), + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ code: 'upstream-error' }), + expect.objectContaining({ status: 400 }), + ); + }); + + it('forwards an uncached proposal POST body only to the supported Safe endpoint', async () => { + const testClass = new ProxySafeUtils(); + const body = { + safeTxHash: `0x${'1'.repeat(64)}`, + senderSignature: '0xsignature', + }; + fetchSpy.mockResolvedValue( + generateResponse({ + status: 201, + json: jest.fn(() => Promise.resolve({})), + }), + ); + + await testClass.request( + createTestRequest('', 'POST', body), + createTestOptions('1', [ + 'v1', + 'safes', + `0x${'a'.repeat(40)}`, + 'multisig-transactions', + ]), + ); + + expect(fetchSpy).toHaveBeenCalledWith( + `https://api.safe.global/tx-service/eth/api/v1/safes/0x${'a'.repeat(40)}/multisig-transactions/`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(body), + cache: 'no-store', + credentials: 'omit', + headers: expect.objectContaining({ + Authorization: 'Bearer test-safe-key', + 'Content-Type': 'application/json', + }) as unknown, + }), + ); + }); + + it('rejects a POST to an unrelated transaction-service endpoint', async () => { + const testClass = new ProxySafeUtils(); + + await testClass.request( + createTestRequest('', 'POST', {}), + createTestOptions('1', ['v1', 'delegates']), + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.UPSTREAM_ERROR, + }), + expect.objectContaining({ status: 400 }), + ); + }); + + it('preserves a successful empty POST response', async () => { + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ + status: 201, + text: jest.fn().mockResolvedValue(''), + }), + ); + + const response = await testClass.request( + createTestRequest('', 'POST', { signature: '0xsignature' }), + createTestOptions('1', [ + 'v1', + 'multisig-transactions', + `0x${'1'.repeat(64)}`, + 'confirmations', + ]), + ); + + expect(response.status).toEqual(201); + expect(nextResponseJsonSpy).not.toHaveBeenCalled(); + }); + + it('caches a read against a per-safe tag so concurrent viewers share one upstream call', async () => { + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ json: jest.fn().mockResolvedValue([]) }), + ); + + await testClass.request( + createTestRequest(), + createTestOptions('1'), + ); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + next: { + revalidate: 10, + tags: ['safe:1', `safe:1:${safeAddress}`], + }, + }), + ); + }); + + it('never caches a write', async () => { + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ json: jest.fn().mockResolvedValue({}) }), + ); + + await testClass.request( + createTestRequest('', 'POST', { signature: '0xsignature' }), + createTestOptions('1', [ + 'v1', + 'safes', + `0x${'a'.repeat(40)}`, + 'multisig-transactions', + ]), + ); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ cache: 'no-store' }), + ); + }); + + it('drops the cached safe state after a successful proposal so the signer sees their own signature', async () => { + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ json: jest.fn().mockResolvedValue({}) }), + ); + const safeAddress = `0x${'a'.repeat(40)}`; + + await testClass.request( + createTestRequest('', 'POST', { nonce: '7' }), + createTestOptions('1', [ + 'v1', + 'safes', + safeAddress, + 'multisig-transactions', + ]), + ); + + // `expire: 0` rather than a stale-while-revalidate profile: read-your-own-writes. + expect(revalidateTagSpy).toHaveBeenCalledWith( + `safe:1:${safeAddress}`, + { + expire: 0, + }, + ); + }); + + it('does not revalidate when the write failed', async () => { + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ ok: false, status: 422 }), + ); + + await testClass.request( + createTestRequest('', 'POST', { nonce: '7' }), + createTestOptions('1', [ + 'v1', + 'safes', + `0x${'a'.repeat(40)}`, + 'multisig-transactions', + ]), + ); + + expect(revalidateTagSpy).not.toHaveBeenCalled(); + }); + + it('returns a typed unsupported-chain response for a chain without a transaction service', async () => { + const testClass = new ProxySafeUtils(); + + // Citrea (4114) is a supported app network with no Safe transaction service. + await testClass.request( + createTestRequest(), + createTestOptions('4114'), + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.UNSUPPORTED_CHAIN, + }), + expect.objectContaining({ status: 501 }), + ); + }); + + it('returns a typed unsupported-chain response for an unknown chain id', async () => { + const testClass = new ProxySafeUtils(); + + await testClass.request( + createTestRequest(), + createTestOptions('72983'), + ); + + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.UNSUPPORTED_CHAIN, + }), + expect.objectContaining({ status: 501 }), + ); + }); + + it('returns a typed degraded response with the upstream backoff on a rate limit', async () => { + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ + ok: false, + status: 429, + headers: new Headers({ 'retry-after': '30' }), + }), + ); + + await testClass.request( + createTestRequest(), + createTestOptions('1'), + ); + + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.RATE_LIMITED, + retryAfter: 30, + }), + expect.objectContaining({ + status: 429, + headers: { 'Retry-After': '30' }, + }), + ); + }); + + it('provides a default backoff when a rate limit response omits retry-after', async () => { + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ ok: false, status: 429 }), + ); + + await testClass.request( + createTestRequest(), + createTestOptions('1'), + ); + + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.RATE_LIMITED, + retryAfter: 60, + }), + expect.objectContaining({ + status: 429, + headers: { 'Retry-After': '60' }, + }), + ); + }); + + it('forwards an upstream not-found as a typed not-found response', async () => { + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ ok: false, status: 404 }), + ); + + await testClass.request( + createTestRequest(), + createTestOptions('1'), + ); + + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.NOT_FOUND, + }), + expect.objectContaining({ status: 404 }), + ); + }); + + it('returns a typed upstream-error response on an unexpected upstream status', async () => { + testLogger.suppressErrors(); + const testClass = new ProxySafeUtils(); + fetchSpy.mockResolvedValue( + generateResponse({ ok: false, status: 503 }), + ); + + await testClass.request( + createTestRequest(), + createTestOptions('1'), + ); + + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.UPSTREAM_ERROR, + }), + expect.objectContaining({ status: 503 }), + ); + }); + + it('returns a typed connection-error response when the upstream cannot be reached', async () => { + testLogger.suppressErrors(); + const testClass = new ProxySafeUtils(); + fetchSpy.mockRejectedValue(new Error('network down')); + + await testClass.request( + createTestRequest(), + createTestOptions('1'), + ); + + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.CONNECTION_ERROR, + }), + expect.objectContaining({ status: 502 }), + ); + }); + + it('returns a typed not-configured response when the key cannot be read outside a server runtime', async () => { + testLogger.suppressErrors(); + const testClass = new ProxySafeUtils(); + delete process.env.NEXT_RUNTIME; + + await testClass.request( + createTestRequest(), + createTestOptions('1'), + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + code: SafeServiceErrorCode.NOT_CONFIGURED, + }), + expect.objectContaining({ status: 503 }), + ); + }); + + it('rejects a path attempting to escape the transaction service base url', async () => { + const testClass = new ProxySafeUtils(); + + await testClass.request( + createTestRequest(), + createTestOptions('1', ['v1', '..', 'other']), + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(nextResponseJsonSpy).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.any(String) as unknown, + }), + expect.objectContaining({ status: 400 }), + ); + }); + }); + + describe('buildUpstreamPath', () => { + it('joins the segments and appends the trailing slash required by the transaction service', () => { + const testClass = new ProxySafeUtils(); + expect( + testClass['buildUpstreamPath'](['v1', 'safes', '0xAddress']), + ).toEqual('/v1/safes/0xAddress/'); + }); + + it.each([ + { path: [], description: 'empty path' }, + { path: ['v1', ''], description: 'empty segment' }, + { path: ['..', 'v1'], description: 'traversal segment' }, + ])('returns undefined for a $description', ({ path }) => { + const testClass = new ProxySafeUtils(); + expect(testClass['buildUpstreamPath'](path)).toBeUndefined(); + }); + }); +}); diff --git a/apps/app/src/modules/application/utils/proxySafeUtils/proxySafeUtils.ts b/apps/app/src/modules/application/utils/proxySafeUtils/proxySafeUtils.ts new file mode 100644 index 0000000000..591730c089 --- /dev/null +++ b/apps/app/src/modules/application/utils/proxySafeUtils/proxySafeUtils.ts @@ -0,0 +1,402 @@ +import { revalidateTag } from 'next/cache'; +import { type NextRequest, NextResponse } from 'next/server'; +import { SafeServiceErrorCode } from '@/shared/api/safeService/domain'; +import { monitoringUtils } from '@/shared/utils/monitoringUtils'; +import { responseUtils } from '@/shared/utils/responseUtils'; +import { + assertServerSafeConfig, + resolveServerSafeApiKey, + resolveServerSafeUrl, +} from './resolveServerSafeUrl'; +import { safeNetworkFromChainId } from './safeTxServiceNetworks'; + +const DEFAULT_RATE_LIMIT_BACKOFF_SECONDS = 60; + +/** + * How long a Safe read may be served from Next's data cache. + * + * This is the only place where N concurrent viewers of one Safe collapse into one upstream call: + * without it, every viewer's poll is its own request against a single shared, rate-limited API key. + * + * Deliberately short. The Safe nonce drives liveness derivation, and execution happens onchain — + * outside this proxy — so nothing invalidates the cache when the nonce advances. Ten seconds bounds + * that staleness, and the post-execution indexing hold already covers the window in the UI. + */ +const SAFE_READ_CACHE_SECONDS = 10; + +export interface ISafeRequestParams { + /** + * Chain-id of the Safe transaction service to forward the request to. + */ + chainId: string; + /** + * Remaining path segments of the upstream Safe transaction service request. + */ + path: string[]; +} + +export interface ISafeRequestOptions { + /** + * Parameters of the Safe proxy call. + */ + params: Promise; +} + +interface ISafeErrorResponseParams { + code: SafeServiceErrorCode; + error: string; + status: number; + retryAfter?: number; +} + +const safeAddressPattern = /^0x[a-fA-F0-9]{40}$/; +const safeTransactionHashPattern = /^0x[a-fA-F0-9]{64}$/; + +export class ProxySafeUtils { + constructor() { + assertServerSafeConfig(); + } + + request = async (request: NextRequest, { params }: ISafeRequestOptions) => { + const { chainId, path } = await params; + + const network = safeNetworkFromChainId(chainId); + const endpoint = + network != null ? resolveServerSafeUrl(network) : undefined; + + if (endpoint == null) { + // Not an error: Citrea and Chiliz have no Safe transaction service, and consumers + // render a dedicated state for it. + return this.errorResponse({ + code: SafeServiceErrorCode.UNSUPPORTED_CHAIN, + error: `Chain ${chainId} is not served by the Safe transaction service`, + status: 501, + }); + } + + const upstreamPath = this.buildUpstreamPath(path); + + if (upstreamPath == null) { + return this.errorResponse({ + code: SafeServiceErrorCode.UPSTREAM_ERROR, + error: 'Invalid Safe transaction service path', + status: 400, + }); + } + + const apiKey = resolveServerSafeApiKey(); + + if (apiKey == null) { + return this.errorResponse({ + code: SafeServiceErrorCode.NOT_CONFIGURED, + error: 'Safe API key is not configured for this deployment', + status: 503, + }); + } + + const upstreamUrl = `${endpoint.baseUrl}${upstreamPath}${request.nextUrl.search}`; + const monitoringContext = { + chainId, + shortName: endpoint.shortName, + upstreamPath, + }; + + try { + const requestOptions = await this.buildRequestOptions( + request, + apiKey, + chainId, + path, + ); + + if (requestOptions == null) { + return this.errorResponse({ + code: SafeServiceErrorCode.UPSTREAM_ERROR, + error: 'Invalid Safe transaction service request', + status: 400, + }); + } + + const result = await fetch(upstreamUrl, requestOptions); + + if (result.status === 429) { + // Quota exhaustion is expected under load: answer with a typed degraded response + // carrying the upstream backoff instead of an anonymous 500. + const retryAfter = this.parseRetryAfter(result); + + monitoringUtils.logMessage( + 'Safe transaction service rate limit', + { + context: { + retryAfter, + ...monitoringContext, + }, + level: 'warning', + noiseClass: 'infra', + }, + ); + + return this.errorResponse({ + code: SafeServiceErrorCode.RATE_LIMITED, + error: 'Safe transaction service rate limit reached', + status: 429, + retryAfter, + }); + } + + if (!result.ok) { + const isNotFound = result.status === 404; + + if (!isNotFound) { + monitoringUtils.logError( + new Error( + 'Safe transaction service returned error status', + ), + { + context: { + status: result.status, + statusText: result.statusText, + ...monitoringContext, + }, + }, + ); + } + + return this.errorResponse({ + code: isNotFound + ? SafeServiceErrorCode.NOT_FOUND + : SafeServiceErrorCode.UPSTREAM_ERROR, + error: `Safe request failed with status ${String(result.status)}`, + status: result.status, + }); + } + + // A signer must see their own signature on the next read, so drop the cached Safe + // state now rather than serving the pre-signature queue for the rest of its window. + if (request.method === 'POST') { + for (const tag of this.buildCacheTags(chainId, path)) { + revalidateTag(tag, { expire: 0 }); + } + } + + if ( + request.method === 'POST' && + [201, 204, 205].includes(result.status) + ) { + return new NextResponse(null, { status: result.status }); + } + + const parsedResult = + await responseUtils.safeJsonParseForResponse(result); + + if (parsedResult == null && result.status !== 204) { + return this.errorResponse({ + code: SafeServiceErrorCode.INVALID_RESPONSE, + error: 'Invalid JSON response from the Safe transaction service', + status: 502, + }); + } + + if (parsedResult == null) { + return new NextResponse(null, { status: result.status }); + } + + return result.status === 200 + ? NextResponse.json(parsedResult) + : NextResponse.json(parsedResult, { status: result.status }); + } catch (fetchError) { + monitoringUtils.logError(fetchError, { + context: { errorType: 'fetch_error', ...monitoringContext }, + }); + + return this.errorResponse({ + code: SafeServiceErrorCode.CONNECTION_ERROR, + error: 'Failed to connect to the Safe transaction service', + status: 502, + }); + } + }; + + /** + * Joins the catch-all segments into an upstream path, rejecting anything that could escape + * the Safe transaction service base URL. Returns undefined for an invalid path. + * + * A trailing slash is always appended because every Safe transaction service endpoint + * requires one, while Next.js strips it from the incoming request. + */ + private buildUpstreamPath = (path: string[]): string | undefined => { + if (path.length === 0) { + return undefined; + } + + const isValid = path.every( + (segment) => segment.length > 0 && !segment.includes('..'), + ); + + if (!isValid) { + return undefined; + } + + const encodedPath = path + .map((segment) => encodeURIComponent(segment)) + .join('/'); + + return `/${encodedPath}/`; + }; + + private parseRetryAfter = (response: Response): number => { + const retryAfterHeader = response.headers.get('retry-after'); + const retryAfterSeconds = Number(retryAfterHeader); + + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { + return Math.ceil(retryAfterSeconds); + } + + if (retryAfterHeader != null) { + const retryAt = Date.parse(retryAfterHeader); + + if (Number.isFinite(retryAt) && retryAt > Date.now()) { + return Math.ceil((retryAt - Date.now()) / 1000); + } + } + + return DEFAULT_RATE_LIMIT_BACKOFF_SECONDS; + }; + + /** + * Builds the upstream request options. No request headers are forwarded: the Safe service + * needs none of them, and forwarding cookies would leak user data to a third party. + */ + private buildRequestOptions = async ( + request: NextRequest, + apiKey: string, + chainId: string, + path: string[], + ): Promise => { + const method = request.method; + + if (method !== 'GET' && method !== 'POST') { + return undefined; + } + + if (!this.isSupportedPath(method, path)) { + return undefined; + } + + let body: string | undefined; + + if (method === 'POST') { + try { + const parsedBody: unknown = await request.json(); + + if ( + parsedBody == null || + typeof parsedBody !== 'object' || + Array.isArray(parsedBody) + ) { + return undefined; + } + + body = JSON.stringify(parsedBody); + } catch { + return undefined; + } + } + + // Reads are shared across viewers; writes must never be cached. + const isCacheableRead = method === 'GET'; + + return { + method, + body, + ...(isCacheableRead + ? { + next: { + revalidate: SAFE_READ_CACHE_SECONDS, + tags: this.buildCacheTags(chainId, path), + }, + } + : { cache: 'no-store' as RequestCache }), + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + ...(method === 'POST' + ? { 'Content-Type': 'application/json' } + : {}), + }, + credentials: 'omit', + }; + }; + + /** + * Cache tags for a Safe read: one per chain and, when the path names a Safe, one per Safe. + * + * A proposal POST carries the Safe address and can invalidate precisely. A confirmation POST + * does not — its path is keyed by `safeTxHash` — so it falls back to the chain tag and + * invalidates every Safe on that chain. Confirmations are human signing actions and therefore + * rare, so the occasional extra read is cheaper than threading the address through the + * confirmation URL purely to narrow a tag. + */ + private buildCacheTags = (chainId: string, path: string[]): string[] => { + const chainTag = `safe:${chainId}`; + const address = path[1] === 'safes' ? path[2] : undefined; + + if (address == null || !safeAddressPattern.test(address)) { + return [chainTag]; + } + + return [chainTag, `${chainTag}:${address.toLowerCase()}`]; + }; + + /** + * Every path this proxy will forward, by method. + * + * `/v2/safe/*` on the Aragon backend now serves the reads a governance body needs, so what is + * left here is only what the backend does not: balances, and the two signature-bearing writes. + * The surface is allowlisted rather than open because the route is unauthenticated and spends a + * shared API key — an open GET would let anyone drive the whole transaction service on our quota. + */ + private isSupportedPath = (method: string, path: string[]): boolean => { + if (method === 'GET') { + return ( + path.length === 4 && + path[0] === 'v1' && + path[1] === 'safes' && + safeAddressPattern.test(path[2]) && + path[3] === 'balances' + ); + } + + const isProposalPath = + path.length === 4 && + path[0] === 'v1' && + path[1] === 'safes' && + safeAddressPattern.test(path[2]) && + path[3] === 'multisig-transactions'; + const isConfirmationPath = + path.length === 4 && + path[0] === 'v1' && + path[1] === 'multisig-transactions' && + safeTransactionHashPattern.test(path[2]) && + path[3] === 'confirmations'; + + return isProposalPath || isConfirmationPath; + }; + + private errorResponse = ({ + code, + error, + status, + retryAfter, + }: ISafeErrorResponseParams) => + NextResponse.json( + { error, code, retryAfter }, + { + status, + headers: + retryAfter != null + ? { 'Retry-After': String(retryAfter) } + : undefined, + }, + ); +} diff --git a/apps/app/src/modules/application/utils/proxySafeUtils/resolveServerSafeUrl.test.ts b/apps/app/src/modules/application/utils/proxySafeUtils/resolveServerSafeUrl.test.ts new file mode 100644 index 0000000000..803287b5b9 --- /dev/null +++ b/apps/app/src/modules/application/utils/proxySafeUtils/resolveServerSafeUrl.test.ts @@ -0,0 +1,92 @@ +/** + * @jest-environment node + */ + +import { Network } from '@/shared/api/daoService'; +import { testLogger } from '@/test/utils'; +import { + assertServerSafeConfig, + resolveServerSafeApiKey, + resolveServerSafeUrl, +} from './resolveServerSafeUrl'; + +describe('resolveServerSafeUrl', () => { + const originalProcessEnv = process.env; + + beforeEach(() => { + process.env.NEXT_SECRET_SAFE_API_KEY = 'test-safe-key'; + process.env.NEXT_RUNTIME = 'nodejs'; + process.env.CI = 'false'; + }); + + afterEach(() => { + process.env = { ...originalProcessEnv }; + }); + + it.each([ + { network: Network.ETHEREUM_MAINNET, shortName: 'eth' }, + { network: Network.ETHEREUM_SEPOLIA, shortName: 'sep' }, + { network: Network.POLYGON_MAINNET, shortName: 'pol' }, + { network: Network.BASE_MAINNET, shortName: 'base' }, + { network: Network.ARBITRUM_MAINNET, shortName: 'arb1' }, + { network: Network.OPTIMISM_MAINNET, shortName: 'oeth' }, + { network: Network.AVAX_MAINNET, shortName: 'avax' }, + { network: Network.ZKSYNC_MAINNET, shortName: 'zksync' }, + { network: Network.HEMI_MAINNET, shortName: 'hemi' }, + { network: Network.KATANA_MAINNET, shortName: 'katana' }, + { network: Network.MONAD_MAINNET, shortName: 'monad' }, + ])( + 'resolves the $shortName endpoint for the $network network', + ({ network, shortName }) => { + expect(resolveServerSafeUrl(network)).toEqual({ + shortName, + baseUrl: `https://api.safe.global/tx-service/${shortName}/api`, + }); + }, + ); + + it.each([ + { network: Network.CITREA_MAINNET }, + { network: Network.CHILIZ_MAINNET }, + ])('returns undefined for the unserved $network network', ({ network }) => { + expect(resolveServerSafeUrl(network)).toBeUndefined(); + }); + + describe('resolveServerSafeApiKey', () => { + it('returns the key when running on a node server runtime', () => { + expect(resolveServerSafeApiKey()).toEqual('test-safe-key'); + }); + + it('returns undefined outside a server runtime so the key stays out of client chunks', () => { + testLogger.suppressErrors(); + delete process.env.NEXT_RUNTIME; + expect(resolveServerSafeApiKey()).toBeUndefined(); + }); + }); + + describe('assertServerSafeConfig', () => { + it('throws when the key is missing outside CI', () => { + delete process.env.NEXT_SECRET_SAFE_API_KEY; + expect(() => assertServerSafeConfig()).toThrow( + /NEXT_SECRET_SAFE_API_KEY/, + ); + }); + + it('does not throw when the key is missing on CI', () => { + delete process.env.NEXT_SECRET_SAFE_API_KEY; + process.env.CI = 'true'; + expect(() => assertServerSafeConfig()).not.toThrow(); + }); + + it('does not throw when the key is set', () => { + expect(() => assertServerSafeConfig()).not.toThrow(); + }); + + it('does not read or require the key outside the node server runtime', () => { + delete process.env.NEXT_RUNTIME; + delete process.env.NEXT_SECRET_SAFE_API_KEY; + + expect(() => assertServerSafeConfig()).not.toThrow(); + }); + }); +}); diff --git a/apps/app/src/modules/application/utils/proxySafeUtils/resolveServerSafeUrl.ts b/apps/app/src/modules/application/utils/proxySafeUtils/resolveServerSafeUrl.ts new file mode 100644 index 0000000000..3774f4af7f --- /dev/null +++ b/apps/app/src/modules/application/utils/proxySafeUtils/resolveServerSafeUrl.ts @@ -0,0 +1,87 @@ +import type { Network } from '@/shared/api/daoService'; +import { monitoringUtils } from '@/shared/utils/monitoringUtils'; +import { safeShortNameFromNetwork } from './safeTxServiceNetworks'; + +const SAFE_API_KEY_ENV_VAR = 'NEXT_SECRET_SAFE_API_KEY'; + +const safeTxServiceBaseUrl = 'https://api.safe.global/tx-service'; + +export interface ISafeTxServiceEndpoint { + /** + * Safe transaction service short name of the chain (e.g. `eth`). + */ + shortName: string; + /** + * Upstream base URL for the chain, without a trailing slash. + */ + baseUrl: string; +} + +/** + * Resolves the upstream Safe transaction service base URL for a network. Returns undefined when + * the network has no short name — the caller must surface that as a typed "unsupported chain" + * state, never as a fetch failure. Unlike the RPC resolver there is no local override and no + * public fallback: the Safe service is the only source. + */ +export const resolveServerSafeUrl = ( + network: Network, +): ISafeTxServiceEndpoint | undefined => { + const shortName = safeShortNameFromNetwork(network); + + if (shortName == null) { + return undefined; + } + + return { + shortName, + baseUrl: `${safeTxServiceBaseUrl}/${shortName}/api`, + }; +}; + +/** + * Reads the Safe API key (`process.env.NEXT_SECRET_SAFE_API_KEY`), which is sent upstream as an + * `Authorization: Bearer` header. The key is a secret and must never reach the client, so the + * read is gated on `process.env.NEXT_RUNTIME` (build-time-folded by Next.js) — the gate lets the + * bundler tree-shake the key out of any client chunk that transitively reaches this module. We + * don't apply `'server-only'` for the same reason as `resolveServerRpcUrl`: the import tracer + * would falsely report a client-side import. + * + * Returns undefined when the key is unset (e.g. CI) or when called outside a server runtime. + */ +export const resolveServerSafeApiKey = (): string | undefined => { + if (process.env.NEXT_RUNTIME !== 'nodejs') { + monitoringUtils.logError( + new Error( + 'Safe API key read attempted outside of a server runtime', + ), + { context: { nextRuntime: process.env.NEXT_RUNTIME ?? null } }, + ); + + return undefined; + } + + return process.env[SAFE_API_KEY_ENV_VAR]; +}; + +/** + * Server-only: validates that the Safe API key is present in the environment. Throws when it is + * missing, except in CI where the check is skipped so that unit tests can run without the real + * secret. Used by `proxySafeUtils` to fail fast at server boot in misconfigured deployments. + */ +export const assertServerSafeConfig = (): void => { + if (process.env.NEXT_RUNTIME !== 'nodejs') { + return; + } + + if (process.env.CI === 'true') { + return; + } + + if (process.env[SAFE_API_KEY_ENV_VAR]) { + return; + } + + throw new Error( + `Missing Safe API key. Required env var: ${SAFE_API_KEY_ENV_VAR}`, + ); +}; diff --git a/apps/app/src/modules/application/utils/proxySafeUtils/safeTxServiceNetworks.test.ts b/apps/app/src/modules/application/utils/proxySafeUtils/safeTxServiceNetworks.test.ts new file mode 100644 index 0000000000..7e7ca9bdee --- /dev/null +++ b/apps/app/src/modules/application/utils/proxySafeUtils/safeTxServiceNetworks.test.ts @@ -0,0 +1,41 @@ +import { Network } from '@/shared/api/daoService'; +import { safeAppAccountUrl } from './safeTxServiceNetworks'; + +describe('safeTxServiceNetworks', () => { + describe('safeAppAccountUrl', () => { + it('addresses the Safe app EIP-3770 style, with the chain short name', () => { + expect( + safeAppAccountUrl({ + network: Network.ETHEREUM_SEPOLIA, + address: '0xd84C233A7D1578021d21E39785439bEdDB165F3D', + }), + ).toEqual( + 'https://app.safe.global/home?safe=sep:0xd84C233A7D1578021d21E39785439bEdDB165F3D', + ); + }); + + it('checksums the address rather than trusting the caller casing', () => { + // The Safe app resolves nothing for a lowercased address, and the backend stores some + // Safe addresses lowercased. + expect( + safeAppAccountUrl({ + network: Network.ETHEREUM_MAINNET, + address: '0xd84c233a7d1578021d21e39785439beddb165f3d', + }), + ).toEqual( + 'https://app.safe.global/home?safe=eth:0xd84C233A7D1578021d21E39785439bEdDB165F3D', + ); + }); + + it('returns no link for a network Safe does not serve', () => { + // Better to state the Safe without a link than to send someone to a page that cannot + // resolve it. + expect( + safeAppAccountUrl({ + network: Network.CITREA_MAINNET, + address: '0xd84C233A7D1578021d21E39785439bEdDB165F3D', + }), + ).toBeUndefined(); + }); + }); +}); diff --git a/apps/app/src/modules/application/utils/proxySafeUtils/safeTxServiceNetworks.ts b/apps/app/src/modules/application/utils/proxySafeUtils/safeTxServiceNetworks.ts new file mode 100644 index 0000000000..ba1712eaad --- /dev/null +++ b/apps/app/src/modules/application/utils/proxySafeUtils/safeTxServiceNetworks.ts @@ -0,0 +1,91 @@ +import { Network } from '@/shared/api/daoService'; +import { checksumSafeAddress } from '@/shared/api/safeService/safeAddressUtils'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; + +/** + * Chain short names used by the Safe transaction service, keyed by app network. Networks absent + * from this map are not served by Safe (Citrea, Chiliz) — a first-class "unsupported" state, not + * a failure. This module holds no secrets and is safe to import from client code. + */ +export const safeTxServiceShortNames: Partial> = { + [Network.ETHEREUM_MAINNET]: 'eth', + [Network.ETHEREUM_SEPOLIA]: 'sep', + [Network.POLYGON_MAINNET]: 'pol', + [Network.BASE_MAINNET]: 'base', + [Network.ARBITRUM_MAINNET]: 'arb1', + [Network.OPTIMISM_MAINNET]: 'oeth', + [Network.AVAX_MAINNET]: 'avax', + [Network.ZKSYNC_MAINNET]: 'zksync', + [Network.HEMI_MAINNET]: 'hemi', + [Network.KATANA_MAINNET]: 'katana', + [Network.MONAD_MAINNET]: 'monad', +}; + +/** + * Resolves the app network matching the given chain-id, or undefined when the chain-id is + * unknown to the app. The chain-id is the only thing taken from the request — nothing the caller + * claims about a Safe is trusted. + */ +export const safeNetworkFromChainId = ( + chainId: string, +): Network | undefined => { + const parsedChainId = Number(chainId); + + if (!Number.isInteger(parsedChainId)) { + return undefined; + } + + return Object.values(Network).find( + (network) => networkDefinitions[network].id === parsedChainId, + ); +}; + +/** + * Resolves the Safe transaction service short name for a network, or undefined when the network + * is not served. + */ +export const safeShortNameFromNetwork = ( + network: Network, +): string | undefined => safeTxServiceShortNames[network]; + +/** + * Addresses a Safe in the Safe web app, EIP-3770 style (`:`). The app + * rejects any other casing, so the address is canonicalised here rather than trusted from the + * caller. + * + * Undefined when Safe does not serve the network, so a caller renders plain text instead of a link + * that cannot resolve. + */ +const safeAppUrl = ( + path: string, + params: { network: Network; address: string }, +): string | undefined => { + const { network, address } = params; + const shortName = safeShortNameFromNetwork(network); + + if (shortName == null) { + return undefined; + } + + return `https://app.safe.global/${path}?safe=${shortName}:${checksumSafeAddress(address)}`; +}; + +/** + * Link to a Safe's own account page in the Safe web app. + */ +export const safeAppAccountUrl = (params: { + network: Network; + address: string; +}): string | undefined => safeAppUrl('home', params); + +/** + * Link to a Safe's executed transactions in the Safe web app. + * + * The Safe's history rather than one transaction: a deep link needs the `safeTxHash`, which is only + * available while the transaction is still queued - the queue read serves unexecuted transactions, + * and Aragon's indexed body result carries no transaction hash. + */ +export const safeAppHistoryUrl = (params: { + network: Network; + address: string; +}): string | undefined => safeAppUrl('transactions/history', params); diff --git a/apps/app/src/modules/ens/hooks/useEnsName.ts b/apps/app/src/modules/ens/hooks/useEnsName.ts index 42ce7385d2..5475b09f2c 100644 --- a/apps/app/src/modules/ens/hooks/useEnsName.ts +++ b/apps/app/src/modules/ens/hooks/useEnsName.ts @@ -1,7 +1,10 @@ import { addressUtils } from '@aragon/gov-ui-kit'; import { useEffect } from 'react'; -// biome-ignore lint/style/noRestrictedImports: authorised wrapper over wagmi's useEnsName (centralises chainId and cache) -import { useEnsName as useWagmiEnsName } from 'wagmi'; +import { + type UseEnsNameReturnType, + // biome-ignore lint/style/noRestrictedImports: authorised wrapper over wagmi's useEnsName (centralises chainId and cache) + useEnsName as useWagmiEnsName, +} from 'wagmi'; import { memberRegistrySubdomainSuffix } from '../constants/contracts'; import { ensCache, ensChainId } from '../constants/ensConfig'; import { logEnsError } from '../utils/logEnsError'; @@ -17,6 +20,13 @@ export interface IUseEnsNameOptions { stripAragonRegistrySuffix?: boolean; } +/** + * Result of {@link useEnsName}: wagmi's query result, with `data` narrowed by the + * `stripAragonRegistrySuffix` option. Named here so consumers and tests state the contract instead + * of reaching back into the hook's inferred shape. + */ +export type IUseEnsNameReturn = UseEnsNameReturnType; + /** * Resolves the primary ENS name for a given Ethereum address. * diff --git a/apps/app/src/modules/ens/index.ts b/apps/app/src/modules/ens/index.ts index 86da0d6fba..951742d7c1 100644 --- a/apps/app/src/modules/ens/index.ts +++ b/apps/app/src/modules/ens/index.ts @@ -34,7 +34,7 @@ export { useDelegateStatementCid, } from './hooks/useDelegateStatementCid'; export { useEnsAvatar } from './hooks/useEnsAvatar'; -export { useEnsName } from './hooks/useEnsName'; +export { type IUseEnsNameReturn, useEnsName } from './hooks/useEnsName'; export { useEnsProfileRecords } from './hooks/useEnsProfileRecords'; export { type IEnsResolverRecords, diff --git a/apps/app/src/modules/governance/constants/moduleSlots.ts b/apps/app/src/modules/governance/constants/moduleSlots.ts index 73161e7840..195903b640 100644 --- a/apps/app/src/modules/governance/constants/moduleSlots.ts +++ b/apps/app/src/modules/governance/constants/moduleSlots.ts @@ -19,4 +19,6 @@ export enum GovernanceSlotId { GOVERNANCE_PERMISSION_CHECK_PROPOSAL_CREATION = 'GOVERNANCE_PERMISSION_CHECK_PROPOSAL_CREATION', GOVERNANCE_PERMISSION_CHECK_VOTE_SUBMISSION = 'GOVERNANCE_PERMISSION_CHECK_VOTE_SUBMISSION', GOVERNANCE_EXECUTE_CHECK_VERSION_SUPPORTED = 'GOVERNANCE_EXECUTE_CHECK_VERSION_SUPPORTED', + GOVERNANCE_PROPOSAL_VOTING_HIDDEN_TABS = 'GOVERNANCE_PROPOSAL_VOTING_HIDDEN_TABS', + GOVERNANCE_BODY_VOTES_AFTER_WINDOW = 'GOVERNANCE_BODY_VOTES_AFTER_WINDOW', } diff --git a/apps/app/src/modules/safe/components/safeBalanceList/index.ts b/apps/app/src/modules/safe/components/safeBalanceList/index.ts new file mode 100644 index 0000000000..161607734f --- /dev/null +++ b/apps/app/src/modules/safe/components/safeBalanceList/index.ts @@ -0,0 +1,5 @@ +export { type ISafeBalanceListProps, SafeBalanceList } from './safeBalanceList'; +export { + type ISafeBalanceListItemProps, + SafeBalanceListItem, +} from './safeBalanceListItem'; diff --git a/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceList.test.tsx b/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceList.test.tsx new file mode 100644 index 0000000000..d72bb68281 --- /dev/null +++ b/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceList.test.tsx @@ -0,0 +1,71 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import { Network } from '@/shared/api/daoService'; +import * as safeServiceApi from '@/shared/api/safeService'; +import { + generateReactQueryResultSuccess, + generateSafeBalance, +} from '@/shared/testUtils'; +import { type ISafeBalanceListProps, SafeBalanceList } from './safeBalanceList'; + +describe(' component', () => { + const useSafeBalancesSpy = jest.spyOn(safeServiceApi, 'useSafeBalances'); + + beforeEach(() => { + useSafeBalancesSpy.mockReturnValue( + generateReactQueryResultSuccess({ data: [] }), + ); + }); + + afterEach(() => { + useSafeBalancesSpy.mockReset(); + }); + + const createTestComponent = (props?: Partial) => { + const completeProps: ISafeBalanceListProps = { + network: Network.ETHEREUM_MAINNET, + address: '0x1c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + ...props, + }; + + return ( + + + + ); + }; + + it('renders the native currency of the chain and the tokens held by the Safe', () => { + useSafeBalancesSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: [ + generateSafeBalance({ balance: '1000000000000000000' }), + generateSafeBalance({ + tokenAddress: + '0x2c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + token: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + logoUri: null, + }, + balance: '2500000', + }), + ], + }), + ); + render(createTestComponent()); + + expect(screen.getByText('Ether')).toBeInTheDocument(); + expect(screen.getByText('USD Coin')).toBeInTheDocument(); + expect(screen.getByText('2.5 USDC')).toBeInTheDocument(); + }); + + it('renders an empty state when the Safe holds no assets', () => { + render(createTestComponent()); + + expect( + screen.getByText('app.safe.safeBalanceList.empty.heading'), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceList.tsx b/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceList.tsx new file mode 100644 index 0000000000..e5f39137a2 --- /dev/null +++ b/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceList.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { + AssetDataListItem, + DataListContainer, + DataListPagination, + DataListRoot, +} from '@aragon/gov-ui-kit'; +import type { Network } from '@/shared/api/daoService'; +import { useSafeBalances } from '@/shared/api/safeService'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { safeDataListUtils } from '../../utils/safeDataListUtils'; +import { SafeBalanceListItem } from './safeBalanceListItem'; + +const balancesPerPage = 6; + +export interface ISafeBalanceListProps { + /** + * Network the Safe is deployed on. + */ + network: Network; + /** + * Address of the Safe. + */ + address: string; +} + +export const SafeBalanceList: React.FC = (props) => { + const { network, address } = props; + + const { t } = useTranslations(); + + const { + data: balances, + isError, + isLoading, + } = useSafeBalances({ urlParams: { network, address } }); + + const balanceList = balances ?? []; + const state = safeDataListUtils.getDataListState({ isError, isLoading }); + + return ( + + + {balanceList.map((balance) => ( + + ))} + + + + ); +}; diff --git a/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceListItem.tsx b/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceListItem.tsx new file mode 100644 index 0000000000..6693c50f1c --- /dev/null +++ b/apps/app/src/modules/safe/components/safeBalanceList/safeBalanceListItem.tsx @@ -0,0 +1,52 @@ +import { + AssetDataListItemStructure, + ChainEntityType, +} from '@aragon/gov-ui-kit'; +import type { Network } from '@/shared/api/daoService'; +import type { ISafeBalance } from '@/shared/api/safeService'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { useDaoChain } from '@/shared/hooks/useDaoChain'; +import { safeBalanceUtils } from '../../utils/safeBalanceUtils'; + +export interface ISafeBalanceListItemProps { + /** + * Network the Safe is deployed on. + */ + network: Network; + /** + * Balance to be rendered. + */ + balance: ISafeBalance; +} + +export const SafeBalanceListItem: React.FC = ( + props, +) => { + const { network, balance } = props; + + const { buildEntityUrl } = useDaoChain({ network }); + + const { name, symbol, amount, logoSrc, tokenAddress } = + safeBalanceUtils.getBalanceAsset({ + balance, + nativeCurrency: networkDefinitions[network].nativeCurrency, + }); + + const tokenUrl = + tokenAddress != null + ? buildEntityUrl({ type: ChainEntityType.TOKEN, id: tokenAddress }) + : undefined; + + return ( + + ); +}; diff --git a/apps/app/src/modules/safe/components/safeOwnerList/index.ts b/apps/app/src/modules/safe/components/safeOwnerList/index.ts new file mode 100644 index 0000000000..0c44adaa85 --- /dev/null +++ b/apps/app/src/modules/safe/components/safeOwnerList/index.ts @@ -0,0 +1 @@ +export { type ISafeOwnerListProps, SafeOwnerList } from './safeOwnerList'; diff --git a/apps/app/src/modules/safe/components/safeOwnerList/safeOwnerList.test.tsx b/apps/app/src/modules/safe/components/safeOwnerList/safeOwnerList.test.tsx new file mode 100644 index 0000000000..90c85e43e4 --- /dev/null +++ b/apps/app/src/modules/safe/components/safeOwnerList/safeOwnerList.test.tsx @@ -0,0 +1,70 @@ +import { addressUtils, GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import { Network } from '@/shared/api/daoService'; +import * as safeServiceApi from '@/shared/api/safeService'; +import { + generateReactQueryResultError, + generateReactQueryResultSuccess, + generateSafeInfoResponse, +} from '@/shared/testUtils'; +import { type ISafeOwnerListProps, SafeOwnerList } from './safeOwnerList'; + +describe(' component', () => { + const useSafeInfoSpy = jest.spyOn(safeServiceApi, 'useSafeInfo'); + + beforeEach(() => { + useSafeInfoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateSafeInfoResponse(), + }), + ); + }); + + afterEach(() => { + useSafeInfoSpy.mockReset(); + }); + + const createTestComponent = (props?: Partial) => { + const completeProps: ISafeOwnerListProps = { + network: Network.ETHEREUM_MAINNET, + address: '0x1c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + ...props, + }; + + return ( + + + + ); + }; + + it('renders one item per owner of the Safe', () => { + const owners = [ + '0x1c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + '0x2c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + ]; + useSafeInfoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateSafeInfoResponse({ owners, threshold: 2 }), + }), + ); + render(createTestComponent()); + + for (const owner of owners) { + expect( + screen.getByText(addressUtils.truncateAddress(owner)), + ).toBeInTheDocument(); + } + }); + + it('renders an error state when the Safe info cannot be read', () => { + useSafeInfoSpy.mockReturnValue( + generateReactQueryResultError({ error: new Error('failed') }), + ); + render(createTestComponent()); + + expect( + screen.getByText('app.safe.safeOwnerList.error.heading'), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/modules/safe/components/safeOwnerList/safeOwnerList.tsx b/apps/app/src/modules/safe/components/safeOwnerList/safeOwnerList.tsx new file mode 100644 index 0000000000..6e9e5befa8 --- /dev/null +++ b/apps/app/src/modules/safe/components/safeOwnerList/safeOwnerList.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { + ChainEntityType, + DataListContainer, + DataListPagination, + DataListRoot, + MemberDataListItem, +} from '@aragon/gov-ui-kit'; +import type { Network } from '@/shared/api/daoService'; +import { useSafeInfo } from '@/shared/api/safeService'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { useDaoChain } from '@/shared/hooks/useDaoChain'; +import { safeDataListUtils } from '../../utils/safeDataListUtils'; + +const ownersPerPage = 6; + +export interface ISafeOwnerListProps { + /** + * Network the Safe is deployed on. + */ + network: Network; + /** + * Address of the Safe. + */ + address: string; +} + +export const SafeOwnerList: React.FC = (props) => { + const { network, address } = props; + + const { t } = useTranslations(); + const { buildEntityUrl } = useDaoChain({ network }); + + const { + data: safeInfo, + isError, + isLoading, + } = useSafeInfo({ urlParams: { network, address } }); + + const owners = safeInfo?.owners ?? []; + const state = safeDataListUtils.getDataListState({ isError, isLoading }); + + return ( + + + {owners.map((owner) => ( + + ))} + + + + ); +}; diff --git a/apps/app/src/modules/safe/components/safePendingTransactionList/index.ts b/apps/app/src/modules/safe/components/safePendingTransactionList/index.ts new file mode 100644 index 0000000000..7f914b3566 --- /dev/null +++ b/apps/app/src/modules/safe/components/safePendingTransactionList/index.ts @@ -0,0 +1,8 @@ +export { + type ISafePendingTransactionListProps, + SafePendingTransactionList, +} from './safePendingTransactionList'; +export { + type ISafePendingTransactionListItemProps, + SafePendingTransactionListItem, +} from './safePendingTransactionListItem'; diff --git a/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionList.test.tsx b/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionList.test.tsx new file mode 100644 index 0000000000..96f8fd58aa --- /dev/null +++ b/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionList.test.tsx @@ -0,0 +1,127 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import { Network } from '@/shared/api/daoService'; +import type { + ISafeMultisigTransaction, + ISafeQueueResponse, +} from '@/shared/api/safeService'; +import * as safeServiceApi from '@/shared/api/safeService'; +import { + generateReactQueryResultSuccess, + generateSafeConfirmation, + generateSafeQueueResponse, + generateSafeTransaction, +} from '@/shared/testUtils'; +import { + type ISafePendingTransactionListProps, + SafePendingTransactionList, +} from './safePendingTransactionList'; + +describe(' component', () => { + const useSafePendingTransactionsSpy = jest.spyOn( + safeServiceApi, + 'useSafePendingTransactions', + ); + + const generateResponse = (results: ISafeMultisigTransaction[]) => + generateReactQueryResultSuccess({ + data: generateSafeQueueResponse({ + count: results.length, + results, + }), + }); + + beforeEach(() => { + useSafePendingTransactionsSpy.mockReturnValue(generateResponse([])); + }); + + afterEach(() => { + useSafePendingTransactionsSpy.mockReset(); + }); + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ISafePendingTransactionListProps = { + network: Network.ETHEREUM_MAINNET, + address: '0x1c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + currentNonce: '10', + ...props, + }; + + return ( + + + + ); + }; + + it('renders the nonce and confirmation progress of every queued transaction', () => { + useSafePendingTransactionsSpy.mockReturnValue( + generateResponse([ + generateSafeTransaction({ + nonce: '11', + safeTxHash: '0xTxHash', + confirmations: [generateSafeConfirmation()], + confirmationsRequired: 2, + }), + ]), + ); + render(createTestComponent()); + + expect( + screen.getByText( + 'app.safe.safePendingTransactionList.item.confirmations (count=1,required=2)', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'app.safe.safePendingTransactionList.item.nonce (nonce=11)', + ), + ).toBeInTheDocument(); + }); + + it('renders an empty state when the queue is empty', () => { + render(createTestComponent()); + + expect( + screen.getByText( + 'app.safe.safePendingTransactionList.empty.heading', + ), + ).toBeInTheDocument(); + }); + + it('hides transactions whose nonce the Safe has already consumed', () => { + // The backend returns every unexecuted transaction and does not filter by nonce, so a + // permanently dead one must be dropped here rather than shown as pending. + useSafePendingTransactionsSpy.mockReturnValue( + generateResponse([ + generateSafeTransaction({ nonce: '4', safeTxHash: '0xdead' }), + generateSafeTransaction({ nonce: '6', safeTxHash: '0xlive' }), + ]), + ); + + render(createTestComponent({ currentNonce: '6' })); + + expect( + screen.getByText( + 'app.safe.safePendingTransactionList.item.nonce (nonce=6)', + ), + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'app.safe.safePendingTransactionList.item.nonce (nonce=4)', + ), + ).not.toBeInTheDocument(); + }); + + it('reads the queue without waiting for the nonce', () => { + // The two reads are independent now: gating the queue on the nonce made every view a + // two-hop waterfall for no benefit, since liveness is derived after both have arrived. + render(createTestComponent({ currentNonce: undefined })); + + expect(useSafePendingTransactionsSpy).toHaveBeenCalledWith({ + urlParams: expect.anything(), + }); + }); +}); diff --git a/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionList.tsx b/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionList.tsx new file mode 100644 index 0000000000..6551dde55e --- /dev/null +++ b/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionList.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { + DataListContainer, + DataListPagination, + DataListRoot, +} from '@aragon/gov-ui-kit'; +import type { Network } from '@/shared/api/daoService'; +import { + type ISafeInfo, + useSafePendingTransactions, +} from '@/shared/api/safeService'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { safeDataListUtils } from '../../utils/safeDataListUtils'; +import { SafePendingTransactionListItem } from './safePendingTransactionListItem'; + +const transactionsPerPage = 6; + +export interface ISafePendingTransactionListProps { + /** + * Network the Safe is deployed on. + */ + network: Network; + /** + * Address of the Safe. + */ + address: string; + /** + * Current nonce of the Safe. The backend returns every unexecuted transaction and does not + * filter by nonce — a server-side filter would put the nonce in the cache key and orphan an + * entry on every advance — so liveness is derived here. Undefined until the Safe info resolves. + */ + currentNonce?: ISafeInfo['nonce']; +} + +export const SafePendingTransactionList: React.FC< + ISafePendingTransactionListProps +> = (props) => { + const { network, address, currentNonce } = props; + + const { t } = useTranslations(); + + const { + data: pendingTransactions, + isError, + isLoading, + } = useSafePendingTransactions({ urlParams: { network, address } }); + + // Unexecuted transactions below the current nonce are permanently dead, so they are never shown. + const transactions = + currentNonce == null + ? [] + : (pendingTransactions?.results ?? []).filter( + (transaction) => + BigInt(transaction.nonce) >= BigInt(currentNonce), + ); + const state = safeDataListUtils.getDataListState({ + isError, + isLoading: isLoading || currentNonce == null, + }); + + return ( + + + {transactions.map((transaction) => ( + + ))} + + + + ); +}; diff --git a/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionListItem.tsx b/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionListItem.tsx new file mode 100644 index 0000000000..77d28814f6 --- /dev/null +++ b/apps/app/src/modules/safe/components/safePendingTransactionList/safePendingTransactionListItem.tsx @@ -0,0 +1,60 @@ +import { + addressUtils, + DataListItem, + DateFormat, + formatterUtils, + Tag, +} from '@aragon/gov-ui-kit'; +import type { ISafeMultisigTransaction } from '@/shared/api/safeService'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +export interface ISafePendingTransactionListItemProps { + /** + * Pending transaction to be rendered. + */ + transaction: ISafeMultisigTransaction; +} + +export const SafePendingTransactionListItem: React.FC< + ISafePendingTransactionListItemProps +> = (props) => { + const { transaction } = props; + const { nonce, safeTxHash, confirmations, confirmationsRequired } = + transaction; + + const { t } = useTranslations(); + + const submittedOn = formatterUtils.formatDate(transaction.submissionDate, { + format: DateFormat.YEAR_MONTH_DAY, + }); + + return ( + +
+
+ + + {addressUtils.truncateHash(safeTxHash)} + +
+ + {t('app.safe.safePendingTransactionList.item.submittedOn', { + date: submittedOn, + })} + +
+ + {t('app.safe.safePendingTransactionList.item.confirmations', { + count: confirmations.length, + required: confirmationsRequired, + })} + +
+ ); +}; diff --git a/apps/app/src/modules/safe/pages/safeAccountPage/index.ts b/apps/app/src/modules/safe/pages/safeAccountPage/index.ts new file mode 100644 index 0000000000..2cede249f0 --- /dev/null +++ b/apps/app/src/modules/safe/pages/safeAccountPage/index.ts @@ -0,0 +1,5 @@ +export { + type ISafeAccountPageProps, + SafeAccountPage, +} from './safeAccountPage'; +export type { ISafeAccountPageClientProps } from './safeAccountPageClient'; diff --git a/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPage.test.tsx b/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPage.test.tsx new file mode 100644 index 0000000000..4c099137d7 --- /dev/null +++ b/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPage.test.tsx @@ -0,0 +1,43 @@ +import type * as ReactQuery from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { Network } from '@/shared/api/daoService'; +import { type ISafeAccountPageProps, SafeAccountPage } from './safeAccountPage'; + +jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + HydrationBoundary: (props: { children: ReactNode }) => props.children, +})); + +jest.mock('./safeAccountPageClient', () => ({ + SafeAccountPageClient: (props: { address: string; network: string }) => ( +
+ ), +})); + +describe(' component', () => { + const createTestComponent = (props?: Partial) => { + const completeProps: ISafeAccountPageProps = { + network: Network.ETHEREUM_MAINNET, + address: '0x1c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + ...props, + }; + + return ; + }; + + it('renders the page client component for the given Safe', () => { + render(createTestComponent()); + const client = screen.getByTestId('page-client-mock'); + + expect(client).toBeInTheDocument(); + expect(client.dataset.address).toEqual( + '0x1c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + ); + expect(client.dataset.network).toEqual(Network.ETHEREUM_MAINNET); + }); +}); diff --git a/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPage.tsx b/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPage.tsx new file mode 100644 index 0000000000..8343648610 --- /dev/null +++ b/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPage.tsx @@ -0,0 +1,15 @@ +import { Page } from '@/shared/components/page'; +import type { ISafeAccountPageParams } from '../../types'; +import { SafeAccountPageClient } from './safeAccountPageClient'; + +export interface ISafeAccountPageProps extends ISafeAccountPageParams {} + +export const SafeAccountPage: React.FC = (props) => { + const { network, address } = props; + + return ( + + + + ); +}; diff --git a/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPageClient.test.tsx b/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPageClient.test.tsx new file mode 100644 index 0000000000..abc7e3c73c --- /dev/null +++ b/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPageClient.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import { Network } from '@/shared/api/daoService'; +import * as safeServiceApi from '@/shared/api/safeService'; +import * as useDaoChainModule from '@/shared/hooks/useDaoChain'; +import { + generateReactQueryResultSuccess, + generateSafeInfoResponse, +} from '@/shared/testUtils'; +import { + type ISafeAccountPageClientProps, + SafeAccountPageClient, +} from './safeAccountPageClient'; + +jest.mock('../../components/safeOwnerList', () => ({ + SafeOwnerList: () =>
, +})); + +jest.mock('../../components/safePendingTransactionList', () => ({ + SafePendingTransactionList: (props: { currentNonce?: string }) => ( +
+ ), +})); + +jest.mock('../../components/safeBalanceList', () => ({ + SafeBalanceList: () =>
, +})); + +describe(' component', () => { + const useSafeInfoSpy = jest.spyOn(safeServiceApi, 'useSafeInfo'); + const useDaoChainSpy = jest.spyOn(useDaoChainModule, 'useDaoChain'); + + beforeEach(() => { + useSafeInfoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateSafeInfoResponse(), + }), + ); + useDaoChainSpy.mockReturnValue({ + chainId: 1, + network: Network.ETHEREUM_MAINNET, + networkDefinition: undefined, + buildEntityUrl: () => 'https://explorer.test/address', + isLoading: false, + }); + }); + + afterEach(() => { + useSafeInfoSpy.mockReset(); + useDaoChainSpy.mockReset(); + }); + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ISafeAccountPageClientProps = { + network: Network.ETHEREUM_MAINNET, + address: '0x1c8Cae0e29e1a0dc65f0f0E4C74DCE9f9C9F4a2B', + ...props, + }; + + return ; + }; + + it('renders the owners, pending transactions and assets of the Safe', () => { + useSafeInfoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateSafeInfoResponse({ nonce: '42' }), + }), + ); + render(createTestComponent()); + + expect(screen.getByTestId('owner-list-mock')).toBeInTheDocument(); + expect(screen.getByTestId('balance-list-mock')).toBeInTheDocument(); + expect(screen.getByTestId('pending-list-mock').dataset.nonce).toEqual( + '42', + ); + }); + + it('displays the reported Safe version verbatim', () => { + useSafeInfoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateSafeInfoResponse({ version: '1.4.1+L2' }), + }), + ); + render(createTestComponent()); + + expect(screen.getByText('1.4.1+L2')).toBeInTheDocument(); + }); + + it('renders a Safe below the EIP-1271 floor without gating the read view', () => { + useSafeInfoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateSafeInfoResponse({ version: '1.1.1' }), + }), + ); + render(createTestComponent()); + + expect(screen.getByText('1.1.1')).toBeInTheDocument(); + expect(screen.getByTestId('owner-list-mock')).toBeInTheDocument(); + }); + + it.each([ + { network: Network.CHILIZ_MAINNET }, + { network: Network.CITREA_MAINNET }, + ])( + 'renders an unsupported surface and skips the request on $network', + ({ network }) => { + render(createTestComponent({ network })); + + expect( + screen.getByText( + 'app.safe.safeAccountPage.unsupportedNetwork.heading', + ), + ).toBeInTheDocument(); + expect( + screen.queryByTestId('owner-list-mock'), + ).not.toBeInTheDocument(); + expect(useSafeInfoSpy).toHaveBeenCalledWith(expect.anything(), { + enabled: false, + }); + }, + ); +}); diff --git a/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPageClient.tsx b/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPageClient.tsx new file mode 100644 index 0000000000..004fcf2545 --- /dev/null +++ b/apps/app/src/modules/safe/pages/safeAccountPage/safeAccountPageClient.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { + addressUtils, + CardEmptyState, + ChainEntityType, + DefinitionList, +} from '@aragon/gov-ui-kit'; +import { safeShortNameFromNetwork } from '@/modules/application/utils/proxySafeUtils/safeTxServiceNetworks'; +import type { Network } from '@/shared/api/daoService'; +import { SafeServiceError, useSafeInfo } from '@/shared/api/safeService'; +import { Page } from '@/shared/components/page'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { useDaoChain } from '@/shared/hooks/useDaoChain'; +import { SafeBalanceList } from '../../components/safeBalanceList'; +import { SafeOwnerList } from '../../components/safeOwnerList'; +import { SafePendingTransactionList } from '../../components/safePendingTransactionList'; + +export interface ISafeAccountPageClientProps { + /** + * Network the Safe is deployed on. + */ + network: Network; + /** + * Checksummed address of the Safe. + */ + address: string; +} + +export const SafeAccountPageClient: React.FC = ( + props, +) => { + const { network, address } = props; + + // The Safe transaction service requires a checksummed address. The route passes the raw + // address through (the DAO page does the same) and normalisation happens here, client-side, + // where gov-ui-kit's facade is bound. + const checksummedAddress = addressUtils.getChecksum(address); + + const { t } = useTranslations(); + const { buildEntityUrl, networkDefinition } = useDaoChain({ network }); + + // Citrea and Chiliz have no Safe transaction service. Skipping the request keeps that an + // expected, renderable state instead of a failed read. + const isNetworkSupported = safeShortNameFromNetwork(network) != null; + + const { data: safeInfo, error: safeInfoError } = useSafeInfo( + { urlParams: { network, address: checksummedAddress } }, + { enabled: isNetworkSupported }, + ); + + const isUnsupported = + !isNetworkSupported || + SafeServiceError.isUnsupportedChainError(safeInfoError); + + const truncatedAddress = addressUtils.truncateAddress(checksummedAddress); + const addressLink = buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: checksummedAddress, + }); + + const header = ( + + ); + + if (isUnsupported) { + return ( + <> + {header} + + + + + + + ); + } + + return ( + <> + {header} + + + + + + + + + + + + + + + + +

+ {networkDefinition?.name} +

+
+ + {truncatedAddress} + + +

+ {safeInfo?.version ?? + t( + 'app.safe.safeAccountPage.aside.details.unknown', + )} +

+
+ +

+ {safeInfo?.nonce} +

+
+ +

+ {safeInfo != null + ? t( + 'app.safe.safeAccountPage.aside.details.thresholdValue', + { + threshold: safeInfo.threshold, + owners: safeInfo.owners + .length, + }, + ) + : undefined} +

+
+
+
+
+
+ + ); +}; diff --git a/apps/app/src/modules/safe/types/index.ts b/apps/app/src/modules/safe/types/index.ts new file mode 100644 index 0000000000..eee54f6545 --- /dev/null +++ b/apps/app/src/modules/safe/types/index.ts @@ -0,0 +1 @@ +export type { ISafeAccountPageParams } from './safeAccountPageParams'; diff --git a/apps/app/src/modules/safe/types/safeAccountPageParams.ts b/apps/app/src/modules/safe/types/safeAccountPageParams.ts new file mode 100644 index 0000000000..71b3792095 --- /dev/null +++ b/apps/app/src/modules/safe/types/safeAccountPageParams.ts @@ -0,0 +1,12 @@ +import type { Network } from '@/shared/api/daoService'; + +export interface ISafeAccountPageParams { + /** + * Network the Safe is deployed on, i.e. ethereum-mainnet. + */ + network: Network; + /** + * Address of the Safe. + */ + address: string; +} diff --git a/apps/app/src/modules/safe/utils/safeBalanceUtils/index.ts b/apps/app/src/modules/safe/utils/safeBalanceUtils/index.ts new file mode 100644 index 0000000000..b61ea4283c --- /dev/null +++ b/apps/app/src/modules/safe/utils/safeBalanceUtils/index.ts @@ -0,0 +1,5 @@ +export { + type IGetBalanceAssetParams, + type ISafeBalanceAsset, + safeBalanceUtils, +} from './safeBalanceUtils'; diff --git a/apps/app/src/modules/safe/utils/safeBalanceUtils/safeBalanceUtils.test.ts b/apps/app/src/modules/safe/utils/safeBalanceUtils/safeBalanceUtils.test.ts new file mode 100644 index 0000000000..9a32278465 --- /dev/null +++ b/apps/app/src/modules/safe/utils/safeBalanceUtils/safeBalanceUtils.test.ts @@ -0,0 +1,49 @@ +import { generateSafeBalance } from '@/shared/testUtils'; +import { safeBalanceUtils } from './safeBalanceUtils'; + +describe('safeBalanceUtils', () => { + const nativeCurrency = { name: 'Ether', symbol: 'ETH', decimals: 18 }; + + describe('getBalanceAsset', () => { + it('falls back to the native currency of the chain when the balance has no token', () => { + const balance = generateSafeBalance({ + tokenAddress: null, + token: null, + balance: '1500000000000000000', + }); + + expect( + safeBalanceUtils.getBalanceAsset({ balance, nativeCurrency }), + ).toEqual({ + name: 'Ether', + symbol: 'ETH', + amount: '1.5', + logoSrc: undefined, + tokenAddress: undefined, + }); + }); + + it('uses the token metadata and decimals when the balance is an ERC-20', () => { + const balance = generateSafeBalance({ + tokenAddress: '0xTokenAddress', + token: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + logoUri: 'https://logo.test/usdc.png', + }, + balance: '2500000', + }); + + expect( + safeBalanceUtils.getBalanceAsset({ balance, nativeCurrency }), + ).toEqual({ + name: 'USD Coin', + symbol: 'USDC', + amount: '2.5', + logoSrc: 'https://logo.test/usdc.png', + tokenAddress: '0xTokenAddress', + }); + }); + }); +}); diff --git a/apps/app/src/modules/safe/utils/safeBalanceUtils/safeBalanceUtils.ts b/apps/app/src/modules/safe/utils/safeBalanceUtils/safeBalanceUtils.ts new file mode 100644 index 0000000000..50ecde0849 --- /dev/null +++ b/apps/app/src/modules/safe/utils/safeBalanceUtils/safeBalanceUtils.ts @@ -0,0 +1,61 @@ +import { formatUnits } from 'viem'; +import type { ISafeBalance } from '@/shared/api/safeService'; +import type { INetworkDefinition } from '@/shared/constants/networkDefinitions'; + +export interface ISafeBalanceAsset { + /** + * Name of the asset. + */ + name: string; + /** + * Symbol of the asset. + */ + symbol: string; + /** + * Balance converted from the smallest unit into a human-readable amount. + */ + amount: string; + /** + * Logo of the asset, when known. + */ + logoSrc?: string; + /** + * Address of the token, or undefined for the native currency of the chain. + */ + tokenAddress?: string; +} + +export interface IGetBalanceAssetParams { + /** + * Balance entry returned by the Safe transaction service. + */ + balance: ISafeBalance; + /** + * Native currency of the chain the Safe is deployed on. + */ + nativeCurrency: INetworkDefinition['nativeCurrency']; +} + +class SafeBalanceUtils { + /** + * Resolves the display metadata of a Safe balance. The transaction service reports the native + * currency with a null token, so the chain definition is the only source for its name, symbol + * and decimals. + */ + getBalanceAsset = (params: IGetBalanceAssetParams): ISafeBalanceAsset => { + const { balance, nativeCurrency } = params; + const { token, tokenAddress } = balance; + + const decimals = token?.decimals ?? nativeCurrency.decimals; + + return { + name: token?.name ?? nativeCurrency.name, + symbol: token?.symbol ?? nativeCurrency.symbol, + amount: formatUnits(BigInt(balance.balance), decimals), + logoSrc: token?.logoUri ?? undefined, + tokenAddress: tokenAddress ?? undefined, + }; + }; +} + +export const safeBalanceUtils = new SafeBalanceUtils(); diff --git a/apps/app/src/modules/safe/utils/safeDataListUtils/index.ts b/apps/app/src/modules/safe/utils/safeDataListUtils/index.ts new file mode 100644 index 0000000000..7dfbf3ad3a --- /dev/null +++ b/apps/app/src/modules/safe/utils/safeDataListUtils/index.ts @@ -0,0 +1,4 @@ +export { + type IGetDataListStateParams, + safeDataListUtils, +} from './safeDataListUtils'; diff --git a/apps/app/src/modules/safe/utils/safeDataListUtils/safeDataListUtils.test.ts b/apps/app/src/modules/safe/utils/safeDataListUtils/safeDataListUtils.test.ts new file mode 100644 index 0000000000..0069b46235 --- /dev/null +++ b/apps/app/src/modules/safe/utils/safeDataListUtils/safeDataListUtils.test.ts @@ -0,0 +1,31 @@ +import { safeDataListUtils } from './safeDataListUtils'; + +describe('safeDataListUtils', () => { + describe('getDataListState', () => { + it.each([ + { + params: { isError: true, isLoading: false }, + expected: 'error', + }, + { + params: { isError: true, isLoading: true }, + expected: 'error', + }, + { + params: { isError: false, isLoading: true }, + expected: 'initialLoading', + }, + { + params: { isError: false, isLoading: false }, + expected: 'idle', + }, + ])( + 'returns $expected for isError=$params.isError isLoading=$params.isLoading', + ({ params, expected }) => { + expect(safeDataListUtils.getDataListState(params)).toEqual( + expected, + ); + }, + ); + }); +}); diff --git a/apps/app/src/modules/safe/utils/safeDataListUtils/safeDataListUtils.ts b/apps/app/src/modules/safe/utils/safeDataListUtils/safeDataListUtils.ts new file mode 100644 index 0000000000..ebcb7e6840 --- /dev/null +++ b/apps/app/src/modules/safe/utils/safeDataListUtils/safeDataListUtils.ts @@ -0,0 +1,34 @@ +import type { DataListState } from '@aragon/gov-ui-kit'; + +export interface IGetDataListStateParams { + /** + * Whether the underlying query failed. + */ + isError: boolean; + /** + * Whether the underlying query is fetching for the first time. + */ + isLoading: boolean; +} + +class SafeDataListUtils { + /** + * Maps the flags of a TanStack query onto the state expected by the DataList primitives. The + * Safe read view never paginates or filters, so only the three terminal states can occur. + */ + getDataListState = (params: IGetDataListStateParams): DataListState => { + const { isError, isLoading } = params; + + if (isError) { + return 'error'; + } + + if (isLoading) { + return 'initialLoading'; + } + + return 'idle'; + }; +} + +export const safeDataListUtils = new SafeDataListUtils(); diff --git a/apps/app/src/plugins/index.ts b/apps/app/src/plugins/index.ts index 9d6ceea7f6..f3612262a5 100644 --- a/apps/app/src/plugins/index.ts +++ b/apps/app/src/plugins/index.ts @@ -9,6 +9,8 @@ import { initialiseLockToVotePlugin } from './lockToVotePlugin'; import { lockToVotePluginDialogsDefinitions } from './lockToVotePlugin/constants/lockToVotePluginDialogsDefinitions'; import { initialiseMultisigPlugin } from './multisigPlugin'; import { multisigPluginDialogsDefinitions } from './multisigPlugin/constants/multisigPluginDialogsDefinitions'; +import { initialiseSafeMultisigPlugin } from './safeMultisigPlugin'; +import { safeMultisigPluginDialogsDefinitions } from './safeMultisigPlugin/constants/safeMultisigPluginDialogsDefinitions'; import { initialiseSppPlugin } from './sppPlugin'; import { sppPluginDialogsDefinitions } from './sppPlugin/constants/sppPluginDialogsDefinitions'; import { initialiseTokenPlugin } from './tokenPlugin'; @@ -21,6 +23,7 @@ export const initialisePlugins = () => { initialiseCapitalDistributorPlugin(); initialiseLockToVotePlugin(); initialiseSppPlugin(); + initialiseSafeMultisigPlugin(); initialiseGaugeVoterPlugin(); initialiseCrossChainControllerPlugin(); }; @@ -31,6 +34,7 @@ export const pluginDialogsDefinitions = { ...lockToVotePluginDialogsDefinitions, ...multisigPluginDialogsDefinitions, ...sppPluginDialogsDefinitions, + ...safeMultisigPluginDialogsDefinitions, ...tokenPluginDialogsDefinitions, ...gaugeVoterPluginDialogsDefinitions, }; diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/index.ts b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/index.ts new file mode 100644 index 0000000000..0838e40dea --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/index.ts @@ -0,0 +1,4 @@ +export { + type ISafeMultisigProposalVotingBreakdownProps, + SafeMultisigProposalVotingBreakdown, +} from './safeMultisigProposalVotingBreakdown'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/safeMultisigProposalVotingBreakdown.test.tsx b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/safeMultisigProposalVotingBreakdown.test.tsx new file mode 100644 index 0000000000..421e36d620 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/safeMultisigProposalVotingBreakdown.test.tsx @@ -0,0 +1,195 @@ +import { ProposalStatus, ProposalVotingTab, Tabs } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import { Network } from '@/shared/api/daoService'; +import { + generateSppProposal, + generateSppStage, +} from '../../../sppPlugin/testUtils'; +import { SppProposalType } from '../../../sppPlugin/types'; +import { sppStageUtils } from '../../../sppPlugin/utils/sppStageUtils'; +import * as safeBodyStateApi from '../../hooks/useSafeMultisigBodyState'; +import { + generateSafeBodyState, + generateSafeConfirmation, + generateSafeInfo, + generateSafeMultisigTransaction, +} from '../../testUtils'; +import { SafeTransactionState } from '../../types'; +import { + type ISafeMultisigProposalVotingBreakdownProps, + SafeMultisigProposalVotingBreakdown, +} from './safeMultisigProposalVotingBreakdown'; + +describe(' component', () => { + const useSafeMultisigBodyStateSpy = jest.spyOn( + safeBodyStateApi, + 'useSafeMultisigBodyState', + ); + const getStageStatusSpy = jest.spyOn(sppStageUtils, 'getStageStatus'); + const signer = '0x0000000000000000000000000000000000000011'; + + const state = generateSafeBodyState({ + safeInfo: generateSafeInfo({ + nonce: '7', + threshold: 3, + version: '1.3.0', + owners: [signer, `0x${'2'.repeat(40)}`, `0x${'3'.repeat(40)}`], + }), + isLoading: false, + isError: false, + pendingReport: { + transaction: generateSafeMultisigTransaction({ + nonce: '7', + confirmationsRequired: 2, + confirmations: [generateSafeConfirmation({ owner: signer })], + }), + report: { + proposalId: BigInt(42), + stageId: 1, + resultType: SppProposalType.VETO, + tryAdvance: false, + }, + state: SafeTransactionState.LIVE, + status: ProposalStatus.ACTIVE, + hasNonceCompetition: false, + }, + signers: [signer], + hasConnectedWalletSigned: true, + approvalsAmount: 1, + minApprovals: 2, + membersCount: 3, + isRateLimited: false, + isStale: false, + }); + + beforeEach(() => { + useSafeMultisigBodyStateSpy.mockReturnValue(state); + getStageStatusSpy.mockReturnValue(ProposalStatus.ACTIVE); + }); + + afterEach(() => { + useSafeMultisigBodyStateSpy.mockReset(); + getStageStatusSpy.mockReset(); + }); + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ISafeMultisigProposalVotingBreakdownProps = { + body: '0x0000000000000000000000000000000000000001', + proposal: generateSppProposal({ + network: Network.ETHEREUM_MAINNET, + proposalIndex: '42', + }), + stage: generateSppStage({ stageIndex: 1 }), + isVeto: true, + ...props, + }; + + return ( + + + + ); + }; + + it('links a reported body out to the Safe once its transaction executed', () => { + // The action slot is gone by then - the chrome drops it as soon as the proposal executes - + // so the provenance has to live on the body itself. + useSafeMultisigBodyStateSpy.mockReturnValue({ + ...state, + settledResultType: SppProposalType.VETO, + }); + + render(createTestComponent()); + + const link = screen.getByRole('link', { + name: 'app.plugins.safeMultisig.safeMultisigProposalVotingBreakdown.executed', + }); + + expect(link).toHaveAttribute( + 'href', + 'https://app.safe.global/transactions/history?safe=eth:0x0000000000000000000000000000000000000001', + ); + }); + + it('shows no Safe link while the body has not reported', () => { + render(createTestComponent()); + + expect( + screen.queryByRole('link', { + name: 'app.plugins.safeMultisig.safeMultisigProposalVotingBreakdown.executed', + }), + ).not.toBeInTheDocument(); + }); + + it.each([ + { + label: 'with the upstream retry window', + rateLimitedRetryAfter: 42, + expected: + 'app.plugins.safeMultisig.safeMultisigProposalVotingBreakdown.rateLimitedRetry (seconds=42)', + }, + { + label: 'without a retry window', + rateLimitedRetryAfter: undefined, + expected: + 'app.plugins.safeMultisig.safeMultisigProposalVotingBreakdown.rateLimited', + }, + ])( + 'renders an exhausted Safe API quota as a degraded state $label', + ({ rateLimitedRetryAfter, expected }) => { + // A rate-limited read recovers on its own once the poll backs off, so it must not read + // as the generic hard failure the user is expected to act on. + useSafeMultisigBodyStateSpy.mockReturnValue({ + ...state, + safeInfo: undefined, + isError: true, + isRateLimited: true, + rateLimitedRetryAfter, + }); + + render(createTestComponent()); + + expect(screen.getByText(expected)).toBeInTheDocument(); + expect( + screen.queryByText( + 'app.plugins.safeMultisig.safeMultisigProposalVotingBreakdown.error', + ), + ).not.toBeInTheDocument(); + }, + ); + + it('states the live approval count against the Safe owner set', () => { + render(createTestComponent()); + + // Fed from live Safe state rather than an indexed snapshot: 1 of 3 owners have signed. + expect(screen.getByText('of 3 members')).toBeInTheDocument(); + }); + + it('leaves the Safe particulars to the settings tab', () => { + render(createTestComponent()); + + // These used to be restated here beside gov-ui-kit's own approval header. Their home is the + // body's settings, so the breakdown must not grow them back. + expect(screen.queryByText('1.3.0')).not.toBeInTheDocument(); + expect(screen.queryByText('7')).not.toBeInTheDocument(); + expect( + screen.queryByText( + 'app.plugins.safeMultisig.safeMultisigProposalVotingBreakdown.viewSafeAccount', + ), + ).not.toBeInTheDocument(); + }); + + it('renders the action passed by the terminal', () => { + render( + createTestComponent({ + children: , + }), + ); + + expect( + screen.getByRole('button', { name: 'Approve' }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/safeMultisigProposalVotingBreakdown.tsx b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/safeMultisigProposalVotingBreakdown.tsx new file mode 100644 index 0000000000..681c024208 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingBreakdown/safeMultisigProposalVotingBreakdown.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { + Button, + IconType, + ProposalVoting, + ProposalVotingTab, + Tabs, +} from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import type { ReactNode } from 'react'; +import { safeAppHistoryUrl } from '@/modules/application/utils/proxySafeUtils/safeTxServiceNetworks'; +import type { ISppProposal, ISppStage } from '@/plugins/sppPlugin/types'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { useSafeMultisigBodyState } from '../../hooks/useSafeMultisigBodyState'; + +export interface ISafeMultisigProposalVotingBreakdownProps { + proposal: ISppProposal; + body: string; + stage: ISppStage; + isVeto?: boolean; + children?: ReactNode; +} + +const translationKey = + 'app.plugins.safeMultisig.safeMultisigProposalVotingBreakdown'; + +/** + * Breakdown of a Safe body: the multisig approval summary, fed from live Safe state. + * + * The Safe's own particulars - address, threshold, nonce, version - are the body's standing + * configuration and live in the Settings tab. Restating them here duplicated gov-ui-kit's own + * approval header, and the per-owner signature state belongs to the Votes tab. + */ +export const SafeMultisigProposalVotingBreakdown: React.FC< + ISafeMultisigProposalVotingBreakdownProps +> = (props) => { + const { proposal, body, stage, isVeto, children } = props; + const { t } = useTranslations(); + + const { + safeInfo, + approvalsAmount, + minApprovals, + membersCount, + isLoading, + isError, + isRateLimited, + rateLimitedRetryAfter, + settledResultType, + } = useSafeMultisigBodyState({ + network: proposal.network, + address: body, + proposal, + stage, + }); + + // A rate-limited read is a degraded state, not a bug: the poll backs off and recovers on its + // own, so it must not read as the generic hard failure the user is expected to act on. + let placeholderText = t( + `${translationKey}.${isError ? 'error' : 'loading'}`, + ); + + if (isRateLimited) { + placeholderText = + rateLimitedRetryAfter == null + ? t(`${translationKey}.rateLimited`) + : t(`${translationKey}.rateLimitedRetry`, { + seconds: rateLimitedRetryAfter, + }); + } + + if (safeInfo == null) { + return ( + +
+

+ {placeholderText} +

+
+ {children} +
+ ); + } + + // Once the body has reported, the action slot is gone - the shared chrome stops rendering it as + // soon as the proposal executes - so the provenance lives here, where the body always renders. + const historyHref = safeAppHistoryUrl({ + network: proposal.network, + address: body, + }); + + return ( + + {children} + {settledResultType != null && historyHref != null && ( + + )} + + ); +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/index.ts b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/index.ts new file mode 100644 index 0000000000..8db993c312 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/index.ts @@ -0,0 +1,2 @@ +export { SafeMultisigProposalVotingSummary } from './safeMultisigProposalVotingSummary'; +export type { ISafeMultisigProposalVotingSummaryProps } from './safeMultisigProposalVotingSummary.api'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.api.ts b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.api.ts new file mode 100644 index 0000000000..1c09adfc17 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.api.ts @@ -0,0 +1,20 @@ +import type { ISppProposal, ISppStage } from '@/plugins/sppPlugin/types'; + +export interface ISafeMultisigProposalVotingSummaryProps { + /** + * Parent process proposal the body reports a result for. + */ + proposal: ISppProposal; + /** + * Address of the Safe acting as the body. + */ + body: string; + /** + * Stage the body is set up on. + */ + stage: ISppStage; + /** + * Defines if the body vetoes rather than approves. + */ + isVeto: boolean; +} diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.test.tsx b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.test.tsx new file mode 100644 index 0000000000..8831ebe701 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.test.tsx @@ -0,0 +1,187 @@ +import { ProposalStatus } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import type { IUseEnsNameReturn } from '@/modules/ens'; +import * as ensModule from '@/modules/ens'; +import { Network } from '@/shared/api/daoService'; +import { + generateSppProposal, + generateSppStage, +} from '../../../sppPlugin/testUtils'; +import { SppProposalType } from '../../../sppPlugin/types'; +import { sppStageUtils } from '../../../sppPlugin/utils/sppStageUtils'; +import * as safeBodyStateApi from '../../hooks/useSafeMultisigBodyState'; +import { + generateSafeBodyState, + generateSafeConfirmation, + generateSafeInfo, + generateSafeMultisigTransaction, +} from '../../testUtils'; +import { SafeTransactionState } from '../../types'; +import { SafeMultisigProposalVotingSummary } from './safeMultisigProposalVotingSummary'; +import type { ISafeMultisigProposalVotingSummaryProps } from './safeMultisigProposalVotingSummary.api'; + +describe(' component', () => { + const body = '0x0000000000000000000000000000000000000001'; + const signer = '0x0000000000000000000000000000000000000011'; + + const useSafeMultisigBodyStateSpy = jest.spyOn( + safeBodyStateApi, + 'useSafeMultisigBodyState', + ); + const getStageStatusSpy = jest.spyOn(sppStageUtils, 'getStageStatus'); + const useEnsNameSpy = jest.spyOn(ensModule, 'useEnsName'); + + // No ENS resolves for the fixture body, so the row must fall back to the truncated address. + const unresolvedEnsName = { + data: null, + isLoading: false, + } as unknown as IUseEnsNameReturn; + + const pendingReport = { + transaction: generateSafeMultisigTransaction({ + nonce: '7', + confirmationsRequired: 2, + confirmations: [generateSafeConfirmation({ owner: signer })], + }), + report: { + proposalId: BigInt(42), + stageId: 1, + resultType: SppProposalType.APPROVAL, + tryAdvance: false, + }, + state: SafeTransactionState.LIVE, + status: ProposalStatus.ACTIVE, + hasNonceCompetition: false, + }; + + const state = generateSafeBodyState({ + safeInfo: generateSafeInfo({ + nonce: '7', + threshold: 2, + owners: [signer, `0x${'2'.repeat(40)}`, `0x${'3'.repeat(40)}`], + }), + pendingReport, + isExecutableNow: true, + signers: [signer], + hasConnectedWalletSigned: true, + approvalsAmount: 1, + minApprovals: 2, + membersCount: 3, + }); + + beforeEach(() => { + useSafeMultisigBodyStateSpy.mockReturnValue(state); + getStageStatusSpy.mockReturnValue(ProposalStatus.ACTIVE); + useEnsNameSpy.mockReturnValue(unresolvedEnsName); + }); + + afterEach(() => { + useSafeMultisigBodyStateSpy.mockReset(); + getStageStatusSpy.mockReset(); + useEnsNameSpy.mockReset(); + }); + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ISafeMultisigProposalVotingSummaryProps = { + body, + proposal: generateSppProposal({ + network: Network.ETHEREUM_MAINNET, + proposalIndex: '42', + }), + stage: generateSppStage({ stageIndex: 1 }), + isVeto: false, + ...props, + }; + + return ; + }; + + it('states the live approval count against the Safe owner set', () => { + render(createTestComponent()); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigProposalVotingSummary.approvalLabel', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigProposalVotingSummary.ownerCount (count=3)', + ), + ).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('reads the indexed result once the body has reported, not the Safe queue', () => { + getStageStatusSpy.mockReturnValue(ProposalStatus.ACCEPTED); + useSafeMultisigBodyStateSpy.mockReturnValue({ + ...state, + settledResultType: SppProposalType.APPROVAL, + }); + + render(createTestComponent()); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigProposalVotingSummary.approved', + ), + ).toBeInTheDocument(); + }); + + it('reports a replaced body when the stage closed with a superseded report and no result', () => { + getStageStatusSpy.mockReturnValue(ProposalStatus.REJECTED); + useSafeMultisigBodyStateSpy.mockReturnValue({ + ...state, + pendingReport: { + ...pendingReport, + state: SafeTransactionState.SUPERSEDED, + }, + }); + + render(createTestComponent()); + + // A replacement is not the same as declining to approve, and the owner set never voted it + // down: say what happened to the transaction. + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigProposalVotingSummary.replaced', + ), + ).toBeInTheDocument(); + }); + + it('states the count that stopped short when the stage closed without a report', () => { + getStageStatusSpy.mockReturnValue(ProposalStatus.REJECTED); + useSafeMultisigBodyStateSpy.mockReturnValue({ + ...state, + pendingReport: undefined, + }); + + render(createTestComponent()); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigProposalVotingSummary.notApproved', + ), + ).toBeInTheDocument(); + }); + + it('names the body without a count when the Safe state cannot be read', () => { + useSafeMultisigBodyStateSpy.mockReturnValue({ + ...state, + safeInfo: undefined, + isError: true, + }); + + render(createTestComponent()); + + // A failed read must not be dressed up as zero approvals. + expect( + screen.queryByText( + 'app.plugins.safeMultisig.safeMultisigProposalVotingSummary.approvalLabel', + ), + ).not.toBeInTheDocument(); + expect(screen.getByText('0x0000…0001')).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.tsx b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.tsx new file mode 100644 index 0000000000..63b7f40447 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigProposalVotingSummary/safeMultisigProposalVotingSummary.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { + addressUtils, + formatterUtils, + NumberFormat, + Progress, + ProposalStatus, +} from '@aragon/gov-ui-kit'; +import { useEnsName } from '@/modules/ens'; +import { SppProposalType } from '@/plugins/sppPlugin/types'; +import { sppStageUtils } from '@/plugins/sppPlugin/utils/sppStageUtils'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { useSafeMultisigBodyState } from '../../hooks/useSafeMultisigBodyState'; +import { SafeTransactionState } from '../../types'; +import type { ISafeMultisigProposalVotingSummaryProps } from './safeMultisigProposalVotingSummary.api'; + +const translationKey = + 'app.plugins.safeMultisig.safeMultisigProposalVotingSummary'; + +export const SafeMultisigProposalVotingSummary: React.FC< + ISafeMultisigProposalVotingSummaryProps +> = (props) => { + const { proposal, body, stage, isVeto } = props; + + const { t } = useTranslations(); + const { data: ensName } = useEnsName(body); + + const { + safeInfo, + pendingReport, + settledResultType, + approvalsAmount, + minApprovals, + membersCount, + } = useSafeMultisigBodyState({ + network: proposal.network, + address: body, + proposal, + stage, + }); + + // A Safe has no name onchain, so the body reads as its ENS name or address, exactly as the + // generic external body it replaces did. + const displayName = ensName ?? addressUtils.truncateAddress(body); + const stageStatus = sppStageUtils.getStageStatus(proposal, stage); + + // Before the stage opens there is nothing to count, and a failed read must not be dressed up as + // zero approvals: name the body and say no more. + if (stageStatus === ProposalStatus.PENDING || safeInfo == null) { + return ( +

+ {displayName} +

+ ); + } + + const isSettled = + settledResultType != null || stageStatus !== ProposalStatus.ACTIVE; + + if (isSettled) { + const reached = + settledResultType === SppProposalType.APPROVAL || + settledResultType === SppProposalType.VETO; + const wasReplaced = + settledResultType == null && + pendingReport?.state === SafeTransactionState.SUPERSEDED; + + let statusKey: string; + + if (reached) { + statusKey = isVeto ? 'vetoed' : 'approved'; + } else if (wasReplaced) { + statusKey = 'replaced'; + } else { + statusKey = isVeto ? 'notVetoed' : 'notApproved'; + } + + const statusClass = + reached && isVeto + ? 'text-critical-800' + : reached + ? 'text-success-800' + : 'text-neutral-500'; + + return ( +

+ {displayName}{' '} + + {t(`${translationKey}.${statusKey}`)} + +

+ ); + } + + // Owners are read live and a Safe can be emptied, so guard the division rather than trusting a + // positive member count the way a snapshotted body can. + const approvalsPercentage = + membersCount > 0 ? (approvalsAmount / membersCount) * 100 : 0; + const thresholdPercentage = + membersCount > 0 ? (minApprovals / membersCount) * 100 : 0; + const isThresholdReached = approvalsAmount >= minApprovals; + + return ( +
+

+ {displayName}{' '} + + {t( + `${translationKey}.${isVeto ? 'vetoLabel' : 'approvalLabel'}`, + )} + +

+ +

+ {formatterUtils.formatNumber(approvalsAmount, { + format: NumberFormat.GENERIC_SHORT, + })}{' '} + + {t(`${translationKey}.ownerCount`, { + count: formatterUtils.formatNumber(membersCount, { + format: NumberFormat.GENERIC_SHORT, + })!, + })} + +

+
+ ); +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/index.ts b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/index.ts new file mode 100644 index 0000000000..47f13e8151 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/index.ts @@ -0,0 +1,4 @@ +export { + type ISafeMultisigSubmitVoteProps, + SafeMultisigSubmitVote, +} from './safeMultisigSubmitVote'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/safeMultisigSubmitVote.test.tsx b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/safeMultisigSubmitVote.test.tsx new file mode 100644 index 0000000000..5a0172f0dc --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/safeMultisigSubmitVote.test.tsx @@ -0,0 +1,851 @@ +import { ProposalStatus } from '@aragon/gov-ui-kit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as Wagmi from 'wagmi'; +import * as WagmiActions from 'wagmi/actions'; +import * as connectedWalletGuardApi from '@/modules/application/hooks/useConnectedWalletGuard'; +import * as walletAccountApi from '@/modules/application/hooks/useWalletAccount'; +import { + generateSppProposal, + generateSppStage, +} from '@/plugins/sppPlugin/testUtils'; +import { SppProposalType } from '@/plugins/sppPlugin/types'; +import { Network } from '@/shared/api/daoService'; +import * as safeServiceApi from '@/shared/api/safeService'; +import * as transactionServiceApi from '@/shared/api/transactionService'; +import * as dialogProvider from '@/shared/components/dialogProvider'; +import * as networkSwitchApi from '@/shared/hooks/useNetworkSwitch'; +import { + generateDialogContext, + generateSafeNextNonceResponse, +} from '@/shared/testUtils'; +import { + SafeMultisigPluginDialogId, + safeIndexingTimeout, +} from '../../constants'; +import * as safeBodyStateApi from '../../hooks/useSafeMultisigBodyState'; +import { + generateSafeBodyState, + generateSafeConfirmation, + generateSafeInfo, + generateSafeMultisigTransaction, +} from '../../testUtils'; +import { SafeTransactionState } from '../../types'; +import { + type ISafeMultisigSubmitVoteProps, + SafeMultisigSubmitVote, +} from './safeMultisigSubmitVote'; + +jest.mock('wagmi/actions', () => ({ + ...jest.requireActual('wagmi/actions'), + getConnection: jest.fn(), + sendTransaction: jest.fn(), + waitForTransactionReceipt: jest.fn(), +})); + +jest.mock('@safe-global/protocol-kit', () => ({ + __esModule: true, + default: { init: jest.fn() }, + buildSignatureBytes: jest.fn(), + EthSafeSignature: jest.fn(), + EthSafeTransaction: jest.fn(), +})); + +describe(' component', () => { + const owner = '0x0000000000000000000000000000000000000011'; + const nonOwner = '0x0000000000000000000000000000000000000099'; + const useWalletAccountSpy = jest.spyOn( + walletAccountApi, + 'useWalletAccount', + ); + const useConnectedWalletGuardSpy = jest.spyOn( + connectedWalletGuardApi, + 'useConnectedWalletGuard', + ); + const useNetworkSwitchSpy = jest.spyOn( + networkSwitchApi, + 'useNetworkSwitch', + ); + const useSafeBodyStateSpy = jest.spyOn( + safeBodyStateApi, + 'useSafeMultisigBodyState', + ); + const useProposeSpy = jest.spyOn( + safeServiceApi, + 'useProposeSafeTransaction', + ); + const useConfirmSpy = jest.spyOn( + safeServiceApi, + 'useConfirmSafeTransaction', + ); + const getSafeNextNonceSpy = jest.spyOn( + safeServiceApi.safeService, + 'getSafeNextNonce', + ); + const useTransactionStatusSpy = jest.spyOn( + transactionServiceApi, + 'useTransactionStatus', + ); + const dialogOpen = jest.fn(); + const useDialogContextSpy = jest.spyOn(dialogProvider, 'useDialogContext'); + const useBytecodeSpy = jest.spyOn(Wagmi, 'useBytecode'); + const proposeMutateAsync = jest.fn(); + const confirmMutateAsync = jest.fn(); + + const safeInfo = generateSafeInfo({ threshold: 1, owners: [owner] }); + + const baseState = generateSafeBodyState({ + safeInfo, + minApprovals: 1, + membersCount: 1, + }); + + beforeEach(() => { + useWalletAccountSpy.mockReturnValue({ + address: owner, + chainId: 11_155_111, + isConnecting: false, + isReconnecting: false, + }); + useConnectedWalletGuardSpy.mockReturnValue({ + check: ({ onSuccess } = {}) => onSuccess?.(), + result: true, + }); + useNetworkSwitchSpy.mockReturnValue({ + requiredChainId: 11_155_111, + isCrossNetworkTransaction: false, + networkName: 'Sepolia', + switchChainStatus: 'idle', + withNetworkSwitch: (callback) => callback(), + }); + useSafeBodyStateSpy.mockReturnValue(baseState); + // Standing in for the owner confirming the dialog, so the tests below exercise the signing + // path rather than stopping at the confirmation step. + dialogOpen.mockImplementation((_id, options) => { + const params = options?.params as + | { onConfirm?: () => void } + | undefined; + params?.onConfirm?.(); + }); + useDialogContextSpy.mockReturnValue( + generateDialogContext({ open: dialogOpen }), + ); + useProposeSpy.mockReturnValue({ + mutateAsync: proposeMutateAsync, + } as never); + useConfirmSpy.mockReturnValue({ + mutateAsync: confirmMutateAsync, + } as never); + useBytecodeSpy.mockReturnValue({ + data: undefined, + isLoading: false, + } as ReturnType); + getSafeNextNonceSpy.mockResolvedValue( + generateSafeNextNonceResponse({ + nextNonce: '0', + currentNonce: '0', + }), + ); + // The report stays unattributed unless a test says otherwise, so the indexing hold is the + // default post-execution state rather than a network-dependent one. + useTransactionStatusSpy.mockReturnValue({ data: undefined } as never); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ISafeMultisigSubmitVoteProps = { + daoId: `sep:${owner}`, + proposal: generateSppProposal({ + network: Network.ETHEREUM_SEPOLIA, + }), + externalAddress: safeInfo.address, + stage: generateSppStage({ stageIndex: 1 }), + isVeto: false, + ...props, + }; + + return ( + + + + ); + }; + + it('states that the Safe can still act after the voting window closed', () => { + // The most surprising thing about a Safe body: Aragon's window closes, the Safe queue has + // no deadline, and a verdict still counts while the stage can still advance. + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + canStillAffectOutcome: true, + }); + + render( + createTestComponent({ + stage: generateSppStage({ + stageIndex: 1, + voteDuration: 60, + maxAdvance: 60 * 60 * 24, + }), + proposal: generateSppProposal({ + network: Network.ETHEREUM_SEPOLIA, + stageIndex: 1, + lastStageTransition: Math.floor(Date.now() / 1000) - 3600, + }), + }), + ); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.windowClosed', + ), + ).toBeInTheDocument(); + }); + + it('withholds the action and names the expiry once the stage can never advance', () => { + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + canStillAffectOutcome: false, + }); + + render(createTestComponent()); + + // Executing would still succeed against the Safe and still change nothing, so offering it + // would be a lie. + expect( + screen.queryByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ).not.toBeInTheDocument(); + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.stageExpired', + ), + ).toBeInTheDocument(); + }); + + it('rejects a connected wallet that is not a live Safe owner', async () => { + useWalletAccountSpy.mockReturnValue({ + address: nonOwner, + chainId: 11_155_111, + isConnecting: false, + isReconnecting: false, + }); + render(createTestComponent()); + + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.ownerRequired', + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/WalletConnect/i)).not.toBeInTheDocument(); + }); + + it('keeps an EOA owner actionable on a pre-v1.4.1 Safe', () => { + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + safeInfo: generateSafeInfo({ + owners: [owner], + threshold: 1, + version: '1.3.0', + }), + }); + + render(createTestComponent()); + + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ).toBeEnabled(); + }); + + it('degrades explicitly for a contract owner on a pre-v1.4.1 Safe', () => { + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + safeInfo: generateSafeInfo({ + owners: [owner], + threshold: 1, + version: '1.3.0', + }), + }); + useBytecodeSpy.mockReturnValue({ + data: '0x1234', + isLoading: false, + } as ReturnType); + + render(createTestComponent()); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.versionUnsupported (version=1.3.0)', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ).toBeDisabled(); + }); + + it('offers execution when the pending report has reached threshold', () => { + const transaction = generateSafeMultisigTransaction({ + confirmationsRequired: 1, + confirmations: [generateSafeConfirmation({ owner })], + }); + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + pendingReport: { + transaction, + report: { + proposalId: BigInt(1), + stageId: 1, + resultType: SppProposalType.APPROVAL, + tryAdvance: false, + }, + state: SafeTransactionState.LIVE, + status: ProposalStatus.ACTIVE, + hasNonceCompetition: false, + }, + hasConnectedWalletSigned: true, + approvalsAmount: 1, + isExecutableNow: true, + }); + + render(createTestComponent()); + + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.executeSafeTransaction', + }), + ).toBeEnabled(); + }); + + it('confirms the governance effect before producing a signature', async () => { + render(createTestComponent()); + + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ); + + expect(dialogOpen).toHaveBeenCalledWith( + SafeMultisigPluginDialogId.CONFIRM_SIGNATURE, + expect.objectContaining({ + params: expect.objectContaining({ + isVeto: false, + safeAddress: safeInfo.address, + signerAddress: owner, + }), + }), + ); + }); + + it('warns that the confirmation reaching threshold is followed by a gas transaction', async () => { + // One click, two wallet interactions: a free confirmation, then execution. Promising + // "signing costs no gas" and then opening a gas prompt would be a bait. + render(createTestComponent()); + + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ); + + expect(dialogOpen).toHaveBeenCalledWith( + SafeMultisigPluginDialogId.CONFIRM_SIGNATURE, + expect.objectContaining({ + params: expect.objectContaining({ willExecute: true }), + }), + ); + }); + + it('does not warn of a gas transaction when more owners are still needed', async () => { + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + safeInfo: generateSafeInfo({ + threshold: 3, + owners: [owner, nonOwner, `0x${'4'.repeat(40)}`], + }), + pendingReport: { + transaction: generateSafeMultisigTransaction({ + nonce: '0', + confirmationsRequired: 3, + confirmations: [ + generateSafeConfirmation({ owner: nonOwner }), + ], + }), + report: { + proposalId: BigInt(1), + stageId: 1, + resultType: SppProposalType.APPROVAL, + tryAdvance: false, + }, + state: SafeTransactionState.LIVE, + status: ProposalStatus.ACTIVE, + hasNonceCompetition: false, + }, + approvalsAmount: 1, + minApprovals: 3, + }); + + render(createTestComponent()); + + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approve', + }), + ); + + expect(dialogOpen).toHaveBeenCalledWith( + SafeMultisigPluginDialogId.CONFIRM_SIGNATURE, + expect.objectContaining({ + params: expect.objectContaining({ willExecute: false }), + }), + ); + }); + + it('sends execution straight to the wallet, which already prices the transaction', async () => { + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + pendingReport: { + transaction: generateSafeMultisigTransaction({ + nonce: '0', + confirmationsRequired: 1, + confirmations: [generateSafeConfirmation({ owner })], + }), + report: { + proposalId: BigInt(1), + stageId: 1, + resultType: SppProposalType.APPROVAL, + tryAdvance: false, + }, + state: SafeTransactionState.LIVE, + status: ProposalStatus.ACTIVE, + hasNonceCompetition: false, + }, + hasConnectedWalletSigned: true, + approvalsAmount: 1, + isExecutableNow: true, + }); + + render(createTestComponent()); + + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.executeSafeTransaction', + }), + ); + + // A gasless-signature confirmation in front of a gas-paying transaction would be a lie. + expect(dialogOpen).not.toHaveBeenCalled(); + }); + + it('offers a re-queue when the pending report lost its nonce', () => { + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + pendingReport: { + transaction: generateSafeMultisigTransaction({ + confirmationsRequired: 1, + confirmations: [generateSafeConfirmation({ owner })], + }), + report: { + proposalId: BigInt(1), + stageId: 1, + resultType: SppProposalType.APPROVAL, + tryAdvance: false, + }, + state: SafeTransactionState.SUPERSEDED, + status: ProposalStatus.EXPIRED, + hasNonceCompetition: false, + }, + hasConnectedWalletSigned: true, + approvalsAmount: 1, + }); + + render(createTestComponent()); + + // A superseded report has collected signatures but can never execute, so the owner must be + // able to sign a replacement rather than being told to wait for the other owners. + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.requeueSafeTransaction', + }), + ).toBeEnabled(); + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.replaced', + ), + ).toBeInTheDocument(); + }); + + it('withholds execution while earlier Safe transactions are still ahead', () => { + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + safeInfo: generateSafeInfo({ + threshold: 1, + owners: [owner], + nonce: '4', + }), + pendingReport: { + transaction: generateSafeMultisigTransaction({ + nonce: '6', + confirmationsRequired: 1, + confirmations: [generateSafeConfirmation({ owner })], + }), + report: { + proposalId: BigInt(1), + stageId: 1, + resultType: SppProposalType.APPROVAL, + tryAdvance: false, + }, + state: SafeTransactionState.LIVE, + status: ProposalStatus.ACTIVE, + hasNonceCompetition: false, + }, + hasConnectedWalletSigned: true, + approvalsAmount: 1, + isExecutableNow: false, + transactionsAhead: 2, + }); + + render(createTestComponent()); + + // Fully confirmed but not executable: a Safe runs in nonce order and a confirmation is + // bound to its slot, so the owner is waiting on the queue. The action stays named for what + // is pending - executing the Safe transaction - but must not be offered as available. + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.executeSafeTransaction', + }), + ).toBeDisabled(); + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.nonceQueued (count=2)', + ), + ).toBeInTheDocument(); + }); + + it('names execution as the second gate once the threshold is met', () => { + // gov-ui-kit's card says "approval reached" at threshold, but a Safe body has told Aragon + // nothing until its transaction executes - so the card alone overstates the position. + useSafeBodyStateSpy.mockReturnValue({ + ...baseState, + pendingReport: { + transaction: generateSafeMultisigTransaction({ + nonce: '0', + confirmationsRequired: 1, + confirmations: [generateSafeConfirmation({ owner })], + }), + report: { + proposalId: BigInt(1), + stageId: 1, + resultType: SppProposalType.APPROVAL, + tryAdvance: false, + }, + state: SafeTransactionState.LIVE, + status: ProposalStatus.ACTIVE, + hasNonceCompetition: false, + }, + hasConnectedWalletSigned: true, + approvalsAmount: 1, + isExecutableNow: true, + }); + + render(createTestComponent()); + + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.executeSafeTransaction', + }), + ).toBeEnabled(); + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.awaitingExecution', + ), + ).toBeInTheDocument(); + }); + + it('offers a retry when the Safe read is stale, instead of passing the count off as current', () => { + useSafeBodyStateSpy.mockReturnValue({ ...baseState, isStale: true }); + + render(createTestComponent()); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.unreachable', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.retry', + }), + ).toBeEnabled(); + }); + + const mockThresholdOneExecution = () => { + const signature = { + signer: owner, + data: '0xsignature', + isContractSignature: false, + staticPart: jest.fn(), + dynamicPart: jest.fn(), + }; + const safeTransaction = { + data: { + to: safeInfo.address, + value: '0', + data: '0xreport', + operation: 0, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + nonce: 0, + }, + addSignature: jest.fn(), + encodedSignatures: jest.fn(() => '0xsignatureBytes'), + }; + const protocolKit = { + createTransaction: jest.fn().mockResolvedValue(safeTransaction), + getTransactionHash: jest + .fn() + .mockResolvedValue(`0x${'1'.repeat(64)}`), + signTypedData: jest.fn().mockResolvedValue(signature), + getEncodedTransaction: jest + .fn() + .mockResolvedValue('0xexecTransaction'), + }; + const protocolKitModule = jest.requireMock( + '@safe-global/protocol-kit', + ) as { + default: { init: jest.Mock }; + buildSignatureBytes: jest.Mock; + }; + protocolKitModule.default.init.mockResolvedValue(protocolKit); + protocolKitModule.buildSignatureBytes.mockReturnValue( + '0xsignatureBytes', + ); + jest.mocked(WagmiActions.getConnection).mockReturnValue({ + connector: { + getProvider: jest + .fn() + .mockResolvedValue({ request: jest.fn() }), + }, + } as never); + jest.mocked(WagmiActions.sendTransaction).mockResolvedValue( + `0x${'2'.repeat(64)}`, + ); + jest.mocked(WagmiActions.waitForTransactionReceipt).mockResolvedValue( + {} as never, + ); + + return { signature, safeTransaction, protocolKit, protocolKitModule }; + }; + + it('proposes gaslessly and executes after a threshold-one signature', async () => { + const { signature, safeTransaction, protocolKit, protocolKitModule } = + mockThresholdOneExecution(); + // The Safe sits at nonce 6 with that slot free, so the report lands on it and can execute + // in the same flow. + getSafeNextNonceSpy.mockResolvedValue( + generateSafeNextNonceResponse({ + nextNonce: '6', + currentNonce: '6', + }), + ); + + render(createTestComponent()); + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ); + + await waitFor(() => { + expect(proposeMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + safeTransactionData: safeTransaction.data, + senderAddress: owner, + senderSignature: signature.data, + }), + }), + ); + }); + // The service reads the live nonce itself; nothing from the polled body state is passed in, + // because a polled value can lag and a stale floor allocates a consumed nonce. + expect(getSafeNextNonceSpy).toHaveBeenCalledWith({ + urlParams: { + network: Network.ETHEREUM_SEPOLIA, + address: safeInfo.address, + }, + }); + expect(protocolKit.createTransaction).toHaveBeenCalledWith( + expect.objectContaining({ options: { nonce: 6 } }), + ); + expect(protocolKitModule.buildSignatureBytes).toHaveBeenCalledWith([ + signature, + ]); + expect( + jest.mocked(WagmiActions.sendTransaction).mock.calls[0][1], + ).toEqual(expect.objectContaining({ data: '0xexecTransaction' })); + expect(WagmiActions.waitForTransactionReceipt).toHaveBeenCalled(); + }); + + it('proposes without executing when the allocated nonce sits behind the queue', async () => { + const { signature, safeTransaction } = mockThresholdOneExecution(); + // Something else holds nonce 6, so the report is allocated 7. A Safe executes in strict + // nonce order, so executing now would pay gas for a revert. + getSafeNextNonceSpy.mockResolvedValue( + generateSafeNextNonceResponse({ + nextNonce: '7', + currentNonce: '6', + }), + ); + + render(createTestComponent()); + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ); + + await waitFor(() => { + expect(proposeMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + safeTransactionData: safeTransaction.data, + senderSignature: signature.data, + }), + }), + ); + }); + expect(WagmiActions.sendTransaction).not.toHaveBeenCalled(); + }); + + it('signs without executing when the owner chooses to approve only', async () => { + mockThresholdOneExecution(); + getSafeNextNonceSpy.mockResolvedValue( + generateSafeNextNonceResponse({ + nextNonce: '6', + currentNonce: '6', + }), + ); + + render(createTestComponent()); + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.moreActions', + }), + ); + await userEvent.click( + screen.getByRole('menuitem', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveOnly', + }), + ); + + // The signature is still collected and the transaction is left fully signed in the queue - + // only the gas-paying half is declined. + await waitFor(() => { + expect(proposeMutateAsync).toHaveBeenCalled(); + }); + expect(WagmiActions.sendTransaction).not.toHaveBeenCalled(); + }); + + it('signs the EIP-712 transaction rather than a bare hash', async () => { + const { protocolKit, safeTransaction } = mockThresholdOneExecution(); + + render(createTestComponent()); + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ); + + // Signing the struct is what lets the wallet show the target, value and nonce. Hashing + // offchain and signing the digest asks the owner to approve an opaque blob instead. + await waitFor(() => { + expect(protocolKit.signTypedData).toHaveBeenCalledWith( + safeTransaction, + ); + }); + }); + + it('holds the action while an executed report is not indexed yet', async () => { + mockThresholdOneExecution(); + + render(createTestComponent()); + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ); + + // The executed report has left the Safe queue but the indexed body result does not exist + // yet. Re-offering the idle CTA here would invite a duplicate report at the next nonce. + await waitFor(() => { + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.finalizing', + }), + ).toBeDisabled(); + }); + }); + + it('releases the hold when the executed report is never indexed', async () => { + jest.useFakeTimers(); + + try { + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + mockThresholdOneExecution(); + + render(createTestComponent()); + await user.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ); + await waitFor(() => + expect( + WagmiActions.waitForTransactionReceipt, + ).toHaveBeenCalled(), + ); + + // A stalled indexer is indistinguishable from a slow one, so the hold must expire + // instead of leaving the owner behind a permanent spinner with no way out. + await act(async () => { + jest.advanceTimersByTime(safeIndexingTimeout); + await Promise.resolve(); + }); + + expect( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigSubmitVote.approveAndExecute', + }), + ).toBeEnabled(); + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigSubmitVote.indexingDelayed', + ), + ).toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/safeMultisigSubmitVote.tsx b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/safeMultisigSubmitVote.tsx new file mode 100644 index 0000000000..4824cbd059 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigSubmitVote/safeMultisigSubmitVote.tsx @@ -0,0 +1,746 @@ +'use client'; + +import { + AlertInline, + addressUtils, + Button, + Dropdown, + IconType, +} from '@aragon/gov-ui-kit'; +import { useQueryClient } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; +import { useEffect, useRef, useState } from 'react'; +import type { Hex } from 'viem'; +import { useBytecode } from 'wagmi'; +import { + getBytecode, + getConnection, + sendTransaction, + waitForTransactionReceipt, +} from 'wagmi/actions'; +import { wagmiConfig } from '@/modules/application/constants/wagmi'; +import { useConnectedWalletGuard } from '@/modules/application/hooks/useConnectedWalletGuard'; +import { useWalletAccount } from '@/modules/application/hooks/useWalletAccount'; +import { GovernanceServiceKey } from '@/modules/governance/api/governanceService'; +import type { ISppVotingTerminalBodyVoteDefaultProps } from '@/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyVoteDefault'; +import { SppProposalType } from '@/plugins/sppPlugin/types'; +import { sppStageUtils } from '@/plugins/sppPlugin/utils/sppStageUtils'; +import { + safeService, + safeServiceKeys, + useConfirmSafeTransaction, + useProposeSafeTransaction, +} from '@/shared/api/safeService'; +import { + TransactionType, + useTransactionStatus, +} from '@/shared/api/transactionService'; +import { useDialogContext } from '@/shared/components/dialogProvider'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { useNetworkSwitch } from '@/shared/hooks/useNetworkSwitch'; +import { monitoringUtils } from '@/shared/utils/monitoringUtils'; +import { + SafeMultisigPluginDialogId, + safeIndexingPollInterval, + safeIndexingTimeout, +} from '../../constants'; +import { useSafeMultisigBodyState } from '../../hooks/useSafeMultisigBodyState'; +import { SafeTransactionState } from '../../types'; +import { safeMultisigProposalUtils } from '../../utils/safeMultisigProposalUtils'; +import { safeMultisigTransactionUtils } from '../../utils/safeMultisigTransactionUtils'; + +export interface ISafeMultisigSubmitVoteProps + extends ISppVotingTerminalBodyVoteDefaultProps {} + +interface IEip1193Provider { + request: (args: { + method: string; + params?: readonly unknown[] | object; + }) => Promise; +} + +const isEip1193Provider = (value: unknown): value is IEip1193Provider => + value != null && + typeof value === 'object' && + 'request' in value && + typeof value.request === 'function'; + +const toSafeNonce = (nonce: string): number => { + const parsedNonce = Number(nonce); + + if (!Number.isSafeInteger(parsedNonce) || parsedNonce < 0) { + throw new Error('Safe nonce cannot be represented safely'); + } + + return parsedNonce; +}; + +const translationKey = 'app.plugins.safeMultisig.safeMultisigSubmitVote'; + +export const SafeMultisigSubmitVote: React.FC = ( + props, +) => { + const { proposal, externalAddress, stage, isVeto } = props; + const { t } = useTranslations(); + const { open } = useDialogContext(); + const queryClient = useQueryClient(); + const { address: connectedAddress } = useWalletAccount(); + const latestConnectedAddress = useRef(connectedAddress); + const { check: checkWalletConnection } = useConnectedWalletGuard(); + const { requiredChainId, withNetworkSwitch } = useNetworkSwitch({ + network: proposal.network, + }); + const [actionError, setActionError] = useState(); + const [isExecuting, setIsExecuting] = useState(false); + const [executedHash, setExecutedHash] = useState(); + const [hasIndexingTimedOut, setHasIndexingTimedOut] = useState(false); + + useEffect(() => { + latestConnectedAddress.current = connectedAddress; + }, [connectedAddress]); + + const bodyState = useSafeMultisigBodyState({ + network: proposal.network, + address: externalAddress, + proposal, + stage, + }); + const { + safeInfo, + pendingReport, + hasConnectedWalletSigned, + settledResultType, + isStale, + isExecutableNow, + isCurrentNonceFree, + transactionsAhead, + canStillAffectOutcome, + } = bodyState; + + const { mutateAsync: proposeTransaction } = useProposeSafeTransaction(); + const { mutateAsync: confirmTransaction } = useConfirmSafeTransaction(); + + const liveReport = + pendingReport?.state === SafeTransactionState.LIVE + ? pendingReport + : undefined; + const thresholdReached = + liveReport != null && + safeMultisigProposalUtils.isThresholdReached(liveReport.transaction); + + /** + * Whether this owner's confirmation is the one that reaches the threshold, so execution follows + * in the same flow and the wallet opens twice: once to sign for free, once to pay gas. + * + * Covers the first confirmation too: on a 1-of-n Safe, proposing already satisfies the + * threshold, so the very first click executes. + */ + const willCompleteThreshold = + !thresholdReached && + !hasConnectedWalletSigned && + (liveReport != null + ? liveReport.transaction.confirmations.length + 1 >= + liveReport.transaction.confirmationsRequired + : safeInfo != null && safeInfo.threshold <= 1); + + /** + * Whether execution can actually follow the confirmation. Reaching the threshold is not enough: + * a Safe executes in strict nonce order, so a transaction sitting behind another is signed and + * waiting, and attempting it would pay gas for a revert. + * + * An existing report answers for itself; a report that does not exist yet lands on the lowest + * free nonce, so it is executable only when the current one is unoccupied. + */ + const canBundleExecution = + willCompleteThreshold && + (liveReport != null ? isExecutableNow : isCurrentNonceFree); + const supportsEip1271Signatures = + safeMultisigProposalUtils.supportsEip1271Signatures( + safeInfo?.version ?? null, + ); + const { + data: connectedAccountBytecode, + isLoading: isContractOwnerCheckLoading, + } = useBytecode({ + address: connectedAddress, + chainId: requiredChainId, + query: { + enabled: connectedAddress != null && !supportsEip1271Signatures, + }, + }); + const hasUnsupportedContractOwner = + !supportsEip1271Signatures && connectedAccountBytecode != null; + const hasSettled = settledResultType != null; + + /** + * Between a successful execution and the indexer ingesting it, the Safe queue no longer holds + * the report (it is executed, so the `executed=false` read drops it) and the indexed body + * result does not exist yet. Without holding the action across that window the card falls back + * to its idle CTA and invites a duplicate report at the next nonce. + * + * The hold is bounded: the status endpoint answers `{ isProcessed: false }` for any hash it + * cannot attribute, so a stalled indexer looks exactly like a slow one and would otherwise + * hold the card forever behind a spinner with no way out. + */ + const isAwaitingIndexing = + executedHash != null && !hasSettled && !hasIndexingTimedOut; + + const { data: executedTransactionStatus } = useTransactionStatus( + { + urlParams: { + network: proposal.network, + transactionHash: executedHash ?? '', + }, + queryParams: { type: TransactionType.PROPOSAL_REPORT_RESULTS }, + }, + { + enabled: isAwaitingIndexing, + refetchInterval: ({ state }) => + state.data?.isProcessed === true + ? false + : safeIndexingPollInterval, + }, + ); + + const isReportIndexed = executedTransactionStatus?.isProcessed === true; + + useEffect(() => { + if (!isReportIndexed) { + return; + } + + void queryClient.invalidateQueries({ + queryKey: [GovernanceServiceKey.PROPOSAL_BY_SLUG], + }); + void queryClient.invalidateQueries({ + queryKey: [GovernanceServiceKey.PROPOSAL_LIST], + }); + }, [isReportIndexed, queryClient]); + + useEffect(() => { + if (executedHash == null || isReportIndexed) { + return; + } + + const timeout = setTimeout( + () => setHasIndexingTimedOut(true), + safeIndexingTimeout, + ); + + return () => clearTimeout(timeout); + }, [executedHash, isReportIndexed]); + + const invalidateSafeState = async () => { + if (safeInfo == null) { + return; + } + + const urlParams = { + network: proposal.network, + address: externalAddress, + }; + + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: safeServiceKeys.safeInfo({ urlParams }), + }), + queryClient.invalidateQueries({ + queryKey: safeServiceKeys.safePendingTransactions({ + urlParams, + }), + }), + ]); + }; + + const submitReport = async (bundleExecution: boolean) => { + const ownerAddress = latestConnectedAddress.current; + + if (safeInfo == null || ownerAddress == null) { + return; + } + + setActionError(undefined); + setIsExecuting(true); + + try { + if (!supportsEip1271Signatures) { + const ownerBytecode = await getBytecode(wagmiConfig, { + address: ownerAddress, + chainId: requiredChainId, + }); + + if (ownerBytecode != null) { + setActionError( + t(`${translationKey}.versionUnsupported`, { + version: + safeInfo.version ?? + t(`${translationKey}.unknownVersion`), + }), + ); + return; + } + } + + const connection = getConnection(wagmiConfig); + const provider = await connection.connector?.getProvider({ + chainId: requiredChainId, + }); + + if (!isEip1193Provider(provider)) { + throw new Error('Connected wallet does not expose a provider'); + } + + const { + default: Safe, + buildSignatureBytes, + EthSafeSignature, + EthSafeTransaction, + } = await import('@safe-global/protocol-kit'); + const protocolKit = await Safe.init({ + provider, + signer: ownerAddress, + safeAddress: externalAddress, + }); + + const resultType = isVeto + ? SppProposalType.VETO + : SppProposalType.APPROVAL; + const reportData = + safeMultisigTransactionUtils.buildReportProposalResultData({ + proposalId: proposal.proposalIndex, + stageId: stage.stageIndex, + resultType, + }); + + let safeTransaction; + let signatures; + let confirmationsRequired: number; + let landsOnCurrentNonce: boolean; + + if (liveReport == null) { + // Both the live nonce and the queue are read fresh inside the service, uncached: + // the polled `safeInfo` here may lag, and a stale floor allocates a nonce the Safe + // has already consumed while a stale queue allocates one another transaction holds. + const nextNonce = await safeService.getSafeNextNonce({ + urlParams: { + network: proposal.network, + address: externalAddress, + }, + }); + + /** + * The read that allocates also reports the live nonce, so this is the authoritative + * answer to whether the new transaction can execute immediately. The polled state + * behind `canBundleExecution` may lag it, and paying gas for a guaranteed revert is + * worse than deferring execution. + */ + landsOnCurrentNonce = + BigInt(nextNonce.nextNonce) === + BigInt(nextNonce.currentNonce); + + safeTransaction = await protocolKit.createTransaction({ + transactions: [ + { + to: proposal.pluginAddress, + value: '0', + data: reportData, + }, + ], + onlyCalls: true, + options: { nonce: toSafeNonce(nextNonce.nextNonce) }, + }); + const safeTxHash = + await protocolKit.getTransactionHash(safeTransaction); + /** + * Sign the EIP-712 `SafeTx` struct, not the bare hash. Both produce a signature the + * Safe accepts, but hashing offchain asks the owner to approve an opaque 32-byte + * blob, which wallets flag as blind signing. Typed data shows them the target, value + * and nonce they are actually authorising. + */ + const signature = + await protocolKit.signTypedData(safeTransaction); + + await proposeTransaction({ + urlParams: { + network: proposal.network, + address: externalAddress, + }, + body: { + safeTransactionData: safeTransaction.data, + safeTxHash, + senderAddress: ownerAddress, + senderSignature: signature.data, + origin: 'Aragon', + }, + }); + + signatures = [signature]; + confirmationsRequired = safeInfo.threshold; + } else { + const { transaction } = liveReport; + safeTransaction = new EthSafeTransaction({ + to: transaction.to, + value: transaction.value, + data: transaction.data ?? '0x', + operation: transaction.operation, + safeTxGas: transaction.safeTxGas, + baseGas: transaction.baseGas, + gasPrice: transaction.gasPrice, + gasToken: transaction.gasToken, + refundReceiver: transaction.refundReceiver, + nonce: toSafeNonce(transaction.nonce), + }); + const safeTxHash = + await protocolKit.getTransactionHash(safeTransaction); + + if ( + safeTxHash.toLowerCase() !== + transaction.safeTxHash.toLowerCase() + ) { + throw new Error( + 'Queued Safe transaction hash does not match its transaction data', + ); + } + + const collectedSignatures = transaction.confirmations.map( + ({ owner, signature, signatureType }) => + new EthSafeSignature( + owner, + signature, + signatureType === 'CONTRACT_SIGNATURE', + ), + ); + + const hasEnoughCollectedSignatures = + collectedSignatures.length >= + transaction.confirmationsRequired; + + if (hasEnoughCollectedSignatures || hasConnectedWalletSigned) { + signatures = collectedSignatures; + } else { + const signature = + await protocolKit.signTypedData(safeTransaction); + await confirmTransaction({ + urlParams: { + network: proposal.network, + safeTxHash, + }, + body: { signature: signature.data }, + }); + signatures = [...collectedSignatures, signature]; + } + + confirmationsRequired = transaction.confirmationsRequired; + landsOnCurrentNonce = isExecutableNow; + } + + if ( + bundleExecution && + landsOnCurrentNonce && + signatures.length >= confirmationsRequired + ) { + for (const signature of signatures) { + safeTransaction.addSignature(signature); + } + + const signatureBytes = buildSignatureBytes(signatures); + + if (safeTransaction.encodedSignatures() !== signatureBytes) { + throw new Error( + 'Protocol Kit produced inconsistent Safe signature bytes', + ); + } + + const data = + await protocolKit.getEncodedTransaction(safeTransaction); + const hash = await sendTransaction(wagmiConfig, { + chainId: requiredChainId, + to: externalAddress as Hex, + data: data as Hex, + value: BigInt(0), + }); + await waitForTransactionReceipt(wagmiConfig, { hash }); + setExecutedHash(hash); + } + + await invalidateSafeState(); + } catch (error) { + monitoringUtils.logError(error, { + context: { + safeAddress: externalAddress, + proposalId: proposal.id, + operation: 'safe_report_proposal_result', + }, + }); + setActionError(t(`${translationKey}.error`)); + } finally { + setIsExecuting(false); + } + }; + + const checkOwnershipAndSubmit = (bundleExecution: boolean) => { + const ownerAddress = latestConnectedAddress.current; + const isOwner = + ownerAddress != null && + safeInfo?.owners.some((owner) => + addressUtils.isAddressEqual(owner, ownerAddress), + ) === true; + + if (!isOwner) { + setActionError(t(`${translationKey}.ownerRequired`)); + return; + } + + if (hasUnsupportedContractOwner) { + setActionError( + t(`${translationKey}.versionUnsupported`, { + version: + safeInfo?.version ?? + t(`${translationKey}.unknownVersion`), + }), + ); + return; + } + + const runSubmit = () => + withNetworkSwitch(() => void submitReport(bundleExecution)); + + // Executing is an onchain transaction the wallet already prices and describes. Only the + // offchain signature gets the confirmation step, whose whole claim is that it costs nothing. + if (thresholdReached) { + runSubmit(); + return; + } + + open(SafeMultisigPluginDialogId.CONFIRM_SIGNATURE, { + params: { + proposalTitle: proposal.title, + safeAddress: externalAddress, + signerAddress: ownerAddress, + network: proposal.network, + isVeto, + nonce: liveReport?.transaction.nonce, + willExecute: canBundleExecution && bundleExecution, + onConfirm: runSubmit, + }, + }); + }; + + /** + * Bundling is the default: when the confirmation completes the threshold there is nothing left + * to wait for, so executing in the same flow saves a second visit. `Approve only` opts out and + * leaves the fully-signed transaction in the queue for any owner to execute. + */ + const handleVoteClick = (bundleExecution = true) => + checkWalletConnection({ + onSuccess: () => checkOwnershipAndSubmit(bundleExecution), + }); + + const isSuperseded = + pendingReport?.state === SafeTransactionState.SUPERSEDED; + const isWaitingForOwners = + liveReport != null && hasConnectedWalletSigned && !thresholdReached; + + /** + * A signature binds one exact nonce, so a fully-signed report cannot execute until the + * transactions ahead of it clear - and it cannot be moved: re-nonced calldata is a different + * transaction hash, which voids every signature collected so far. + * + * What is ahead is deliberately not described. A Safe is a universal account and its queue is + * shared with every other application using it, so the blocker may be unrelated to Aragon and + * unknowable here. Owners settle priority in the Safe itself. + */ + const isQueuedBehindNonce = thresholdReached && transactionsAhead > 0; + + // Below threshold the action produces a confirmation, so it is named for its governance intent. + // At threshold the only thing left is executing a Safe transaction, and that is named for the + // Safe: "Execute approval" reads as executing the proposal, which is a later step in an SPP + // process and someone else's permission. + let buttonKey = isVeto ? 'veto' : 'approve'; + + if (canBundleExecution) { + buttonKey = isVeto ? 'vetoAndExecute' : 'approveAndExecute'; + } + + if (hasSettled) { + buttonKey = isVeto ? 'vetoed' : 'approved'; + } else if (isAwaitingIndexing) { + buttonKey = 'finalizing'; + } else if (thresholdReached) { + buttonKey = 'executeSafeTransaction'; + } else if (isSuperseded) { + buttonKey = 'requeueSafeTransaction'; + } + + let helperText: string | undefined; + + if (hasUnsupportedContractOwner) { + helperText = t(`${translationKey}.versionUnsupported`, { + version: safeInfo?.version ?? t(`${translationKey}.unknownVersion`), + }); + } else if (isAwaitingIndexing) { + helperText = t(`${translationKey}.awaitingIndexing`); + } else if (hasIndexingTimedOut && !hasSettled) { + helperText = t(`${translationKey}.indexingDelayed`); + } else if (thresholdReached && !hasSettled) { + // A Safe body passes through two gates, and gov-ui-kit's card only shows the first: enough + // owners have confirmed. Until the Safe transaction executes, Aragon has been told nothing + // and this body counts for nothing - so the second gate is named rather than implied. + helperText = t(`${translationKey}.awaitingExecution`); + } else if (isWaitingForOwners) { + helperText = t(`${translationKey}.waitingForOwners`); + } + + // Safe-only realities are alerts, not layout: the card keeps the multisig grammar and says what + // is true about the queue underneath it. + const alerts: Array<{ + key: string; + variant: 'info' | 'warning' | 'critical'; + message: string; + }> = []; + + /** + * The two surprising states, stated rather than left to be inferred from a rejected header + * sitting above a live action. + * + * A Safe transaction never expires and a verdict has no deadline, so while the stage can still + * advance the owners can still act and it still counts. Once `maxAdvance` has passed the stage + * can never advance: the transaction remains executable in the Safe forever, but it can no + * longer move this proposal. + */ + const stageEndDate = sppStageUtils.getStageEndDate(proposal, stage); + const hasWindowClosed = + stageEndDate != null && DateTime.now() > stageEndDate; + + if (!hasSettled && hasWindowClosed && canStillAffectOutcome) { + alerts.push({ + key: 'windowClosed', + variant: 'info', + message: t(`${translationKey}.windowClosed`), + }); + } + + if (!hasSettled && !canStillAffectOutcome) { + alerts.push({ + key: 'stageExpired', + variant: 'warning', + message: t( + `${translationKey}.${liveReport != null ? 'stageExpiredQueued' : 'stageExpired'}`, + ), + }); + } + + if (isQueuedBehindNonce) { + alerts.push({ + key: 'nonceQueued', + variant: 'warning', + message: t(`${translationKey}.nonceQueued`, { + count: transactionsAhead, + }), + }); + } + + if (isSuperseded) { + alerts.push({ + key: 'replaced', + variant: 'critical', + message: t(`${translationKey}.replaced`), + }); + } + + if (isStale) { + alerts.push({ + key: 'stale', + variant: 'warning', + message: t(`${translationKey}.unreachable`), + }); + } + + const isActionDisabled = + hasSettled || + isAwaitingIndexing || + isWaitingForOwners || + isQueuedBehindNonce || + hasUnsupportedContractOwner || + isContractOwnerCheckLoading || + safeInfo == null; + return ( +
+ {alerts.map((alert) => ( + + ))} + {/* Nothing to offer once the stage can never advance: acting would change nothing, and + a disabled action beside an expired stage only invites the question. */} + {(hasSettled || canStillAffectOutcome) && ( +
+ + {/* Bundling is a convenience, not a requirement: the signature and the + execution are separate acts, so an owner who only wants to authorise can + leave the gas to whoever executes. Offered only when execution would + actually follow - otherwise there is nothing to opt out of. */} + {canBundleExecution && ( + + } + > + handleVoteClick(false)} + > + {t( + `${translationKey}.${isVeto ? 'vetoOnly' : 'approveOnly'}`, + )} + + + )} + {isStale && ( + + )} +
+ )} + {!hasSettled && helperText != null && ( +

+ {helperText} +

+ )} + {actionError != null && ( +

+ {actionError} +

+ )} +
+ ); +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/index.ts b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/index.ts new file mode 100644 index 0000000000..44aff7a4c2 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/index.ts @@ -0,0 +1,2 @@ +export { SafeMultisigVoteList } from './safeMultisigVoteList'; +export type { ISafeMultisigVoteListProps } from './safeMultisigVoteList.api'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.api.ts b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.api.ts new file mode 100644 index 0000000000..a549db9c0a --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.api.ts @@ -0,0 +1,20 @@ +import type { ISppProposal, ISppStage } from '@/plugins/sppPlugin/types'; + +export interface ISafeMultisigVoteListProps { + /** + * Parent process proposal the body reports a result for. + */ + proposal: ISppProposal; + /** + * Address of the Safe acting as the body. + */ + body: string; + /** + * Stage the body is set up on. + */ + stage: ISppStage; + /** + * Defines if the body vetoes rather than approves. + */ + isVeto?: boolean; +} diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.test.tsx b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.test.tsx new file mode 100644 index 0000000000..45676d8f34 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.test.tsx @@ -0,0 +1,135 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import * as walletAccountApi from '@/modules/application/hooks/useWalletAccount'; +import type { IUseEnsNameReturn } from '@/modules/ens'; +import * as ensModule from '@/modules/ens'; +import { + generateSppProposal, + generateSppStage, +} from '@/plugins/sppPlugin/testUtils'; +import { Network } from '@/shared/api/daoService'; +import * as safeBodyStateApi from '../../hooks/useSafeMultisigBodyState'; +import { generateSafeBodyState, generateSafeInfo } from '../../testUtils'; +import { SafeMultisigVoteList } from './safeMultisigVoteList'; +import type { ISafeMultisigVoteListProps } from './safeMultisigVoteList.api'; + +describe(' component', () => { + const viewer = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const otherOwner = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + const useWalletAccountSpy = jest.spyOn( + walletAccountApi, + 'useWalletAccount', + ); + const useEnsNameSpy = jest.spyOn(ensModule, 'useEnsName'); + const useEnsAvatarSpy = jest.spyOn(ensModule, 'useEnsAvatar'); + const useSafeBodyStateSpy = jest.spyOn( + safeBodyStateApi, + 'useSafeMultisigBodyState', + ); + + const bodyState = generateSafeBodyState({ + safeInfo: generateSafeInfo({ owners: [viewer, otherOwner] }), + isLoading: false, + isError: false, + signers: [otherOwner, viewer], + hasConnectedWalletSigned: true, + approvalsAmount: 2, + minApprovals: 2, + membersCount: 2, + isRateLimited: false, + isStale: false, + }); + + const unresolved = { + data: null, + isLoading: false, + } as unknown as IUseEnsNameReturn; + + beforeEach(() => { + useWalletAccountSpy.mockReturnValue({ + address: viewer, + chainId: 1, + isConnecting: false, + isReconnecting: false, + }); + useSafeBodyStateSpy.mockReturnValue(bodyState); + useEnsNameSpy.mockReturnValue(unresolved); + useEnsAvatarSpy.mockReturnValue( + unresolved as unknown as ReturnType, + ); + }); + + afterEach(() => { + useWalletAccountSpy.mockReset(); + useEnsNameSpy.mockReset(); + useEnsAvatarSpy.mockReset(); + useSafeBodyStateSpy.mockReset(); + }); + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ISafeMultisigVoteListProps = { + proposal: generateSppProposal({ + network: Network.ETHEREUM_MAINNET, + proposalIndex: '42', + }), + body: '0x0000000000000000000000000000000000000001', + stage: generateSppStage({ stageIndex: 1 }), + isVeto: false, + ...props, + }; + + return ( + + + + ); + }; + + it('lists the connected owner first so a viewer sees their own signature', () => { + render(createTestComponent()); + + const rendered = screen + .getAllByRole('link') + .map((link) => link.getAttribute('href') ?? ''); + + expect(rendered).toHaveLength(2); + expect(rendered[0]).toContain(viewer); + expect(rendered[1]).toContain(otherOwner); + }); + + it('states no signatures rather than an empty list when nothing is collected', () => { + useSafeBodyStateSpy.mockReturnValue({ ...bodyState, signers: [] }); + + render(createTestComponent()); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigVoteList.empty.heading', + ), + ).toBeInTheDocument(); + }); + + it('separates an unreadable Safe from a body nobody has signed', () => { + useSafeBodyStateSpy.mockReturnValue({ + ...bodyState, + signers: [], + isError: true, + }); + + render(createTestComponent()); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigVoteList.error.heading', + ), + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'app.plugins.safeMultisig.safeMultisigVoteList.empty.heading', + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.tsx b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.tsx new file mode 100644 index 0000000000..69d5140a75 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/components/safeMultisigVoteList/safeMultisigVoteList.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { + addressUtils, + ChainEntityType, + DataListContainer, + DataListPagination, + DataListRoot, + IconType, + useBlockExplorer, + VoteDataListItem, + type VoteIndicator, +} from '@aragon/gov-ui-kit'; +import { useWalletAccount } from '@/modules/application/hooks/useWalletAccount'; +import { safeAppHistoryUrl } from '@/modules/application/utils/proxySafeUtils/safeTxServiceNetworks'; +import { useEnsAvatar, useEnsName } from '@/modules/ens'; +import { safeDataListUtils } from '@/modules/safe/utils/safeDataListUtils'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { useSafeMultisigBodyState } from '../../hooks/useSafeMultisigBodyState'; +import type { ISafeMultisigVoteListProps } from './safeMultisigVoteList.api'; + +const signersPerPage = 6; + +const translationKey = 'app.plugins.safeMultisig.safeMultisigVoteList'; + +export const SafeMultisigVoteList: React.FC = ( + props, +) => { + const { proposal, body, stage, isVeto } = props; + const network = proposal.network; + + const { t } = useTranslations(); + const { address: connectedAddress } = useWalletAccount(); + + // Registered on the vote-list slot, so the component owns its own read. The Safe queries are + // keyed by address, so this shares the body card's cache entry rather than refetching. + const { signers, isLoading, isError, settledResultType } = + useSafeMultisigBodyState({ + network, + address: body, + proposal, + stage, + }); + + /** + * Confirmations come from the Safe's queue, which serves unexecuted transactions only. Once the + * transaction executes it leaves that read, so an executed body has no confirmations to list - + * "none yet" would be false, since a full set was collected to execute at all. + */ + const emptyKey = settledResultType != null ? 'settled' : 'empty'; + const historyHref = safeAppHistoryUrl({ network, address: body }); + + // The owner rows only need a chain link, so resolve the explorer from the body's own network + // rather than fetching the DAO to rediscover it. + const { buildEntityUrl } = useBlockExplorer({ + chainId: networkDefinitions[network].id, + }); + + // A Safe confirmation is only ever agreement: an owner signs or does not, so there is no + // against-indicator to render here. + const voteIndicator: VoteIndicator = isVeto === true ? 'veto' : 'approve'; + const state = safeDataListUtils.getDataListState({ isError, isLoading }); + + // The owner reading the card cares first about whether their own signature is on the report. + const orderedSigners = [...signers].sort((a, b) => { + const aIsViewer = addressUtils.isAddressEqual(a, connectedAddress); + const bIsViewer = addressUtils.isAddressEqual(b, connectedAddress); + + return Number(bIsViewer) - Number(aIsViewer); + }); + + return ( + + + {orderedSigners.map((signer) => ( + + ))} + + + + ); +}; + +interface ISafeMultisigVoteListItemProps { + signer: string; + href?: string; + voteIndicator: VoteIndicator; +} + +/** + * Wrapper for a single confirmation that resolves the owner's ENS name. Safe owners are not DAO + * members, so the row links to the block explorer rather than a member profile. + */ +const SafeMultisigVoteListItem: React.FC = ( + props, +) => { + const { signer, href, voteIndicator } = props; + + const { data: ensName } = useEnsName(signer); + const { data: ensAvatar } = useEnsAvatar(ensName); + + return ( + + ); +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/constants/index.ts b/apps/app/src/plugins/safeMultisigPlugin/constants/index.ts new file mode 100644 index 0000000000..51419c9e37 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/constants/index.ts @@ -0,0 +1,9 @@ +export { + externalPluginId, + safeBodyHiddenTabs, + safeBodyPluginId, + safeBodyPollInterval, + safeIndexingPollInterval, + safeIndexingTimeout, +} from './safeMultisigPlugin'; +export { SafeMultisigPluginDialogId } from './safeMultisigPluginDialogId'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPlugin.ts b/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPlugin.ts new file mode 100644 index 0000000000..3e84dadc03 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPlugin.ts @@ -0,0 +1,49 @@ +import type { ProposalVotingTab } from '@aragon/gov-ui-kit'; + +/** + * Plugin id an external Safe body resolves to. `PluginId` and `PluginInterfaceType` share one + * string namespace, so the id is namespaced under the existing `external` id rather than a bare + * `safe` that could collide with a future backend interface type. + * + * A Safe is not installable and has no repository addresses: this id is only ever used to register + * slot components and functions, never with `registerPlugin`. + */ +export const safeBodyPluginId = 'external-safe'; + +/** + * Poll cadence of the Safe reads while the Safe queue holds a live transaction. An idle body card + * does not poll at all — it refreshes on window focus, and polling pauses on an unfocused tab. + * + * Two queries poll at this cadence, so every active viewer of a live queue costs + * `2 * 3600 / (interval / 1000)` upstream calls per hour against one shared, rate-limited API key. + * Nothing depends on the exact value; it trades how fast an owner sees a co-signer's signature + * against quota spend. + */ +export const safeBodyPollInterval = 30_000; + +/** + * Poll cadence of the indexer while waiting for an executed report to be attributed. + */ +export const safeIndexingPollInterval = 1000; + +/** + * How long the action stays held waiting for an executed report to be indexed. The status endpoint + * answers `{ isProcessed: false }` for any hash it cannot attribute, so a stalled indexer is + * indistinguishable from one that is merely slow and would otherwise hold the card forever. On + * expiry the hold is released with an explanation rather than leaving a permanent spinner. + */ +export const safeIndexingTimeout = 60_000; + +/** + * Plugin id a generic (non-Safe) external body resolves to. Kept beside `safeBodyPluginId` because + * both live in the same string namespace and the resolver switches between them; a plain constant + * living in a client component would drag react-hook-form into server code. + */ +export const externalPluginId = 'external'; + +/** + * Tabs a Safe body hides. A Safe builds its Votes tab from live Safe confirmations rather than an + * indexed sub-proposal, so unlike the generic external body it hides nothing. Registered as a slot + * function so the shared process chrome never needs to know a Safe exists. + */ +export const safeBodyHiddenTabs: ProposalVotingTab[] = []; diff --git a/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPluginDialogId.ts b/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPluginDialogId.ts new file mode 100644 index 0000000000..722f2b9492 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPluginDialogId.ts @@ -0,0 +1,7 @@ +/** + * Dialog ids of the Safe body plugin. Every plugin's definitions are merged into one record, so the + * values are namespaced rather than bare action names. + */ +export enum SafeMultisigPluginDialogId { + CONFIRM_SIGNATURE = 'SAFE_MULTISIG_CONFIRM_SIGNATURE', +} diff --git a/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPluginDialogsDefinitions.ts b/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPluginDialogsDefinitions.ts new file mode 100644 index 0000000000..90581a5999 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/constants/safeMultisigPluginDialogsDefinitions.ts @@ -0,0 +1,13 @@ +import type { IDialogComponentDefinitions } from '@/shared/components/dialogProvider'; +import { SafeMultisigConfirmSignatureDialog } from '../dialogs/safeMultisigConfirmSignatureDialog'; +import { SafeMultisigPluginDialogId } from './safeMultisigPluginDialogId'; + +export const safeMultisigPluginDialogsDefinitions: Record< + SafeMultisigPluginDialogId, + IDialogComponentDefinitions +> = { + [SafeMultisigPluginDialogId.CONFIRM_SIGNATURE]: { + Component: SafeMultisigConfirmSignatureDialog, + requiresWallet: true, + }, +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/index.ts b/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/index.ts new file mode 100644 index 0000000000..69d2c1815a --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/index.ts @@ -0,0 +1,12 @@ +import dynamic from 'next/dynamic'; + +export const SafeMultisigConfirmSignatureDialog = dynamic(() => + import('./safeMultisigConfirmSignatureDialog').then( + (mod) => mod.SafeMultisigConfirmSignatureDialog, + ), +); + +export type { + ISafeMultisigConfirmSignatureDialogParams, + ISafeMultisigConfirmSignatureDialogProps, +} from './safeMultisigConfirmSignatureDialog'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/safeMultisigConfirmSignatureDialog.test.tsx b/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/safeMultisigConfirmSignatureDialog.test.tsx new file mode 100644 index 0000000000..71b29a038d --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/safeMultisigConfirmSignatureDialog.test.tsx @@ -0,0 +1,158 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Network } from '@/shared/api/daoService'; +import * as dialogProvider from '@/shared/components/dialogProvider'; +import { generateDialogContext } from '@/shared/testUtils'; +import { SafeMultisigPluginDialogId } from '../../constants'; +import { + type ISafeMultisigConfirmSignatureDialogParams, + type ISafeMultisigConfirmSignatureDialogProps, + SafeMultisigConfirmSignatureDialog, +} from './safeMultisigConfirmSignatureDialog'; + +jest.mock('@aragon/gov-ui-kit', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@aragon/gov-ui-kit'); + const Dialog = { + Header: (props: { title: string }) =>

{props.title}

, + Content: (props: { + description?: string; + children?: React.ReactNode; + }) => ( +
+

{props.description}

+ {props.children} +
+ ), + Footer: (props: { + primaryAction: { label: string; onClick?: () => void }; + secondaryAction?: { label: string; onClick?: () => void }; + }) => ( +
+ {props.secondaryAction != null && ( + + )} + +
+ ), + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { ...actual, Dialog }; +}); + +describe(' component', () => { + const useDialogContextSpy = jest.spyOn(dialogProvider, 'useDialogContext'); + const close = jest.fn(); + const onConfirm = jest.fn(); + + beforeEach(() => { + useDialogContextSpy.mockReturnValue(generateDialogContext({ close })); + }); + + afterEach(() => { + useDialogContextSpy.mockReset(); + close.mockReset(); + onConfirm.mockReset(); + }); + + const createTestComponent = ( + params?: Partial, + ) => { + const completeParams: ISafeMultisigConfirmSignatureDialogParams = { + proposalTitle: 'Fund the treasury', + safeAddress: '0xd84C233A7D1578021d21E39785439bEdDB165F3D', + signerAddress: '0x0000000000000000000000000000000000000011', + network: Network.ETHEREUM_MAINNET, + isVeto: false, + willExecute: false, + onConfirm, + ...params, + }; + + const completeProps: ISafeMultisigConfirmSignatureDialogProps = { + location: { + id: SafeMultisigPluginDialogId.CONFIRM_SIGNATURE, + params: completeParams, + }, + }; + + return ; + }; + + it('states what is being signed and that the signature is free', () => { + render(createTestComponent()); + + expect(screen.getByText('Fund the treasury')).toBeInTheDocument(); + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigConfirmSignatureDialog.gasless', + ), + ).toBeInTheDocument(); + }); + + it('names the veto effect rather than a neutral signature for a veto body', () => { + render(createTestComponent({ isVeto: true })); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigConfirmSignatureDialog.veto.title', + ), + ).toBeInTheDocument(); + }); + + it('omits the nonce when queueing a report whose nonce is not allocated yet', () => { + render(createTestComponent()); + + // Naming a nonce that the submit-time read may not use would be worse than saying nothing. + expect( + screen.queryByText( + 'app.plugins.safeMultisig.safeMultisigConfirmSignatureDialog.details.nonce', + ), + ).not.toBeInTheDocument(); + }); + + it('shows the nonce when countersigning a queued report', () => { + render(createTestComponent({ nonce: '6' })); + + expect( + screen.getByText( + 'app.plugins.safeMultisig.safeMultisigConfirmSignatureDialog.details.nonce', + ), + ).toBeInTheDocument(); + expect(screen.getByText('6')).toBeInTheDocument(); + }); + + it('closes before signing so the wallet prompt is not stacked behind the dialog', async () => { + render(createTestComponent()); + + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigConfirmSignatureDialog.approval.action', + }), + ); + + expect(close).toHaveBeenCalled(); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('does not sign when the owner cancels', async () => { + render(createTestComponent()); + + await userEvent.click( + screen.getByRole('button', { + name: 'app.plugins.safeMultisig.safeMultisigConfirmSignatureDialog.cancel', + }), + ); + + expect(close).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/safeMultisigConfirmSignatureDialog.tsx b/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/safeMultisigConfirmSignatureDialog.tsx new file mode 100644 index 0000000000..441d53a78f --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/dialogs/safeMultisigConfirmSignatureDialog/safeMultisigConfirmSignatureDialog.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { addressUtils, Dialog, invariant } from '@aragon/gov-ui-kit'; +import type { Network } from '@/shared/api/daoService'; +import { + type IDialogComponentProps, + useDialogContext, +} from '@/shared/components/dialogProvider'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; + +export interface ISafeMultisigConfirmSignatureDialogParams { + /** + * Title of the proposal the signature reports a result for. + */ + proposalTitle: string; + /** + * Address of the Safe acting as the body. + */ + safeAddress: string; + /** + * Owner address the signature is produced with. + */ + signerAddress: string; + /** + * Network the Safe is deployed on. + */ + network: Network; + /** + * Defines if the body vetoes rather than approves. + */ + isVeto: boolean; + /** + * Nonce the signature applies to. Absent when queueing a new report, whose nonce is only + * allocated at submit time. + */ + nonce?: string; + /** + * Whether this confirmation reaches the Safe's threshold, so execution follows immediately in + * the same flow. The owner is then asked for two wallet interactions rather than one: a free + * signature, then a transaction that costs gas. + */ + willExecute: boolean; + /** + * Called once the owner confirms. + */ + onConfirm: () => void; +} + +export interface ISafeMultisigConfirmSignatureDialogProps + extends IDialogComponentProps {} + +const translationKey = + 'app.plugins.safeMultisig.safeMultisigConfirmSignatureDialog'; + +export const SafeMultisigConfirmSignatureDialog: React.FC< + ISafeMultisigConfirmSignatureDialogProps +> = (props) => { + const { location } = props; + + invariant( + location.params != null, + 'SafeMultisigConfirmSignatureDialog: required parameters must be set.', + ); + + const { + proposalTitle, + safeAddress, + signerAddress, + network, + isVeto, + nonce, + willExecute, + onConfirm, + } = location.params; + + const { t } = useTranslations(); + const { close } = useDialogContext(); + + const actionKey = isVeto ? 'veto' : 'approval'; + + const handleConfirm = () => { + close(); + onConfirm(); + }; + + return ( + <> + + +
+ + + {nonce != null && ( + + )} + + +
+ {/* The design showed a gas-fee row here. Confirming alone is an offchain signature + with no fee to quote - but the confirmation that reaches the threshold is + followed straight away by execution, which is onchain and does cost gas. That is + two wallet interactions from one click, so it is said before the first one. */} +

+ {t( + `${translationKey}.${willExecute ? 'bundledExecution' : 'gasless'}`, + )} +

+
+ close(), + }} + /> + + ); +}; + +const SafeMultisigConfirmSignatureRow: React.FC<{ + label: string; + value: string; +}> = (props) => { + const { label, value } = props; + + return ( +
+
{label}
+
+ {value} +
+
+ ); +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/index.ts b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/index.ts new file mode 100644 index 0000000000..73ac2712a2 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/index.ts @@ -0,0 +1,6 @@ +export { useSafeMultisigBodyState } from './useSafeMultisigBodyState'; +export type { + ISafeMultisigBodyReport, + IUseSafeMultisigBodyStateParams, + IUseSafeMultisigBodyStateReturn, +} from './useSafeMultisigBodyState.api'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.api.ts b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.api.ts new file mode 100644 index 0000000000..7fb72253ee --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.api.ts @@ -0,0 +1,153 @@ +import type { ProposalStatus } from '@aragon/gov-ui-kit'; +import type { + ISppProposal, + ISppStage, + SppProposalType, +} from '@/plugins/sppPlugin/types'; +import type { Network } from '@/shared/api/daoService'; +import type { + ISafeInfo, + ISafeMultisigTransaction, +} from '@/shared/api/safeService'; +import type { + ISafeProposalResultReport, + SafeTransactionState, +} from '../../types'; + +export interface IUseSafeMultisigBodyStateParams { + /** + * Network the Safe is deployed on. + */ + network: Network; + /** + * Address of the Safe acting as the process body. + */ + address: string; + /** + * Proposal the body has to report a result for. + */ + proposal: ISppProposal; + /** + * Stage the body is set up on. + */ + stage: ISppStage; +} + +export interface ISafeMultisigBodyReport { + /** + * Safe transaction carrying the report, directly or inside a MultiSend batch. + */ + transaction: ISafeMultisigTransaction; + /** + * Decoded report, including the governance effect it would produce. + */ + report: ISafeProposalResultReport; + /** + * Whether the transaction can still execute. + */ + state: SafeTransactionState; + /** + * Proposal status the state maps onto, so a dead-but-confirmed report reads as expired. + */ + status: ProposalStatus; + /** + * Whether another queued transaction competes for the same nonce. + */ + hasNonceCompetition: boolean; +} + +export interface IUseSafeMultisigBodyStateReturn { + /** + * Live Safe state: owners, threshold, version and nonce. + */ + safeInfo?: ISafeInfo; + /** + * Whether the Safe reads are still loading. + */ + isLoading: boolean; + /** + * Whether the Safe state could not be read. + */ + isError: boolean; + /** + * Whether the read failed because the shared Safe API quota is exhausted. A degraded state + * rather than a bug: the poll backs off and recovers on its own, so it is rendered separately + * from a generic error. + */ + isRateLimited: boolean; + /** + * Seconds to wait, forwarded from the upstream `Retry-After`. Absent when upstream did not say. + */ + rateLimitedRetryAfter?: number; + /** + * Whether the backend served this from its stale window because its fresh window had lapsed. + * The data is usable and must be rendered, but a confirmation count may lag reality. + */ + isStale: boolean; + /** + * Report queued for this proposal and stage, live or superseded. + */ + pendingReport?: ISafeMultisigBodyReport; + /** + * Indexed SPP result for this body. A result does not close the queue: while the stage is still + * current a queued report can execute and overwrite it, so both are read together. + */ + settledResultType?: SppProposalType; + /** + * Whether a report executing now would still affect the outcome - true only while this stage is + * the proposal's current stage and the proposal has not executed. + * + * `reportProposalResult` carries no deadline: it reverts only for a stage that has not started + * yet, and records unconditionally otherwise. So the elapsed voting window does not decide + * this, and a report landing after the window still counts. Past the stage it still succeeds + * onchain but changes nothing. + */ + isStageCurrent: boolean; + /** + * Whether executing could still change the outcome. `maxAdvance` is an onchain bound: once + * `lastStageTransition + maxAdvance` has passed, SPP reports `Expired` and the stage can never + * advance, so a Safe transaction still executes but the proposal stays where it is. + */ + canStillAffectOutcome: boolean; + /** + * Whether the queued transaction can execute against the Safe right now: its nonce is the + * Safe's current nonce. A Safe binds each confirmation to one exact nonce, so a fully-confirmed + * transaction one place further back is not executable - it is waiting. + */ + isExecutableNow: boolean; + /** + * Whether the Safe's current nonce is unoccupied, so a report proposed now would land on it and + * be executable as soon as it reaches threshold. Distinct from `isExecutableNow`, which + * describes a report that already exists. + */ + isCurrentNonceFree: boolean; + /** + * Safe transactions that must clear before the queued report can execute. + * + * Their contents are deliberately not interpreted: a Safe is a universal account, so what sits + * ahead may be any transaction from any application, proposed at any time, and is very possibly + * nothing to do with Aragon. + */ + transactionsAhead: number; + /** + * Owners that have confirmed the queued report. + */ + signers: string[]; + /** + * Whether the connected wallet has already confirmed the queued report. + */ + hasConnectedWalletSigned: boolean; + /** + * Confirmations collected by the queued report. + */ + approvalsAmount: number; + /** + * Confirmations the queued report requires. Captured per transaction, since the Safe threshold + * can change while a transaction is queued. + */ + minApprovals: number; + /** + * Number of current Safe owners. + */ + membersCount: number; +} diff --git a/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.test.ts b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.test.ts new file mode 100644 index 0000000000..83299b0756 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.test.ts @@ -0,0 +1,211 @@ +import { renderHook } from '@testing-library/react'; +import * as walletAccountApi from '@/modules/application/hooks/useWalletAccount'; +import { + generateSppPluginSettings, + generateSppProposal, + generateSppStage, +} from '@/plugins/sppPlugin/testUtils'; +import { SppProposalType } from '@/plugins/sppPlugin/types'; +import { Network } from '@/shared/api/daoService'; +import * as safeServiceApi from '@/shared/api/safeService'; +import { + generateSafeInfo, + generateSafeMultisigTransaction, +} from '../../testUtils'; +import { safeMultisigTransactionUtils } from '../../utils/safeMultisigTransactionUtils'; +import { useSafeMultisigBodyState } from './useSafeMultisigBodyState'; + +describe('useSafeMultisigBodyState hook', () => { + const body = '0x0000000000000000000000000000000000000001'; + const plugin = '0x0000000000000000000000000000000000000002'; + const stageIndex = 1; + const proposalIndex = '42'; + const hour = 60 * 60; + + const useSafeInfoSpy = jest.spyOn(safeServiceApi, 'useSafeInfo'); + const useSafePendingTransactionsSpy = jest.spyOn( + safeServiceApi, + 'useSafePendingTransactions', + ); + const useWalletAccountSpy = jest.spyOn( + walletAccountApi, + 'useWalletAccount', + ); + + /** + * A real queued transaction: the calldata comes from the production encoder, so correlation runs + * for real instead of against a hand-written payload. + */ + const mockQueuedTransaction = (nonce: string) => { + useSafePendingTransactionsSpy.mockReturnValue({ + data: { + results: [ + generateSafeMultisigTransaction({ + nonce, + to: plugin, + data: safeMultisigTransactionUtils.buildReportProposalResultData( + { + proposalId: BigInt(proposalIndex), + stageId: stageIndex, + resultType: SppProposalType.APPROVAL, + }, + ), + }), + ], + meta: { stale: false }, + }, + isLoading: false, + isError: false, + } as unknown as ReturnType< + typeof safeServiceApi.useSafePendingTransactions + >); + }; + + const renderState = (params?: { + currentStage?: number; + maxAdvance?: number; + results?: Array<{ + pluginAddress: string; + stage: number; + resultType: SppProposalType; + }>; + }) => { + const { + currentStage = stageIndex, + maxAdvance = 24 * hour, + results, + } = params ?? {}; + + // Both windows are measured from the last stage transition, so an elapsed `maxAdvance` is + // expressed by placing that transition further in the past. + const stage = generateSppStage({ stageIndex, maxAdvance }); + const proposal = generateSppProposal({ + network: Network.ETHEREUM_MAINNET, + pluginAddress: plugin, + proposalIndex, + stageIndex: currentStage, + lastStageTransition: Math.floor(Date.now() / 1000) - hour, + settings: generateSppPluginSettings({ + stages: [generateSppStage({ stageIndex: 0 }), stage], + }), + results, + }); + + return renderHook(() => + useSafeMultisigBodyState({ + network: proposal.network, + address: body, + proposal, + stage, + }), + ); + }; + + beforeEach(() => { + useWalletAccountSpy.mockReturnValue({ + address: undefined, + chainId: undefined, + isConnecting: false, + isReconnecting: false, + }); + useSafeInfoSpy.mockReturnValue({ + data: generateSafeInfo({ nonce: '6', threshold: 1 }), + isLoading: false, + isError: false, + } as ReturnType); + useSafePendingTransactionsSpy.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + } as ReturnType); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('treats a transaction sitting on the current nonce as executable now', () => { + mockQueuedTransaction('6'); + + const { result } = renderState(); + + expect(result.current.pendingReport).toBeDefined(); + expect(result.current.isExecutableNow).toBe(true); + expect(result.current.transactionsAhead).toEqual(0); + }); + + it('counts the transactions ahead rather than calling a confirmed transaction executable', () => { + // A confirmation binds one exact nonce, so a transaction two places back is waiting however + // completely it is confirmed - and it cannot be moved without voiding those confirmations. + mockQueuedTransaction('8'); + + const { result } = renderState(); + + expect(result.current.isExecutableNow).toBe(false); + expect(result.current.transactionsAhead).toEqual(2); + }); + + it('reports the current nonce free when nothing in the queue holds it', () => { + // Allocation hands out the lowest free slot, so a report proposed now would land on the + // current nonce and execute as soon as it reaches threshold. + mockQueuedTransaction('8'); + + const { result } = renderState(); + + expect(result.current.isCurrentNonceFree).toBe(true); + }); + + it('reports the current nonce taken when the queue occupies it', () => { + mockQueuedTransaction('6'); + + const { result } = renderState(); + + expect(result.current.isCurrentNonceFree).toBe(false); + }); + + it('keeps watching the queue on a reportable stage even once a result is indexed', () => { + // A Safe transaction never expires and a verdict has no deadline, so a queued transaction + // can still execute and overwrite the recorded result. + mockQueuedTransaction('6'); + + const { result } = renderState({ + results: [ + { + pluginAddress: body, + stage: stageIndex, + resultType: SppProposalType.VETO, + }, + ], + }); + + expect(result.current.settledResultType).toEqual(SppProposalType.VETO); + expect(result.current.isStageCurrent).toBe(true); + expect(result.current.pendingReport).toBeDefined(); + expect(useSafePendingTransactionsSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: true }), + ); + }); + + it('separates being recordable from being able to change the outcome', () => { + // Past `maxAdvance` the stage can never advance, so executing still succeeds onchain and + // still changes nothing. + mockQueuedTransaction('6'); + + const { result } = renderState({ maxAdvance: hour / 2 }); + + expect(result.current.isStageCurrent).toBe(true); + expect(result.current.canStillAffectOutcome).toBe(false); + }); + + it('stops reading the queue once the proposal advanced past the stage', () => { + // Past the stage the queue is moot to this proposal, so it must not spend Safe quota. + const { result } = renderState({ currentStage: stageIndex + 1 }); + + expect(result.current.isStageCurrent).toBe(false); + expect(useSafePendingTransactionsSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }), + ); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.ts b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.ts new file mode 100644 index 0000000000..922946f68e --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigBodyState/useSafeMultisigBodyState.ts @@ -0,0 +1,259 @@ +'use client'; + +import { keepPreviousData } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useWalletAccount } from '@/modules/application/hooks/useWalletAccount'; +import { safeShortNameFromNetwork } from '@/modules/application/utils/proxySafeUtils/safeTxServiceNetworks'; +import { sppStageUtils } from '@/plugins/sppPlugin/utils/sppStageUtils'; +import { + SafeServiceError, + useSafeInfo, + useSafePendingTransactions, +} from '@/shared/api/safeService'; +import { safeBodyPollInterval } from '../../constants'; +import { SafeTransactionState } from '../../types'; +import { safeMultisigProposalUtils } from '../../utils/safeMultisigProposalUtils'; +import { safeMultisigTransactionUtils } from '../../utils/safeMultisigTransactionUtils'; +import type { + ISafeMultisigBodyReport, + IUseSafeMultisigBodyStateParams, + IUseSafeMultisigBodyStateReturn, +} from './useSafeMultisigBodyState.api'; + +/** + * Composes the Safe reads and the pure liveness and correlation rules into the view model of a + * Safe body card. + * + * The reads are wallet-independent: the card is informative to an observer with no wallet + * connected, and polling never restarts on connect or account change. Only "you have signed" + * depends on the connected account. + */ +export const useSafeMultisigBodyState = ( + params: IUseSafeMultisigBodyStateParams, +): IUseSafeMultisigBodyStateReturn => { + const { network, address, proposal, stage } = params; + + const { address: connectedAddress } = useWalletAccount(); + + const isNetworkSupported = safeShortNameFromNetwork(network) != null; + const bodyResult = sppStageUtils.getBodyResult( + proposal, + address, + stage.stageIndex, + ); + const isSettled = bodyResult != null; + + /** + * Whether a verdict landing now would still be recorded: `reportProposalResult` has no + * deadline, so this turns on the stage still being the current one, never on the voting window + * having elapsed. + */ + const isStageCurrent = + stage.stageIndex === proposal.stageIndex && !proposal.executed.status; + + /** + * Whether it could still change anything. `maxAdvance` is an onchain bound - SPP's `state` + * returns `Expired` once `lastStageTransition + maxAdvance` has passed, and advancing requires + * `Advanceable` - so beyond it a Safe transaction still executes but the proposal is stuck. + */ + const maxAdvanceDate = sppStageUtils.getStageMaxAdvance(proposal, stage); + const canStillAffectOutcome = + isStageCurrent && + maxAdvanceDate != null && + DateTime.now() < maxAdvanceDate; + + // An idle body card must cost nothing, so polling only runs while the Safe queue holds a + // transaction that can still execute; otherwise the default focus refetch is enough. + const [isQueueLive, setIsQueueLive] = useState(false); + + // A rate-limited read means the shared quota is already exhausted, so the poll must slow down + // rather than keep asking at the normal cadence. The upstream `Retry-After` is honoured when it + // is longer than the usual interval. + const refetchInterval = useCallback( + ({ state }: { state: { error: unknown } }) => { + if (!isQueueLive) { + return false; + } + + const retryAfter = SafeServiceError.isRateLimitedError(state.error) + ? state.error.retryAfter + : undefined; + + return Math.max(safeBodyPollInterval, (retryAfter ?? 0) * 1000); + }, + [isQueueLive], + ); + + const urlParams = useMemo(() => ({ network, address }), [network, address]); + + const { + data: safeInfo, + isLoading: isSafeInfoLoading, + isError: isSafeInfoError, + error: safeInfoError, + } = useSafeInfo( + { urlParams }, + { enabled: isNetworkSupported, refetchInterval }, + ); + + const currentNonce = safeInfo?.nonce; + + const { + data: pendingTransactions, + isLoading: isTransactionsLoading, + isError: isTransactionsError, + error: transactionsError, + } = useSafePendingTransactions( + { urlParams }, + { + // Read while this stage can still be reported on, which an indexed result does not end: + // a Safe transaction has no expiry, so a queued report can execute later and overwrite + // the recorded verdict. Past the stage the queue is moot and the read stops. + enabled: isNetworkSupported && isStageCurrent, + placeholderData: keepPreviousData, + refetchInterval, + }, + ); + + // The backend serves a stale payload rather than failing when its own fresh window has lapsed. + // That is the right trade for a signing UI, but the user has to be told the count may lag. + const isStale = + pendingTransactions?.meta.stale === true || + safeInfo?.meta.stale === true; + + const rateLimitedError = [safeInfoError, transactionsError].find((error) => + SafeServiceError.isRateLimitedError(error), + ); + + const transactions = useMemo( + () => pendingTransactions?.results ?? [], + [pendingTransactions], + ); + + const liveTransactionCount = + currentNonce == null + ? 0 + : safeMultisigProposalUtils.filterLiveTransactions({ + transactions, + currentNonce, + }).length; + + useEffect(() => { + setIsQueueLive(liveTransactionCount > 0); + }, [liveTransactionCount]); + + const { pluginAddress, proposalIndex } = proposal; + const { stageIndex } = stage; + + const pendingReport = useMemo(() => { + if (currentNonce == null) { + return undefined; + } + + const reports: ISafeMultisigBodyReport[] = []; + + for (const transaction of transactions) { + const report = + safeMultisigTransactionUtils.findProposalResultReport({ + transaction, + pluginAddress, + proposalId: proposalIndex, + stageId: stageIndex, + }); + + if (report != null) { + reports.push({ + transaction, + report, + state: safeMultisigProposalUtils.getTransactionState({ + transaction, + currentNonce, + }), + status: safeMultisigProposalUtils.getTransactionStatus({ + transaction, + currentNonce, + }), + hasNonceCompetition: + safeMultisigProposalUtils.hasNonceCompetition({ + transactions, + transaction, + }), + }); + } + } + + // A superseded report is only worth showing when nothing executable is left. + return ( + reports.find(({ state }) => state === SafeTransactionState.LIVE) ?? + reports[0] + ); + }, [ + transactions, + currentNonce, + pluginAddress, + proposalIndex, + stageIndex, + isStageCurrent, + ]); + + const signers = + pendingReport?.transaction.confirmations.map(({ owner }) => owner) ?? + []; + + // Nonce-exact: a Safe binds every signature to one nonce, so only the report sitting on the + // Safe's current nonce can execute. Anything further back is waiting, however well signed. + const reportNonce = pendingReport?.transaction.nonce; + const nonceGap = + reportNonce == null || currentNonce == null + ? 0 + : BigInt(reportNonce) - BigInt(currentNonce); + + /** + * Whether nothing in the queue holds the Safe's current nonce. Allocation hands out the lowest + * free slot, so an empty current nonce is the one case where a newly proposed report executes + * the moment it reaches threshold. `isExecutableNow` cannot answer this: it needs a report to + * already exist. + */ + const isCurrentNonceFree = + currentNonce != null && + !transactions.some( + ({ nonce, isExecuted }) => + !isExecuted && BigInt(nonce) === BigInt(currentNonce), + ); + + return { + safeInfo, + isLoading: + isSafeInfoLoading || (isStageCurrent && isTransactionsLoading), + isError: isSafeInfoError || (isStageCurrent && isTransactionsError), + isRateLimited: rateLimitedError != null, + rateLimitedRetryAfter: rateLimitedError?.retryAfter, + isStale, + pendingReport, + settledResultType: bodyResult?.resultType, + isStageCurrent, + canStillAffectOutcome, + isExecutableNow: + pendingReport != null && + !pendingReport.transaction.isExecuted && + nonceGap === BigInt(0), + isCurrentNonceFree, + transactionsAhead: nonceGap > BigInt(0) ? Number(nonceGap) : 0, + signers, + hasConnectedWalletSigned: + pendingReport != null && + safeMultisigProposalUtils.hasAddressConfirmed({ + transaction: pendingReport.transaction, + address: connectedAddress, + }), + approvalsAmount: isSettled + ? (safeInfo?.threshold ?? 0) + : (pendingReport?.transaction.confirmations.length ?? 0), + minApprovals: + pendingReport?.transaction.confirmationsRequired ?? + safeInfo?.threshold ?? + 0, + membersCount: safeInfo?.owners.length ?? 0, + }; +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigGovernanceSettings/index.ts b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigGovernanceSettings/index.ts new file mode 100644 index 0000000000..84d25618da --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigGovernanceSettings/index.ts @@ -0,0 +1 @@ +export { useSafeMultisigGovernanceSettings } from './useSafeMultisigGovernanceSettings'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigGovernanceSettings/useSafeMultisigGovernanceSettings.ts b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigGovernanceSettings/useSafeMultisigGovernanceSettings.ts new file mode 100644 index 0000000000..d0fd6fcf4e --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/hooks/useSafeMultisigGovernanceSettings/useSafeMultisigGovernanceSettings.ts @@ -0,0 +1,33 @@ +'use client'; + +import { addressUtils, type IDefinitionSetting } from '@aragon/gov-ui-kit'; +import { safeAppAccountUrl } from '@/modules/application/utils/proxySafeUtils/safeTxServiceNetworks'; +import { useEnsName } from '@/modules/ens'; +import type { IUseGovernanceSettingsParams } from '@/modules/settings/types'; +import { useSafeInfo } from '@/shared/api/safeService'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { safeMultisigSettingsUtils } from '../../utils/safeMultisigSettingsUtils'; + +export const useSafeMultisigGovernanceSettings = ( + params: IUseGovernanceSettingsParams, +): IDefinitionSetting[] => { + const { daoId, pluginAddress } = params; + const { t } = useTranslations(); + const { network } = daoUtils.parseDaoId(daoId); + const { data: safeInfo } = useSafeInfo({ + urlParams: { network, address: pluginAddress }, + }); + const { data: ensName } = useEnsName(pluginAddress); + + if (safeInfo == null) { + return []; + } + + return safeMultisigSettingsUtils.parseSettings({ + safeInfo, + safeName: ensName ?? addressUtils.truncateAddress(pluginAddress), + safeHref: safeAppAccountUrl({ network, address: pluginAddress }), + t, + }); +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/index.test.ts b/apps/app/src/plugins/safeMultisigPlugin/index.test.ts new file mode 100644 index 0000000000..7e8a7eedec --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/index.test.ts @@ -0,0 +1,99 @@ +import type { ProposalVotingTab } from '@aragon/gov-ui-kit'; +import { GovernanceSlotId } from '@/modules/governance/constants/moduleSlots'; +import { Network } from '@/shared/api/daoService'; +import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; +import { pluginDialogsDefinitions } from '../index'; +import { generateSppStagePlugin } from '../sppPlugin/testUtils'; +import { VotingBodyBrandIdentity } from '../sppPlugin/types'; +import { sppStageUtils } from '../sppPlugin/utils/sppStageUtils'; +import { SafeMultisigProposalVotingBreakdown } from './components/safeMultisigProposalVotingBreakdown'; +import { SafeMultisigProposalVotingSummary } from './components/safeMultisigProposalVotingSummary'; +import { SafeMultisigSubmitVote } from './components/safeMultisigSubmitVote'; +import { SafeMultisigVoteList } from './components/safeMultisigVoteList'; +import { SafeMultisigPluginDialogId } from './constants'; +import { initialiseSafeMultisigPlugin } from './index'; + +describe('safeMultisigPlugin registrations', () => { + const safeBody = generateSppStagePlugin({ + interfaceType: undefined, + brandId: VotingBodyBrandIdentity.SAFE, + }); + + beforeAll(() => { + initialiseSafeMultisigPlugin(); + }); + + // A slot registered under an id the resolver never produces fails silently: the body simply + // renders through the generic external fallback. Pair the two rather than trusting either. + it.each([ + { + slotId: GovernanceSlotId.GOVERNANCE_PROPOSAL_VOTING_BREAKDOWN, + component: SafeMultisigProposalVotingBreakdown, + }, + { + slotId: GovernanceSlotId.GOVERNANCE_PROPOSAL_VOTING_MULTI_BODY_SUMMARY, + component: SafeMultisigProposalVotingSummary, + }, + { + slotId: GovernanceSlotId.GOVERNANCE_SUBMIT_VOTE, + component: SafeMultisigSubmitVote, + }, + { + slotId: GovernanceSlotId.GOVERNANCE_VOTE_LIST, + component: SafeMultisigVoteList, + }, + ])( + 'serves $slotId for a Safe body on a supported network', + ({ slotId, component }) => { + const pluginId = sppStageUtils.getBodyPluginId( + safeBody, + Network.ETHEREUM_SEPOLIA, + ); + + expect( + pluginRegistryUtils.getSlotComponent({ slotId, pluginId }), + ).toEqual(component); + }, + ); + + // The Votes tab exists for a Safe body only because this function answers for it; unregistered, + // the shared chrome falls back to hiding Votes and the tab silently disappears. + it('keeps the Votes tab for a Safe body through the tab-policy slot', () => { + const getHiddenTabs = pluginRegistryUtils.getSlotFunction< + undefined, + ProposalVotingTab[] + >({ + slotId: GovernanceSlotId.GOVERNANCE_PROPOSAL_VOTING_HIDDEN_TABS, + pluginId: sppStageUtils.getBodyPluginId( + safeBody, + Network.ETHEREUM_SEPOLIA, + ), + }); + + expect(getHiddenTabs?.(undefined)).toEqual([]); + }); + + it('leaves a Safe on an unserved network to the external fallbacks', () => { + const pluginId = sppStageUtils.getBodyPluginId( + safeBody, + Network.CITREA_MAINNET, + ); + + expect( + pluginRegistryUtils.getSlotComponent({ + slotId: GovernanceSlotId.GOVERNANCE_PROPOSAL_VOTING_MULTI_BODY_SUMMARY, + pluginId, + }), + ).toBeUndefined(); + }); + + // Definitions that never reach the merged registry leave `open()` resolving to nothing, with no + // error to notice: the button simply does nothing. + it('reaches the merged plugin dialog registry', () => { + expect( + pluginDialogsDefinitions[ + SafeMultisigPluginDialogId.CONFIRM_SIGNATURE + ], + ).toBeDefined(); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/index.ts b/apps/app/src/plugins/safeMultisigPlugin/index.ts new file mode 100644 index 0000000000..8bce1f5cec --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/index.ts @@ -0,0 +1,48 @@ +import { GovernanceSlotId } from '@/modules/governance/constants/moduleSlots'; +import { SettingsSlotId } from '@/modules/settings/constants/moduleSlots'; +import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; +import { SafeMultisigProposalVotingBreakdown } from './components/safeMultisigProposalVotingBreakdown'; +import { SafeMultisigProposalVotingSummary } from './components/safeMultisigProposalVotingSummary'; +import { SafeMultisigSubmitVote } from './components/safeMultisigSubmitVote'; +import { SafeMultisigVoteList } from './components/safeMultisigVoteList'; +import { safeBodyHiddenTabs, safeBodyPluginId } from './constants'; +import { useSafeMultisigGovernanceSettings } from './hooks/useSafeMultisigGovernanceSettings'; + +export const initialiseSafeMultisigPlugin = () => { + pluginRegistryUtils + .registerSlotComponent({ + slotId: GovernanceSlotId.GOVERNANCE_PROPOSAL_VOTING_BREAKDOWN, + pluginId: safeBodyPluginId, + component: SafeMultisigProposalVotingBreakdown, + }) + .registerSlotComponent({ + slotId: GovernanceSlotId.GOVERNANCE_PROPOSAL_VOTING_MULTI_BODY_SUMMARY, + pluginId: safeBodyPluginId, + component: SafeMultisigProposalVotingSummary, + }) + .registerSlotComponent({ + slotId: GovernanceSlotId.GOVERNANCE_SUBMIT_VOTE, + pluginId: safeBodyPluginId, + component: SafeMultisigSubmitVote, + }) + .registerSlotComponent({ + slotId: GovernanceSlotId.GOVERNANCE_VOTE_LIST, + pluginId: safeBodyPluginId, + component: SafeMultisigVoteList, + }) + .registerSlotFunction({ + slotId: GovernanceSlotId.GOVERNANCE_PROPOSAL_VOTING_HIDDEN_TABS, + pluginId: safeBodyPluginId, + function: () => safeBodyHiddenTabs, + }) + .registerSlotFunction({ + slotId: GovernanceSlotId.GOVERNANCE_BODY_VOTES_AFTER_WINDOW, + pluginId: safeBodyPluginId, + function: () => true, + }) + .registerSlotFunction({ + slotId: SettingsSlotId.SETTINGS_GOVERNANCE_SETTINGS_HOOK, + pluginId: safeBodyPluginId, + function: useSafeMultisigGovernanceSettings, + }); +}; diff --git a/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/index.ts b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/index.ts new file mode 100644 index 0000000000..c595255fcb --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/index.ts @@ -0,0 +1,4 @@ +export { generateSafeBodyState } from './safeBodyState'; +export { generateSafeConfirmation } from './safeConfirmation'; +export { generateSafeInfo } from './safeInfo'; +export { generateSafeMultisigTransaction } from './safeMultisigTransaction'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeBodyState.ts b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeBodyState.ts new file mode 100644 index 0000000000..15fb753395 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeBodyState.ts @@ -0,0 +1,28 @@ +import type { IUseSafeMultisigBodyStateReturn } from '../../hooks/useSafeMultisigBodyState'; + +/** + * Default body state: a Safe read that succeeded, on a stage that can still be reported on, with + * nothing queued. Cases add only the part they exercise. + */ +export const generateSafeBodyState = ( + state?: Partial, +): IUseSafeMultisigBodyStateReturn => ({ + safeInfo: undefined, + isLoading: false, + isError: false, + isRateLimited: false, + isStale: false, + pendingReport: undefined, + settledResultType: undefined, + isStageCurrent: true, + canStillAffectOutcome: true, + isExecutableNow: false, + isCurrentNonceFree: true, + transactionsAhead: 0, + signers: [], + hasConnectedWalletSigned: false, + approvalsAmount: 0, + minApprovals: 0, + membersCount: 0, + ...state, +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeConfirmation.ts b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeConfirmation.ts new file mode 100644 index 0000000000..cac17d3326 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeConfirmation.ts @@ -0,0 +1,10 @@ +import type { ISafeConfirmation } from '@/shared/api/safeService'; + +export const generateSafeConfirmation = ( + confirmation?: Partial, +): ISafeConfirmation => ({ + owner: '0x0000000000000000000000000000000000000011', + signature: '0x', + submissionDate: '2026-01-01T00:00:00Z', + ...confirmation, +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeInfo.ts b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeInfo.ts new file mode 100644 index 0000000000..5e07df43e2 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeInfo.ts @@ -0,0 +1,26 @@ +import type { ISafeInfoResponse } from '@/shared/api/safeService'; + +/** + * Produces what the body state actually holds: the backend response, including the freshness + * metadata a consumer needs to tell a current payload from one served stale. + */ +export const generateSafeInfo = ( + safeInfo?: Partial, +): ISafeInfoResponse => ({ + address: '0x0000000000000000000000000000000000000001', + nonce: '0', + threshold: 2, + owners: [ + '0x0000000000000000000000000000000000000011', + '0x0000000000000000000000000000000000000012', + ], + version: '1.4.1', + modules: [], + guard: null, + meta: { + source: 'chain', + fetchedAt: '2026-08-26T12:00:00.000Z', + stale: false, + }, + ...safeInfo, +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeMultisigTransaction.ts b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeMultisigTransaction.ts new file mode 100644 index 0000000000..1d583ec3c8 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/testUtils/generators/safeMultisigTransaction.ts @@ -0,0 +1,25 @@ +import type { ISafeMultisigTransaction } from '@/shared/api/safeService'; + +export const generateSafeMultisigTransaction = ( + transaction?: Partial, +): ISafeMultisigTransaction => ({ + nonce: '0', + safeTxHash: '0xsafeTxHash', + from: '0x0000000000000000000000000000000000000011', + to: '0x0000000000000000000000000000000000000021', + value: '0', + data: null, + operation: 0, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: '0x0000000000000000000000000000000000000000', + confirmations: [], + confirmationsRequired: 2, + signatures: null, + isExecuted: false, + isSuccessful: null, + submissionDate: '2026-01-01T00:00:00Z', + ...transaction, +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/testUtils/index.ts b/apps/app/src/plugins/safeMultisigPlugin/testUtils/index.ts new file mode 100644 index 0000000000..cb82623fae --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/testUtils/index.ts @@ -0,0 +1 @@ +export * from './generators'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/types/enum/index.ts b/apps/app/src/plugins/safeMultisigPlugin/types/enum/index.ts new file mode 100644 index 0000000000..323eb14fe6 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/types/enum/index.ts @@ -0,0 +1 @@ +export { SafeTransactionState } from './safeTransactionState'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/types/enum/safeTransactionState.ts b/apps/app/src/plugins/safeMultisigPlugin/types/enum/safeTransactionState.ts new file mode 100644 index 0000000000..d8c0a2002e --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/types/enum/safeTransactionState.ts @@ -0,0 +1,13 @@ +/** + * Liveness of a Safe transaction, derived from the Safe nonce rather than read from the service. + * `isExecuted: false` is not "pending": a transaction below the Safe's current nonce can never + * execute again, however many confirmations it collected. + */ +export enum SafeTransactionState { + /** The nonce is still reachable, so the transaction can still be executed. */ + LIVE = 'LIVE', + /** The nonce has been consumed by another transaction — permanently unexecutable. */ + SUPERSEDED = 'SUPERSEDED', + /** The transaction has been executed onchain, successfully or not. */ + EXECUTED = 'EXECUTED', +} diff --git a/apps/app/src/plugins/safeMultisigPlugin/types/index.ts b/apps/app/src/plugins/safeMultisigPlugin/types/index.ts new file mode 100644 index 0000000000..d7bebfd76c --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/types/index.ts @@ -0,0 +1,3 @@ +export * from './enum'; +export type { ISafeCall } from './safeCall'; +export type { ISafeProposalResultReport } from './safeProposalResultReport'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/types/safeCall.ts b/apps/app/src/plugins/safeMultisigPlugin/types/safeCall.ts new file mode 100644 index 0000000000..c3fdc601ce --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/types/safeCall.ts @@ -0,0 +1,22 @@ +/** + * Single call carried by a Safe transaction, either the transaction itself or one of the calls + * unpacked from a MultiSend batch. + */ +export interface ISafeCall { + /** + * Target address of the call. + */ + to: string; + /** + * Calldata of the call, or null for a plain value transfer. + */ + data: string | null; + /** + * Call type: 0 for `CALL`, 1 for `DELEGATECALL`. + */ + operation: number; + /** + * Native value transferred by the call. + */ + value: bigint; +} diff --git a/apps/app/src/plugins/safeMultisigPlugin/types/safeProposalResultReport.ts b/apps/app/src/plugins/safeMultisigPlugin/types/safeProposalResultReport.ts new file mode 100644 index 0000000000..e48f9abde0 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/types/safeProposalResultReport.ts @@ -0,0 +1,24 @@ +import type { SppProposalType } from '@/plugins/sppPlugin/types'; + +/** + * Decoded `reportProposalResult` call found inside a Safe transaction. + */ +export interface ISafeProposalResultReport { + /** + * Onchain index of the SPP proposal the result is reported for. + */ + proposalId: bigint; + /** + * Index of the stage the result is reported for. + */ + stageId: number; + /** + * Governance effect of the report, used to name the pending action instead of showing a bare + * signature count. + */ + resultType: SppProposalType; + /** + * Whether the report also tries to advance the stage. + */ + tryAdvance: boolean; +} diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/index.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/index.ts new file mode 100644 index 0000000000..7d295eaeaf --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/index.ts @@ -0,0 +1,7 @@ +export { + type ISafeConfirmedByParams, + type ISafeNonceCompetitorsParams, + type ISafeTransactionListParams, + type ISafeTransactionLivenessParams, + safeMultisigProposalUtils, +} from './safeMultisigProposalUtils'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/safeMultisigProposalUtils.test.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/safeMultisigProposalUtils.test.ts new file mode 100644 index 0000000000..74fb7a6ee4 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/safeMultisigProposalUtils.test.ts @@ -0,0 +1,274 @@ +import { ProposalStatus } from '@aragon/gov-ui-kit'; +import { + generateSafeConfirmation, + generateSafeMultisigTransaction, +} from '../../testUtils'; +import { SafeTransactionState } from '../../types'; +import { safeMultisigProposalUtils } from './safeMultisigProposalUtils'; + +describe('safeMultisigProposal utils', () => { + describe('supportsEip1271Signatures', () => { + it.each([ + { version: '1.4.1', supported: true }, + { version: '1.4.1+L2', supported: true }, + { version: '1.5.0', supported: true }, + { version: '1.3.0', supported: false }, + { version: '1.1.1', supported: false }, + { version: null, supported: false }, + ])('returns $supported for Safe $version', ({ version, supported }) => { + expect( + safeMultisigProposalUtils.supportsEip1271Signatures(version), + ).toEqual(supported); + }); + }); + + describe('getTransactionState', () => { + it.each([ + { + label: 'a fully confirmed transaction below the current nonce', + nonce: '2234', + isExecuted: false, + confirmations: 6, + currentNonce: '2289', + state: SafeTransactionState.SUPERSEDED, + isSuccessful: null, + }, + { + label: 'a transaction on the current nonce', + nonce: '2289', + isExecuted: false, + confirmations: 1, + currentNonce: '2289', + state: SafeTransactionState.LIVE, + isSuccessful: null, + }, + { + label: 'a transaction queued above the current nonce', + nonce: '2290', + isExecuted: false, + confirmations: 0, + currentNonce: '2289', + state: SafeTransactionState.LIVE, + isSuccessful: null, + }, + { + label: 'a transaction that reverted onchain but consumed its nonce', + nonce: '47', + isExecuted: true, + confirmations: 5, + currentNonce: '48', + state: SafeTransactionState.EXECUTED, + isSuccessful: false, + }, + ])('classifies $label as $state', (testCase) => { + const { + nonce, + isExecuted, + isSuccessful, + confirmations, + currentNonce, + state, + } = testCase; + const transaction = generateSafeMultisigTransaction({ + nonce, + isExecuted, + isSuccessful, + confirmations: Array.from({ length: confirmations }, () => + generateSafeConfirmation(), + ), + }); + + expect( + safeMultisigProposalUtils.getTransactionState({ + transaction, + currentNonce, + }), + ).toEqual(state); + }); + }); + + describe('getTransactionStatus', () => { + it.each([ + { nonce: '10', isExecuted: false, status: ProposalStatus.ACTIVE }, + { nonce: '9', isExecuted: false, status: ProposalStatus.EXPIRED }, + { nonce: '9', isExecuted: true, status: ProposalStatus.EXECUTED }, + ])( + 'maps a transaction at nonce $nonce to $status', + ({ nonce, isExecuted, status }) => { + const transaction = generateSafeMultisigTransaction({ + nonce, + isExecuted, + }); + + expect( + safeMultisigProposalUtils.getTransactionStatus({ + transaction, + currentNonce: '10', + }), + ).toEqual(status); + }, + ); + }); + + describe('filterLiveTransactions', () => { + it('keeps only the transactions whose nonce is still reachable', () => { + const transactions = [ + generateSafeMultisigTransaction({ + nonce: '2234', + safeTxHash: '0xdead', + }), + generateSafeMultisigTransaction({ + nonce: '2289', + safeTxHash: '0xlive', + }), + generateSafeMultisigTransaction({ + nonce: '2290', + safeTxHash: '0xqueued', + }), + ]; + + const result = safeMultisigProposalUtils.filterLiveTransactions({ + transactions, + currentNonce: '2289', + }); + + expect(result.map(({ safeTxHash }) => safeTxHash)).toEqual([ + '0xlive', + '0xqueued', + ]); + }); + }); + + describe('getExecutableTransactions', () => { + it('returns a single executable transaction for a pair queued above the current nonce', () => { + const transactions = [ + generateSafeMultisigTransaction({ + nonce: '13', + safeTxHash: '0xnext', + }), + generateSafeMultisigTransaction({ + nonce: '14', + safeTxHash: '0xlater', + }), + ]; + + const result = safeMultisigProposalUtils.getExecutableTransactions({ + transactions, + currentNonce: '13', + }); + + expect(result.map(({ safeTxHash }) => safeTxHash)).toEqual([ + '0xnext', + ]); + }); + + it('surfaces both candidates of a same-nonce collision as competitors', () => { + const transaction = generateSafeMultisigTransaction({ + nonce: '13', + safeTxHash: '0xvariantA', + }); + const competitor = generateSafeMultisigTransaction({ + nonce: '13', + safeTxHash: '0xvariantB', + }); + const transactions = [transaction, competitor]; + + expect( + safeMultisigProposalUtils.getExecutableTransactions({ + transactions, + currentNonce: '13', + }), + ).toHaveLength(2); + expect( + safeMultisigProposalUtils.hasNonceCompetition({ + transactions, + transaction, + }), + ).toBeTruthy(); + }); + + it('marks the sibling of an executed transaction as dead once the nonce is consumed', () => { + const executed = generateSafeMultisigTransaction({ + nonce: '47', + safeTxHash: '0xreverted', + isExecuted: true, + isSuccessful: false, + }); + const sibling = generateSafeMultisigTransaction({ + nonce: '47', + safeTxHash: '0xstranded', + confirmations: Array.from({ length: 5 }, () => + generateSafeConfirmation(), + ), + }); + + expect( + safeMultisigProposalUtils.getExecutableTransactions({ + transactions: [executed, sibling], + currentNonce: '48', + }), + ).toEqual([]); + expect( + safeMultisigProposalUtils.getTransactionState({ + transaction: sibling, + currentNonce: '48', + }), + ).toEqual(SafeTransactionState.SUPERSEDED); + }); + }); + + describe('hasAddressConfirmed', () => { + const owner = '0x00000000000000000000000000000000000000aB'; + + it.each([ + { + label: 'the same address in a different case', + address: '0x00000000000000000000000000000000000000ab', + confirmed: true, + }, + { + label: 'an owner that has not signed', + address: '0x0000000000000000000000000000000000000012', + confirmed: false, + }, + { + label: 'no connected wallet', + address: undefined, + confirmed: false, + }, + ])('returns $confirmed for $label', ({ address, confirmed }) => { + const transaction = generateSafeMultisigTransaction({ + confirmations: [generateSafeConfirmation({ owner })], + }); + + expect( + safeMultisigProposalUtils.hasAddressConfirmed({ + transaction, + address, + }), + ).toEqual(confirmed); + }); + }); + + describe('isThresholdReached', () => { + it.each([ + { confirmations: 1, confirmationsRequired: 2, reached: false }, + { confirmations: 2, confirmationsRequired: 2, reached: true }, + { confirmations: 6, confirmationsRequired: 6, reached: true }, + ])( + 'returns $reached for $confirmations of $confirmationsRequired confirmations', + ({ confirmations, confirmationsRequired, reached }) => { + const transaction = generateSafeMultisigTransaction({ + confirmationsRequired, + confirmations: Array.from({ length: confirmations }, () => + generateSafeConfirmation(), + ), + }); + + expect( + safeMultisigProposalUtils.isThresholdReached(transaction), + ).toEqual(reached); + }, + ); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/safeMultisigProposalUtils.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/safeMultisigProposalUtils.ts new file mode 100644 index 0000000000..fce4dfc749 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigProposalUtils/safeMultisigProposalUtils.ts @@ -0,0 +1,169 @@ +import { addressUtils, ProposalStatus } from '@aragon/gov-ui-kit'; +import type { ISafeMultisigTransaction } from '@/shared/api/safeService'; +import { SafeTransactionState } from '../../types'; + +export interface ISafeTransactionLivenessParams { + /** + * Transaction to classify. + */ + transaction: ISafeMultisigTransaction; + /** + * Current nonce of the Safe (`ISafeInfo.nonce`). + */ + currentNonce: string; +} + +export interface ISafeTransactionListParams { + /** + * Transactions read from the Safe queue. + */ + transactions: ISafeMultisigTransaction[]; + /** + * Current nonce of the Safe (`ISafeInfo.nonce`). + */ + currentNonce: string; +} + +export interface ISafeNonceCompetitorsParams { + /** + * Transactions read from the Safe queue. + */ + transactions: ISafeMultisigTransaction[]; + /** + * Transaction to find competitors for. + */ + transaction: ISafeMultisigTransaction; +} + +export interface ISafeConfirmedByParams { + /** + * Transaction to check the confirmations of. + */ + transaction: ISafeMultisigTransaction; + /** + * Address to look for, or undefined when no wallet is connected. + */ + address?: string; +} + +const transactionStateToProposalStatus: Record< + SafeTransactionState, + ProposalStatus +> = { + [SafeTransactionState.LIVE]: ProposalStatus.ACTIVE, + // A dead-but-confirmed transaction is Aragon's existing "reached its threshold but can no + // longer execute" state, so it maps onto EXPIRED instead of inventing a status. + [SafeTransactionState.SUPERSEDED]: ProposalStatus.EXPIRED, + [SafeTransactionState.EXECUTED]: ProposalStatus.EXECUTED, +}; + +/** + * Pure liveness rules for a Safe queue. + * + * Safe nonces are a single sequential queue, so liveness is derived from the nonce and never read + * from `isExecuted`: transactions below the Safe's current nonce are permanently unexecutable no + * matter how many confirmations they hold, and a reverted execution consumes its nonce just the + * same. Deriving on every read is what makes the rule recoverable — no transition is tracked. + */ +class SafeMultisigProposalUtils { + supportsEip1271Signatures = (version: string | null): boolean => { + if (version == null) { + return false; + } + + const [major = 0, minor = 0, patch = 0] = version + .split('+')[0] + .split('.') + .map(Number); + + if (![major, minor, patch].every(Number.isInteger)) { + return false; + } + + return ( + major > 1 || + (major === 1 && minor > 4) || + (major === 1 && minor === 4 && patch >= 1) + ); + }; + + getTransactionState = ( + params: ISafeTransactionLivenessParams, + ): SafeTransactionState => { + const { transaction, currentNonce } = params; + + if (transaction.isExecuted) { + return SafeTransactionState.EXECUTED; + } + + return BigInt(transaction.nonce) >= BigInt(currentNonce) + ? SafeTransactionState.LIVE + : SafeTransactionState.SUPERSEDED; + }; + + isTransactionLive = (params: ISafeTransactionLivenessParams): boolean => + this.getTransactionState(params) === SafeTransactionState.LIVE; + + getTransactionStatus = ( + params: ISafeTransactionLivenessParams, + ): ProposalStatus => + transactionStateToProposalStatus[this.getTransactionState(params)]; + + filterLiveTransactions = ( + params: ISafeTransactionListParams, + ): ISafeMultisigTransaction[] => { + const { transactions, currentNonce } = params; + + return transactions.filter((transaction) => + this.isTransactionLive({ transaction, currentNonce }), + ); + }; + + /** + * Transactions that can execute next: the live transactions sitting on the Safe's current + * nonce. More than one means they compete — executing either one permanently kills the rest. + */ + getExecutableTransactions = ( + params: ISafeTransactionListParams, + ): ISafeMultisigTransaction[] => { + const { transactions, currentNonce } = params; + + return transactions.filter( + (transaction) => + !transaction.isExecuted && + BigInt(transaction.nonce) === BigInt(currentNonce), + ); + }; + + getNonceCompetitors = ( + params: ISafeNonceCompetitorsParams, + ): ISafeMultisigTransaction[] => { + const { transactions, transaction } = params; + + return transactions.filter( + (candidate) => + candidate.safeTxHash !== transaction.safeTxHash && + BigInt(candidate.nonce) === BigInt(transaction.nonce), + ); + }; + + hasNonceCompetition = (params: ISafeNonceCompetitorsParams): boolean => + this.getNonceCompetitors(params).length > 0; + + hasAddressConfirmed = (params: ISafeConfirmedByParams): boolean => { + const { transaction, address } = params; + + if (address == null) { + return false; + } + + return transaction.confirmations.some((confirmation) => + addressUtils.isAddressEqual(confirmation.owner, address), + ); + }; + + isThresholdReached = (transaction: ISafeMultisigTransaction): boolean => + transaction.confirmations.length >= transaction.confirmationsRequired; +} + +export const safeMultisigProposalUtils = new SafeMultisigProposalUtils(); diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/index.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/index.ts new file mode 100644 index 0000000000..88b3962f33 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/index.ts @@ -0,0 +1,4 @@ +export { + type ISafeMultisigSettingsParseParams, + safeMultisigSettingsUtils, +} from './safeMultisigSettingsUtils'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/safeMultisigSettingsUtils.test.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/safeMultisigSettingsUtils.test.ts new file mode 100644 index 0000000000..8e68da1fe9 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/safeMultisigSettingsUtils.test.ts @@ -0,0 +1,102 @@ +import { generateSafeInfo } from '../../testUtils'; +import { safeMultisigSettingsUtils } from './safeMultisigSettingsUtils'; + +describe('safeMultisigSettings utils', () => { + const t = jest.fn((key: string, params?: Record) => + params == null ? key : `${key}:${JSON.stringify(params)}`, + ); + + const safeName = 'founders.safe.eth'; + const safeHref = + 'https://app.safe.global/home?safe=sep:0xd84C233A7D1578021d21E39785439bEdDB165F3D'; + afterEach(() => { + t.mockClear(); + }); + + const parse = (safeInfo = generateSafeInfo()) => + safeMultisigSettingsUtils.parseSettings({ + safeInfo, + safeName, + safeHref, + t, + }); + + it('states the Safe particulars that used to be repeated on the breakdown', () => { + const settings = parse( + generateSafeInfo({ + address: '0x0000000000000000000000000000000000000001', + threshold: 3, + owners: [ + '0x0000000000000000000000000000000000000011', + '0x0000000000000000000000000000000000000012', + '0x0000000000000000000000000000000000000013', + '0x0000000000000000000000000000000000000014', + ], + nonce: '42', + version: '1.4.1+L2', + }), + ); + + const byTerm = Object.fromEntries( + settings.map((setting) => [setting.term, setting.definition]), + ); + const key = 'app.plugins.safeMultisig.safeMultisigGovernanceSettings'; + + expect(byTerm[`${key}.strategy`]).toEqual(`${key}.strategyValue`); + expect(byTerm[`${key}.threshold`]).toEqual( + `${key}.thresholdValue:{"min":3,"max":4}`, + ); + // Named "current" because it is live account state: it advances with every transaction the + // Safe executes, so it is not the nonce this proposal's transaction used. + expect(byTerm[`${key}.currentNonce`]).toEqual('42'); + expect(byTerm[`${key}.version`]).toEqual('1.4.1+L2'); + expect(byTerm[`${key}.execution`]).toEqual(`${key}.executionValue`); + }); + + const safeRowOf = (settings: ReturnType) => + settings.find( + (setting) => + setting.term === + 'app.plugins.safeMultisig.safeMultisigGovernanceSettings.safe', + ); + + it('sends the Safe row out to the Safe app, and offers the raw address to copy', () => { + const safeInfo = generateSafeInfo({ + address: '0x0000000000000000000000000000000000000001', + }); + const safeRow = safeRowOf(parse(safeInfo)); + + expect(safeRow?.definition).toEqual(safeName); + expect(safeRow?.link?.href).toEqual(safeHref); + // The Safe's own account page is another product on another domain: leaving the app must be + // visible, not a surprise. + expect(safeRow?.link?.isExternal).toBe(true); + // The truncated name is what reads well; the full address is what a user needs to paste. + expect(safeRow?.copyValue).toEqual(safeInfo.address); + }); + + it('states the Safe without a link when the Safe app cannot address the network', () => { + const settings = safeMultisigSettingsUtils.parseSettings({ + safeInfo: generateSafeInfo(), + safeName, + safeHref: undefined, + t, + }); + + expect(safeRowOf(settings)?.definition).toEqual(safeName); + expect(safeRowOf(settings)?.link).toBeUndefined(); + }); + + it('states an unknown version explicitly rather than leaving the row blank', () => { + const settings = parse(generateSafeInfo({ version: null })); + const versionRow = settings.find( + (setting) => + setting.term === + 'app.plugins.safeMultisig.safeMultisigGovernanceSettings.version', + ); + + expect(versionRow?.definition).toEqual( + 'app.plugins.safeMultisig.safeMultisigGovernanceSettings.unknownVersion', + ); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/safeMultisigSettingsUtils.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/safeMultisigSettingsUtils.ts new file mode 100644 index 0000000000..c3df152eea --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigSettingsUtils/safeMultisigSettingsUtils.ts @@ -0,0 +1,76 @@ +import type { IDefinitionSetting } from '@aragon/gov-ui-kit'; +import type { ISafeInfo } from '@/shared/api/safeService'; +import type { TranslationFunction } from '@/shared/components/translationsProvider'; + +export interface ISafeMultisigSettingsParseParams { + /** + * Live Safe state: owners, threshold, version and nonce. + */ + safeInfo: ISafeInfo; + /** + * Name the Safe is shown under - its ENS name, or the truncated address. + */ + safeName: string; + /** + * Link to the Safe's own account page in the Safe web app. Absent when Safe does not serve the + * network, in which case the row states the Safe without linking anywhere. + */ + safeHref?: string; + t: TranslationFunction; +} + +class SafeMultisigSettingsUtils { + /** + * Settings are where a body's standing configuration belongs, so the Safe's own particulars + * (address, threshold, nonce, version) are stated here rather than repeated on the breakdown + * beside gov-ui-kit's own approval summary. + */ + parseSettings = ( + params: ISafeMultisigSettingsParseParams, + ): IDefinitionSetting[] => { + const { safeInfo, safeName, safeHref, t } = params; + const translationKey = + 'app.plugins.safeMultisig.safeMultisigGovernanceSettings'; + + return [ + { + term: t(`${translationKey}.strategy`), + definition: t(`${translationKey}.strategyValue`), + }, + { + term: t(`${translationKey}.safe`), + definition: safeName, + link: + safeHref == null + ? undefined + : { href: safeHref, isExternal: true }, + copyValue: safeInfo.address, + }, + { + term: t(`${translationKey}.threshold`), + definition: t(`${translationKey}.thresholdValue`, { + min: safeInfo.threshold, + max: safeInfo.owners.length, + }), + }, + { + // Live account state, not this body's configuration: it advances with every + // transaction the Safe executes, including ones with nothing to do with Aragon. Said + // as "current" so it is never read as the nonce this proposal's transaction used. + term: t(`${translationKey}.currentNonce`), + definition: safeInfo.nonce, + }, + { + term: t(`${translationKey}.version`), + definition: + safeInfo.version ?? t(`${translationKey}.unknownVersion`), + }, + { + term: t(`${translationKey}.execution`), + definition: t(`${translationKey}.executionValue`), + }, + ]; + }; +} + +export const safeMultisigSettingsUtils = new SafeMultisigSettingsUtils(); diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/index.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/index.ts new file mode 100644 index 0000000000..764037ecf2 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/index.ts @@ -0,0 +1,5 @@ +export { + type IBuildReportProposalResultDataParams, + type IFindProposalResultReportParams, + safeMultisigTransactionUtils, +} from './safeMultisigTransactionUtils'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultiSendAbi.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultiSendAbi.ts new file mode 100644 index 0000000000..cf9bcaf581 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultiSendAbi.ts @@ -0,0 +1,21 @@ +/** + * Minimal ABI of the Safe `MultiSend` helper, needed to unpack a batch back into its inner calls. + * + * MultiSend is a Safe utility contract rather than part of the Aragon stack, and `safe-deployments` + * ships canonical, eip155 and zksync variants per version — so a batch is always detected by its + * selector, never by the address it was deployed at. + */ +export const safeMultiSendAbi = [ + { + type: 'function', + name: 'multiSend', + inputs: [{ name: 'transactions', type: 'bytes' }], + outputs: [], + stateMutability: 'payable', + }, +] as const; + +/** + * Selector of `multiSend(bytes)`. + */ +export const safeMultiSendSelector = '0x8d80ff0a'; diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultisigTransactionUtils.test.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultisigTransactionUtils.test.ts new file mode 100644 index 0000000000..6bee2622c8 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultisigTransactionUtils.test.ts @@ -0,0 +1,216 @@ +import { + concatHex, + encodeFunctionData, + encodePacked, + type Hex, + size, +} from 'viem'; +import { SppProposalType } from '@/plugins/sppPlugin/types'; +import { generateSafeMultisigTransaction } from '../../testUtils'; +import { safeMultiSendAbi } from './safeMultiSendAbi'; +import { safeMultisigTransactionUtils } from './safeMultisigTransactionUtils'; + +describe('safeMultisigTransaction utils', () => { + const pluginAddress = '0x1111111111111111111111111111111111111111'; + const multiSendAddress = '0x9999999999999999999999999999999999999999'; + const unrelatedAddress = '0x2222222222222222222222222222222222222222'; + const proposalId = '42'; + const stageId = 1; + + const buildReport = (params?: { + proposalId?: string; + stageId?: number; + resultType?: SppProposalType; + }) => + safeMultisigTransactionUtils.buildReportProposalResultData({ + proposalId: params?.proposalId ?? proposalId, + stageId: params?.stageId ?? stageId, + resultType: params?.resultType ?? SppProposalType.APPROVAL, + }); + + const encodeMultiSend = (calls: Array<{ to: string; data: Hex }>): Hex => { + const packedCalls = calls.map(({ to, data }) => + encodePacked( + ['uint8', 'address', 'uint256', 'uint256', 'bytes'], + [0, to as Hex, BigInt(0), BigInt(size(data)), data], + ), + ); + + return encodeFunctionData({ + abi: safeMultiSendAbi, + functionName: 'multiSend', + args: [concatHex(packedCalls)], + }); + }; + + describe('buildReportProposalResultData', () => { + it.each([ + { resultType: SppProposalType.NONE }, + { resultType: SppProposalType.APPROVAL }, + { resultType: SppProposalType.VETO }, + ])( + 'decodes the governance effect $resultType back from the built calldata', + ({ resultType }) => { + const data = buildReport({ resultType }); + + expect( + safeMultisigTransactionUtils.decodeProposalResultReport( + data, + ), + ).toEqual({ + proposalId: BigInt(proposalId), + stageId, + resultType, + tryAdvance: false, + }); + }, + ); + + it.each([ + { data: null, label: 'a value transfer' }, + { data: '0x', label: 'empty calldata' }, + { data: '0xdeadbeef', label: 'an unknown selector' }, + ])('returns undefined for $label', ({ data }) => { + expect( + safeMultisigTransactionUtils.decodeProposalResultReport(data), + ).toBeUndefined(); + }); + }); + + describe('findProposalResultReport', () => { + it.each([ + { + label: 'a direct call to the plugin', + to: pluginAddress, + data: buildReport(), + found: true, + }, + { + label: 'a report bundled inside a multiSend batch', + to: multiSendAddress, + data: encodeMultiSend([ + { to: pluginAddress, data: buildReport() }, + ]), + found: true, + }, + { + label: 'a report behind an undecodable inner call', + to: multiSendAddress, + data: encodeMultiSend([ + { to: unrelatedAddress, data: '0xdeadbeefcafe' }, + { to: pluginAddress, data: buildReport() }, + ]), + found: true, + }, + { + label: 'a report nested in a batch of batches', + to: multiSendAddress, + data: encodeMultiSend([ + { + to: multiSendAddress, + data: encodeMultiSend([ + { to: pluginAddress, data: buildReport() }, + ]), + }, + ]), + found: true, + }, + { + label: 'a report targeting another plugin', + to: unrelatedAddress, + data: buildReport(), + found: false, + }, + { + label: 'a report for another proposal', + to: pluginAddress, + data: buildReport({ proposalId: '43' }), + found: false, + }, + { + label: 'a report for another stage', + to: pluginAddress, + data: buildReport({ stageId: 2 }), + found: false, + }, + { + label: 'a batch without any report', + to: multiSendAddress, + data: encodeMultiSend([ + { to: unrelatedAddress, data: '0xdeadbeef' }, + ]), + found: false, + }, + { + label: 'a plain value transfer', + to: pluginAddress, + data: null, + found: false, + }, + ])('$label is correlated: $found', ({ to, data, found }) => { + const transaction = generateSafeMultisigTransaction({ to, data }); + + const report = + safeMultisigTransactionUtils.findProposalResultReport({ + transaction, + pluginAddress, + proposalId, + stageId, + }); + + expect(report != null).toEqual(found); + }); + + it.each([ + { label: 'string', proposalId: '42' }, + { label: 'bigint', proposalId: BigInt(42) }, + ])( + 'matches a proposal id given as a $label', + ({ proposalId: proposalIdParam }) => { + const transaction = generateSafeMultisigTransaction({ + to: pluginAddress, + data: buildReport(), + }); + + expect( + safeMultisigTransactionUtils.findProposalResultReport({ + transaction, + pluginAddress, + proposalId: proposalIdParam, + stageId, + }), + ).toEqual({ + proposalId: BigInt(proposalId), + stageId, + resultType: SppProposalType.APPROVAL, + tryAdvance: false, + }); + }, + ); + + it('reports the governance effect of a veto nested in a batch', () => { + const transaction = generateSafeMultisigTransaction({ + to: multiSendAddress, + data: encodeMultiSend([ + { to: unrelatedAddress, data: '0xdeadbeef' }, + { + to: pluginAddress, + data: buildReport({ + resultType: SppProposalType.VETO, + }), + }, + ]), + }); + + const report = + safeMultisigTransactionUtils.findProposalResultReport({ + transaction, + pluginAddress, + proposalId, + stageId, + }); + + expect(report?.resultType).toEqual(SppProposalType.VETO); + }); + }); +}); diff --git a/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultisigTransactionUtils.ts b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultisigTransactionUtils.ts new file mode 100644 index 0000000000..e71e3c9d54 --- /dev/null +++ b/apps/app/src/plugins/safeMultisigPlugin/utils/safeMultisigTransactionUtils/safeMultisigTransactionUtils.ts @@ -0,0 +1,253 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import { + type AbiFunction, + decodeFunctionData, + encodeFunctionData, + getAbiItem, + type Hex, + toFunctionSelector, +} from 'viem'; +import { sppReportProposalResultAbi } from '@/plugins/sppPlugin/dialogs/sppReportProposalResultDialog/sppReportProposalResultAbi'; +import { SppProposalType } from '@/plugins/sppPlugin/types'; +import type { ISafeMultisigTransaction } from '@/shared/api/safeService'; +import type { ISafeCall, ISafeProposalResultReport } from '../../types'; +import { safeMultiSendAbi, safeMultiSendSelector } from './safeMultiSendAbi'; + +export interface IBuildReportProposalResultDataParams { + /** + * Onchain index of the SPP proposal, as returned by the API (`proposalIndex`) or already + * decoded. + */ + proposalId: string | bigint; + /** + * Index of the stage the result is reported for. + */ + stageId: number; + /** + * Governance effect being reported. + */ + resultType: SppProposalType; +} + +export interface IFindProposalResultReportParams { + /** + * Safe transaction to search, including any MultiSend batch it carries. + */ + transaction: ISafeMultisigTransaction; + /** + * Address of the SPP plugin the report must target. + */ + pluginAddress: string; + /** + * Onchain index of the SPP proposal. The API returns it as a string while the decoded call + * yields a bigint, so both forms are accepted and normalised here. + */ + proposalId: string | bigint; + /** + * Index of the stage the report must be for. + */ + stageId: number; +} + +const reportProposalResultAbiItem = getAbiItem({ + abi: sppReportProposalResultAbi, + name: 'reportProposalResult', +}) as AbiFunction; + +const reportProposalResultSelector = toFunctionSelector( + reportProposalResultAbiItem, +); + +// Each packed MultiSend call is operation (1 byte) + to (20) + value (32) + data length (32), +// expressed here in hex characters. +const multiSendHeaderLength = 2 + 40 + 64 + 64; + +// Batches nest in theory but never deeply in practice; the cap bounds a hostile payload. +const maxMultiSendDepth = 4; + +const isSppProposalType = (value: number): value is SppProposalType => + [ + SppProposalType.NONE, + SppProposalType.APPROVAL, + SppProposalType.VETO, + ].includes(value as SppProposalType); + +/** + * Pure encoding and correlation of SPP result reports carried by Safe transactions. + * + * Reports created outside the app are routinely bundled into a MultiSend batch, so correlation + * walks nested calls as well as the top-level one. Everything is decoded from the raw `data`: + * the transaction service only decodes ABIs it knows, and SPP is not one of them. + */ +class SafeMultisigTransactionUtils { + buildReportProposalResultData = ( + params: IBuildReportProposalResultDataParams, + ): Hex => { + const { proposalId, stageId, resultType } = params; + + // `_tryAdvance` stays false: advancing the stage closes the report-overwrite window that + // makes recovery from a wrong report possible. + return encodeFunctionData({ + abi: sppReportProposalResultAbi, + functionName: 'reportProposalResult', + args: [ + this.normalizeProposalId(proposalId), + stageId, + resultType, + false, + ], + }); + }; + + findProposalResultReport = ( + params: IFindProposalResultReportParams, + ): ISafeProposalResultReport | undefined => { + const { transaction, pluginAddress, proposalId, stageId } = params; + + const call: ISafeCall = { + to: transaction.to, + data: transaction.data, + operation: transaction.operation, + value: BigInt(transaction.value), + }; + + return this.findReportInCall(call, { + pluginAddress, + proposalId: this.normalizeProposalId(proposalId), + stageId, + }); + }; + + decodeProposalResultReport = ( + data: string | null, + ): ISafeProposalResultReport | undefined => { + if (this.getSelector(data) !== reportProposalResultSelector) { + return undefined; + } + + let args: readonly unknown[] | undefined; + + try { + ({ args } = decodeFunctionData({ + abi: sppReportProposalResultAbi, + data: data as Hex, + })); + } catch { + return undefined; + } + + const [proposalId, stageId, resultType, tryAdvance] = args ?? []; + + if ( + typeof proposalId !== 'bigint' || + typeof stageId !== 'number' || + typeof resultType !== 'number' || + typeof tryAdvance !== 'boolean' || + !isSppProposalType(resultType) + ) { + return undefined; + } + + return { proposalId, stageId, resultType, tryAdvance }; + }; + + decodeMultiSendCalls = (data: string | null): ISafeCall[] => { + if (this.getSelector(data) !== safeMultiSendSelector) { + return []; + } + + try { + const { args } = decodeFunctionData({ + abi: safeMultiSendAbi, + data: data as Hex, + }); + + return this.unpackMultiSendCalls(args[0]); + } catch { + return []; + } + }; + + private findReportInCall = ( + call: ISafeCall, + target: { + pluginAddress: string; + proposalId: bigint; + stageId: number; + }, + depth = 0, + ): ISafeProposalResultReport | undefined => { + if (addressUtils.isAddressEqual(call.to, target.pluginAddress)) { + const report = this.decodeProposalResultReport(call.data); + + if ( + report != null && + report.proposalId === target.proposalId && + report.stageId === target.stageId + ) { + return report; + } + } + + if (depth >= maxMultiSendDepth) { + return undefined; + } + + for (const innerCall of this.decodeMultiSendCalls(call.data)) { + const report = this.findReportInCall(innerCall, target, depth + 1); + + if (report != null) { + return report; + } + } + + return undefined; + }; + + private unpackMultiSendCalls = (packedCalls: Hex): ISafeCall[] => { + const packed = packedCalls.slice(2); + const calls: ISafeCall[] = []; + let cursor = 0; + + while (cursor + multiSendHeaderLength <= packed.length) { + const operation = Number.parseInt( + packed.slice(cursor, cursor + 2), + 16, + ); + const to = `0x${packed.slice(cursor + 2, cursor + 42)}`; + const value = BigInt( + `0x${packed.slice(cursor + 42, cursor + 106)}`, + ); + const dataLength = Number( + BigInt(`0x${packed.slice(cursor + 106, cursor + 170)}`), + ); + + const dataStart = cursor + multiSendHeaderLength; + const dataEnd = dataStart + dataLength * 2; + + if (dataEnd > packed.length) { + return calls; + } + + calls.push({ + to, + operation, + value, + data: `0x${packed.slice(dataStart, dataEnd)}`, + }); + cursor = dataEnd; + } + + return calls; + }; + + private getSelector = (data: string | null): string | undefined => + data != null && data.length >= 10 + ? data.slice(0, 10).toLowerCase() + : undefined; + + private normalizeProposalId = (proposalId: string | bigint): bigint => + typeof proposalId === 'bigint' ? proposalId : BigInt(proposalId); +} + +export const safeMultisigTransactionUtils = new SafeMultisigTransactionUtils(); diff --git a/apps/app/src/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyContent.tsx b/apps/app/src/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyContent.tsx index ae72e88bbf..490aec303b 100644 --- a/apps/app/src/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyContent.tsx +++ b/apps/app/src/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyContent.tsx @@ -16,6 +16,7 @@ import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent import { useDaoPluginInfo } from '@/shared/hooks/useDaoPluginInfo'; import { useSlotSingleFunction } from '@/shared/hooks/useSlotSingleFunction'; import { daoUtils } from '@/shared/utils/daoUtils'; +import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; import { SppVotingTerminalBodyBreakdownDefault } from './sppVotingTerminalBodyBreakdownDefault'; import { SppVotingTerminalBodyVoteDefault } from './sppVotingTerminalBodyVoteDefault'; @@ -53,7 +54,31 @@ export const SppVotingTerminalBodyContent: React.FC< > = (props) => { const { plugin, daoId, subProposal, stage, proposal, children } = props; - const canVote = sppStageUtils.canBodyVote(proposal, stage, plugin); + const { network } = daoUtils.parseDaoId(daoId); + const bodyPluginId = sppStageUtils.getBodyPluginId(plugin, network); + + /** + * Whether this body type can still be asked to act after its voting window closed. Asked of the + * registry, because it is a property of the body and not of the stage: a body that votes through + * an external queue has no say in when that queue clears, and `reportProposalResult` carries no + * deadline - it records while the stage is the proposal's current one. + * + * What is then offered is the body's own call. It knows whether anything is pending and whether + * the stage can still advance, so it can explain an expired stage instead of showing an action. + */ + const votesAfterWindow = + pluginRegistryUtils.getSlotFunction({ + slotId: GovernanceSlotId.GOVERNANCE_BODY_VOTES_AFTER_WINDOW, + pluginId: bodyPluginId, + })?.(undefined) === true; + + const canActLate = + votesAfterWindow && + stage.stageIndex === proposal.stageIndex && + !proposal.executed.status; + + const canVote = + sppStageUtils.canBodyVote(proposal, stage, plugin) || canActLate; const isExternalBody = plugin.interfaceType == null; // Approve/veto is a per-body property: a single stage can mix approving and @@ -74,7 +99,7 @@ export const SppVotingTerminalBodyContent: React.FC< pluginAddress: plugin.address, }, slotId: SettingsSlotId.SETTINGS_GOVERNANCE_SETTINGS_HOOK, - pluginId: plugin.interfaceType ?? 'external', + pluginId: bodyPluginId, fallback: useSppGovernanceSettingsDefault, }); @@ -83,7 +108,6 @@ export const SppVotingTerminalBodyContent: React.FC< address: plugin.address, settings, }); - const { network } = daoUtils.parseDaoId(daoId); const voteListParams = { queryParams: { @@ -116,9 +140,7 @@ export const SppVotingTerminalBodyContent: React.FC< canVote={canVote} Fallback={SppVotingTerminalBodyBreakdownDefault} isVeto={isVeto} - pluginId={ - isExternalBody ? 'external' : plugin.interfaceType - } + pluginId={bodyPluginId} proposal={isExternalBody ? proposal : subProposal} slotId={ GovernanceSlotId.GOVERNANCE_PROPOSAL_VOTING_BREAKDOWN @@ -128,11 +150,6 @@ export const SppVotingTerminalBodyContent: React.FC<
{canVote && ( - {processedSubProposal && ( + {/* An indexed sub-proposal has indexed votes; a body without one can still have + its own notion of votes, so the slot answers for it. Nothing registered means + no votes to show, and the tab-policy slot has already hidden the tab. */} + {processedSubProposal != null ? ( + ) : ( + + + )} )} diff --git a/apps/app/src/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyVoteDefault.tsx b/apps/app/src/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyVoteDefault.tsx index a2cbd6dc7e..bade5a8bfc 100644 --- a/apps/app/src/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyVoteDefault.tsx +++ b/apps/app/src/plugins/sppPlugin/components/sppVotingTerminal/components/sppVotingTerminalBodyVoteDefault.tsx @@ -4,11 +4,7 @@ import { useConnectedWalletGuard } from '@/modules/application/hooks/useConnecte import { useWalletAccount } from '@/modules/application/hooks/useWalletAccount'; import { SppPluginDialogId } from '@/plugins/sppPlugin/constants/sppPluginDialogId'; import type { ISppReportProposalResultDialogParams } from '@/plugins/sppPlugin/dialogs/sppReportProposalResultDialog'; -import { - type ISppProposal, - type ISppStage, - VotingBodyBrandIdentity, -} from '@/plugins/sppPlugin/types'; +import type { ISppProposal, ISppStage } from '@/plugins/sppPlugin/types'; import { sppStageUtils } from '@/plugins/sppPlugin/utils/sppStageUtils'; import { useDialogContext } from '@/shared/components/dialogProvider'; import { useTranslations } from '@/shared/components/translationsProvider'; @@ -26,10 +22,6 @@ export interface ISppVotingTerminalBodyVoteDefaultProps { * External body address. */ externalAddress: string; - /** - * Branded identity of the external body, used to tailor the help text. - */ - brandId?: VotingBodyBrandIdentity; /** * Stage on which the body is setup. */ @@ -44,7 +36,7 @@ export interface ISppVotingTerminalBodyVoteDefaultProps { export const SppVotingTerminalBodyVoteDefault: React.FC< ISppVotingTerminalBodyVoteDefaultProps > = (props) => { - const { daoId, proposal, externalAddress, brandId, stage, isVeto } = props; + const { daoId, proposal, externalAddress, stage, isVeto } = props; const { t } = useTranslations(); const { open } = useDialogContext(); @@ -96,8 +88,6 @@ export const SppVotingTerminalBodyVoteDefault: React.FC< const handleVoteClick = () => checkWalletConnection({ onSuccess: checkPermissions }); - const isSafe = brandId === VotingBodyBrandIdentity.SAFE; - return (