Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions .github/workflows/branch-gate.yml

This file was deleted.

5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ permissions:

jobs:
branch-gate:
if: github.base_ref == 'main'
runs-on: ubuntu-latest
steps:
- name: Only release branch can target main
if: github.head_ref != 'release'
if: github.base_ref == 'main' && github.head_ref != 'release'
run: |
echo "::error::PRs to main must come from the 'release' branch, not '${{ github.head_ref }}'."
echo "Flow: feature → staging → release → main"
exit 1
- name: Gate passed
run: echo "OK"

quality:
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ watch_file('.env')
secret_settings(disable_scrub = True)

include('./k8s/tools/Tiltfile')

LOCALNET_ENABLED = os.getenv('LOCALNET_ENABLED', '') == 'true'
if LOCALNET_ENABLED:
include('./k8s/tools/localnet/Tiltfile')

include('./k8s/apps/provider/Tiltfile')
include('./k8s/apps/middleman/Tiltfile')
include('./k8s/apps/middleman-workflows/Tiltfile')
Expand Down
50 changes: 50 additions & 0 deletions apps/middleman-workflows/src/activities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,56 @@ function extractDomainsForAddressGroup(ag: AddressGroupsJson[number]): string[]
})
}

export type GovernanceSyncResult = {
inserted: number;
updated: number;
disabled: number;
}

export const governanceActivities = (dal: DAL) => ({
async syncProvidersFromGovernance(): Promise<GovernanceSyncResult> {
const settings = await dal.appSettings.getFirst()
if (!settings) {
throw ApplicationFailure.nonRetryable('Application settings not found', 'settings_not_found')
}

const cdnUrlTemplate = process.env.PROVIDERS_CDN_URL
if (!cdnUrlTemplate) {
throw ApplicationFailure.nonRetryable('PROVIDERS_CDN_URL environment variable is not defined', 'missing_env')
}

const cdnUrl = cdnUrlTemplate.replace(
'{chainId}',
settings.chainId.replace('lego-testnet', 'beta'),
)

log.info('syncProvidersFromGovernance: Fetching from CDN', { cdnUrl })

const response = await fetch(cdnUrl)
if (!response.ok) {
throw ApplicationFailure.retryable(`Failed to fetch providers: ${response.statusText}`, 'fetch_failed')
}

type CdnProvider = {
name: string;
identity: string;
identityHistory: string[];
url: string;
}

const providersFromCdn = (await response.json()) as CdnProvider[]
log.info('syncProvidersFromGovernance: Fetched providers', { count: providersFromCdn.length })

const result = await dal.provider.upsertFromGovernance(
providersFromCdn,
settings.ownerIdentity,
)

log.info('syncProvidersFromGovernance: Done', result)
return result
},
})

export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain, providerService: ProviderService) => ({
/**
* Returns the latest block height from the blockchain.
Expand Down
6 changes: 6 additions & 0 deletions apps/middleman-workflows/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { Logger } from '@igniter/logger'

enum ScheduledWorkflowType {
GovernanceSync = 'GovernanceSync',
ProviderStatus = "ProviderStatus",
ExecutePendingTransaction = "ExecutePendingTransactions",
SupplierStatus = 'SupplierStatus',
Expand All @@ -19,6 +20,11 @@ const ScheduledWorkflowConfig: Record<
ScheduledWorkflowType,
{ interval: string; args: any[]; envVar: string }
> = {
[ScheduledWorkflowType.GovernanceSync]: {
interval: '5m',
args: [],
envVar: 'SCHEDULE_GOVERNANCE_SYNC_INTERVAL',
},
[ScheduledWorkflowType.ProviderStatus]: {
interval: "1m",
args: [],
Expand Down
73 changes: 73 additions & 0 deletions apps/middleman-workflows/src/lib/dal/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,79 @@ export default class Provider {
});
}

async listAll() {
return this.dbClient.db
.select()
.from(providersTable)
}

async upsertFromGovernance(
providers: Array<{ name: string; identity: string; identityHistory: string[]; url: string }>,
updatedBy: string,
) {
const current = await this.listAll()
const currentMap = new Map(current.map((p) => [p.identity, p]))

const allCdnIdentities = new Set<string>()
for (const p of providers) {
allCdnIdentities.add(p.identity)
p.identityHistory.forEach((h) => allCdnIdentities.add(h))
}

let inserted = 0
let updated = 0
let disabled = 0

await this.dbClient.db.transaction(async (tx) => {
for (const cdnProvider of providers) {
const possibleIds = [cdnProvider.identity, ...cdnProvider.identityHistory]
const matchingCurrent = possibleIds.map((id) => currentMap.get(id)).find(Boolean) ?? null

if (matchingCurrent) {
const shouldUpdateIdentity = matchingCurrent.identity !== cdnProvider.identity
const shouldUpdateName = matchingCurrent.name !== cdnProvider.name
const shouldUpdateUrl = matchingCurrent.url !== cdnProvider.url

if (shouldUpdateIdentity || shouldUpdateName || shouldUpdateUrl) {
await tx
.update(providersTable)
.set({
identity: cdnProvider.identity,
name: cdnProvider.name,
url: cdnProvider.url,
updatedBy,
})
.where(eq(providersTable.id, matchingCurrent.id))
updated++
}
} else {
await tx.insert(providersTable).values({
name: cdnProvider.name,
identity: cdnProvider.identity,
url: cdnProvider.url,
enabled: true,
visible: true,
createdBy: updatedBy,
updatedBy,
})
inserted++
}
}

for (const provider of current) {
if (!allCdnIdentities.has(provider.identity) && (provider.enabled || provider.visible)) {
await tx
.update(providersTable)
.set({ enabled: false, visible: false, updatedAt: new Date(), updatedBy })
.where(eq(providersTable.identity, provider.identity))
disabled++
}
}
})

return { inserted, updated, disabled }
}

async updateProvider(
providerId: number,
provider: Partial<ProviderModel>
Expand Down
3 changes: 2 additions & 1 deletion apps/middleman-workflows/src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { delegatorActivities } from './activities'
import { delegatorActivities, governanceActivities } from './activities'
import { importSupplierRecoveryActivities } from './activities/importSupplierRecovery'
import bootstrap from './bootstrap'
import {
Expand Down Expand Up @@ -94,6 +94,7 @@ export async function setupTemporalWorker() {
const { worker, disconnect } = await getWorker(logger, {
workflowsPath: require.resolve('./workflows'),
activities: {
...governanceActivities(dal),
...delegatorActivities(dal, blockchainProvider, providerService),
...importSupplierRecoveryActivities(dal, providerService),
},
Expand Down
13 changes: 13 additions & 0 deletions apps/middleman-workflows/src/workflows/GovernanceSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { proxyActivities } from '@temporalio/workflow'
import type { governanceActivities } from '@/activities'

const { syncProvidersFromGovernance } = proxyActivities<ReturnType<typeof governanceActivities>>({
startToCloseTimeout: '30s',
retry: {
maximumAttempts: 3,
},
})

export async function GovernanceSync() {
return syncProvidersFromGovernance()
}
1 change: 1 addition & 0 deletions apps/middleman-workflows/src/workflows/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./ExecuteTransaction";
export * from './GovernanceSync'
export * from "./ProviderStatus";
export * from './ExecutePendingTransactions';
export * from './SupplierStatus'
Expand Down
6 changes: 3 additions & 3 deletions apps/middleman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ All vars below are sourced from `docker-compose/apps/middleman/.env.sample` and
| `OWNER_EMAIL` | Required | Email address for the owner account | `delegator@example.com` |
| `APP_IDENTITY` | Required | Hex-encoded private key used by Middleman for governance signing | *(your private key hex)* |
| `MINIMUM_STAKE_BUFFER` | Optional | Buffer subtracted from minimum on-chain stake to allow nodes to operate after slashes, in uPOKT | `500000000` |
| `PROVIDERS_CDN_URL` | Optional | CDN URL template for fetching the list of available providers. `{chainId}` is replaced at runtime with `CHAIN_ID` | `https://raw.githubusercontent.com/pokt-network/igniter-governance/refs/heads/main/{chainId}/provider.json` |
| `PROVIDERS_CDN_URL` | Optional | CDN URL template for fetching the list of available providers. Used by bootstrap-seed and the `GovernanceSync` Temporal workflow (in middleman-workflows). `{chainId}` is replaced at runtime with `CHAIN_ID` | `https://raw.githubusercontent.com/pokt-network/igniter-governance/refs/heads/main/{chainId}/provider.json` |

> **Note:** `PROVIDERS_CDN_URL` uses `{chainId}` template substitution — the runtime replaces it with the value of `CHAIN_ID`. This URL points to the governance repository where providers register themselves to be discoverable by Middleman instances.
> **Note:** `PROVIDERS_CDN_URL` must be set in both the middleman app (for bootstrap) and middleman-workflows (for the scheduled GovernanceSync workflow). In local Tilt development, both are automatically overridden to use the local `governance-nginx` service.

### Application

Expand Down Expand Up @@ -230,7 +230,7 @@ After completing all 4 steps and clicking **Complete**, you are redirected to `/

Middleman discovers available providers via the `PROVIDERS_CDN_URL`. This URL points to a governance repository JSON file that lists providers who have registered themselves as available for staking.

At runtime, `{chainId}` in `PROVIDERS_CDN_URL` is replaced with the value of `CHAIN_ID` to fetch the provider list for the correct network. The providers fetched from this URL are what appear in Step 3 of the bootstrap wizard (Configure Providers) and in the staking flow.
At runtime, `{chainId}` in `PROVIDERS_CDN_URL` is replaced with the value of `CHAIN_ID` to fetch the provider list for the correct network. The providers fetched from this URL are used during bootstrap (initial setup) and by the `GovernanceSync` Temporal workflow that runs every 5 minutes in middleman-workflows. The **Reload** button on the Providers admin page triggers this workflow manually. New providers are added as enabled and visible by default; providers removed from governance are disabled and hidden.

When a user stakes through Middleman, the application communicates directly with the selected Provider's API to request supplier addresses, and the owner signs the stake transaction locally. Middleman discovers which providers are available via the governance JSON, but the staking and import-suppliers flows require direct network access between Middleman and Provider instances.

Expand Down
19 changes: 5 additions & 14 deletions apps/middleman/src/actions/ApplicationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ import urlJoin from 'url-join'
import { getServerApolloClient } from '@igniter/ui/graphql/server'
import { indexerStatusDocument } from '@igniter/graphql'
import { env } from '@/config/env'
import {
revalidateTag,
unstable_cache,
} from 'next/cache'
import { revalidatePath } from 'next/cache'

const UrlSchema = z.string().url('Please enter a valid URL').min(1, 'URL is required')

Expand Down Expand Up @@ -51,15 +48,9 @@ const CreateSettingsSchema = z.object({

const appSettingsCacheTag = 'appSettings';

const getAppSettings = unstable_cache(
async () => {
return await fetchApplicationSettings()
},
undefined,
{
tags: [appSettingsCacheTag],
},
)
async function getAppSettings() {
return await fetchApplicationSettings()
}

export async function GetAppName() {
const appSettings = await getAppSettings()
Expand Down Expand Up @@ -106,7 +97,7 @@ export async function UpsertApplicationSettings(
})
}

revalidateTag(appSettingsCacheTag)
revalidatePath('/', 'layout')
}

export async function completeSetup() {
Expand Down
17 changes: 17 additions & 0 deletions apps/middleman/src/actions/ImportSuppliers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ import * as importAttemptsDal from '@/lib/dal/importSupplierAttempts'
import { getDb } from '@/db'
import { ImportedSupplier } from '@/lib/services/importSuppliers'
import { getExistingNodes, getNodeAddressesByOwnerAndProvider } from '@/lib/dal/nodes'
import { getApplicationSettings } from '@/lib/dal/applicationSettings'

async function getCurrentHeight(): Promise<number> {
try {
const settings = await getApplicationSettings()
const rpcUrl = settings.rpcUrl?.replace(/\/$/, '')
if (!rpcUrl) return 0
const res = await fetch(`${rpcUrl}/cosmos/base/node/v1beta1/status`)
if (!res.ok) return 0
const data = await res.json()
return parseInt(data.height, 10) || 0
} catch {
return 0
}
}

/**
* Fetches an import attempt and asserts it exists and is owned by the user.
Expand Down Expand Up @@ -84,6 +99,7 @@ export async function CompleteImportAttempt(
const db = getDb()
const supplierAddresses = suppliers.map((s) => s.address)
const existingSuppliers = await getExistingNodes(supplierAddresses, userIdentity)
const height = await getCurrentHeight()

const nodesToInsert: Array<InsertNode> = []

Expand All @@ -100,6 +116,7 @@ export async function CompleteImportAttempt(
providerId: providerIdentity,
createdBy: attempt.userIdentity,
balance: BigInt(0),
lastUpdatedHeight: height,
})
}

Expand Down
14 changes: 12 additions & 2 deletions apps/middleman/src/actions/Nodes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use server'

import type { NodeWithDetails } from '@igniter/db/middleman/schema'
import { getNode, getNodesByUser, getOwnerAddressesByUser, getProviderCountByUser, getStakedNodesAddress } from '@/lib/dal/nodes'
import { requireAuth, assertOwnership } from "@/lib/utils/actions";
import { countAllNodes, getAllNodes, getNode, getNodesByUser, getOwnerAddressesByUser, getProviderCountByUser, getStakedNodesAddress } from '@/lib/dal/nodes'
import { requireAuth, requireAdmin, assertOwnership } from "@/lib/utils/actions";
import { getApplicationSettings } from '@/lib/dal/applicationSettings'
import { normalizeIdentityToAddress } from '@/lib/crypto'
import { summaryDocument, StakeStatus } from '@igniter/graphql'
Expand All @@ -11,6 +11,16 @@ import { getLatestBlock } from '@igniter/ui/api/blocks'
import { amountToPokt } from '@igniter/ui/lib/utils'
import { batchArray } from '@igniter/ui/lib/batch'

export async function GetAllNodes() {
await requireAdmin()
return getAllNodes()
}

export async function CountAllNodes() {
await requireAdmin()
return countAllNodes()
}

export async function GetUserNodes() {
const userIdentity = await requireAuth()
return getNodesByUser(userIdentity)
Expand Down
Loading
Loading