Artículo de Blog
Autor
Stellar Development Foundation
Fecha de publicación
Escalabilidad
Canales de pago
La escalabilidad, específicamente, cómo lograrla, ha estado en el centro de algunos de los desacuerdos más amargos en blockchain. Hemos intentado abordar el problema con una mente abierta. En la medida en que una idea mejore lo que nuestros usuarios valoran: velocidad, capacidad de procesamiento, privacidad, la exploraremos, y dado que un pago típico de Lightning:
el protocolo siempre nos ha interesado. Como dijimos en nuestra Hoja de Ruta 2018, ahora está claro que Lightning es el camino correcto para Stellar.
Lightning es una solución de escalado para redes de pago distribuidas, originalmente propuesta para la blockchain de Bitcoin. Lightning está diseñado para permitir a los usuarios realizar pagos fuera de la cadena a través de enrutadores y hubs. Lightning incluso tiene el potencial de admitir pagos entre protocolos, como un pago donde el remitente envía Bitcoins en la red de Bitcoin y el destinatario recibe lumens en la red de Stellar, sin tener que confiar en ninguna parte intermedia.
Lightning está construido a partir de bloques de construcción conocidos como canales de pago. El concepto detrás de los canales de pago es simple pero poderoso. Permiten a los usuarios abrir un canal fuera de la cadena y transaccionar allí en lugar de en el libro mayor público. Debido a que están fuera de la cadena, las transacciones en el canal pueden ser extremadamente rápidas y baratas, pero similar a las transacciones en la cadena, no hay riesgo de contraparte. Cuando los participantes del canal están listos para separarse, cierran el canal y se liquidan de vuelta al libro mayor público. No importa lo que haya sucedido en el canal, el resto del mundo solo ve esa transacción final. Es como mostrarle a alguien el último fotograma de una película; de esa única imagen fija, no hay forma de desempacar el resto de la película.
Los desarrolladores han comenzado a trabajar en diseños e implementaciones de canales de pago para varias cadenas y libros mayores más allá de solo Bitcoin, incluyendo Ethereum y Zcash. Los canales de cada plataforma son únicos y dependen de las sutilezas de la plataforma, pero como regla, cualquier implementación admitirá algunos requisitos básicos:
Stellar admite una generalización más flexible de los canales de pago llamada canales de estado, lo que significa que cualquier operación que puedas ejecutar en la red de Stellar (como no solo pagos, sino también creación, eliminación o cambio de permisos en cuentas), puedes ejecutarla dentro de un canal de pago.
La implementación de canales de estado de Stellar se basa en el hecho de que cada transacción de Stellar especifica una cuenta fuente y un número de secuencia. Hemos descubierto cómo usar esos números de secuencia como un mecanismo de versionado natural para pagos fuera de la cadena; es similar a cómo tu banco recibe alertas por cheques fuera de orden. Para hacer el versionado, estamos aprovechando una nueva operación, BUMP_SEQUENCE
, que describiremos en detalle completo a continuación.
Nuestro cronograma de lanzamiento para Lightning en Stellar es:
El creador de Stellar, Jed McCaleb, exploró Lightning por primera vez en 2015; nuestra implementación de 2018 todavía refleja la inteligencia de su plan original, pero Jeremy Rubin, con el apoyo de Nicolas Barry y David Mazières de SDF, ha agregado las mejoras necesarias para hacer que Lightning sea adecuado para nosotros. La explicación que sigue es de ellos.
Este post describe cómo se pueden implementar los canales de estado en Stellar. En futuros posts, mostraremos cómo estos canales de estado pueden encadenarse usando Contratos de Tiempo de Bloqueo Hash (HTLCs), para habilitar pagos de múltiples saltos e interoperabilidad con implementaciones de la Red Lightning en otras cadenas (para permitir intercambios atómicos entre cadenas de Bitcoin por lumens, por ejemplo). Este diseño no está finalizado, y alentamos fuertemente la retroalimentación de otros investigadores y la comunidad mientras trabajamos hacia una especificación y una implementación listas para producción.
Un canal de estado es un arreglo entre n usuarios, u1...un, que desean realizar transacciones fuera de la cadena que se liquidan como efectos secundarios (pagos netos, pero también creaciones/eliminaciones de cuentas, etc.). Los usuarios colaboran para crear una serie de "transacciones instantáneas"—secuencias de efectos secundarios, T1, T2, . . . , Tk, de tal manera que solo la última secuencia, Tk, será ejecutada en el libro mayor público. Para asegurar que Tj no pueda ser ejecutada una vez que los usuarios crean Tj+1, el protocolo hace una suposición de sincronía: asume que todos los participantes pueden observar y responder al libro mayor, incluyendo superar cualquier tiempo de inactividad o ataques de DoS, dentro de algún retraso limitado D, como una semana.
Para implementar canales de estado en Stellar, aprovechamos el hecho de que cada transacción de Stellar especifica una cuenta fuente y un número de secuencia. El número de secuencia de una transacción debe coincidir con el número de secuencia monótonamente creciente de su cuenta fuente. Nuestro enfoque será asignar rangos sucesivamente más altos de números de secuencia en una cuenta de depósito en garantía R a las transacciones en cada secuencia Tj. La secuencia Tj inicialmente no puede ejecutarse porque sus números de secuencia son demasiado altos. Sin embargo, una vez que todos los usuarios han firmado Tj, pasan a firmar un segundo conjunto de "transacciones de trinquete", Vj, que elevan el número de secuencia de la cuenta R al punto en el que Tj puede ejecutarse. Elevar R el número de secuencia también invalida permanentemente las transacciones instantáneas T_i para i < j ¿Esto es donde juega la suposición de sincronía? Las transacciones en los conjuntos Vj y Tj se dan límites de tiempo tales que el tiempo más temprano en el que Tj puede ejecutarse es al menos D retraso después del último tiempo en el que Vj puede ejecutarse. Este retraso permite a otros usuarios notar que Vj ha sido presentado y contrarrestar presentando Vk, asegurando así Tk puede ser ejecutada y Tj no puede.
Para admitir canales de estado así como algunas otras aplicaciones, Stellar está agregando una nueva operación, BUMP_SEQUENCE
. La nueva operación permite transacciones para aumentar arbitrariamente el número de secuencia de una cuenta objetivo. Aquí puedes ver la semántica propuesta de BUMP_SEQUENCE
.
Comenzamos la especificación del protocolo con la presunción de un conjunto de usuarios y cuentas tales que:
Nuestro canal de estado se configura usando una cuenta de depósito en garantía R.
Mientras esta especificación describe una cuenta de depósito en garantía que usa una clave pública agregada única de las claves privadas de los participantes, una alternativa (usada por la implementación de ejemplo descrita abajo) sería usar una cuenta de multisignatura N-de-N, con una clave para cada uno de los participantes.
El estado del canal se actualizará en rondas, para las cuales Mj es el tiempo de inicio de la ronda j.
Primero creamos una secuencia de transacciones T1 que distribuye el contenido de R.
Nota que debido a la secuencia seleccionada, no es inmediatamente utilizable. Debemos primero crear un conjunto de transacciones V1 para aumentar el número de secuencia de R al valor apropiado.
Luego los usuarios pueden firmar y enviar una transacción compuesta financiando conjuntamente R.
Cada pago fuera de cadena entonces consiste en crear una nueva secuencia de transacciones Tj y Vj distribuyendo los fondos en R de tal manera que se efectúe la liquidación neta de las primeras j transacciones fuera de cadena.
Para ayudar a ilustrar esto, aquí hay una visualización de un canal relámpago de Stellar en el proceso de actualización de la ronda 4 a la ronda 5.
actualización instantánea j:
(a_k) monitorear:
tiempo de espera:
cierre honesto:
Usaremos el SDK de JavaScript de Stellar para mostrar cómo se puede crear un canal de estado entre Alice y Bob. Este ejemplo se simplifica con fines educativos y no implementa un canal de pago completamente funcional, ni refleja precisamente la especificación o la implementación final.
El canal tendrá 1000 lumens depositados en él, con un saldo inicial de 250 para Alice y 750 para Bob. Luego firmarán transacciones que actualizan el saldo a 500/500, sin que ninguna de esas transacciones tenga que llegar a la cadena. Finalmente, cerrarán el canal.
Alice y Bob necesitan seleccionar valores para TIMEOUT_CLAIM
y TIMEOUT_CLAIM_DELAY
basados en su frecuencia de pago y expectativas de conectividad de red (incluyendo la suposición de sincronía para la red, D). TIMEOUT_CLAIM_DELAY
debe ser al menos D, mientras que TIMEOUT_CLAIM
debe ser al menos D más el tiempo máximo esperado entre rondas. Para poder usar períodos de tiempo concretos en los ejemplos abajo, escogeremos un valor de una semana para D, estableceremos TIMEOUT_CLAIM_DELAY
a 1 semana, y estableceremos TIMEOUT_CLAIM
a 2 semanas. (Estos tiempos son conservadores de manera irrealista, pero deberían ser fáciles de seguir en los ejemplos a continuación.)
Empezaremos así:
const moment = require('moment');
const bigInt = require('big-integer')
const {
Account,
Asset,
Keypair,
Network,
Operation,
Server,
TransactionBuilder,
} = require('stellar-sdk')
const TIMEOUT_CLAIM = moment.duration(2, 'week').seconds()
const TIMEOUT_CLAIM_DELAY = moment.duration(1, 'week').seconds()
const server = new Server('https://horizon-testnet.stellar.org') Network.useTestNetwork()
// Alice and Bob are preexisting funded accounts controlled by AliceKeypair and BobKeypair
const AliceKeypair = Keypair.fromSecret('SCIXVMGTGHIOVMHRA7B7ICJ4XWAYSQP67VNSLNXS7OYZKXDS7I45OJUE')
const AliceKey = AliceKeypair.publicKey()
const Alice = await server.loadAccount(AliceKeypair.publicKey())
// Alice generates throwaway keys for her version account and for the ratchet account
const AliceVersionKeypair = Keypair.random()
const AliceRatchetKeypair = Keypair.random()
const AliceVersionKey = AliceVersionKeypair.publicKey()
const AliceRatchetKey = AliceRatchetKeypair.publicKey()
// Bob does the same
const BobKeypair = Keypair.fromSecret('SAJ2ISPPRUA4MPCDFOILZ6E4H3X6I4OVTMPX4QZBLXTMWMSKO5MC4H6E')
const BobKey = BobKeypair.publicKey()
const Bob = await server.loadAccount(BobKey)
const BobVersionKeypair = Keypair.random()
const BobRatchetKeypair = Keypair.random()
const BobVersionKey = BobVersionKeypair.publicKey()
const BobRatchetKey = BobRatchetKeypair.publicKey()
// the Ratchet account ID is Alice's ratchet key
const RatchetAccountId = AliceRatchetKeypair.publicKey()
Luego creamos tres cuentas:
const setupAccountsTx = new TransactionBuilder(Alice)
.addOperation(
Operation.createAccount({
destination: AliceVersionKey,
startingBalance: "1"
})
)
.addOperation(
Operation.createAccount({
destination: BobVersionKey,
startingBalance: "1"
})
)
.addOperation(
// set up the ratchet account
// which initially has only Alice's ratchet key
// the funding transaction will add Bob's key
Operation.createAccount({
destination: AliceRatchetKey,
startingBalance: "2"
})
)
.build();
setupAccountsTx.sign(AliceKeypair);
await server.submitTransaction(setupAccountsTx);
const AliceVersion = await server.loadAccount(AliceVersionKey);
const BobVersion = await server.loadAccount(BobVersionKey);
const Ratchet = await server.loadAccount(RatchetAccountId);
Alice y Bob ahora deben preparar la ronda 0 antes de financiar el canal. Primero, preparan transacciones de instantánea reflejando sus saldos actuales, e intercambian sus firmas en ellas.
const Round0Time = moment().unix();
const RatchetSequenceNumber = bigInt(Ratchet.sequenceNumber());
const Ratchet0SequenceNumber = RatchetSequenceNumber.plus(3);
const Snapshot0Alice = new TransactionBuilder(
new Account(RatchetAccountId, Ratchet0SequenceNumber.toString()),
{
timebounds: {
minTime: Round0Time + TIMEOUT_CLAIM + TIMEOUT_CLAIM_DELAY,
maxTime: 0
}
}
)
.addOperation(
Operation.payment({
destination: Alice.accountId(),
asset: Asset.native(),
amount: "250"
})
)
.build();
const Snapshot0Bob = new TransactionBuilder(
new Account(RatchetAccountId, Ratchet0SequenceNumber.plus(1).toString()),
{
timebounds: {
minTime: Round0Time + TIMEOUT_CLAIM + TIMEOUT_CLAIM_DELAY,
maxTime: 0
}
}
)
.addOperation(
// gives control over the ratchet, and its remaining 750 lumens, to Bob
Operation.setOptions({ signer: { ed25519PublicKey: BobKey, weight: 2 } })
)
.build();
// exchange signatures
Snapshot0Bob.sign(AliceRatchetKeypair);
Snapshot0Alice.sign(BobRatchetKeypair);
Luego intercambian sus transacciones iniciales de Ratchet, lo cual aumentará el número de secuencia de la cuenta de ratchet al número de secuencia inmediatamente anterior a las transacciones de instantánea. (Nota que esto aún no funcionará en el SDK existente, porque la BUMP_SEQUENCE
la operación aún no es admitida en la red.)
const Ratchet0Alice = new TransactionBuilder(
new Account(AliceVersion.accountId(), AliceVersion.sequenceNumber()),
{ timebounds: { minTime: Round0Time, maxTime: Round0Time + TIMEOUT_CLAIM } }
)
.addOperation(
Operation.BumpSequence({
sourceAccount: RatchetKey,
target: Ratchet0SequenceNumber.minus(1).toString()
})
)
.build();
const Ratchet0Bob = new TransactionBuilder(
new Account(BobVersion.accountId(), BobVersion.sequenceNumber()),
{ timebounds: { minTime: Round0Time, maxTime: Round0Time + TIMEOUT_CLAIM } }
)
.addOperation(
Operation.BumpSequence({
sourceAccount: RatchetKey,
target: Ratchet0SequenceNumber.minus(1).toString()
})
)
.build();
Ahora que las transacciones de instantánea y de trinquete están en su lugar, ya sea Alice o Bob tendrán la capacidad de cerrar el canal y recibir su parte de los lumens. Esto significa que ahora es seguro para Alice y Bob financiar el canal.
const fundingTx = new TransactionBuilder(Ratchet)
.addOperation(
Operation.payment({
source: Alice.accountId(),
destination: Ratchet.accountId(),
asset: Asset.native(),
amount: "248"
// Alice ya ha pagado 2 lumens
})
)
.addOperation(
Operation.payment({
source: Bob.accountId(),
destination: Ratchet.accountId(),
asset: Asset.native(),
amount: "750"
})
)
.addOperation(
Operation.setOptions({
signer: { ed25519PublicKey: BobRatchetKey, weight: 1 },
lowThreshold: 2,
medThreshold: 2,
highThreshold: 2
})
)
.build();
fundingTx.sign(AliceKeypair);
fundingTx.sign(BobKeypair);
fundingTx.sign(AliceRatchetKeypair);
await server.submitTransaction(fundingTx);
Ahora el canal está completamente establecido. Si, en este punto, ya sea Alice o Bob actuara deshonestamente (por ejemplo, desconectándose o negándose a responder) cualquiera de las partes puede iniciar su transacción de trinquete, luego las transacciones de instantánea, para volver a su estado inicial. Críticamente, la parte redentora debe actuar dentro del rango de tiempo especificado. En este caso, si no hay más rondas en el canal y Bob no coopera en crear más rondas, Alice debería intentar cerrar el canal dentro de una semana (para darse al menos D tiempo para incluir su transacción). Luego debe esperar dos semanas (un total de tres semanas desde el inicio del canal) para que las transacciones de instantánea sean válidas. Ahora, Bob quiere pagar a Alice 250 lumens a través del canal. En otras palabras, quieren actualizar el estado del canal, así que los saldos cambian de 250/750 (con Alice poseyendo 250) a 500/500. Alice y Bob crean nuevas transacciones de instantánea, reflejando el estado actualizado, e intercambian sus firmas en ellas.
const Ratchet1SequenceNumber = Ratchet0SequenceNumber.plus(3);
const Ratchet1Account = new Account(
Ratchet.accountId(),
Ratchet1SequenceNumber.toString()
);
const Round1Time = moment().unix();
const Snapshot1Alice = new TransactionBuilder(
new Account(RatchetAccountId, Ratchet1SequenceNumber.toString()),
{
timebounds: {
minTime: Round1Time + TIMEOUT_CLAIM + TIMEOUT_CLAIM_DELAY,
maxTime: 0
}
}
)
.addOperation(
Operation.payment({
destination: Alice.accountId(),
asset: Asset.native(),
amount: "500"
})
)
.build();
const Snapshot1Bob = new TransactionBuilder(
new Account(RatchetAccountId, Ratchet1SequenceNumber.plus(1).toString()),
{
timebounds: {
minTime: Round1Time + TIMEOUT_CLAIM + TIMEOUT_CLAIM_DELAY,
maxTime: 0
}
}
)
.addOperation(
Operation.setOptions({ signer: { ed25519PublicKey: BobKey, weight: 2 } })
)
.build();
// intercambio de firmas
Snapshot1Alice.sign(AliceRatchetKeypair);
Snapshot1Bob.sign(AliceRatchetKeypair);
Snapshot1Alice.sign(BobRatchetKeypair);
Snapshot1Bob.sign(BobRatchetKeypair);
Ahora pueden crear e intercambiar firmas en nuevas transacciones de trinquete:
const Ratchet1Bob = new TransactionBuilder(
new Account(BobVersion.accountId(), BobVersion.sequenceNumber()),
{ timebounds: { minTime: Round1Time, maxTime: Round1Time + TIMEOUT_CLAIM } }
)
.addOperation(
Operation.BumpSequence({
sourceAccount: RatchetKey,
target: Ratchet1SequenceNumber.minus(1).toString()
})
)
.build();
const Ratchet1Alice = new TransactionBuilder(
new Account(AliceVersion.accountId(), AliceVersion.sequenceNumber()),
{ timebounds: { minTime: Round1Time, maxTime: Round1Time + TIMEOUT_CLAIM } }
)
.addOperation(
Operation.BumpSequence({
sourceAccount: RatchetKey,
target: Ratchet1SequenceNumber.minus(1).toString()
})
)
.build();
Ratchet1Bob.sign(AliceRatchetKeypair);
Ratchet1Alice.sign(BobRatchetKeypair);
Este pago ahora está hecho. **Nota que ninguna de estas transacciones se transmite
a la red.** Sin embargo, ahora hay un problema potencial: Alice y Bob todavía
tienen transacciones de trinquete y de instantánea válidas de la ronda 0, cuando sus saldos
eran diferentes. ¿Qué pasa si Bob intenta enviar esas transacciones, para cerrar
el canal en un estado desactualizado? Cada uno de Alice y Bob debería por lo tanto monitorear
la red para detectar cualquier transacción de la cuenta de versión del otro. Si detectan
una, deberían enviar inmediatamente la transacción de trinquete de
la última ronda.
const streamHandler = server
.transactions()
.forAccount(BobVersion.accountId())
.cursor("now")
.stream({
onmessage: async function(transaction) {
if (transaction.hash !== Ratchet1Bob.hash().toString("hex")) {
await server.submitTransaction(Ratchet1Alice);
}
}
});
Para asegurar que hay suficiente tiempo para que Alice o Bob desafíen cualquier presentación inválida, deberían asegurarse de que las rondas sucedan con suficiente frecuencia para que el tiempo restante en que la última transacción de trinquete es válida sea al menos tan largo como D, así tendrán tiempo para responder a cualquier presentación de transacciones de trinquete obsoletas. Las partes pueden agregar tantos pagos como deseen al canal creando y firmando nuevas transacciones de instantánea reflejando el nuevo estado del canal, así como transacciones de trinquete que establecen esas transacciones de instantánea. Para cada nueva ronda, el número de secuencia inicial de las transacciones de instantánea se incrementa en 3. Ninguna de estas transacciones necesita ser enviada a la red. Finalmente, para cerrar el canal, Alice y Bob firman y envían transacciones de cierre a la red, usando los saldos de las últimas transacciones de instantánea. Estas transacciones son similares a las rondas anteriores: involucran tanto transacciones de trinquete como de instantánea, excepto que las transacciones de instantánea no necesitan límites de tiempo, y solo se requiere una transacción de trinquete compartida.
const CooperativeCloseSequenceNumber = Ratchet1SequenceNumber.plus(3);
const CooperativeCloseSnapshotAlice = new TransactionBuilder(
new Account(RatchetAccountId, CooperativeCloseSequenceNumber.toString())
)
.addOperation(
Operation.payment({
destination: Alice.accountId(),
asset: Asset.native(),
amount: "500"
})
)
.build();
const CooperativeCloseSnapshotBob = new TransactionBuilder(
new Account(
RatchetAccountId,
CooperativeCloseSequenceNumber.plus(1).toString()
)
)
.addOperation(
Operation.setOptions({ signer: { ed25519PublicKey: BobKey, weight: 2 } })
)
.build();
CooperativeCloseSnapshotAlice.sign(AliceRatchetKeypair);
CooperativeCloseSnapshotBob.sign(AliceRatchetKeypair);
CooperativeCloseSnapshotAlice.sign(BobRatchetKeypair);
CooperativeCloseSnapshotBob.sign(BobRatchetKeypair);
const CooperativeCloseRatchet = new TransactionBuilder(
new Account(Ratchet.accountId(), RatchetInitialSequenceNumber.toString()),
{ timebounds: { minTime: ClosingTime, maxTime: ClosingTime + TIMEOUT_CLAIM } }
)
.addOperation(
Operation.BumpSequence({
target: CooperativeCloseSequenceNumber.minus(1).toString()
})
)
.build();
CooperativeCloseRatchet.sign(AliceRatchetKeypair);
CooperativeCloseRatchet.sign(BobRatchetKeypair);
await server.submitTransaction(CooperativeCloseRatchet);
await server.submitTransaction(CooperativeCloseSnapshotAlice);
await server.submitTransaction(CooperativeCloseSnapshotBob);
Esta es una manera de hacer un cierre honesto y seguro; hay otras que revelan incluso menos información a la red.
Podemos demostrar de manera informal que en este punto hemos hecho imposible cerrar en el estado inicial del canal después de que la próxima ronda se haya completado. Nuestro argumento se generaliza a cualquier número de estados anteriores, y también se sostiene cuando los roles de Alice y Bob se invierten.
Supongamos que Bob es malicioso y Alice es honesta.
Supongamos que Bob desaparece después de Snapshot1Alice, y Alice es honesta.
Supongamos que Bob desaparece en medio de un pago o cierre honesto, después de firmar Snapshot1Alice y Snapshot1Bob, pero antes de firmar Ratchet1Alice, y Alice es honesta.
Supongamos que Bob desaparece en medio de un pago o cierre honesto, después de recibir la firma de Alice en BobRatchet1, pero antes de dar su firma a Alice en Ratchet1Alice, y Alice es honesta.
Supongamos que Alice desaparece en medio de un pago o cierre honesto, después de crear Snapshot1Alice, pero antes de crear Ratchet1Alice, mientras Bob es honesto.
Este es un diseño simple para canales de pago en Stellar, pero todavía hay mucho trabajo por hacer. Actualmente estamos trabajando en soporte para pagos multi-salto, mayor privacidad y escalabilidad, e interoperabilidad con canales de Lightning Network en otras blockchains como Bitcoin. Si estás interesado en ayudarnos a desarrollar nuestro protocolo, únete a nosotros en GitHub o StackExchange.Queremos que Stellar se convierta en la vía de pago digital del mundo. Ya somos los más listos para despliegue de las principales plataformas (ver el gráfico abajo), pero dada la escala del futuro que vemos para Stellar, sabemos que necesitamos seguir avanzando nuestra tecnología.