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
5 changes: 5 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ LND_MACAROON_BASE64=''
LND_GRPC_HOST='127.0.0.1:10009'
BOT_TOKEN=''

# When 'true' the bot stops trading: it never connects to the LN node,
# schedules no jobs, and replies to every message with a
# service-discontinued notice pointing users to Mostro
SUNSET_MODE='false'

# The max fee amount that the Bot will charge to the seller, 0.002 = 0.2%
MAX_FEE=0
# Percentage of the total amount that the bot will take as a fee, 0.7 = 70%
Expand Down
6 changes: 6 additions & 0 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ import { startMonitoring } from './monitoring';
},
};
const bot = await start(String(process.env.BOT_TOKEN), options);
if (process.env.SUNSET_MODE === 'true') {
logger.notice(
'SUNSET_MODE is on: skipping LN node connection and monitoring.',
);
return;
}
// Wait 1 seconds before try to resubscribe hold invoices
await delay(1000);
await resubscribeInvoices(bot);
Expand Down
24 changes: 23 additions & 1 deletion bot/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,22 @@ const COMMIT_HASH = (() => {
}
})();

const isSunsetMode = (): boolean => process.env.SUNSET_MODE === 'true';

// When SUNSET_MODE is on the bot no longer trades: every incoming update is
// answered with a service-discontinued notice in the user's language
const sunsetMiddleware = async (ctx: MainContext): Promise<void> => {
try {
if (ctx.from === undefined) return;
const user = await User.findOne({ tg_id: ctx.from.id.toString() });
const language = user?.lang || ctx.from.language_code || 'en';
ctx.i18n.locale(language);
await ctx.reply(ctx.i18n.t('sunset'), { disable_web_page_preview: true });
} catch (error) {
logger.error(error);
}
};

const initialize = (
botToken: string,
options: Partial<Telegraf.Options<CommunityContext>>,
Expand All @@ -194,6 +210,12 @@ const initialize = (
bot.use(session());
bot.use(limit());
bot.use(i18n.middleware());

if (isSunsetMode()) {
bot.use(sunsetMiddleware);
return bot;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

bot.use(stageMiddleware());
bot.use(commandArgsMiddleware());

Expand Down Expand Up @@ -1199,4 +1221,4 @@ const start = async (
return bot;
};

export { initialize, start };
export { initialize, start, sunsetMiddleware };
9 changes: 7 additions & 2 deletions ln/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,14 @@ if (
macaroon = fs.readFileSync(macaroonPath).toString('base64');
}

// Enforcing presence of LND_GRPC_HOST environment variable
// Enforcing presence of LND_GRPC_HOST environment variable,
// not needed in sunset mode because the node is never called
const socket = process.env.LND_GRPC_HOST;
if (!socket && process.env.NODE_ENV !== 'test') {
if (
!socket &&
process.env.NODE_ENV !== 'test' &&
process.env.SUNSET_MODE !== 'true'
Comment on lines +38 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid constructing the LND client in sunset mode

When SUNSET_MODE='true' is deployed without LND_GRPC_HOST as advertised, this guard is bypassed, but the module immediately calls authenticatedLndGrpc({ socket: undefined, ... }), which rejects the missing socket/credentials. Because app.ts and bot/start.ts import ../ln before their sunset checks run, the process fails during module loading and never launches the sunset bot; defer importing/constructing the LND client entirely in this mode.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verified empirically against the pinned lightning@10.25.0: authenticatedLndGrpc({ cert: undefined, macaroon: undefined, socket: undefined }) does not throw at construction — the gRPC client is lazy and only dials on the first RPC call, which never happens in sunset mode (no jobs scheduled, no trading handlers registered, resubscribeInvoices skipped).

Reproduction:

$ env -i SUNSET_MODE=true node -e "require('./dist/ln/connect.js'); require('./dist/ln/index.js'); console.log('loaded OK')"
loaded OK

The existing test suite already relies on this same behavior: tests import ../ln with cert/macaroon/socket all undefined and pass. No change needed for this version; if the lightning dependency is ever upgraded to a version that validates the socket eagerly, this would need the lazy-import treatment.

) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
throw new Error('You must provide a LND_GRPC_HOST environment variable');
}

Expand Down
11 changes: 11 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,17 @@ upgraded_to_supergroup: The chat was upgraded to 'supergroup' and the ID has cha
community_deleted: This community was deleted due to inactivity. I have unlinked you from it, try to create the order again
dispute_too_soon: You can't start a dispute too soon, be patient and wait a few minutes for your counterparty to reply
maintenance: 🚨 Bot in maintenance, please try again later 🚨
sunset: |
⚠️ This bot is no longer in service

Thank you for being part of this community. The bot no longer processes orders or payments.

📄 Official announcement: https://x.com/negrunch/status/2086899005355704703

🥜 We invite you to keep trading Bitcoin P2P without KYC using Mostro:

If you want to use Mostro: https://mostro.network
If you want to run your own Mostro node: https://mostro.community/

# START modules/community
community_admin: |
Expand Down
11 changes: 11 additions & 0 deletions locales/es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,17 @@ upgraded_to_supergroup: El chat fue actualizado a 'supergrupo' y el Id ha cambia
community_deleted: Esta comunidad fue eliminada por inactividad, te he desvinculado de ella, intenta crear la orden nuevamente
dispute_too_soon: No puedes iniciar una disputa tan pronto, ten paciencia y espera unos minutos a que tu contraparte responda
maintenance: 🚨 Bot en mantenimiento, inténtalo de nuevo más tarde 🚨
sunset: |
⚠️ Este bot ha dejado de prestar servicios

Gracias por haber sido parte de esta comunidad. El bot ya no procesa órdenes ni pagos.

📄 Comunicado oficial: https://x.com/negrunch/status/2086896990256799795

🥜 Te invitamos a seguir comerciando Bitcoin de forma P2P y sin KYC con Mostro:

Si quieres usar Mostro: https://mostro.network
Si quieres correr tu propio nodo Mostro: https://mostro.community/

# START modules/community
community_admin: |
Expand Down
105 changes: 105 additions & 0 deletions tests/bot/sunset.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import path from 'path';
import fs from 'fs';

import { sunsetMiddleware } from '../../bot/start';
import { User } from '../../models';

const sinon = require('sinon');
const { expect } = require('chai');

const SPANISH_ANNOUNCEMENT =
'https://x.com/negrunch/status/2086896990256799795';
const ENGLISH_ANNOUNCEMENT =
'https://x.com/negrunch/status/2086899005355704703';

const makeCtx = (from: any) => {
const locales: string[] = [];
return {
from,
i18n: {
locale: (lang: string) => locales.push(lang),
t: (key: string) => `translated:${key}`,
},
reply: sinon.stub().resolves(),
locales,
};
};

describe('sunset mode', () => {
let sandbox: any;

beforeEach(() => {
sandbox = sinon.createSandbox();
});

afterEach(() => {
sandbox.restore();
});

describe('sunsetMiddleware', () => {
it('replies with the sunset notice in the language stored for the user', async () => {
sandbox.stub(User, 'findOne').resolves({ lang: 'es' });
const ctx = makeCtx({ id: 1, language_code: 'de' });

await sunsetMiddleware(ctx as any);

expect(ctx.locales).to.deep.equal(['es']);
expect(ctx.reply.calledOnce).to.equal(true);
expect(ctx.reply.firstCall.args[0]).to.equal('translated:sunset');
});

it('falls back to the Telegram client language when the user is unknown', async () => {
sandbox.stub(User, 'findOne').resolves(null);
const ctx = makeCtx({ id: 1, language_code: 'fr' });

await sunsetMiddleware(ctx as any);

expect(ctx.locales).to.deep.equal(['fr']);
expect(ctx.reply.calledOnce).to.equal(true);
});

it('falls back to English when no language can be determined', async () => {
sandbox.stub(User, 'findOne').resolves(null);
const ctx = makeCtx({ id: 1 });

await sunsetMiddleware(ctx as any);

expect(ctx.locales).to.deep.equal(['en']);
expect(ctx.reply.calledOnce).to.equal(true);
});

it('does not reply when the update has no sender', async () => {
const findOne = sandbox.stub(User, 'findOne');
const ctx = makeCtx(undefined);

await sunsetMiddleware(ctx as any);

expect(findOne.called).to.equal(false);
expect(ctx.reply.called).to.equal(false);
});
});

describe('sunset locale messages', () => {
const readLocale = (lang: string) =>
fs.readFileSync(
path.join(__dirname, '../../../locales', `${lang}.yaml`),
'utf8',
);

it('spanish message links to the spanish announcement and Mostro', () => {
const es = readLocale('es');
expect(es).to.include('sunset:');
expect(es).to.include(SPANISH_ANNOUNCEMENT);
expect(es).to.include('https://mostro.network');
expect(es).to.include('https://mostro.community');
});

it('english message links to the english announcement and Mostro', () => {
const en = readLocale('en');
expect(en).to.include('sunset:');
expect(en).to.include(ENGLISH_ANNOUNCEMENT);
expect(en).to.include('https://mostro.network');
expect(en).to.include('https://mostro.community');
});
});
});