Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
3 changes: 1 addition & 2 deletions config/airseeker.example.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"sponsorWalletMnemonic": "${SPONSOR_WALLET_MNEMONIC}",
"chains": {
"31337": {
"alias": "hardhat",
Expand Down Expand Up @@ -30,7 +29,7 @@
"signedDataFetchInterval": 10,
"signedApiUrls": [],
"useSignedApiUrlsFromContract": true,
"walletDerivationScheme": { "type": "managed" },
"walletDerivationScheme": { "type": "managed", "sponsorWalletMnemonic": "${SPONSOR_WALLET_MNEMONIC}" },
"stage": "dev",
"version": "4.3.0"
}
43 changes: 33 additions & 10 deletions config/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,6 @@ Airseeker needs two configuration files, `airseeker.json` and `secrets.env`. All
are referring to values from secrets and are interpolated inside the `airseeker.json` at runtime. You are advised to put
sensitive information inside secrets.

### `sponsorWalletMnemonic`

The mnemonic of the wallet used to derive sponsor wallets. Sponsor wallets are derived for each data feed separately. It
is recommended to interpolate this value from secrets. For example:

```jsonc
// The mnemonic is interpolated from the "SPONSOR_WALLET_MNEMONIC" secret.
"sponsorWalletMnemonic": "${SPONSOR_WALLET_MNEMONIC}",
```

### `chains`

A record of chain configurations. The record key is the chain ID. For example:
Expand Down Expand Up @@ -286,6 +276,39 @@ The following options are available:
agnostic to update parameters, and the same wallet is used when the dAPI is upgraded/downgraded.
- `fixed` - Derives the wallet from the specified `sponsorAddress`. All data feed updates will be done via this single
wallet.
- `keycard` - Uses a Keycard hardware wallet to sign transactions.

#### `sponsorWalletMnemonic`

The mnemonic of the wallet used to derive sponsor wallets. Sponsor wallets are derived for each data feed separately. It
is recommended to interpolate this value from secrets. Required for `self-funded`, `managed`, and `fixed` types. For
example:

```jsonc
// The mnemonic is interpolated from the "SPONSOR_WALLET_MNEMONIC" secret.
"walletDerivationScheme": { "type": "managed", "sponsorWalletMnemonic": "${SPONSOR_WALLET_MNEMONIC}" },
```

#### `sponsorAddress`

The address used to derive the sponsor wallet. Required only when `type` is set to `fixed`. All data feed updates will
use a wallet derived from this address. For example:

```jsonc
"walletDerivationScheme": {
"type": "fixed",
"sponsorAddress": "0x1234567890123456789012345678901234567890",
"sponsorWalletMnemonic": "${SPONSOR_WALLET_MNEMONIC}"
},
```

#### `pin`

The PIN for the Keycard hardware wallet. Required only when `type` is set to `keycard`. For example:

```jsonc
"walletDerivationScheme": { "type": "keycard", "pin": "${KEYCARD_PIN}" },

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding following line to secrets.example.env can be considered

KEYCARD_PIN=000000

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally didn't include this environment variable (afbe354) because it isn't included in the airseeker.example.json template.

```

### `stage`

Expand Down
3 changes: 1 addition & 2 deletions local-test-configuration/airseeker/airseeker.example.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"sponsorWalletMnemonic": "${SPONSOR_WALLET_MNEMONIC}",
"chains": {
"31337": {
"alias": "hardhat",
Expand Down Expand Up @@ -33,7 +32,7 @@
"signedDataFetchInterval": 10,
"signedApiUrls": [],
"useSignedApiUrlsFromContract": true,
"walletDerivationScheme": { "type": "managed" },
"walletDerivationScheme": { "type": "managed", "sponsorWalletMnemonic": "${SPONSOR_WALLET_MNEMONIC}" },
"stage": "dev",
"version": "4.3.0"
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"dotenv": "^17.2.3",
"ethers": "^6.16.0",
"immer": "^11.1.3",
"keycard-manager": "^0.9.6",
"lodash": "^4.17.21",
"workerpool": "^9.3.4",
"zod": "^4.3.5"
Expand Down
1,717 changes: 788 additions & 929 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

103 changes: 97 additions & 6 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test('validates example config', () => {
},
{
code: 'custom',
path: ['sponsorWalletMnemonic'],
path: ['walletDerivationScheme', 'sponsorWalletMnemonic'],
message: 'Invalid mnemonic',
},
])
Expand Down Expand Up @@ -244,14 +244,102 @@ describe('chains schema', () => {

describe('walletDerivationScheme schema', () => {
it('parses the walletDerivationScheme as "fixed"', () => {
const walletDerivationScheme = { type: 'fixed', sponsorAddress: '0x0000000000000000000000000000000000000001' };
const walletDerivationScheme = {
type: 'fixed',
sponsorAddress: '0x0000000000000000000000000000000000000001',
sponsorWalletMnemonic: 'test test test test test test test test test test test junk',
};
const parsed = walletDerivationSchemeSchema.parse(walletDerivationScheme);

expect(parsed).toStrictEqual(walletDerivationScheme);
});

it('parses the walletDerivationScheme as "self-funded"', () => {
const walletDerivationScheme = {
type: 'self-funded',
sponsorWalletMnemonic: 'test test test test test test test test test test test junk',
};
const parsed = walletDerivationSchemeSchema.parse(walletDerivationScheme);

expect(parsed).toStrictEqual(walletDerivationScheme);
});

it('parses the walletDerivationScheme as "managed"', () => {
const walletDerivationScheme = {
type: 'managed',
sponsorWalletMnemonic: 'test test test test test test test test test test test junk',
};
const parsed = walletDerivationSchemeSchema.parse(walletDerivationScheme);

expect(parsed).toStrictEqual(walletDerivationScheme);
});

it('parses the walletDerivationScheme as "keycard"', () => {
const walletDerivationScheme = {
type: 'keycard',
pin: '123456',
};
const parsed = walletDerivationSchemeSchema.parse(walletDerivationScheme);

expect(parsed).toStrictEqual(walletDerivationScheme);
});

it('sponsorAddress is present when walletDerivationScheme is "fixed"', () => {
const walletDerivationScheme = { type: 'fixed' };
it('throws when pin is missing for keycard type', () => {
const walletDerivationScheme = {
type: 'keycard',
};

expect(() => walletDerivationSchemeSchema.parse(walletDerivationScheme)).toThrow(
new ZodError([
{
expected: 'string',
code: 'invalid_type',
path: ['pin'],
message: 'Invalid input: expected string, received undefined',
},
])
);
});

it('throws when sponsorWalletMnemonic is missing for self-funded type', () => {
const walletDerivationScheme = {
type: 'self-funded',
};

expect(() => walletDerivationSchemeSchema.parse(walletDerivationScheme)).toThrow(
new ZodError([
{
expected: 'string',
code: 'invalid_type',
path: ['sponsorWalletMnemonic'],
message: 'Invalid input: expected string, received undefined',
},
])
);
});

it('throws when sponsorWalletMnemonic is missing for managed type', () => {
const walletDerivationScheme = {
type: 'managed',
};

expect(() => walletDerivationSchemeSchema.parse(walletDerivationScheme)).toThrow(
new ZodError([
{
expected: 'string',
code: 'invalid_type',
path: ['sponsorWalletMnemonic'],
message: 'Invalid input: expected string, received undefined',
},
])
);
});

it('throws when sponsorAddress is missing for fixed type', () => {
const walletDerivationScheme = {
type: 'fixed',
sponsorWalletMnemonic: 'test test test test test test test test test test test junk',
};

expect(() => walletDerivationSchemeSchema.parse(walletDerivationScheme)).toThrow(
new ZodError([
Expand All @@ -265,8 +353,11 @@ describe('walletDerivationScheme schema', () => {
);
});

it('sponsorAddress must be a valid EVM address when walletDerivationScheme is "fixed"', () => {
const walletDerivationScheme = { type: 'fixed' };
it('throws when sponsorAddress is not a valid EVM address for fixed type', () => {
const walletDerivationScheme = {
type: 'fixed',
sponsorWalletMnemonic: 'test test test test test test test test test test test junk',
};

expect(() => walletDerivationSchemeSchema.parse({ ...walletDerivationScheme, sponsorAddress: '' })).toThrow(
new ZodError([
Expand Down
16 changes: 12 additions & 4 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,19 @@ export const individualBeaconUpdateSettingsSchema = z

export type IndividualBeaconUpdateSettings = z.infer<typeof individualBeaconUpdateSettingsSchema>;

export const sponsorWalletMnemonicSchema = z
.string()
.refine((mnemonic) => ethers.Mnemonic.isValidMnemonic(mnemonic), 'Invalid mnemonic');

export const walletDerivationSchemeSchema = z.discriminatedUnion('type', [
z.strictObject({ type: z.literal('self-funded') }),
z.strictObject({ type: z.literal('managed') }),
z.strictObject({ type: z.literal('fixed'), sponsorAddress: addressSchema }),
z.strictObject({ type: z.literal('self-funded'), sponsorWalletMnemonic: sponsorWalletMnemonicSchema }),
z.strictObject({ type: z.literal('managed'), sponsorWalletMnemonic: sponsorWalletMnemonicSchema }),
z.strictObject({
type: z.literal('fixed'),
sponsorAddress: addressSchema,
sponsorWalletMnemonic: sponsorWalletMnemonicSchema,
}),
z.strictObject({ type: z.literal('keycard'), pin: z.string() }),
]);

export type WalletDerivationScheme = z.infer<typeof walletDerivationSchemeSchema>;
Expand All @@ -189,7 +198,6 @@ export const configSchema = z.strictObject({
individualBeaconUpdateSettings: individualBeaconUpdateSettingsSchema,
signedApiUrls: z.array(z.url()),
signedDataFetchInterval: z.number().positive(),
sponsorWalletMnemonic: z.string().refine((mnemonic) => ethers.Mnemonic.isValidMnemonic(mnemonic), 'Invalid mnemonic'),
stage: z
.string()
.regex(/^[\da-z-]{1,256}$/, 'Only lowercase letters, numbers and hyphens are allowed (max 256 characters)'),
Expand Down
16 changes: 11 additions & 5 deletions src/heartbeat-loop/heartbeat-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,20 @@ export interface HeartbeatPayload {
}

export const logHeartbeat = async () => {
const {
config: { stage, version, walletDerivationScheme },
deploymentTimestamp,
} = getState();

if (walletDerivationScheme.type === 'keycard') {
logger.debug('Heartbeat logging is not supported for Keycard wallet derivation scheme. Skipping heartbeat log.');
return;
}

logger.debug('Creating heartbeat log.');

const rawConfig = loadRawConfig(); // We want to log the raw config, not the one with interpolated secrets.
const configHash = createSha256Hash(serializePlainObject(rawConfig));
const {
config: { sponsorWalletMnemonic, stage, version },
deploymentTimestamp,
} = getState();

logger.debug('Creating heartbeat payload.');
const currentTimestamp = Math.floor(Date.now() / 1000).toString();
Expand All @@ -46,7 +52,7 @@ export const logHeartbeat = async () => {
version,
deploymentTimestamp,
};
const sponsorWallet = ethers.Wallet.fromPhrase(sponsorWalletMnemonic);
const sponsorWallet = ethers.Wallet.fromPhrase(walletDerivationScheme.sponsorWalletMnemonic);
const signature = await signHeartbeat(sponsorWallet, unsignedHeartbeatPayload);
const heartbeatPayload: HeartbeatPayload = { ...unsignedHeartbeatPayload, signature };

Expand Down
24 changes: 21 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,39 @@ import { startDataFetcherLoop } from './data-fetcher-loop';
import { initializeVerifierPool } from './data-fetcher-loop/signed-data-verifier-pool';
import { loadEnv } from './env/env';
import { startHeartbeatLoop } from './heartbeat-loop';
import { initializeKeycardWallet, terminateKeycardWallet } from './keycard';
import { logger } from './logger';
import { setInitialState } from './state';
import { startUpdateFeedsLoops } from './update-feeds-loops';

function main() {
const shutdown = (signal: string) => {
logger.info(`Received ${signal}, shutting down.`);
terminateKeycardWallet();
process.exit(0);
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

const main = async () => {
logger.info('Loading configuration and setting initial state.');
const config = loadConfig();
setInitialState(config);
initializeVerifierPool();

if (config.walletDerivationScheme.type === 'keycard') {
logger.info('Initializing keycard wallet.');
await initializeKeycardWallet();
}

logger.info('Starting Airseeker loops.');
startDataFetcherLoop();
void startUpdateFeedsLoops();
const env = loadEnv();
if (env.LOG_HEARTBEAT) startHeartbeatLoop();
}
};

main();
main().catch((error: Error) => {
logger.error('Failed to start Airseeker.', error);
process.exit(1);
});
35 changes: 35 additions & 0 deletions src/keycard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { KeycardWallet } from 'keycard-manager';

import { loadConfig } from './config';

let keycardWallet: KeycardWallet | undefined;

export const initializeKeycardWallet = async () => {
if (keycardWallet) {
throw new Error('Keycard wallet is already initialized.');
}

const { walletDerivationScheme } = loadConfig();
if (walletDerivationScheme.type !== 'keycard') {
throw new Error('Wallet derivation scheme is not keycard. This function should not be called.');
}

// Dynamic import to avoid loading keycard-manager when not needed
const { getKeycardWallet: createKeycardWallet } = await import('keycard-manager');
keycardWallet = await createKeycardWallet(undefined, walletDerivationScheme.pin);
};

export const terminateKeycardWallet = () => {
if (!keycardWallet) {
throw new Error('Keycard wallet is not initialized.');
}

keycardWallet.disconnect();
keycardWallet = undefined;
};

export const getKeycardWallet = () => {
if (keycardWallet) return keycardWallet;

throw new Error('Keycard wallet is not initialized.');
};
1 change: 1 addition & 0 deletions src/update-feeds-loops/get-updatable-feeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const adjustHeartbeatInterval = (heartbeatInterval: bigint, heartbeatIntervalMod
heartbeatIntervalModifier,
});
}
// eslint-disable-next-line unicorn/prefer-math-min-max
return calculatedHeartbeatInterval < 0n ? 0n : calculatedHeartbeatInterval;
};

Expand Down
4 changes: 2 additions & 2 deletions src/update-feeds-loops/pending-transaction-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const updatePendingTransactionsInfo = (
feedsToUpdate: UpdatableDataFeed[]
) => {
const {
config: { sponsorWalletMnemonic, walletDerivationScheme },
config: { walletDerivationScheme },
pendingTransactionsInfo: pendingTransactionsInfo,
} = getState();

Expand All @@ -55,7 +55,7 @@ export const updatePendingTransactionsInfo = (
continue;
}

const sponsorWalletAddress = getDerivedSponsorWallet(sponsorWalletMnemonic, {
const sponsorWalletAddress = getDerivedSponsorWallet({
...walletDerivationScheme,
dapiNameOrDataFeedId: dapiName ?? dataFeedId,
updateParameters,
Expand Down
Loading