Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ jobs:
file: ./apps/${{ inputs.app }}/Dockerfile
push: ${{ inputs.push }}
tags: ghcr.io/pokt-network/${{ inputs.app }}:${{ inputs.tag }}
build-args: |
APP_VERSION=${{ inputs.tag }}
no-cache: ${{ inputs.no-cache }}
cache-from: ${{ inputs.no-cache && ' ' || 'type=gha' }}
cache-to: type=gha,mode=max
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ jobs:
- name: Test
run: pnpm turbo test

- name: No-console guard
run: bash scripts/no-console-guard.sh

docker-build:
needs: quality
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ jobs:
tags: |
ghcr.io/pokt-network/${{ matrix.app }}:${{ env.NEW_VERSION }}
ghcr.io/pokt-network/${{ matrix.app }}:latest
build-args: |
APP_VERSION=${{ env.NEW_VERSION }}
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,scope=${{ matrix.app }},mode=max,ignore-error=true

Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
name: Deploy Staging

on:
pull_request:
# pull_request_target, NOT pull_request: a `pull_request` event raised by a PR
# from a FORK gets a read-only GITHUB_TOKEN and no secrets, regardless of the
# `permissions:` block below — so the GHCR push fails with "installation not
# allowed to Write organization package" and post-deploy never runs. That is
# exactly what happened when PR #326 (external contributor) was merged.
# pull_request_target runs in the base-repo context with a read/write token.
# The usual pull_request_target hazard (running untrusted PR-head code with a
# privileged token) does not apply here: every checkout below pins `ref: staging`,
# never the PR head, and the job only runs once the PR is already merged.
pull_request_target:
types: [closed]
branches: [staging]
workflow_dispatch:
Expand Down Expand Up @@ -65,6 +74,8 @@ jobs:
file: ./apps/${{ matrix.app }}/Dockerfile
push: true
tags: ghcr.io/pokt-network/${{ matrix.app }}:${{ env.IMAGE_TAG }}
build-args: |
APP_VERSION=${{ env.IMAGE_TAG }}
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,scope=${{ matrix.app }},mode=max,ignore-error=true

Expand Down
6 changes: 6 additions & 0 deletions apps/middleman-workflows/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,11 @@ RUN pnpm turbo run build --filter=@igniter/middleman-workflows
# Switch user
USER app

ENV SERVICE_NAME=middleman-workflows
# APP_VERSION sits after the build RUN so a per-deploy version bump only
# invalidates this layer, not the install/build layers above.
ARG APP_VERSION=unknown
ENV APP_VERSION=${APP_VERSION}

# Start the server
CMD ["node", "./apps/middleman-workflows/dist/src/worker.js"]
72 changes: 58 additions & 14 deletions apps/middleman-workflows/src/activities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,19 @@ const VERIFY_UNAVAILABLE_ALERT_THRESHOLD = Number(process.env.VERIFY_UNAVAILABLE
/** Per-sweep hash-scan window, matching the on-chain mempool expiration window. */
// TX_EXPIRATION_BLOCKS imported from @igniter/tx-verify above

/**
* Warns once per worker process that `indexerApiUrl` is missing from app settings,
* instead of every sweep (this fired ~34x/2h in the debuggability audit — pure noise
* once the operator has seen it once). Module-level state is safe here: this file
* only runs activity/node-side, never inside the Temporal workflow sandbox.
*/
let indexerApiUrlWarned = false
function warnIndexerApiUrlMissingOnce(context: Record<string, unknown> = {}) {
if (indexerApiUrlWarned) return
indexerApiUrlWarned = true
log.warn('indexerApiUrl not configured in app settings — skipping address group rewards fetch', context)
}

/**
* Parses a transaction's unsigned payload into the expected on-chain supplier effect.
* Returns null when the tx has no supplier-state path (send / OperationalFunds), so the
Expand Down Expand Up @@ -199,7 +212,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,

async upsertSupplierStatus(params: UpsertSupplierStatusParams): Promise<boolean> {
try {
log.info('Querying supplier status', { params })
log.debug('Querying supplier status', { params })
const [node, balance, supplier] = await Promise.all([
dal.node.loadNode(params.address),
pocketRpcClient.getBalance(params.address),
Expand Down Expand Up @@ -350,14 +363,14 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,
}
}

log.info('Updating supplier', { params, update }) //NOTE: adding the update could result in an error due to BIGINT
log.debug('Updating supplier', { params, update }) //NOTE: adding the update could result in an error due to BIGINT
try {
await dal.node.updateNode(params.address, update, params.height)
} catch (e) {
log.error('Error updating node record', { error: e })
throw new ApplicationFailure('errored updating node record', 'update_error', false, null, e as Error)
}
log.info('Upsert Supplier done!', { params })
log.debug('Upsert Supplier done!', { params })

// Node update has persisted — now deliver the captured supplier-change
// notifications (best-effort; dispatchUserNotification never throws). On a
Expand Down Expand Up @@ -492,7 +505,20 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,
throw new Error('Transaction is not signed')
}

return pocketRpcClient.sendTransaction(transaction.signedPayload)
const result = await pocketRpcClient.sendTransaction(transaction.signedPayload)

if (result.transactionHash) {
log.info('transaction broadcast', { transactionId, hash: result.transactionHash, type: transaction.type })
} else {
log.warn('transaction broadcast failed', {
transactionId,
type: transaction.type,
code: result.code,
message: result.message,
})
}

return result
},
/**
* Retrieves the current block height from the RPC client.
Expand Down Expand Up @@ -584,8 +610,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,

return dal.node.insert(newNodes, transaction.id)
} catch (error) {
console.log('Something went wrong while parsing the transaction to extract the staked nodes information.')
console.error(error)
log.error('Failed to parse transaction for staked nodes', { transactionId, error })
return []
}
},
Expand Down Expand Up @@ -619,8 +644,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,

return addresses
} catch (error) {
console.log('Something went wrong while parsing the transaction to extract the unstaking nodes information.')
console.error(error)
log.error('Failed to parse transaction for unstaking nodes', { transactionId, error })
return []
}
},
Expand Down Expand Up @@ -815,7 +839,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,
const indexerApiUrl = appSettings?.indexerApiUrl

if (!indexerApiUrl) {
log.warn('indexerApiUrl not configured in app settings — skipping address group rewards fetch')
warnIndexerApiUrlMissingOnce()
return
}

Expand Down Expand Up @@ -1019,7 +1043,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,
const indexerApiUrl = appSettings?.indexerApiUrl

if (!indexerApiUrl) {
log.warn('indexerApiUrl not configured in app settings — skipping address group rewards fetch', { providerIdentity })
warnIndexerApiUrlMissingOnce({ providerIdentity })
return { statusResult }
}

Expand Down Expand Up @@ -1260,7 +1284,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,
* height (or its execution height on the first sweep). Maps the pocket tri-state
* result down to the minimal shape the pure decision logic consumes.
*/
async verifyTxHash(transactionId: number): Promise<VerifyOutcome<{ success: boolean; code: number; gasUsed: string }>> {
async verifyTxHash(transactionId: number): Promise<VerifyOutcome<{ success: boolean; code: number; gasUsed: string; rawLog?: string }>> {
const txn = await dal.transaction.getTransaction(transactionId)
if (!txn?.hash) {
throw new Error('verifyTxHash: tx missing hash')
Expand All @@ -1272,7 +1296,7 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,
// activity boundary (the default payload converter cannot encode BigInt).
return {
status: 'confirmed',
data: { success: out.data.success, code: out.data.code, gasUsed: out.data.gasUsed.toString() },
data: { success: out.data.success, code: out.data.code, gasUsed: out.data.gasUsed.toString(), rawLog: out.data.rawLog },
}
},

Expand Down Expand Up @@ -1375,14 +1399,34 @@ export const delegatorActivities = (dal: DAL, pocketRpcClient: PocketBlockchain,

const status = decision.tx === 'success' ? TransactionStatus.Success : TransactionStatus.Failure
const verificationHeight = await pocketRpcClient.getHeight().catch(() => undefined)
// Failure log: prefer the chain's own error text (rawLog, present when the tx
// was found on-chain and failed) so the UI can show the real reason; the
// hardcoded summaries remain for paths where no chain text exists (absent-tx
// failure) or as suffix context (sibling-met goal).
const fields: { code?: number; consumedFee?: number; verificationHeight?: number; log?: string } = {
verificationHeight,
log: decision.tx === 'success' ? 'verified'
: decision.effects === 'apply-success' ? 'tx failed on-chain; goal met by sibling tx'
: 'verification negative (validity bound covered, no effect)',
: decision.effects === 'apply-success'
? `tx failed on-chain; goal met by sibling tx${decision.rawLog ? ` (${decision.rawLog})` : ''}`
: decision.rawLog || 'verification negative (validity bound covered, no effect)',
}
if (decision.code !== undefined) fields.code = decision.code
if (decision.gasUsed !== undefined) fields.consumedFee = Number(decision.gasUsed)

const verifyLogFields = {
transactionId,
hash: txn.hash ?? undefined,
type: txn.type,
success: decision.tx === 'success',
...(decision.code !== undefined ? { code: decision.code } : {}),
...(decision.gasUsed !== undefined ? { gasUsed: decision.gasUsed } : {}),
}
if (decision.tx === 'success') {
log.info('transaction verified', verifyLogFields)
} else {
log.warn('transaction verification failed', { ...verifyLogFields, reason: fields.log })
}

const claimed = await dal.transaction.claimTerminalTransition(transactionId, status, fields)

// Only the CAS winner gets the row back, so the owner is notified exactly
Expand Down
12 changes: 6 additions & 6 deletions apps/middleman-workflows/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ async function bootstrapNamespace(client: Client, config: TemporalConfig, logger

try {
await workflowService.describeNamespace({ namespace });
logger.info({ namespace }, 'Namespace already exists. Skipping registration...')
logger.info('Namespace already exists. Skipping registration...', { namespace })
} catch (error: any) {
if (error.details.match(/not found/i)) {
try {
logger.warn({ namespace }, 'Namespace does not exist. Registering...')
logger.warn('Namespace does not exist. Registering...', { namespace })
await workflowService.registerNamespace({
namespace,
workflowExecutionRetentionPeriod: {
Expand All @@ -74,14 +74,14 @@ async function bootstrapNamespace(client: Client, config: TemporalConfig, logger
),
},
});
logger.info({ namespace }, 'Namespace registered successfully, waiting 20s for it to be fully registered...')
logger.info('Namespace registered successfully, waiting 20s for it to be fully registered...', { namespace })
await new Promise((resolve) => setTimeout(resolve, 20000));
} catch (error) {
logger.error({ error, namespace }, 'Error registering namespace')
logger.error('Error registering namespace', { error, namespace })
throw error;
}
} else {
logger.error({ error, namespace }, 'Error describing namespace')
logger.error('Error describing namespace', { error, namespace })
throw error;
}
}
Expand All @@ -95,8 +95,8 @@ export function buildWatchdogEntries(config: WatchdogConfig, logger?: Logger): W
const parsed = parseDuration(rawOverride)
if (parsed == null) {
logger?.warn(
{ scheduleId: `${wt}-scheduled`, envVar: wf.envVar, raw: rawOverride, fallback: wf.interval },
'Invalid schedule interval override; falling back to default',
{ scheduleId: `${wt}-scheduled`, envVar: wf.envVar, raw: rawOverride, fallback: wf.interval },
)
}
// Never carry an invalid override forward: fall the STRING back to the default
Expand Down
24 changes: 14 additions & 10 deletions apps/middleman-workflows/src/lib/blockchain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Buffer } from 'buffer'
import { sha256 } from '@cosmjs/crypto'
import { toHex } from '@cosmjs/encoding'
import { connectComet } from '@cosmjs/tendermint-rpc'
import { getLogger, type Logger } from '@igniter/logger'

export interface SendTransactionResult {
transactionHash: string;
Expand Down Expand Up @@ -32,16 +33,19 @@ export class Blockchain implements IBlockchain {
private readonly rpcUrl: string;
private readonly denom: string;
private readonly apiUrl?: string;
private readonly logger: Logger;

/**
* @param rpcUrl bech32 Cosmos SDK RPC endpoint, e.g. https://rpc.cosmos.network
* @param denom staking token denom, e.g. "uatom" or "upokt"
* @param apiUrl optional REST API endpoint for Tier 2 tx lookup
* @param logger optional injected logger; defaults to the root app logger
*/
constructor(rpcUrl: string, denom: string = 'upokt', apiUrl?: string) {
constructor(rpcUrl: string, denom: string = 'upokt', apiUrl?: string, logger: Logger = getLogger(['middleman-workflows', 'blockchain'])) {
this.rpcUrl = rpcUrl;
this.denom = denom;
this.apiUrl = apiUrl;
this.logger = logger;
}

/** Returns the numeric token balance for `address` in the configured `denom`. */
Expand All @@ -59,7 +63,7 @@ export class Blockchain implements IBlockchain {
try {
return await client.getHeight();
} catch (err) {
console.error(err);
this.logger.error('Failed to fetch height from blockchain', { error: err });
throw new Error('Unable to fetch the height from the blockchain.');
}
}
Expand Down Expand Up @@ -113,12 +117,12 @@ export class Blockchain implements IBlockchain {
};
}
} catch (error) {
console.warn('Tier 1 (RPC getTx) failed:', error);
this.logger.warn('Tier 1 (RPC getTx) failed', { txHash, error });
}

// Tier 2: REST API
if (this.apiUrl) {
console.info(`Tier 1 returned null for ${txHash}, trying REST API fallback`);
this.logger.debug('Tier 1 returned null, trying REST API fallback', { txHash });
try {
const url = `${this.apiUrl.replace(/\/$/, '')}/cosmos/tx/v1beta1/txs/${txHash}`;
const response = await fetch(url);
Expand All @@ -138,13 +142,13 @@ export class Blockchain implements IBlockchain {
}
}
} catch (error) {
console.warn('Tier 2 (REST API) failed:', error);
this.logger.warn('Tier 2 (REST API) failed', { txHash, error });
}
}

// Tier 3: Block scan
if (height) {
console.info(`Tier 2 returned null for ${txHash}, trying block scan at height ${height}`);
this.logger.debug('Tier 2 returned null, trying block scan', { txHash, height });
const maxBlocks = 30;
try {
const comet = await connectComet(this.rpcUrl);
Expand All @@ -167,7 +171,7 @@ export class Blockchain implements IBlockchain {
const results = await comet.blockResults(h);
const txData = results.results[i];
if (!txData) {
console.warn(`Block results missing entry at index ${i} for height ${h}`);
this.logger.warn('Block results missing entry', { txHash, height: h, index: i });
return null;
}
return {
Expand All @@ -182,16 +186,16 @@ export class Blockchain implements IBlockchain {
}
}
} catch (blockError) {
console.warn(`Block scan error at height ${h}:`, blockError);
this.logger.warn('Block scan error', { txHash, height: h, error: blockError });
continue;
}
}
} catch (error) {
console.warn('Tier 3 (block scan) failed to connect:', error);
this.logger.warn('Tier 3 (block scan) failed to connect', { txHash, error });
}
}

console.warn(`All tiers failed to find transaction ${txHash}`);
this.logger.warn('All tiers failed to find transaction', { txHash });
return null;
}
}
2 changes: 1 addition & 1 deletion apps/middleman-workflows/src/lib/dal/DAL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default class DAL {
this.provider = new Provider(dbClient, logger)
this.importSupplierAttempts = new ImportSupplierAttempts(dbClient, logger)
this.supplierChanges = new SupplierChanges(dbClient, logger)
this.watchdog = new Watchdog(dbClient, logger.child({ context: 'Watchdog' }))
this.watchdog = new Watchdog(dbClient, logger.getChild('Watchdog'))
this.notifications = new Notifications(dbClient, logger)
}

Expand Down
4 changes: 2 additions & 2 deletions apps/middleman-workflows/src/lib/dal/applicationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ export default class ApplicationSettings {
async update(data: Partial<{ pocketRpcUrl: string; pocketApiUrl: string }>) {
const first = await this.getFirst()
if (!first) {
this.logger.warn({ data }, 'Cannot update settings — no settings row exists')
this.logger.warn('Cannot update settings — no settings row exists', { data })
return
}
await this.dbClient.db
.update(schema.applicationSettingsTable)
.set(data)
.where(eq(schema.applicationSettingsTable.id, first.id))
this.logger.info({ data }, 'Updated application settings')
this.logger.info('Updated application settings', { data })
}

async isBootstrapped(): Promise<boolean> {
Expand Down
Loading
Loading