40 minutos de lectura
Resumen
Es difícil desarrollar servicios de suscripción en Solana a una limitación estructural. Una cuenta de tokens SPL tiene exactamente una delegar espacio; así pues, cuando un monedero aprueba un cargo recurrente, esa única aprobación ocupa ese espacio. Si se aprueba un segundo cargo, este sobrescribe silenciosamente al primero. Un monedero puede autorizar un cargo recurrente, pero no dos: el mismo usuario no puede pagar dos servicios diferentes desde la misma cuenta de tokens.
El programa de suscripciones Solana elimina ese límite. En lugar de ceder la plaza de delegado a un único comerciante, la asigna a una cuenta controlada por el programa y canaliza cada cargo a través de un registro independiente para cada suscripción. De este modo, una misma cartera puede gestionar tantos pagos recurrentes independientes como desee, cada uno con su propio importe, calendario y fecha de vencimiento, y todos ellos con la misma seguridad que una aprobación de token habitual.
Esta guía te explica paso a paso cómo crear un servicio de suscripción operativo en Solana , utilizando scripts independientes en TypeScript. Configurarás el flow completo entre el comerciante y el suscriptor, en el que el suscriptor se da de alta una sola vez y tú cobras en USDC de forma periódica sin necesidad de que el suscriptor autorice cada cargo.
Además, te encargarás de los dos aspectos que hacen que una integración funcione de verdad: controlar el acceso en función de si un monedero está realmente suscrito y reaccionar en tiempo real ante las suscripciones y bajas mediante Quicknode Streams.
- Crea un ciclo de vida completo de pagos recurrentes en Solana con el programa Solana Subscriptions» (v0.4.0)
- Un comerciante publica un plan de 1 USDC por hora, un suscriptor se inscribe una vez y el comerciante cobra cada período de facturación
- Comprueba el estado de la suscripción de forma correcta verificando
expiresAtTsen función del tiempo en cadena, y controlar el acceso a un endpoint exclusivo para miembros endpoint mediante un nonce firmado - Filtrar suscripciones: transacciones del programa con unStreams Quicknode Streams
- Cada script es independiente y se ejecuta en un endpoint Solana Quicknode endpoint
solana
Lo que necesitarás
- Una Quicknode con un endpoint Solana .
- Devnet SOL para los monederos de los comerciantes y los suscriptores, procedente del Solana o del grifoSolana Devnet Quicknode.
- USDC de Devnet para los pagos de la suscripción, procedente del «faucet» de Circle (red «Solana »).
- Conocimientos básicos de TypeScript y Solana : direcciones derivadas de programas (PDA), tokens SPL y cuentas de tokens asociadas (ATA).
En la sección de configuración, instalarás los siguientes paquetes:
| Paquete | Versión | Se utiliza para |
|---|---|---|
solana | 0.4.0 | Cliente del programa de suscripciones: instrucciones, derivación de PDA, programas de obtención de datos de cuentas y el suscripcionesPrograma() Complemento Kit |
solana | 6.10.0 | SDK básico Solana : direcciones, transacciones y RPC |
solana | 0.12.1 | Paquetes de complementos para conexiones RPC (solanaDevnetRpc, solanaRpcConnection) |
solana | 0.12.1 | Complemento de Kit que carga un firmante desde un archivo de par de claves (signerFromFile) |
solana | 0.13.0 | Ayudas sobre los tokens de SPL: cuentas de tokens asociadas y la dirección del programa de tokens |
expreso | ^5.1.0 | Servidor HTTP para el endpoint de control de acceso |
tsx, Typescript, @types/express, @types/node | últimas noticias | Dependencias de desarrollo: ejecutar y comprobar el tipo de los scripts de TypeScript |
@solana/subscriptions@0.4.0 declara un rango de pares de @solana/kit@^6.4.0, but kit 7.0.0 is published and the current releases of every kit-adjacent package have already moved to 7.x. A bare npm install fails with ERESOLVE, and pinning only solana is not enough: solana, solana, y solana each need a kit-6-compatible version too. The install command below pins all four.
¿Cómo funciona el programa de suscripciones?
El programa de suscripciones asigna a cada monedero un delegado controlado por el programa (un PDA de autoridad de suscripción) que ocupa la única ranura de delegado de la cuenta de tokens y, a continuación, canaliza cada cargo a través de los PDA de delegación establecidos en cada acuerdo. Un monedero puede gestionar un número ilimitado de pagos recurrentes independientes, cada uno con su propio importe, periodicidad y fecha de vencimiento, sin necesidad de firma para cada cargo.
La facturación periódica requiere numerosas autorizaciones independientes y de larga duración por monedero, pero el programa de tokens básico solo asigna a cada cuenta de tokens esa única ranura, por lo que no puede gestionarlas.
Solución del programa «Suscripciones»: para cada (usuario, mint) par, crea un Autoridad de suscripción (SA), una PDA que se convierte en el único delegado de la cuenta de tokens del usuario. La SA nunca mueve tokens por iniciativa propia. Solo realiza transferencias cuando una delegación PDA autoriza esa transferencia concreta: esta cantidad, a este destino, en este periodo de facturación. Una plaza de delegación se ramifica ahora en un número ilimitado de delegaciones independientes:
User token account
└─ delegate = Subscription Authority PDA (u64::MAX approval)
├─ Fixed Delegation PDA (prepaid allowance)
├─ Recurring Delegation PDA (per-period allowance)
├─ Subscription Delegation PDA (linked to a merchant Plan)
└─ ...as many as you want
Una transferencia solo se lleva a cabo con éxito cuando el SA es el delegado autorizado y un PDA de delegación válido indica que esa transferencia concreta está permitida en ese momento.
El programa admite tres modelos de delegación:
| Modelo | Analogía | ¿Quién tira? | Semántica de los límites |
|---|---|---|---|
| Corregido | Tarjeta regalo prepagada | El delegado | Un saldo acumulado que va disminuyendo y nunca se repone |
| Recurrente | Límite de gasto mensual | El delegado | Hasta importePorPeríodo por periodo (en segundos); se reinicia cada periodo |
| Plan de suscripción | El plan para comerciantes que elijas | Titular del plan o usuarios con acceso autorizado | Términos extraídos de un archivo compartido Plan; hasta cantidad por horas por período; se reinicia cada período |
Esta guía describe el modelo de planes de suscripción de principio a fin, ya que se ajusta al caso de uso de los comerciantes: un plan publicado, muchos suscriptores y la posibilidad de cancelar y reanudar la suscripción.
Algunos datos más que conviene conocer antes de empezar a construir:
- El programa es compatible tanto con el programa SPL Token como con Token-2022, incluyendo acuñaciones con enlaces de transferencia y comisiones por transferencia.
- La cuota de alquiler de cada cuenta que crea el programa (SA, plan, suscripción) se puede recuperar cuando se cierra la cuenta.
Cada cambio de estado también genera un evento en la cadena de bloques mediante una auto-CPI (invocación entre programas), que es la forma en que la Streams envía posteriormente notificaciones en tiempo real. La versión 0.4.0 define siete tipos de eventos:
| Evento | Se emite cuando |
|---|---|
Suscripción creada | Un abonado se da de alta en un plan |
Suscripción cancelada | Se cancela una suscripción, lo que determina la fecha de caducidad de su acceso |
Transferencia de suscripción | Se ha cobrado el pago correspondiente al plan de suscripción |
Transferencia fija | Se ha realizado una transferencia de delegación fija (prepagada) |
Transferencia periódica | Se ha procesado una transferencia de delegación recurrente |
Se ha reanudado la suscripción | Se reanuda una suscripción cancelada |
Plan actualizado | Cambios en el estado, la fecha de finalización o los responsables de un plan |
Posteriormente, Streams filtra las transacciones que contienen estos eventos; Suscripción creada y Suscripción cancelada son los que activarás y examinarás.
Configurar el proyecto
Crea el proyecto e instala las dependencias:
mkdir solana-subscriptions && cd solana-subscriptions
npm init -y
npm pkg set type=module
npm install @solana/subscriptions@0.4.0 @solana/kit@6.10.0 @solana/kit-plugin-rpc@0.12.1 @solana/kit-plugin-signer@0.12.1 @solana-program/token@0.13.0 express@^5.1.0
npm install -D tsx typescript @types/express @types/node
Cada script de esta guía es totalmente autónomo: compila su propio cliente Kit, incorpora sus propias constantes y vuelve a derivar los PDA que necesita a partir de las direcciones que se le pasan en la línea de comandos. Cada uno de ellos se ejecuta directamente con tsx, pasando por el .env archivo con el nativo de Node --env-file bandera:
npx tsx --env-file=.env 01-authority.ts
Crear y recargar las carteras de demostración
La aplicación utiliza dos estándares solana archivos de cartera:
- Comerciante (publica el plan y cobra los pagos)
- Suscriptor (se da de alta y se le factura)
mkdir -p .keys
solana-keygen new -o .keys/merchant.json --no-bip39-passphrase
solana-keygen new -o .keys/subscriber.json --no-bip39-passphrase
Estas son claves de devnet de un solo uso. Añádelas a tu .gitignore y nunca las reutilices en mainnet las respaldes con activos reales.
Ambas carteras necesitan SOL de la red de desarrollo para pagar las comisiones y el alquiler. Solicítalo en faucet.solana.com o de Quicknode Solana
Faucet de Devnet para cada dirección (solana -k .keys/merchant.json muestra una dirección).
El suscriptor necesita USDC de DevNet (el saldo contra el que se factura el plan). Consíguelo a través del «faucet» de Circle: selecciona la redSolana » y pega la dirección del suscriptor. La transferencia del «faucet» también crea la cuenta de tokens USDC del suscriptor, necesaria para la configuración de la Autoridad de Suscripción.
El comerciante necesita una cuenta de tokens USDC para recibir los pagos. La forma más sencilla de crearla es enviar a la dirección del comerciante una pequeña cantidad de USDC desde el mismo «faucet» de Circle.
Devnet USDC utiliza la acuñación de Circle 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU (seis decimales), que es la constante de «mint» que se incorpora en cada script.
Configurar el entorno
Crear un .env archivo con tu endpoint Solana Quicknode endpoint cópialo de la página endpoint en el Quicknode). DIRECCIÓN_DEL_COMERCIANTE Esto cobra importancia más adelante en la guía, cuando desbloquees contenidos:
# Your Quicknode Solana devnet endpoint URL (https://...)
QUICKNODE_ENDPOINT=
# Access-gating server (gating/server.ts)
GATE_PORT=3000
# The merchant/plan-owner address the gating server checks subscriptions against
# (printed by 02-create-plan.ts).
MERCHANT_ADDRESS=
Crear una autoridad de suscripción
Para que un monedero pueda contener cualquier delegación o suscripción, necesita su Autoridad de Suscripción (SA) correspondiente a la emisión que se va a utilizar. Existe una SA por cada (usuario, mint) par, debe existir antes que cualquier otra cosa, y su inicialización realiza dos acciones en una sola transacción: crea el PDA de SA y lo aprueba como delegado de la cuenta del token con u64::MAX. Esa autorización ilimitada es segura porque el SA está controlado por un programa y solo transfiere tokens cuando un PDA de delegación autoriza una transferencia concreta.
Este primer script también muestra el patrón de cliente que utilizan todos los scripts: createClient() con el signerFromFile Complemento Kit, el solanaDevnetRpc el complemento esté configurado para tu endpoint, y el suscripcionesPrograma() complemento que añade client.subscriptions.instructions.* y client.subscriptions.queries.*.
/**
* Step 1 - Subscription Authority: create the subscriber's Subscription
* Authority PDA for the USDC mint.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
TOKEN_PROGRAM_ADDRESS,
findAssociatedTokenPda,
} from '@solana-program/token';
import { subscriptionsProgram } from '@solana/subscriptions';
// Devnet USDC (Circle), 6 decimals. Faucet: https://faucet.circle.com
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
// Build the Kit client: subscriber wallet from a file, pointed at your
// Quicknode devnet endpoint, with the subscriptions plugin.
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const user = subscriber.identity.address;
const { initialized, pda } =
await subscriber.subscriptions.queries.isSubscriptionAuthorityInitialized(
user,
USDC_MINT,
);
console.log(`Subscription Authority PDA: ${pda}`);
console.log(` https://explorer.solana.com/address/${pda}?cluster=devnet`);
if (initialized) {
console.log('Already initialized - nothing to do.');
} else {
const [userAta] = await findAssociatedTokenPda({
owner: user,
mint: USDC_MINT,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
console.log('Initializing (creates the PDA + approves it as token delegate)...');
await subscriber.subscriptions.instructions
.initSubscriptionAuthority({
tokenMint: USDC_MINT,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
userAta,
})
.sendTransaction();
console.log('Subscription Authority initialized.');
}
console.log('\nNext: npx tsx --env-file=.env 02-create-plan.ts');
Ejecútalo:
npx tsx --env-file=.env 01-authority.ts
Resultado esperado (abreviado para mayor claridad):
Subscription Authority PDA: EjzPr...iVjz
https://explorer.solana.com/address/EjzP...iVjz?cluster=devnet
Initializing (creates the PDA + approves it as token delegate)...
Subscription Authority initialized.
Next: npx tsx --env-file=.env 02-create-plan.ts
Si abres ahora la cuenta de tokens USDC del suscriptor en el explorador, verás que el SA PDA figura como su delegado. La cuenta del SA tiene un coste de alquiler de aproximadamente 0,00163 SOL, que se devuelve cuando se cierra en la fase de limpieza.
Crear un plan de suscripción
El comerciante publica un plan con las condiciones de facturación que los suscriptores aceptan. El PDA de un plan se calcula a partir de la dirección del comerciante y un número planId, y sus campos se dividen en dos grupos:
- Immutable su creación:
planId,propietario,menta, los términos (cantidad,horas por período,createdAt), ydestinos. Los suscriptores aceptan estas condiciones tal y como están redactadas, por lo que nunca podrán modificarse durante la vigencia de una suscripción ya existente. - Modificable a través de
updatePlan:estado(Activo o en fase de retirada),endTs,extractores, ymetadataUri.
destinos Es una lista de permitidos que puede contener hasta cuatro cuentas de token a las que se pueden enviar pagos; si la lista está vacía, se permite cualquier destino. extractores Es una lista de direcciones autorizadas que incluye hasta cuatro direcciones adicionales con permiso para recopilar datos; el titular del plan siempre está autorizado de forma implícita.
El plan de demostración es de 1 USDC por cada periodo de una hora, y esto es a propósito. Una hora es el periodo mínimo de facturación del programa, y el reloj de la red de desarrollo (devnet) es real, por lo que no hay forma de adelantar el tiempo. Mantener el periodo en el mínimo significa que puedes observar realmente cómo se produce el comportamiento recurrente: ejecuta el script de recopilación, espera una hora, vuelve a ejecutarlo y comprueba cómo se restablece de verdad la asignación por periodo. Un plan de producción utilizaría 24n (diario) o 720n (mensual).
El script se ejecuta como el comerciante. Obtiene la dirección del plan mediante findPlanPda({ propietario, idPlan }), comprueba si ese plan ya existe con fetchMaybePlan (por lo que se puede volver a ejecutar el script sin problema), y si no es así, lo publica llamando a createPlan en el complemento de suscripciones y en el envío de la transacción. Finaliza mostrando la dirección del comerciante que el suscriptor necesitará en el siguiente paso.
/**
* Step 2 - Create a plan (merchant): publish the
* billing terms subscribers will opt into.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
fetchMaybePlan,
findPlanPda,
subscriptionsProgram,
} from '@solana/subscriptions';
// Devnet USDC (Circle), 6 decimals. Plan: 1 USDC (1_000_000 base units) per
// 1-hour period. periodHours = 1 is the program minimum (see header).
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
const PLAN_AMOUNT = 1_000_000n;
const PLAN_PERIOD_HOURS = 1n;
const PLAN_METADATA_URI = 'https://example.com/plan.json';
const merchant = await createClient()
.use(signerFromFile('.keys/merchant.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const owner = merchant.identity.address;
const [planPda] = await findPlanPda({ owner, planId: PLAN_ID });
console.log(`Plan PDA: ${planPda}`);
console.log(` https://explorer.solana.com/address/${planPda}?cluster=devnet`);
const existing = await fetchMaybePlan(merchant.rpc, planPda);
if (existing.exists) {
console.log('Plan already exists - skipping creation.');
} else {
console.log(
`Creating plan: ${Number(PLAN_AMOUNT) / 1e6} USDC every ${PLAN_PERIOD_HOURS} hour(s), no end date...`,
);
await merchant.subscriptions.instructions
.createPlan({
planId: PLAN_ID,
mint: USDC_MINT,
amount: PLAN_AMOUNT,
periodHours: PLAN_PERIOD_HOURS,
endTs: 0n, // no expiry
destinations: [], // empty = any destination allowed
pullers: [], // owner is always an authorized puller
metadataUri: PLAN_METADATA_URI,
})
.sendTransaction();
console.log('Plan created.');
}
// The subscriber just needs to know the merchant address.
console.log(
`\nNext, the subscriber subscribes to this plan:\n` +
` npx tsx --env-file=.env 03-subscribe.ts ${owner}`,
);
Ejecútalo:
npx tsx --env-file=.env 02-create-plan.ts
Resultado esperado:
Plan PDA: 2Tw4...qy5x
https://explorer.solana.com/address/2Tw4...qy5x?cluster=devnet
Creating plan: 1 USDC every 1 hour(s), no end date...
Plan created.
Next, the subscriber subscribes to this plan:
npx tsx --env-file=.env 03-subscribe.ts 7erA...BErg
La dirección del comerciante aparece en la última línea del resultado anterior (7erA...BErg), dentro del impreso 03-subscribe.ts comando. Esa dirección, y no la PDA del plan, es el único dato que necesita el suscriptor para localizar el plan, y es el valor que se pasa a 03-subscribe.ts y todos los guiones posteriores.
Conjunto DIRECCIÓN_DEL_COMERCIANTE en tu .env Guarda ahora este valor en el archivo. Lo necesitarás más adelante, cuando Contenido exclusivo para socios de Gate sobre el estado de la suscripción a este plan.
Ten en cuenta que el Plan PDA 2Tw4...qy5x que aparece en la primera línea (la tuya será diferente). La necesitarás más adelante, en el Streams », para limitar el filtro «Stream» a este plan concreto.
Suscríbete al plan
Ahora el suscriptor da su consentimiento, indicando la dirección del comerciante que se ha impreso en el paso 2. suscribirse crea un delegación de suscripción PDA (semillas ["suscripción", planPda, suscriptor]) que recoge las condiciones del plan en el momento del consentimiento. La instrucción incluye previsto* campos (importe previsto, horas previstas por período, fecha prevista de creación, expectedSubscriptionAuthorityInitId) que debe coincidir con el plan vigente en la cadena de bloques, lo cual constituye una garantía de consentimiento. El suscriptor acepta íntegramente las condiciones publicadas, y cualquier plan modificado o recreado es rechazado.
/**
* Step 3 - Subscribe: the subscriber opts into the merchant's plan.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
if (!process.argv[2]) {
throw new Error(
'Pass the merchant address: npx tsx --env-file=.env 03-subscribe.ts <MERCHANT_ADDRESS>',
);
}
const merchant = address(process.argv[2]);
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const [planPda] = await findPlanPda({ owner: merchant, planId: PLAN_ID });
console.log(`Subscribing ${subscriber.identity.address}`);
console.log(` to plan ${planPda} (merchant ${merchant})...`);
await subscriber.subscriptions.instructions
.subscribe({
merchant,
planId: PLAN_ID,
tokenMint: USDC_MINT,
// expectedAmount / expectedPeriodHours / expectedCreatedAt /
// expectedSubscriptionAuthorityInitId are auto-fetched by the plugin client.
})
.sendTransaction();
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.identity.address,
});
// The transaction has landed, but the RPC node serving the read may not have
// caught up to it yet, so poll briefly instead of failing on the first fetch.
let subscription = await fetchMaybeSubscriptionDelegation(
subscriber.rpc,
subscriptionPda,
);
for (let attempt = 0; !subscription.exists && attempt < 10; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 1_000));
subscription = await fetchMaybeSubscriptionDelegation(
subscriber.rpc,
subscriptionPda,
);
}
if (!subscription.exists) {
throw new Error(
`Subscription ${subscriptionPda} was not visible after 10s. The subscribe ` +
'transaction likely still landed: check the explorer before retrying.',
);
}
console.log('\nSubscribed! Onchain subscription state:');
console.log(` subscription PDA: ${subscriptionPda}`);
console.log(` https://explorer.solana.com/address/${subscriptionPda}?cluster=devnet`);
console.log(` amount/period: ${Number(subscription.data.terms.amount) / 1e6} USDC`);
console.log(` period (hours): ${subscription.data.terms.periodHours}`);
console.log(` expiresAtTs: ${subscription.data.expiresAtTs} (0 = active)`);
console.log(
`\nNext, the merchant collects a payment (pass this subscriber address):\n` +
` npx tsx --env-file=.env 04-collect.ts ${subscriber.identity.address}`,
);
Prueba con la dirección de tu comercio:
npx tsx --env-file=.env 03-subscribe.ts <MERCHANT_ADDRESS>
Resultado esperado:
Subscribing 5khv...1oRqZ
to plan 2Tw4..qy5x (merchant 7erA...SBErg)...
Subscribed! Onchain subscription state:
subscription PDA: F6TfMm1bQknU9dnXDovSBoVvgo3YMSH5HMvaWUMW8Ztf
https://explorer.solana.com/address/F6TfM...W8Ztf?cluster=devnet
amount/period: 1 USDC
period (hours): 1
expiresAtTs: 0 (0 = active)
Next, the merchant collects a payment (pass this subscriber address):
npx tsx --env-file=.env 04-collect.ts 5khv...oRqZ
Esa es la última firma que el suscriptor proporciona para la facturación. A partir de aquí, todo lo que haga el comerciante se realizará en virtud de la delegación.
Cobrar un pago periódico
En cada ciclo de facturación, el titular del plan (o un usuario autorizado incluido en la lista blanca) realiza una llamada a transferSubscription para cobrar al suscriptor el importe previsto en el plan. El programa realiza los cálculos del periodo en la cadena de bloques: en el momento de la transferencia, si el periodo actual ha finalizado, avanza un periodo y se reinicia importeRecaudadoEnElPeríodo a cero y, a continuación, comprueba que ese cargo no supere el límite establecido por período. El límite es por período, no acumulativo, por lo que un comerciante que se salte un ciclo no podrá cobrar el doble más adelante.
El script realiza una consulta y, a continuación, vuelve a intentarlo inmediatamente en el mismo intervalo para comprobar que el límite es real:
/**
* Step 4 - Collect a payment (merchant): pull this billing period's amount
* from the subscriber, then demonstrate that a second pull in the SAME
* period is rejected.
*
* In production this script is what your scheduler (a cron job or serverless
* function on your own infrastructure, holding the puller key) runs every
* billing cycle.
*/
import { address, createClient } from '@solana/kit';
import {
TOKEN_PROGRAM_ADDRESS,
findAssociatedTokenPda,
} from '@solana-program/token';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
findPlanPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
const PLAN_AMOUNT = 1_000_000n; // 1 USDC per period, in base units
if (!process.argv[2]) {
throw new Error(
'Pass the subscriber address: npx tsx --env-file=.env 04-collect.ts <SUBSCRIBER_ADDRESS>',
);
}
const delegator = address(process.argv[2]);
const merchant = await createClient()
.use(signerFromFile('.keys/merchant.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
// The merchant re-derives the plan and subscription PDAs itself.
const [planPda] = await findPlanPda({
owner: merchant.identity.address,
planId: PLAN_ID,
});
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: delegator,
});
const [receiverAta] = await findAssociatedTokenPda({
owner: merchant.identity.address,
mint: USDC_MINT,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
// getTokenAccountBalance already returns a human-readable uiAmountString.
const { value: balanceBefore } = await merchant.rpc
.getTokenAccountBalance(receiverAta)
.send();
console.log(`Merchant USDC before: ${balanceBefore.uiAmountString}`);
console.log(`\nPulling ${Number(PLAN_AMOUNT) / 1e6} USDC for the current billing period...`);
await merchant.subscriptions.instructions
.transferSubscription({
delegator,
planPda,
subscriptionPda,
tokenMint: USDC_MINT,
amount: PLAN_AMOUNT,
receiverAta,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
.sendTransaction();
const { value: balanceAfter } = await merchant.rpc
.getTokenAccountBalance(receiverAta)
.send();
console.log(`Collected! Merchant USDC after: ${balanceAfter.uiAmountString}`);
console.log(` https://explorer.solana.com/address/${receiverAta}?cluster=devnet`);
// Now prove the per-period cap: an immediate second pull must fail, because
// this period's allowance is already fully collected.
console.log('\nAttempting a second pull in the same period (this should FAIL)...');
try {
await merchant.subscriptions.instructions
.transferSubscription({
delegator,
planPda,
subscriptionPda,
tokenMint: USDC_MINT,
amount: PLAN_AMOUNT,
receiverAta,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
.sendTransaction();
console.error('Unexpected: the second pull succeeded - it should have been rejected.');
} catch (error) {
console.log('Rejected, as expected:');
console.log(` ${(error as Error).message.split('\n')[0]}`);
console.log(
'\nThe program caps collection at the plan amount per billing period.\n' +
'The allowance resets when the next period starts - this plan uses a\n' +
'1-hour period precisely so you can re-run this script after an hour\n' +
'and watch the pull succeed again. (Real plans use 24h/720h periods.)',
);
}
Ejecútalo con tu dirección de suscriptor:
npx tsx --env-file=.env 04-collect.ts <SUBSCRIBER_ADDRESS>
Verás que el saldo de USDC del comerciante aumenta en 1 USDC en la primera extracción; a continuación, el segundo intento fallará debido a un error del programa, ya que la asignación de este periodo ya se ha agotado por completo. Se trata de la garantía recurrente, que se aplica en la cadena de bloques: el suscriptor ha autorizado un máximo de 1 USDC por hora, y ni siquiera el comerciante puede superarlo. Espera una hora y vuelve a ejecutar la operación para ver cómo la primera extracción vuelve a tener éxito al comenzar un nuevo periodo.
Automatizar la recopilación en el entorno de producción
transferSubscription no requería ninguna acción por parte del suscriptor. Esto significa que la recogida de datos es un problema de programación por parte del comerciante. En producción, este script de recogida (que recorre tus suscriptores) es exactamente lo que ejecuta el programador que utilizas en cada ciclo de facturación: una tarea cron o una función sin servidor en tu propia infraestructura, que contiene la clave de extracción. Streams que se describe más adelante en esta guía te informa en tiempo real de lo que ha ocurrido en la cadena de bloques (nuevos suscriptores, cancelaciones), mientras que tu programador decide cuándo realizar la extracción.
tuktuk, el servicio Solana que no requiere permisos, parece la opción más obvia para automatizar las descargas sin tener que ejecutar tu propio programador, pero en este caso no funciona tal cual.
transferSubscription exige que su persona que llama ser un firmante de transacciones que sea el propietario del plan o un «puller» incluido en la lista blanca, y un «cranker» sin permisos no puede firmar en ninguno de los dos casos. Para que funcione, es necesario implementar un programa envoltorio ligero cuyo PDA figure en la lista blanca como «puller» del plan y que firme la propia extracción a través de CPI, con tuktuk ejecutando el programa envoltorio. Se trata de una arquitectura legítima, pero consiste en un programa personalizado en Rust más la integración con Keeper, lo que supone un tema avanzado que va más allá del alcance de esta guía.
¿Cómo se comprueba si una Solana está activa?
Recupera la cuenta de suscripción y, a continuación, compara su expiresAtTs en tiempo en cadena (getSlot entonces getBlockTime), nunca el reloj del ordenador.
Una cuenta de suscripción existente en la cadena de bloques no significa que la suscripción está activa. Cuando se cancela una suscripción y finaliza su periodo de gracia, o cuando caduca, la cuenta no desaparece. Permanece en la cadena de bloques hasta que alguien la active. revocarSuscripción para reclamar el pago del alquiler. Por lo tanto, una comprobación de mera existencia («¿existe el PDA?») indica que las suscripciones caducadas siguen activas y, sin que nadie se dé cuenta, permite el acceso gratuito a los usuarios dados de baja.
| Estado | existe | expiresAtTs | isActive |
|---|---|---|---|
| Suscrito, sin posibilidad de cancelación | verdadero | 0 | verdadero |
| Cancelado, periodo de gracia en curso | verdadero | marca de tiempo futura | verdadero (pagado durante ese periodo) |
| Cancelado, el plazo de gracia ha vencido | verdadero | marca de tiempo anterior | false (la trampa) |
| Revocado (alquiler recuperado) | false | n/a | false |
La comprobación en sí misma consiste simplemente en unas pocas líneas que puedes incorporar a tu propio código: derivar los PDA, leer la hora en la cadena de bloques, recuperar la cuenta, evaluar expiresAtTs. Fíjate en que el cliente aquí solo utiliza signerFromFile y solanaDevnetRpc: la ruta de lectura no necesita suscripcionesPrograma() complemento, ya que los asistentes de PDA y fetchMaybeSubscriptionDelegation son importaciones sin más.
/**
* Step 5 - Check status: the RIGHT way to answer "is this wallet actively
* subscribed?"
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
} from '@solana/subscriptions';
const PLAN_ID = 1n;
if (!process.argv[2]) {
throw new Error(
'Pass the merchant address: npx tsx --env-file=.env 05-check-status.ts <MERCHANT_ADDRESS>',
);
}
const merchant = address(process.argv[2]);
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }));
// Locate the subscription: plan PDA from (merchant, planId), then the
// subscription PDA from (plan, subscriber).
const [planPda] = await findPlanPda({ owner: merchant, planId: PLAN_ID });
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.identity.address,
});
// Use the chain's clock, not the machine's.
const slot = await subscriber.rpc.getSlot().send();
const blockTime = await subscriber.rpc.getBlockTime(slot).send();
const now = BigInt(blockTime ?? Math.floor(Date.now() / 1000));
// The naive check: does the account exist?
const account = await fetchMaybeSubscriptionDelegation(subscriber.rpc, subscriptionPda);
// The correct check: expiresAtTs === 0 means active with no cancellation
// pending; > 0 but not yet reached means canceled but still in the paid-up
// grace period (still active); once now >= expiresAtTs it is expired - even
// though the account still exists until revokeSubscription reclaims its rent.
const expiresAtTs = account.exists ? BigInt(account.data.expiresAtTs) : null;
const isActive =
expiresAtTs !== null && (expiresAtTs === 0n || now < expiresAtTs);
console.log(`Subscription: ${subscriptionPda}`);
console.log(` account exists: ${account.exists}`);
console.log(` isActive: ${isActive}`);
console.log(` expiresAtTs: ${expiresAtTs ?? 'n/a'}`);
console.log(` onchain now: ${now}`);
if (account.exists && !isActive) {
console.log(
'\n>>> Note the divergence: the account EXISTS but the subscription is\n' +
'>>> NOT active. An existence-only check would wrongly grant access here.',
);
}
Ejecútalo ahora, mientras la suscripción acaba de activarse:
npx tsx --env-file=.env 05-check-status.ts <MERCHANT_ADDRESS>
Resultado esperado:
Subscription: F6Tf...W8Ztf
account exists: true
isActive: true
expiresAtTs: 0
onchain now: 1783453811
Ten este script a mano. Tras el paso de cancelación que viene a continuación, lo volverás a ejecutar una hora más tarde y verás existe quedarse verdadero mientras que isActive pasa a false.
Contenido exclusivo para socios de Gate
Lo más habitual es hacer con isActive El contenido de la puerta de acceso puede ser: una página exclusiva para miembros, una API o una descarga. En esta sección se crea esa puerta de acceso como una API del lado del servidor y se gestiona mediante un cliente «headless», sin necesidad de navegador. En producción, esa misma puerta de acceso se encuentra detrás de tu página web. La arquitectura es más importante que el código, y consta de dos capas:
- Las comprobaciones del lado del cliente solo afectan a la experiencia de usuario. Todo lo que se ejecute en el navegador del usuario puede ser falsificado, por lo que una lectura del estado de la suscripción desde el lado del cliente puede mostrar u ocultar elementos de la interfaz de usuario, pero nunca debe constituir el filtro de acceso.
- El servidor es la puerta de acceso. El usuario demuestra que controla el monedero firmando un nonce generado por el servidor. El servidor verifica la firma y, a continuación, ejecuta el
isActivecomprueba en la cadena de bloques y, solo entonces, muestra el contenido protegido.
Antes de iniciar el servidor, configura DIRECCIÓN_DEL_COMERCIANTE en tu .env a la dirección del comerciante indicada en el paso 2.
El servidor crea un cliente de solo lectura con el solanaRpcConnection plugin, ya que la puerta solo lee el estado de la cadena.
/**
* Access-gating server: the server-side gate that protects members-only
* content behind an active subscription.
*
* Two layers, and why the split matters:
* - Any client-side check is UX only - trivially spoofed, never the gate.
* - THIS server is the real gate: the caller proves wallet ownership by
* signing a nonce, and the server verifies (a) the signature and (b) the
* onchain isActive status before serving protected content.
*
* Config: set QUICKNODE_ENDPOINT and MERCHANT_ADDRESS (the plan owner to gate
* on) in .env. Run with: npx tsx --env-file=.env gating/server.ts
*
* Flow:
* GET /api/nonce?wallet=<address> -> { nonce, message }
* POST /api/members { wallet, nonce, signature } -> 200 content | 401
*/
import {
address,
createClient,
getAddressEncoder,
type Address,
type Rpc,
type SolanaRpcApi,
} from '@solana/kit';
import { solanaRpcConnection } from '@solana/kit-plugin-rpc';
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
} from '@solana/subscriptions';
import express from 'express';
import { randomBytes } from 'node:crypto';
const PORT = Number(process.env.GATE_PORT ?? 3000);
const NONCE_TTL_MS = 5 * 60 * 1000;
const PLAN_ID = 1n;
const RPC_URL = process.env.QUICKNODE_ENDPOINT;
if (!RPC_URL) throw new Error('Set QUICKNODE_ENDPOINT (e.g. via --env-file=.env).');
const MERCHANT_ADDRESS = process.env.MERCHANT_ADDRESS;
if (!MERCHANT_ADDRESS) throw new Error('Set MERCHANT_ADDRESS (the plan owner to gate on).');
// Read-only client: the gate only reads chain state, so it installs just an
// RPC (solanaRpcConnection) - no signer, no transaction sending.
const client = createClient().use(solanaRpcConnection({ rpcUrl: RPC_URL }));
const [planPda] = await findPlanPda({
owner: address(MERCHANT_ADDRESS),
planId: PLAN_ID,
});
// presence != active: the account lingers onchain after expiry until
// revokeSubscription, so evaluate expiresAtTs against onchain time.
async function isActive(
rpc: Rpc<SolanaRpcApi>,
subscriber: Address,
): Promise<{ subscriptionPda: Address; active: boolean; expiresAtTs: bigint | null }> {
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber,
});
const account = await fetchMaybeSubscriptionDelegation(rpc, subscriptionPda);
if (!account.exists) return { subscriptionPda, active: false, expiresAtTs: null };
const expiresAtTs = BigInt(account.data.expiresAtTs);
const slot = await rpc.getSlot().send();
const now = BigInt((await rpc.getBlockTime(slot).send()) ?? 0);
return {
subscriptionPda,
active: expiresAtTs === 0n || now < expiresAtTs,
expiresAtTs,
};
}
const app = express();
app.use(express.json());
// nonce -> { wallet, expiresAt }; one-time use, short-lived
const nonces = new Map<string, { wallet: string; expiresAt: number }>();
const signInMessage = (wallet: string, nonce: string) =>
`Sign in to the members area\nWallet: ${wallet}\nNonce: ${nonce}`;
app.get('/api/nonce', (req, res) => {
const wallet = String(req.query.wallet ?? '');
if (!wallet) {
res.status(400).json({ error: 'wallet query param required' });
return;
}
const nonce = randomBytes(16).toString('hex');
nonces.set(nonce, { wallet, expiresAt: Date.now() + NONCE_TTL_MS });
res.json({ nonce, message: signInMessage(wallet, nonce) });
});
app.post('/api/members', async (req, res) => {
try {
const { wallet, nonce, signature } = req.body as {
wallet?: string;
nonce?: string;
signature?: string;
};
if (!wallet || !nonce || !signature) {
res.status(400).json({ error: 'wallet, nonce, and signature required' });
return;
}
// 1. The nonce must be one we issued, unexpired, and for this wallet.
const issued = nonces.get(nonce);
nonces.delete(nonce); // one-time use
if (!issued || issued.wallet !== wallet || issued.expiresAt < Date.now()) {
res.status(401).json({ error: 'invalid or expired nonce' });
return;
}
// 2. Verify the ed25519 signature proves ownership of the wallet.
// A wallet address IS the raw ed25519 public key (32 bytes).
const publicKeyBytes = new Uint8Array(
getAddressEncoder().encode(address(wallet)),
);
const publicKey = await crypto.subtle.importKey(
'raw',
publicKeyBytes,
'Ed25519',
false,
['verify'],
);
const messageBytes = new TextEncoder().encode(signInMessage(wallet, nonce));
const signatureBytes = Uint8Array.from(Buffer.from(signature, 'base64'));
const validSignature = await crypto.subtle.verify(
'Ed25519',
publicKey,
signatureBytes,
messageBytes,
);
if (!validSignature) {
res.status(401).json({ error: 'signature verification failed' });
return;
}
// 3. The wallet is proven - now the actual gate: active subscription?
const status = await isActive(client.rpc, address(wallet));
if (!status.active) {
res.status(401).json({ error: 'no active subscription' });
return;
}
res.json({
message: `Welcome, member ${wallet}!`,
content: 'This is the protected members-only content.',
subscription: {
pda: status.subscriptionPda,
expiresAtTs: status.expiresAtTs?.toString(),
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
app.listen(PORT, () => {
console.log(`Gating server listening on http://localhost:${PORT}`);
console.log(`Gating on plan ${planPda} (merchant ${MERCHANT_ADDRESS})`);
console.log('Try it: npx tsx --env-file=.env gating/sign-in.ts');
});
El cliente de inicio de sesión es la otra mitad de la demostración: actúa como suscriptor y pone a prueba la pasarela en modo sin interfaz gráfica, para que puedas ver el flow completo flow una cartera en el navegador. Realiza el protocolo de autenticación de tres pasos con el servidor: obtiene un nonce de un solo uso, firma el mensaje del servidor con la clave del suscriptor (createSignableMessage, entonces signer.signMessages), y envía mediante un método POST la cartera, el nonce y la firma en base64 a /api/miembros.
En el entorno de producción, esa firma procedería del monedero del navegador del usuario, pero la verificación por parte del servidor es idéntica.
/**
* Headless sign-in client: proves wallet ownership to the gating server and
* requests the protected content.
*
* Start the server first: npx tsx --env-file=.env gating/server.ts
*/
import { createClient, createSignableMessage } from '@solana/kit';
import { signerFromFile } from '@solana/kit-plugin-signer';
const PORT = Number(process.env.GATE_PORT ?? 3000);
const BASE = `http://localhost:${PORT}`;
// Load the subscriber's wallet via the plugin; client.identity is the signer.
const client = await createClient().use(signerFromFile('.keys/subscriber.json'));
const signer = client.identity;
console.log(`Signing in as ${signer.address}`);
// 1. Get a one-time nonce for this wallet.
const nonceResponse = await fetch(`${BASE}/api/nonce?wallet=${signer.address}`);
const { nonce, message } = (await nonceResponse.json()) as {
nonce: string;
message: string;
};
console.log(`Nonce: ${nonce}`);
// 2. Sign the server's message to prove we control this wallet.
const [signatureDictionary] = await signer.signMessages([
createSignableMessage(message),
]);
const signature = Buffer.from(signatureDictionary[signer.address]).toString(
'base64',
);
// 3. Request the protected content.
const membersResponse = await fetch(`${BASE}/api/members`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ wallet: signer.address, nonce, signature }),
});
const body = await membersResponse.json();
console.log(`\nHTTP ${membersResponse.status}`);
console.log(JSON.stringify(body, null, 2));
if (membersResponse.status === 200) {
console.log('\nAccess granted - the subscription is active.');
} else {
console.log(
'\nAccess denied. If you expected access, check the subscription status\n' +
'with: npx tsx --env-file=.env 05-check-status.ts',
);
}
Ejecuta el servidor en una terminal y el cliente de inicio de sesión en otra:
npx tsx --env-file=.env gating/server.ts # terminal 1
npx tsx --env-file=.env gating/sign-in.ts # terminal 2
El servidor comienza a funcionar según tu plan:
Gating server listening on http://localhost:3000
Gating on plan 2Tw4...qy5x (merchant 7erA...SBErg)
Try it: npx tsx --env-file=.env gating/sign-in.ts
Y, una vez activada la suscripción, el cliente de inicio de sesión obtiene el contenido protegido:
Signing in as 5khv...oRqZ
Nonce: c4e768a506454b0e883543deba940195
HTTP 200
{
"message": "Welcome, member 5khv...oRqZ!",
"content": "This is the protected members-only content.",
"subscription": {
"pda": "F6Tf...8Ztf",
"expiresAtTs": "0"
}
}
Access granted - the subscription is active.
Vuelve a ejecutarlo una vez que haya caducado la suscripción (tras seguir el paso de cancelación que se indica a continuación) y la misma solicitud devolverá un código HTTP 401.
Cancelar, reanudar y limpiar
La cancelación está pensada para facilitar las cosas al suscriptor: no interrumpe el acceso de forma inmediata. cancelarSuscripción conjuntos expiresAtTs hasta el final del periodo de facturación actual, ya abonado, lo que da lugar a un periodo de gracia. Hasta ese momento, la suscripción sigue considerándose activa (el suscriptor ha recibido lo que ha pagado) y el suscriptor puede cambiar de opinión mediante reanudar la suscripción, lo que anula la cancelación por completo.
reanudar la suscripción ahora exige tokenMint en su entrada, que se utiliza para obtener y validar la autorización de suscripción del suscriptor. Los ejemplos más antiguos lo omiten y no se compilarán con el SDK actual.
El script realiza las acciones de cancelar, reanudar y volver a cancelar, dejando la suscripción cancelada para que puedas comprobar su caducidad más adelante:
/**
* Step 6 - Cancel and resume (subscriber).
*
* Usage: pass the merchant address (to locate the plan).
* npx tsx --env-file=.env 06-cancel-resume.ts <MERCHANT_ADDRESS>
*
* This script cancels, shows the grace-period state, resumes, shows it's
* active again, then cancels once more and LEAVES it canceled - so that an
* hour from now you can re-run 05-check-status and see the exists/isActive
* divergence for real.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
if (!process.argv[2]) {
throw new Error(
'Pass the merchant address: npx tsx --env-file=.env 06-cancel-resume.ts <MERCHANT_ADDRESS>',
);
}
const merchant = address(process.argv[2]);
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const [planPda] = await findPlanPda({ owner: merchant, planId: PLAN_ID });
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.identity.address,
});
// Compact status check: active when expiresAtTs is 0 (no cancellation) or the
// grace-period expiry hasn't been reached yet, measured against onchain time.
// Back-to-back transactions can outrun the RPC node serving the reads, so each
// check polls until expiresAtTs reflects the transaction that just landed.
const show = async (
label: string,
settled: (expiresAtTs: bigint) => boolean,
) => {
let expiresAtTs: bigint | null = null;
for (let attempt = 0; attempt < 10; attempt++) {
const account = await fetchMaybeSubscriptionDelegation(subscriber.rpc, subscriptionPda);
expiresAtTs = account.exists ? BigInt(account.data.expiresAtTs) : null;
if (expiresAtTs !== null && settled(expiresAtTs)) break;
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
const slot = await subscriber.rpc.getSlot().send();
const now = BigInt((await subscriber.rpc.getBlockTime(slot).send()) ?? 0);
const isActive =
expiresAtTs !== null && (expiresAtTs === 0n || now < expiresAtTs);
console.log(`${label}: isActive=${isActive}, expiresAtTs=${expiresAtTs}`);
};
console.log('Canceling...');
await subscriber.subscriptions.instructions
.cancelSubscription({ planPda, subscriptionPda })
.sendTransaction();
await show(
'After cancel (grace period until end of paid period)',
(ts) => ts > 0n,
);
console.log('\nResuming (un-cancels before the grace period ends)...');
await subscriber.subscriptions.instructions
.resumeSubscription({ planPda, subscriptionPda, tokenMint: USDC_MINT })
.sendTransaction();
await show('After resume', (ts) => ts === 0n);
console.log('\nCanceling again (leaving it canceled this time)...');
await subscriber.subscriptions.instructions
.cancelSubscription({ planPda, subscriptionPda })
.sendTransaction();
await show('Final state', (ts) => ts > 0n);
console.log(
'\nThe subscription now expires at the end of the current 1-hour period.\n' +
'Re-run 05-check-status after it passes to see the account still existing\n' +
'while isActive flips to false. When done: 07-cleanup.',
);
Prueba con la dirección de tu comercio:
npx tsx --env-file=.env 06-cancel-resume.ts <MERCHANT_ADDRESS>
Resultado esperado:
Canceling...
After cancel (grace period until end of paid period): isActive=true, expiresAtTs=1783456014
Resuming (un-cancels before the grace period ends)...
After resume: isActive=true, expiresAtTs=0
Canceling again (leaving it canceled this time)...
Final state: isActive=true, expiresAtTs=1783456014
The subscription now expires at the end of the current 1-hour period.
Re-run 05-check-status after it passes to see the account still existing
while isActive flips to false. When done: 07-cleanup.
cancelado, sin embargo isActive=true con un hormigón expiresAtTs Es el periodo de gracia en acción. Dentro de una hora, ejecuta 05-comprobar-estado Vuelve a hacerlo y verás la divergencia en directo: La cuenta existe: true, isActive: false.
Limpieza y recuperación del alquiler
Todas las cuentas creadas por esta guía cuentan con un depósito de alquiler recuperable, y la limpieza lo devuelve íntegramente. La pega es que los pasos tienen un plazo límite y dependen del orden en que se realicen, por lo que el script de limpieza intenta cada uno de ellos, tolera los errores del tipo «todavía no» y te indica cuándo debes volver:
revocarSuscripcióncierra la suscripción a la PDA y devuelve el importe del alquiler, pero solo una vez que la suscripción cancelada haya vencido efectivamente.closeSubscriptionAuthoritycierra la SA y devuelve su cuota. El programa permite al usuario cerrarlo en cualquier momento, incluso con suscripciones pendientes, pero si se cierra antes de tiempo, estas se abandonan: el comerciante ya no puede cobrar, y la cuota de la PDA de la suscripción abandonada debe reclamarse entonces a través delrevocarSuscripciónAbandonadaruta. El script evita eso cerrando la SA únicamente una vez que la suscripción haya desaparecido.updatePlandesactiva el plan (bloqueando a los nuevos suscriptores) y establece un límiteendTs. Una vez establecido, un número finitoendTssolo puede acortarse, nunca alargarse.deletePlanreclama el alquiler previsto en el plan, pero solo después deendTsha pasado.
Aquí intervienen ambas partes, por lo que este script carga ambas carteras locales y vuelve a derivar todo por sí mismo, sin necesidad de argumentos:
/**
* Step 7 - Clean up: reclaim rent and retire the plan.
*
* Order matters, and some steps are time-gated, so each step here tolerates
* "not yet" failures and tells you when to come back:
* 1. revokeSubscription - subscriber reclaims the subscription PDA's rent.
* Only allowed once the canceled subscription has
* actually expired (grace period passed).
* 2. closeSubscriptionAuthority - subscriber closes the SA and reclaims its
* rent. The program allows this at any time, but
* closing while a subscription still exists
* abandons it, so this script waits for step 1.
* 3. updatePlan (Sunset + finite endTs) - merchant stops new subscribers.
* A finite endTs can only ever be shortened later.
* 4. deletePlan - merchant reclaims the plan PDA's rent. Only
* allowed after endTs has passed.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
PlanStatus,
fetchMaybePlan,
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
const PLAN_METADATA_URI = 'https://example.com/plan.json';
// Both parties act in cleanup, so this script loads both local wallets and
// re-derives the plan + subscription PDAs itself (no arguments needed).
const merchant = await createClient()
.use(signerFromFile('.keys/merchant.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const [planPda] = await findPlanPda({
owner: merchant.identity.address,
planId: PLAN_ID,
});
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.identity.address,
});
const attempt = async (label: string, fn: () => Promise<unknown>) => {
process.stdout.write(`${label}... `);
try {
await fn();
console.log('done.');
return true;
} catch (error) {
console.log('not yet:');
console.log(` ${(error as Error).message.split('\n')[0]}`);
return false;
}
};
// 1. Reclaim the subscription PDA's rent (needs the grace period to have passed).
let subscriptionGone = !(
await fetchMaybeSubscriptionDelegation(subscriber.rpc, subscriptionPda)
).exists;
if (subscriptionGone) {
console.log('Subscription already revoked.');
} else {
subscriptionGone = await attempt('Revoking subscription (reclaims rent)', () =>
subscriber.subscriptions.instructions
.revokeSubscription({ planPda, subscriptionPda })
.sendTransaction(),
);
if (!subscriptionGone) {
console.log(
' A canceled subscription can only be revoked after its grace period\n' +
' ends (up to 1 hour for this plan). Re-run this script later.',
);
}
}
// 2. Close the Subscription Authority. The program allows closing it at any
// time, but closing while a subscription still exists abandons it (the
// merchant can no longer collect, and the stranded PDA's rent then needs
// revokeAbandonedSubscription), so wait until the subscription is gone.
if (subscriptionGone) {
await attempt('Closing Subscription Authority', () =>
subscriber.subscriptions.instructions
.closeSubscriptionAuthority({ tokenMint: USDC_MINT })
.sendTransaction(),
);
} else {
console.log(
'Skipping the Subscription Authority close until the subscription is revoked.',
);
}
// 3. Sunset the plan with a finite endTs so it becomes deletable.
const plan = await fetchMaybePlan(merchant.rpc, planPda);
if (!plan.exists) {
console.log('Plan already deleted - nothing left to do.');
} else {
const slot = await merchant.rpc.getSlot().send();
const blockTime = await merchant.rpc.getBlockTime(slot).send();
const now = BigInt(blockTime ?? Math.floor(Date.now() / 1000));
if (plan.data.status !== PlanStatus.Sunset) {
// Give existing subscriptions until the end of one more period window.
await attempt('Sunsetting plan (blocks new subscribers)', () =>
merchant.subscriptions.instructions
.updatePlan({
planPda,
status: PlanStatus.Sunset,
endTs: now + 2n * 3600n,
pullers: [],
metadataUri: PLAN_METADATA_URI,
})
.sendTransaction(),
);
} else {
console.log('Plan already sunset.');
}
// 4. Delete the plan (only after endTs passes).
const deleted = await attempt('Deleting plan (reclaims rent)', () =>
merchant.subscriptions.instructions.deletePlan({ planPda }).sendTransaction(),
);
if (!deleted) {
console.log(
' deletePlan only succeeds after the plan\'s endTs has passed.\n' +
' Re-run this script once it has to finish reclaiming rent.',
);
}
}
console.log('\nCleanup pass complete.');
Correr npx tsx --env-file=.env 07-cleanup.ts una vez que haya caducado tu suscripción cancelada, y de nuevo un par de horas más tarde para completar la eliminación del plan, que está sujeta a un plazo de espera.
Cuando todo se cierra, el importe total del alquiler (suscripción a PDA, SA y plan) se devuelve a las carteras que lo pagaron. Ten en cuenta que un plan «Sunset» no admite nuevos suscriptores, por lo que, si quieres volver a ejecutar el ciclo de vida o activar Streams tras la limpieza, crea primero un plan nuevo (un nuevo planId en 02-create-plan.ts, o bien eliminar por completo el plan anterior una vez que su endTs pases).
Eventos de suscripción a transmisiones en directo
Ahora vas a añadir un Stream para supervisar las suscripciones de tu plan, de modo que cuando un monedero se suscriba o cancele su suscripción, te enteres de inmediato (para enviar un correo electrónico de bienvenida, actualizar una base de datos o avisar al equipo de operaciones), en lugar de tener que consultar cada cuenta de suscripción de forma periódica. Quicknode Streams supervisa cada bloque y envía las transacciones que te interesan a un destino que tú controlas.
Streams se ejecuta en un entorno aislado por parte Quicknode, sin importaciones de npm disponibles, por lo que realiza la única tarea para la que ese entorno es adecuado: reducir el volumen. Conserva las transacciones relacionadas con el programa de suscripciones y las reenvía sin procesar al destino que configures.
Escribe el filtro de flujo
A Streams filtro es una función de JavaScript que se ejecuta en cada bloque antes de su entrega. Recibe el conjunto de datos sin procesar en stream.data y devuelve solo lo que quieres que se entregue, o null para que no se envíe nada, de modo que tu webhook solo reciba información sobre las transacciones que te interesan, en lugar de cada bloque de la red de desarrollo.
Todos los planes de los comerciantes comparten la misma dirección del programa de suscripciones, por lo que, si solo se utiliza el programa como criterio de búsqueda, se obtendrían los datos de actividad de todos los planes del programa, no solo los de tu plan. Para supervisar únicamente tu plan, el filtro comprueba además que cada transacción incluya la PDA de tu plan (la dirección que guardaste al crear el plan). Dado que esa PDA es una de las cuentas que figuran en todas las instrucciones de suscripción, cancelación y transferencia de tu plan, es una forma fiable de limitar los resultados a un único plan.
Si ejecutas más de un plan, añade el PDA de cada plan y compáralo con todos ellos (sustituye la constante por una matriz y comprueba PLAN_PDAS.some((pda) => allKeys.includes(pda))).
function main(stream) {
const PROGRAM_ID = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44';
// Your plan PDA, printed by 02-create-plan.ts. Scopes delivery to this one plan.
const PLAN_PDA = 'YOUR_PLAN_PDA';
// For multiple plans, list them instead and match with .some() below:
// const PLAN_PDAS = ['PLAN_PDA_ONE', 'PLAN_PDA_TWO'];
const blocks = Array.isArray(stream.data) ? stream.data : [stream.data];
const matched = [];
for (const block of blocks) {
const transactions = block && block.transactions;
if (!transactions) continue;
for (const tx of transactions) {
if (!tx || !tx.meta || tx.meta.err) continue; // skip failed txs
const message = tx.transaction && tx.transaction.message;
if (!message) continue;
// Full account list: static keys + any address-lookup-table keys.
const staticKeys = (message.accountKeys || []).map((key) =>
typeof key === 'string' ? key : key.pubkey,
);
const loaded = tx.meta.loadedAddresses || {};
const allKeys = staticKeys.concat(
loaded.writable || [],
loaded.readonly || [],
);
// Simple string checks - no decoding needed. The program must be
// involved (so the tx carries a self-CPI event), and the plan PDA must
// be present (so it is OUR plan, not some other merchant's).
if (!allKeys.includes(PROGRAM_ID)) continue;
if (!allKeys.includes(PLAN_PDA)) continue;
// Multiple plans: if (!PLAN_PDAS.some((pda) => allKeys.includes(pda))) continue;
matched.push({
signature:
(tx.transaction.signatures && tx.transaction.signatures[0]) || null,
slot: block.parentSlot != null ? block.parentSlot + 1 : null,
blockTime: block.blockTime != null ? block.blockTime : null,
transaction: tx, // pass it along raw for the destination to inspect
});
}
}
return matched.length > 0 ? matched : null;
}
El ranura El campo se calcula de la siguiente manera: parentSlot + 1 Por motivos de facilidad de visualización, y dado que Solana ranuras de forma habitual, la ranura real de un bloque no siempre es la de su bloque padre más uno. Trata la transacción firma y blockTime como identificadores fiables, y no esa ranura derivada.
Crear la transmisión
Configúralo todo en el Quicknode :
- Abre webhook.cool y copia la URL única que te genera. Te ofrece una bandeja de entrada en la que puedes consultar las entregas sin necesidad de gestionar ningún servidor, lo cual es suficiente para ver cómo funciona el proceso de principio a fin.
- En el Quicknode , crea un «Stream» enla red de desarrollo Solana con el conjunto de datos «Block ».
- Pega la función de filtro anterior en el filtro de Stream, sustituyendo el
PLAN_PDAsustituye este marcador de posición por tu propio plan PDA (la dirección que guardaste al crear el plan). Utiliza la función de prueba del panel de control con un bloque reciente para confirmar que funciona (la mayoría de los bloques devuelvennull, lo cual es correcto: ninguna transacción afecta a tu plan). - Establece el destino en «Webhook», utilizando tu URL de webhook.cool.
- Inicia el Stream y, a continuación, activa los eventos: ejecutar
03-suscribirse(frente a un plan nuevo, que no sea «Sunset», si ya has ejecutado la limpieza) o06-cancelar-reanudar, y observa cómo las transacciones coincidentes llegan a tu bandeja de entrada de webhook.cool apenas se registran en la cadena.
Para cualquier elemento que genere efectos secundarios reales (correos electrónicos, registros de facturación), configura el Stream para que realice la entrega en el nivel de compromiso «confirmado» o «finalizado», en lugar de «procesado». En producción, cambia webhook.cool para un receptor en tu propia infraestructura que verifique la firma HMAC del mensaje y descodifique los eventos «self-CPI» (véase la tabla de eventos anterior) antes de actuar en consecuencia.
Cuándo utilizar una delegación recurrente
Utiliza una delegación recurrente cuando un único pagador realice un pago a un único beneficiario sin que exista un plan compartido y publicado. Ofrece el mismo cargo periódico que un plan de suscripción, pero omite la creación del plan, el consentimiento de adhesión y las condiciones de cancelación con plazo de gracia. Opta por un plan de suscripción siempre que haya muchos suscriptores que acepten las mismas condiciones que publicas una sola vez.
El modelo del plan parte de la base de que el comerciante tiene muchos suscriptores. Si solo hay un par de pagador y beneficiario (una DAO que paga mensualmente a un colaborador, una asignación o un bot con un límite de gasto), el programa... delegación recurrente El modelo realiza la misma retirada por período sin un plan compartido: el pagador llama a createRecurringDelegation en la que se designa al beneficiario como delegado, y el beneficiario llama transferencia periódica cada período.
Hay dos diferencias que hay que tener claras:
- Unidades de tiempo: el período de una delegación recurrente es
periodLengthS, en segundos, mientras que las tarifas de los planes se facturan enhoras por período. Si se mezclan, se obtienen plazos de facturación totalmente erróneos. - No hay características propias del plan: no hay condiciones compartidas a las que adherirse, ni condiciones de cancelación con plazo de gracia, solo el límite por período propio de la delegación y la caducidad opcional.
El requisito relativo a la autoridad de suscripción es idéntico: primero debe existir la SA del pagador para la casa de la moneda.
Próximos pasos
- Crea un receptor de webhooks real que descodifique los eventos de auto-CPI (consulta la tabla de eventos anterior) en lugar de limitarte a examinar las entregas sin procesar.
- Prueba a acuñar un Token-2022. El programa admite enlaces de transferencia y comisiones de transferencia, y las instrucciones de transferencia aceptan
transferHookAccountspara la resolución de ganchos - Haz que el proceso de incorporación sea totalmente sin gasolina con Kora: ejecútalo como pagador de la cuota, de modo que un suscriptor que no posea SOL pueda seguir suscribiéndose (y, si lo desea, pagar la cuota en USDC). El programa registra
pagadorEste campo permite a Kora adelantarse al pago del alquiler de la cuenta y, posteriormente, reclamarlo en la fase de limpieza medianterentRecipient - Crea la ruta de automatización avanzada con un programa envoltorio cuyo PDA sea un «puller» incluido en la lista blanca, que se ejecute según una programación mediante tuktuk.
Si tienes alguna duda o quieres compartir lo que estás creando, pásate por el servidor Quicknode .
Conclusión
Antes de la Suscripciones En el programa, no era posible realizar facturación periódica en Solana varias suscripciones o planes. Ahora, tu cuenta de comerciante publica las condiciones en la cadena de bloques, tu cuenta de suscriptor da su consentimiento a una instantánea exacta de las mismas y el propio programa aplica el límite por período cuando intentas sobrepasarlo.
A lo largo del proceso, has aprendido a comprobar expiresAtTs basándose en el tiempo en cadena en lugar de confiar en que una cuenta exista, y restringiendo el acceso al contenido en el servidor mediante un nonce firmado, en lugar de hacerlo en el navegador. Al utilizar tu Stream para filtrar las transacciones del programa y enviarlas en el momento en que se registran, no es necesario realizar consultas periódicas para detectar cambios. A partir de ahí, cambiar el plan de demostración de una hora por uno mensual es un proceso sencillo y constante.
Preguntas frecuentes
¿Qué problema resuelve la «Subscription Authority» en comparación con una aprobación normal mediante token?
Una cuenta de tokens SPL tiene exactamente una ranura de delegación, por lo que una segunda aprobación anula la primera y una cartera solo puede admitir una retirada periódica a la vez. La Autoridad de Suscripción es una PDA controlada por programa que ocupa esa única ranura una vez por cada par (usuario, acuñación), y solo transfiere tokens cuando una PDA de delegación independiente autoriza una transferencia específica. Esto permite que una cuenta de tokens tenga suscripciones independientes ilimitadas con el mismo modelo de seguridad que una aprobación normal.
¿Debería optar por un plan de suscripción o por una delegación periódica?
Utiliza un plan de suscripción cuando un comerciante preste servicio a muchos suscriptores: te ofrece condiciones publicadas compartidas, una instantánea del consentimiento en el momento de la suscripción, semántica de cancelación con periodo de gracia y hasta cuatro «pullers» incluidos en la lista blanca. Utiliza una delegación recurrente para un acuerdo entre un único pagador y un único beneficiario sin plan compartido, como por ejemplo una DAO que paga a un colaborador. Ten en cuenta que las unidades difieren: los planes se facturan en «periodHours», mientras que las delegaciones recurrentes utilizan «periodLengthS» en segundos.
¿Qué ocurre cuando un suscriptor cancela su suscripción a mitad de ciclo?
La cancelación establece el parámetro «expiresAtTs» de la suscripción al final del periodo de facturación actual, ya pagado, en lugar de interrumpir el acceso de forma inmediata. La suscripción sigue considerándose activa durante este periodo de gracia, y el suscriptor puede llamar a la función «resumeSubscription» (que requiere «tokenMint» a partir de la versión v0.4.0) para anular la cancelación. Una vez transcurrido el periodo de gracia, se bloquean las consultas y la suscripción pasa a considerarse inactiva.
¿Por qué no basta con comprobar que la cuenta de suscripción existe para restringir el acceso?
Porque la cuenta sigue existiendo tras la finalización de la suscripción. Tras la cancelación y la caducidad, el PDA de la suscripción permanece en la cadena de bloques hasta que alguien llame a la función «revokeSubscription» para reclamar su cuota; por lo tanto, una comprobación basada únicamente en la existencia indica que las suscripciones caducadas siguen activas. Evalúa siempre los valores de «expiresAtTs» comparándolos con el tiempo de la cadena de bloques (utilizando «getSlot» y luego «getBlockTime»): un valor de 0 significa que está activa; una marca de tiempo futura significa que está cancelada pero en periodo de gracia; y una marca de tiempo pasada significa que está inactiva, aunque la cuenta siga existiendo.
¿Funciona el programa de suscripciones con las emisiones de Token-2022?
Sí. A partir de la versión 0.4.0, el programa es compatible con las emisiones de Token-2022, incluidas extensiones como los «transfer hooks» y las comisiones de transferencia; las instrucciones de transferencia admiten un parámetro «transferHookAccounts» para que se puedan reenviar las cuentas de enlace. Las versiones anteriores rechazaban ciertas extensiones durante la inicialización, pero esa restricción se ha eliminado, por lo que las instrucciones que describen una lista de extensiones bloqueadas han quedado obsoletas.
