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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ node_modules
# Local env files
apps/provider/.env
.env

# Bootstrap config files (dev uses local copies of *.example.json)
**/bootstrap.json
!**/bootstrap.example.json
.env.local
.env.development.local
.env.test.local
Expand Down
37 changes: 36 additions & 1 deletion DEVELOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,46 @@ The `.env.sample` already points to these file paths by default. You can alterna
| `PROVIDER_JSON` | Inline JSON array of providers (consumed by Middleman) |
| `PROVIDER_JSON_FILE` | Path to a provider JSON file (consumed by Middleman) |

### Auto-bootstrap (optional)

By default, both apps require completing a setup wizard on first launch. For faster dev iterations, you can skip the wizard by providing a bootstrap config file.

1. Copy the example config and adjust values:

```bash
# Provider
cp k8s/apps/provider/overlays/dev/bootstrap.example.json \
k8s/apps/provider/overlays/dev/bootstrap.json

# Middleman
cp k8s/apps/middleman/overlays/dev/bootstrap.example.json \
k8s/apps/middleman/overlays/dev/bootstrap.json
```

2. Add the bootstrap paths to your `.env`:

```bash
PROVIDER_BOOTSTRAP_CONFIG_PATH=../overlays/dev/bootstrap.json
MIDDLEMAN_BOOTSTRAP_CONFIG_PATH=../overlays/dev/bootstrap.json
```

When these variables are set, Tilt injects an init container that seeds the database before the app starts. The seed script:
- Is **idempotent** — if the app is already bootstrapped, it skips
- Fetches **minimum stake** and **current height** from the Pocket API at runtime (not hardcoded)
- Fetches **delegators** (for Provider) and **providers** (for Middleman) from the governance CDN
- Derives the **app identity** (compressed public key) from the `APP_IDENTITY` private key

The `bootstrap.json` files are gitignored. The `.example.json` files are committed as templates.

> **Important:** The governance files (`k8s/tools/governance/providers.json` and `delegators.json`) must be configured first — the bootstrap seed fetches from them via the governance nginx CDN.

### Other variables

| Variable | Description |
|----------|-------------|
| `MINIMUM_STAKE_BUFFER` | Buffer in uPOKT removed from minimum stake to allow operation after slashes (default: `500000000`) |
| `MINIMUM_STAKE_BUFFER` | Buffer in uPOKT added to on-chain minimum stake (default: `500000000`) |
| `PROVIDER_BOOTSTRAP_CONFIG_PATH` | Path to provider bootstrap JSON (relative to Tiltfile). Enables auto-bootstrap when set |
| `MIDDLEMAN_BOOTSTRAP_CONFIG_PATH` | Path to middleman bootstrap JSON (relative to Tiltfile). Enables auto-bootstrap when set |

---

Expand Down
9 changes: 5 additions & 4 deletions apps/middleman/src/app/admin/setup/blockchainFrom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,17 @@ const FormComponent: React.FC<FormProps> = ({ defaultValues, goNext }) => {
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Shannon API URL</FormLabel>
<FormLabel>Pocket API URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://your-shannon-rpc.example.com"
placeholder="https://your-pocket-api.example.com"
/>
</FormControl>
<FormDescription>
A public RPC or gateway URL for the Pocket Network. This is used to auto-detect
the network and minimum stake. The network (chain ID) cannot be changed after setup.
The Cosmos SDK REST API of your Pocket Network node (port <code>1317</code>).
<strong> Do not use the Tendermint RPC</strong> (port <code>26657</code>).
This auto-detects your network and minimum stake. The chain ID is locked after setup.
</FormDescription>
<FormMessage />
</FormItem>
Expand Down
205 changes: 205 additions & 0 deletions apps/middleman/src/db/bootstrap-seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { eq } from 'drizzle-orm'
import * as fs from 'fs'
import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing'
import * as schema from '@igniter/db/middleman/schema'
import { UserRole, ProviderFee } from '@igniter/db/middleman/enums'
import { setup } from '@igniter/db/connection'
import { getLogger } from '@igniter/logger'

const { usersTable, applicationSettingsTable, providersTable } = schema

interface BootstrapConfig {
settings: {
name: string
supportEmail?: string
ownerEmail: string
fee: number
delegatorRewardsAddress: string
privacyPolicy?: string
rpcUrl: string
indexerApiUrl: string
chainId: string
}
}

interface CdnProvider {
name: string
identity: string
identityHistory?: string[]
url: string
}

async function main() {
const configPath = process.env.BOOTSTRAP_CONFIG_PATH
if (!configPath) {
console.log('[bootstrap-seed] BOOTSTRAP_CONFIG_PATH not set, skipping.')
process.exit(0)
}

if (!fs.existsSync(configPath)) {
console.log(`[bootstrap-seed] Config file not found at ${configPath}, skipping.`)
process.exit(0)
}

const ownerIdentity = process.env.OWNER_IDENTITY
if (!ownerIdentity) {
console.error('[bootstrap-seed] OWNER_IDENTITY is required.')
process.exit(1)
}

const ownerEmail = process.env.OWNER_EMAIL
const appIdentityPrivateKey = process.env.APP_IDENTITY
if (!appIdentityPrivateKey) {
console.error('[bootstrap-seed] APP_IDENTITY is required.')
process.exit(1)
}

// Derive compressed public key from APP_IDENTITY private key
const privateKeyBytes = Buffer.from(appIdentityPrivateKey, 'hex')
const wallet = await DirectSecp256k1Wallet.fromKey(privateKeyBytes)
const [account] = await wallet.getAccounts()
if (!account) {
console.error('[bootstrap-seed] Failed to derive public key from APP_IDENTITY.')
process.exit(1)
}
const appIdentity = Buffer.from(account.pubkey).toString('hex')

const logger = getLogger()
const { db, disconnect } = setup({ schema, logger })

try {
// Check if already bootstrapped
const existing = await db
.select({ isBootstrapped: applicationSettingsTable.isBootstrapped })
.from(applicationSettingsTable)
.limit(1)

if (existing.length > 0 && existing[0].isBootstrapped) {
console.log('[bootstrap-seed] Already bootstrapped, skipping.')
process.exit(0)
}

const config: BootstrapConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
console.log('[bootstrap-seed] Starting bootstrap...')

// Step 0: Fetch blockchain params from RPC (minimumStake + current height)
const rpcBase = config.settings.rpcUrl.replace(/\/$/, '')
const stakeBuffer = parseInt(process.env.MINIMUM_STAKE_BUFFER || '0', 10)
let minimumStake = 0
let updatedAtHeight = '0'

try {
const supplierParamsUrl = `${rpcBase}/pokt-network/poktroll/supplier/params`
console.log(`[bootstrap-seed] Fetching minimum stake from ${supplierParamsUrl}...`)
const paramsResponse = await fetch(supplierParamsUrl)
if (!paramsResponse.ok) throw new Error(`Supplier params: HTTP ${paramsResponse.status}`)
const paramsData = await paramsResponse.json()
const rawAmount = parseFloat(paramsData.params.min_stake.amount)
minimumStake = (rawAmount + stakeBuffer) / 1e6
console.log(`[bootstrap-seed] Minimum stake: ${minimumStake} POKT (raw: ${rawAmount}, buffer: ${stakeBuffer})`)

const statusUrl = `${rpcBase}/cosmos/base/node/v1beta1/status`
console.log(`[bootstrap-seed] Fetching current height from ${statusUrl}...`)
const statusResponse = await fetch(statusUrl)
if (!statusResponse.ok) throw new Error(`Node status: HTTP ${statusResponse.status}`)
const statusData = await statusResponse.json()
updatedAtHeight = statusData.height
console.log(`[bootstrap-seed] Current height: ${updatedAtHeight}`)
} catch (err) {
console.error('[bootstrap-seed] Failed to fetch blockchain params:', err)
process.exit(1)
}

// Step 1: Create owner user
const resolvedOwnerEmail = config.settings.ownerEmail || ownerEmail || ''
await db
.insert(usersTable)
.values({
identity: ownerIdentity,
email: resolvedOwnerEmail,
role: UserRole.Owner,
})
.onConflictDoNothing({ target: usersTable.identity })

console.log('[bootstrap-seed] Owner user created.')

// Step 2: Insert application settings (not yet bootstrapped)
if (existing.length === 0) {
await db.insert(applicationSettingsTable).values({
name: config.settings.name,
appIdentity,
supportEmail: config.settings.supportEmail ?? resolvedOwnerEmail,
ownerEmail: resolvedOwnerEmail,
ownerIdentity,
fee: config.settings.fee,
minimumStake,
isBootstrapped: false,
chainId: config.settings.chainId,
delegatorRewardsAddress: config.settings.delegatorRewardsAddress,
rpcUrl: config.settings.rpcUrl,
indexerApiUrl: config.settings.indexerApiUrl,
updatedAtHeight,
privacyPolicy: config.settings.privacyPolicy ?? null,
createdBy: ownerIdentity,
updatedBy: ownerIdentity,
})
}

console.log('[bootstrap-seed] Application settings created.')

// Step 3: Fetch providers from CDN and insert them
const providersCdnUrl = process.env.PROVIDERS_CDN_URL
if (providersCdnUrl) {
const resolvedUrl = providersCdnUrl.replace('{chainId}', config.settings.chainId)
console.log(`[bootstrap-seed] Fetching providers from ${resolvedUrl}...`)
try {
const response = await fetch(resolvedUrl)
if (!response.ok) {
console.warn(`[bootstrap-seed] Failed to fetch providers (${response.status}), skipping.`)
} else {
const cdnProviders: CdnProvider[] = await response.json()
for (const provider of cdnProviders) {
await db
.insert(providersTable)
.values({
name: provider.name,
identity: provider.identity,
url: provider.url,
enabled: true,
visible: true,
fee: 0,
feeType: ProviderFee.UpTo,
minimumStake: 0,
operationalFunds: 5,
allowPublicStaking: false,
createdBy: ownerIdentity,
updatedBy: ownerIdentity,
})
.onConflictDoNothing({ target: providersTable.identity })

console.log(`[bootstrap-seed] Provider "${provider.name}" created.`)
}
}
} catch (err) {
console.warn('[bootstrap-seed] Error fetching providers, skipping:', err)
}
} else {
console.log('[bootstrap-seed] PROVIDERS_CDN_URL not set, skipping providers.')
}

// Step 4: Mark as bootstrapped
await db
.update(applicationSettingsTable)
.set({ isBootstrapped: true, updatedBy: ownerIdentity })
.where(eq(applicationSettingsTable.isBootstrapped, false))

console.log('[bootstrap-seed] Bootstrap complete!')
} catch (error) {
console.error('[bootstrap-seed] Error:', error)
process.exit(1)
} finally {
await disconnect()
}
}

main()
5 changes: 5 additions & 0 deletions apps/provider-workflows/src/activities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ export const providerActivities = (dal: DAL, pocketRpcClient: PocketBlockchain)
// means the key is imported and is already staked, let's set it to the owner address
update.ownerAddress = supplier.ownerAddress
}

// Backfill for legacy keys with empty owner address
if (!key.ownerAddress && supplier.ownerAddress) {
update.ownerAddress = supplier.ownerAddress
}
}

log.debug('remediateSupplier: Checking if is owner initial stake remediation needed', {
Expand Down
22 changes: 15 additions & 7 deletions apps/provider-workflows/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,35 @@ import { RemediationHistoryEntryReason } from "@igniter/db/provider/enums"
enum ScheduledWorkflowType {
SupplierStatus = 'SupplierStatus',
SupplierRemediation = 'SupplierRemediation',
SupplierInitialStake = 'SupplierInitialStake',
}

const ScheduledWorkflowConfig: Record<
ScheduledWorkflowType,
{ interval: string; args: any[]; envVar: string }
{ workflowType: string; interval: string; args: any[]; envVar: string }
> = {
[ScheduledWorkflowType.SupplierStatus]: {
workflowType: 'SupplierStatus',
interval: '2m',
args: [],
envVar: 'SCHEDULE_SUPPLIER_STATUS_INTERVAL',
},
[ScheduledWorkflowType.SupplierRemediation]: {
workflowType: 'SupplierRemediation',
interval: '10s',
args: [{
reasons: [
RemediationHistoryEntryReason.OwnerInitialStake,
RemediationHistoryEntryReason.ServiceMismatch,
]
reasons: [RemediationHistoryEntryReason.ServiceMismatch]
}],
envVar: 'SCHEDULE_SUPPLIER_REMEDIATION_INTERVAL',
},
[ScheduledWorkflowType.SupplierInitialStake]: {
workflowType: 'SupplierRemediation',
interval: '10s',
args: [{
reasons: [RemediationHistoryEntryReason.OwnerInitialStake]
}],
envVar: 'SCHEDULE_SUPPLIER_INITIAL_STAKE_INTERVAL',
},
}

function parseDurationToMs(duration: string): number {
Expand Down Expand Up @@ -89,7 +97,7 @@ async function bootstrapScheduledWorkflows(client: Client, config: TemporalConfi
try {
const desc = await handle.describe()

const currentArgs = (desc as any).action.args || []
const currentArgs = desc.action.args ?? []
const currentIntervalMs = desc.spec.intervals?.[0]?.every
const desiredIntervalMs = parseDurationToMs(interval)

Expand Down Expand Up @@ -122,7 +130,7 @@ async function bootstrapScheduledWorkflows(client: Client, config: TemporalConfi
await client.schedule.create({
action: {
type: 'startWorkflow',
workflowType,
workflowType: wfConfig.workflowType,
taskQueue: config.taskQueue!,
args: wfConfig.args,
},
Expand Down
5 changes: 5 additions & 0 deletions apps/provider-workflows/src/workflows/SupplierRemediation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export async function SupplierRemediation(input: SupplierRemediationInput): Prom
getKeysMinAndMax(),
])

if (minId == null || maxId == null) {
log.info('SupplierRemediation: No keys found, nothing to remediate.')
return { height, minId: 0, maxId: 0 }
}

const loggerContext = {
height,
minId,
Expand Down
5 changes: 5 additions & 0 deletions apps/provider-workflows/src/workflows/SupplierStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export async function SupplierStatus(): Promise<{ height: number, minId: number,
getKeysMinAndMax(),
])

if (minId == null || maxId == null) {
log.info('SupplierStatus: No keys found, nothing to check.')
return { height, minId: 0, maxId: 0 }
}

const loggerContext = {
height,
minId,
Expand Down
2 changes: 1 addition & 1 deletion apps/provider/src/actions/Keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function UpdateKeysState(ids: number[], state: KeyState): Promise<A
})
}

export async function MarkKeysForRemediation(): Promise<ActionResult<void>> {
export async function ClearKeysRemediation(): Promise<ActionResult<void>> {
return withRequireOwner(async () => {
await updateKeysStateWhereCurrentStateIn([
KeyState.AttentionNeeded,
Expand Down
Loading
Loading