Noticias de la Fundación

El modelo de autorización componible de Stellar

Author

Leigh McCulloch

Publishing date

La autorización es de esas cosas que parecen simples hasta que intentas desarrollar una aplicación real. ¿Quién puede llamar a esta función? ¿Quién paga las comisiones? ¿Qué pasa cuando el Contrato A llama al Contrato B en nombre de un usuario? El marco de autorización de Stellar responde a todas estas preguntas con un diseño a la vez mínimo y notablemente flexible.

En esencia, el modelo se basa en tres ideas: los contratos declaran qué necesita autorización, el tiempo de ejecución gestiona cómo que se verifica esa autorización, y que los datos de autorización en sí son desacoplables de la transacción. Esta separación habilita patrones como transacciones patrocinadas, swaps atómicos multi-parte y cuentas programables que son dolorosas o imposibles en la mayoría de las plataformas de contratos inteligentes.

Cómo funciona la autorización

Un contrato de Stellar nunca ve firmas ni credenciales directamente. En su lugar, llama require_auth() en una Address:

pub fn withdraw(env: Env, owner: Address, amount: i128) {
    owner.require_auth();
    // ... transfer logic
}

Esa única línea es toda la política de seguridad del contrato para esta función. El tiempo de ejecución se encarga del resto: haciendo coincidir la require_auth con las entradas de autorización adjuntas a la transacción, verificando firmas, comprobando nonces, evitando repeticiones y aplicando la expiración.

Esta separación significa que los contratos son simples de escribir y auditar. La lógica de autorización es declarativa (“esta Address debe haber autorizado esta llamada”) y la maquinaria criptográfica vive fuera del contrato.

Autorización desacoplada

En la mayoría de las blockchains, la autorización está soldada a la transacción. La persona que firma la transacción es quien paga las comisiones, y esa es la persona que autorizó toda la operación.

Ethereum ilustra bien la tensión. El modelo base es msg.sender: quien envió la transacción (y pagó gas) es la parte autorizada. Esto es simple pero rígido: si Alice quiere que Bob actúe en su nombre, no hay una forma incorporada de expresar eso. Con el tiempo, el ecosistema fue atornillando soluciones. ERC-20 approve/transferFrom te permite preautorizar a un gastador, pero es una transacción on-chain separada con su propio costo de gas, y el exceso de aprobación es un “footgun” bien conocido. EIP-2612 permit añadió firmas off-chain para aprobaciones, lo cual se acerca más a la autorización desacoplada, pero es específico del token, no un marco general. ERC-4337 account abstraction introdujo toda una canalización de transacciones paralela (bundlers, paymasters, user operations) para desacoplar el pago de comisiones de la autorización. Cada una de estas es una solución puntual en capas sobre un modelo que originalmente no consideró el problema.

Stellar maneja todos estos casos (msg.sender-style auth del llamador, permit-style con firmas desacopladas, y abstracción de comisiones) con un mecanismo incorporado en el tiempo de ejecución de contratos inteligentes de Stellar, Soroban.

Una transacción de Stellar lleva una matriz de SorobanAuthorizationEntry elementos junto con la invocación del contrato. Cada entrada está firmada de forma independiente por la parte a la que representa, y está estructuralmente separada de la firma del sobre de la transacción. Esto significa:

  1. El autorizador y quien paga las comisiones pueden ser personas distintas. Un usuario firma una entrada de autorización que cubre una llamada específica al contrato. Entrega esa entrada firmada a un relayer. El relayer la envuelve en una transacción, paga las comisiones con su propia cuenta, firma el sobre y envía. El usuario nunca toca XLM para el gas.
  2. Las entradas de autorización son portátiles. Una entrada de autorización firmada puede pasarse, almacenarse e incluirse en una transacción por cualquiera. Está vinculada a una red específica, a un nonce (para prevenir repeticiones), a un libro mayor de expiración y al árbol de autorización exacto, pero no a una transacción o pagador de comisiones particular.
  3. Las transacciones multipartes son naturales. Cuando un swap requiere que tanto Alice como Bob autoricen transferencias de tokens, cada uno firma su propia entrada de autorización de forma independiente. Un coordinador recopila ambas entradas, las pone en una sola transacción y envía. Sin ceremonia multi-sig, sin firmas secuenciales.

La estructura de la transacción se ve así:

TransactionEnvelope
  Transaction
    Operations[0]: InvokeHostFunctionOp
      hostFunction: invokeContract(swap, "execute",
        [alice, bob, token_a, 100, token_b, 50])
      auth: [
        SorobanAuthorizationEntry {         // Alice's auth
          credentials: ADDRESS { alice, nonce, expiration, signature },
          rootInvocation: swap.execute(alice, bob, token_a, 100, token_b, 50)
            └── token_a.transfer(alice, bob, 100)
        },
        SorobanAuthorizationEntry {         // Bob's auth
          credentials: ADDRESS { bob, nonce, expiration, signature },
          rootInvocation: swap.execute(alice, bob, token_a, 100, token_b, 50)
            └── token_b.transfer(bob, alice, 50)
        }
      ]
  Signatures: [envelope_sig_from_coordinator]  // fee-payer

Alice y Bob firmaron cada uno solo el árbol de autorización que les corresponde. El coordinador firmó el sobre de la transacción. Tres firmas independientes, una transacción atómica.

Ten en cuenta que, para casos simples, cuando la cuenta fuente de la transacción

es el autorizador, puedes usar SOROBAN_CREDENTIALS_SOURCE_ACCOUNT credenciales, que derivan la autorización de la firma del sobre. Esto evita una firma de entrada de autorización separada pero sacrifica el desacople. Es útil para casos simples en los que quien llama paga sus propias comisiones y un solo autorizador participa en la transacción.

Árboles de autorización vs. Árboles de invocación

Una distinción crítica en el modelo de Soroban: el árbol de autorización que firma un usuario es no lo mismo que el árbol de invocación (la pila real de llamadas de funciones del contrato). El árbol de autorización contiene solo las funciones que llaman require_auth para esa Address. Puede superponerse con el árbol de invocación, pero no tiene por qué.

Autorización raíz

Considera un contrato DEX que permite adquirir un token:

pub fn buy(env: Env, buyer: Address, token: Address, amount: i128) {
    buyer.require_auth();
    token::Client::new(&env, &token).transfer(&buyer, &env.current_contract_address(), &amount);
}

Tanto dex.buy y token.transfer llaman require_auth(buyer). El árbol de autorización del comprador cubre ambos:

auth tree for buyer:
  dex.buy(buyer, token, amount)
    └── token.transfer(buyer, dex, amount)

El comprador firma este árbol de autorización. El nodo dex.buy es la raíz del árbol de autorización, y resulta ser también la raíz del árbol de invocación. Esto es “autorización raíz”. Esta también es una autorización donde todas las invocaciones del camino están incluidas en el árbol de autorización.

Auth no root

Ahora considera qué ocurre si el contrato DEX no llama a require_auth:

pub fn buy(env: Env, buyer: Address, token: Address, amount: i128) {
    // No require_auth here!
    token::Client::new(&env, &token).transfer(&buyer, &env.current_contract_address(), &amount);
}

El árbol de invocación es el mismo (dex.buy llama a token.transfer), pero el árbol de autenticación es diferente. Como dex.buy nunca llama a require_auth(buyer), no aparece en el árbol de autenticación. El árbol de autenticación del comprador es simplemente:

auth tree for buyer:
  token.transfer(buyer, dex, amount)

Esto es "autenticación no raíz". La raíz del árbol de autenticación del comprador (token.transfer) es una sub-invocación en el árbol de invocación, no la llamada de nivel superior. El usuario está autorizando la transferencia del token directamente e independiente de cualquier otra operación. Las llamadas autorizadas pueden desacoplarse del resto del árbol de llamadas. Esto significa que el usuario está diciendo que la transferencia puede ocurrir con o sin esas otras llamadas. Si la función buy actualizara el estado para llevar algún registro de la compra, esto podría ser un error grave. Es importante que un desarrollador sea cuidadoso sobre cuándo usar la autenticación no raíz.

La autenticación no raíz proporciona flexibilidad. Debido a que las entradas de autenticación no necesitan estar enraizadas en la invocación de nivel superior, un usuario puede firmar autorizaciones independientes para operaciones específicas (como una transferencia de tokens) y entregar esas entradas firmadas a cualquiera para incluirlas en cualquier transacción. Por ejemplo, un agregador podría incluir ejecuciones de otros.

Un usuario puede firmar varias entradas de autenticación independientes (una aprobación de token aquí, una transferencia allá) y un coordinador puede agruparlas todas en una sola transacción junto con otras operaciones. Las entradas de autenticación no necesitan saber unas de otras ni acerca de la invocación de nivel superior que las vincula si no se pretende que sean atómicas.

En Ethereum, lograr algo similar requiere pasar por aros. Necesitarías una combinación de EIP-2612 permit firmas (solo disponibles para tokens que lo implementan), un contrato de multicall o de batching para ejecutarlas de forma atómica, y una orquestación cuidadosa para asegurar que los permits y las operaciones que los consumen caigan en el orden correcto dentro de la misma transacción. Cada token podría implementar permits de manera diferente (o no hacerlo), y el contrato de batching necesita soporte explícito para cada tipo de operación. La autenticación no raíz de Soroban maneja esto de forma genérica. Cualquier require_auth llamada en cualquier contrato puede satisfacerse con una entrada de autenticación separada, sin necesitar estándares especiales de tokens ni infraestructura de batching.

Por qué require_auth importa en cada nivel

El árbol de autenticación solo contiene nodos donde require_auth es llamado. Esto significa que cada require_auth llamada en la cadena agrega un nodo al árbol, dando a quien autoriza visibilidad y control sobre el contexto. Si un contrato intermedio omite require_auth, su llamada es invisible en el árbol de autenticación, y quien autoriza no puede distinguir si la sub-llamada ocurrió en un contexto u otro. Esto podría estar bien si esa sub-llamada es un detalle sin importancia de la secuencia de llamadas.

Considera tres contratos en una cadena: un router, un pool y un token:

// Router contract
pub fn swap(env: Env, user: Address, pool: Address, amount: i128) {
    user.require_auth();  // ← (1) this anchors the auth tree
    pool::Client::new(&env, &pool).buy(&user, &amount);
}

// Pool contract
pub fn buy(env: Env, user: Address, amount: i128) {
    user.require_auth();  // ← (2) this retains pool.buy in the auth tree
    token_client.transfer(&user, &env.current_contract_address(), &amount);
}

El árbol de autenticación del usuario es:

router.swap(user, pool, amount)
  └── pool.buy(user, amount)
      └── token.transfer(user, pool, amount)

El usuario puede ver exactamente qué está autorizando: un swap en este router, comprar en este pool, transferir este monto. Ahora imagina que el pool omite su require_auth:

// Pool contract (missing require_auth)
pub fn buy(env: Env, user: Address, amount: i128) {
    // No require_auth!
    token_client.transfer(&user, &env.current_contract_address(), &amount);
}

El árbol de autenticación colapsa. pool.buy se poda porque nunca llamó a require_auth(user):

router.swap(user, pool, amount)
  └── token.transfer(user, pool, amount)

El pool.buy nodo ya no está. El usuario autorizó una transferencia al pool, pero no hay registro en el árbol de autenticación de que pasó a través de la buy específicamente. Si el pool tuviera múltiples funciones que todas llamaran a token.transfer con los mismos argumentos, el árbol de autenticación no podría distinguir entre ellas.

Peor aún, si el router también omite require_auth, el árbol de autenticación se convierte simplemente en:

token.transfer(user, pool, amount)

Ahora el usuario está autorizando una transferencia de tokens simple, sin ningún contexto. Cualquier contrato que llame a token.transfer(user, pool, amount) satisfaría esta autorización. El usuario no tiene garantía sobre por qué se mueven sus tokens.

La regla: todo contrato en la cadena de llamadas que sea importante para quien autoriza debe llamar a require_auth. Cada llamada agrega un nodo al árbol de autenticación, y cada nodo le da a quien firma contexto sobre qué está autorizando y en qué circunstancias.

Autenticación automática del invocador del contrato

Hay una optimización importante para llamadas de contrato a contrato. Cuando el Contrato A llama directamente al Contrato B, y B llama a require_auth(address_of_A), la autorización tiene éxito automáticamente sin necesidad de firma. El runtime sabe que A es el llamador directo, por lo que no se requiere prueba externa.

Esta es la base de la composabilidad. Un contrato que posee tokens puede autorizar su uso en llamadas posteriores simplemente por ser el llamador:

// Inside Contract A
pub fn process(env: Env) {
    // Contract A holds tokens. When it calls transfer, the token contract
    // will require_auth on A's address, which succeeds automatically
    // because A is the direct caller.
    token_client.transfer(&env.current_contract_address(), &recipient, &amount);
}

Esto solo funciona para el invocador directo invocador. Si A llama a B, B llama a C, y C llama a

require_auth(address_of_A), no se aprobará automáticamente porque A no es el invocador directo de C. Para cadenas más profundas, los contratos usan authorize_as_current_contract:

pub fn process(env: Env, recipient: Address, amount: i128) {
    let my_addr = env.current_contract_address();

    // Pre-authorize the deeper call that B will make on our behalf
    env.authorize_as_current_contract(vec![
        &env,
        InvokerContractAuthEntry::Contract(SubContractInvocation {
            context: ContractContext {
                contract: token_addr.clone(),
                fn_name: symbol_short!("transfer"),
                args: (my_addr.clone(), recipient.clone(), amount).into_val(&env),
            },
            sub_invocations: vec![&env],
        }),
    ]);

    // B will call token.transfer(my_addr, recipient, amount) internally
    contract_b_client.route_payment(&my_addr, &recipient, &amount);
}

Argumentos personalizados y control detallado

A veces quieres autorizar algo distinto a los argumentos exactos de la función. require_auth_for_args te permite especificar argumentos de autorización personalizados:

pub fn batch_mint(env: Env, transfers: Vec<Transfer>) {
    let admin = env.storage().instance().get(DataKey::Admin).unwrap();

    // Authorize the entire batch as a unit, not each individual transfer,
    // and include the token as part of the signature payload so that
    // so that the signature is only valid for the token the contract is
    // currently configured with.
    admin.require_auth_for_args(
        (
            symbol_short!("batch"),
            token_client.address(),
            transfers.clone(),
        ).into_val(&env)
    );

    let token = env.storage().instance().get(DataKey::Token).unwrap();
    let token_client = TokenClient::new(&env, &token);

    for t in transfers.iter() {
        token_client.mint(&t.to, &t.amount);
    }
}

Esto es útil cuando la semántica de autorización no se ajusta claramente a la firma de la función: autorizar un subconjunto de argumentos, autorizar un valor calculado, autorizar un valor almacenado, o hacer múltiples llamadas a require_auth en la misma función para fines diferentes.

Abstracción de cuenta

De Soroban Address es un tipo abstracto. Puede representar una cuenta Stellar clásica (G-address con claves ed25519) o una cuenta de contrato (C-address). Las cuentas de contrato implementan una __check_auth función que define lógica de autenticación arbitraria:

impl CustomAccountInterface for MyWallet {
    fn __check_auth(
        env: Env,
        signature_payload: BytesN<32>,
        signatures: Val,
        auth_contexts: Vec<Context>,
    ) -> Result<(), Error> {
        // Any logic you want:
        // - WebAuthn / passkey verification
        // - Multi-sig with weighted signers
        // - Spending limits per time period
        // - Policy-based access control
        // - Social recovery
    }
}

El host llama a __check_auth automáticamente siempre que require_auth se invoca en la dirección de la cuenta de contrato. El auth_contexts le indica a la cuenta qué está siendo autorizado, para que pueda aplicar políticas detalladas como "permitir hasta 1000 USDC por día sin aprobación adicional."

Como esto usa el mismo require_auth mecanismo, las cuentas de contrato funcionan en todos los lugares donde funcionan las cuentas normales. Un protocolo DeFi no necesita código especial para admitir billeteras inteligentes. Simplemente llama a require_auth, y el runtime enruta a la lógica de verificación correcta.

Casos de uso de ejemplo

Transacciones patrocinadas

Una app de billetera móvil donde los usuarios nunca mantienen XLM:

  1. El usuario firma una entrada de auth que autoriza una llamada de contrato
  2. El backend de la app recibe la entrada firmada
  3. El backend construye una transacción con su propia cuenta de origen (pagando comisiones), incluye la entrada de auth del usuario y envía

La entrada de auth del usuario está vinculada a la llamada específica, el nonce y la expiración, por lo que el backend no puede reutilizarla para nada más.

Swaps atómicos multipartes

Una mesa OTC que empareja a dos contrapartes:

pub fn execute_swap(
    env: Env,
    alice: Address,
    bob: Address,
    token_a: Address,
    amount_a: i128,
    token_b: Address,
    amount_b: i128,
) {
    alice.require_auth();
    bob.require_auth();

    token::Client::new(&env, &token_a).transfer(&alice, &bob, &amount_a);
    token::Client::new(&env, &token_b).transfer(&bob, &alice, &amount_b);
}

Alice firma un árbol de auth que cubre swap.execute y su token_a transferencia. Bob firma uno que cubre swap.execute y su token_b transferencia. La mesa envía ambos en una sola transacción. O bien ambas transferencias ocurren o no ocurre ninguna.

Composabilidad DeFi

Un enrutador multiprotocolo que deposita en múltiples protocolos:

pub fn deposit(env: Env, user: Address, amount: i128) {
    user.require_auth();

    // Pull tokens from user
    token_client.transfer(&user, &env.current_contract_address(), &amount);

    // Split across protocols. These calls use automatic invoker auth
    // because this contract is the direct caller and holds the tokens
    let half = amount / 2;
    protocol_a_client.deposit(&env.current_contract_address(), &half);
    protocol_b_client.deposit(&env.current_contract_address(), &(amount - half));
}

El usuario firma un árbol de auth que cubre el depósito de nivel superior y la transferencia del token. Las llamadas posteriores del contrato a Protocolo A y Protocolo B se autorizan automáticamente porque el contrato es el invocador directo que mueve sus propios tokens.

Claves de sesión mediante cuentas de contrato

Una cuenta de contrato que emite autorización de sesión limitada:

fn __check_auth(
    env: Env,
    signature_payload: BytesN<32>,
    signatures: Val,
    auth_contexts: Vec<Context>,
) -> Result<(), Error> {
    let sig: SessionSignature = signatures.try_into()?;

    // Verify the session key is active and not expired
    let session = get_session(&env, &sig.session_key)?;
    verify_not_expired(&env, &session)?;

    // Enforce session policy: only allow specific contracts/functions
    for ctx in auth_contexts.iter() {
        enforce_session_policy(&session, &ctx)?;
    }

    // Verify the session key's signature
    env.crypto().ed25519_verify(&sig.session_key, &signature_payload, &sig.signature);
    Ok(())
}

La clave principal del usuario crea una sesión con una política limitada. El cliente usa la clave de sesión para transacciones sin solicitar la clave principal del usuario para cada acción.

Resumen

El modelo de auth de Soroban logra composabilidad mediante la separación de responsabilidades:

  • Contratos declaran requisitos de autorización con require_auth
  • Entradas de auth llevan árboles de autorización firmados de forma independiente
  • El tiempo de ejecución hace coincidir, verifica y aplica

La autorización separable desacopla la autorización del pago de comisiones. Los árboles de autorización hacen que las llamadas entre contratos sean seguras y auditables. La autorización automática del invocador hace que la composición de contrato a contrato sea sin fricciones. Y las cuentas programables permiten que cualquier lógica de verificación se conecte al mismo marco.

El resultado es un sistema donde un simple owner.require_auth() en tu contrato admite automáticamente transacciones patrocinadas, coordinación de múltiples partes, billeteras inteligentes, claves de sesión y composición arbitraria, sin que tu contrato lo sepa ni le importe en absoluto.