Synsemadocsv0.6.xENES

Operación

Vela (Horizen)

Vela es el coprocesador confidencial de Horizen: una aplicación corre como módulo WebAssembly dentro de un TEE (un AWS Nitro Enclave en producción, un proceso aislado en el kit local), su estado viaja cifrado y cada resultado se liquida on-chain como un state root firmado. El toolchain oficial es Go + TinyGo. Desde el paquete de guests, la aplicación puede ser un archivo .syn, y el lado de afuera del enclave — claves, deploys, requests cifrados, eventos, reportes, meta-transacciones — también puede ser Synsema.

Esta página es el cuadro completo. El directorio del repositorio es el contrato: packages/guests/vela (README, adaptador, tres apps de ejemplo, un cliente, un trigger contract, un ERC-20, dos sondas). Nada de esto es una suposición: el ABI se leyó del código Go de Horizen (vela, vela-common-go, vela-nova, vela-starterkit, v0.2.0) y cada flujo de abajo corrió contra el starter kit en Docker.

Empezá por el kit: transferencias privadas. SYNSEMA/vela-transfers es un repositorio plantilla y una app completa (saldos y transferencias cifrados en el enclave, una factura y un recibo público por transferencia, el historial para un auditor autorizado): la app con sus tests, el cliente, scripts/build.sh (tu programa dentro del módulo guest de la release: sin compilador, unos segundos), scripts/smoke.mjs, scripts/devnet.sh (el starter kit de Horizen en Docker) y scripts/e2e.sh (claves → deploy → registro → depósito → un request cifrado → tus eventos), más un workflow de CI que arma app.wasm en cada push. "Use this template" y los cuatro comandos de su README. ¿Sin Docker? synsema run vela_client.syn -- devnet escribe en client/.env un token propio del devnet público (abajo).

Nómina privada. SYNSEMA/vela-payroll es nómina privada en Vela, construida sobre el kit: una empresa fondea la app con una stablecoin y corre la nómina desde un CSV; cada persona ve sólo sus recibos; la cadena ve depósitos, retiros y un recibo público por corrida (número de corrida, cantidad de personas, un hash de los ítems); las personas retiran como pull-payment y no necesitan ETH, porque el facilitador de la empresa envía por ellas; un auditor autorizado obtiene el cuadro en claro desde el enclave. Su cliente agrega fund, payrun <csv>, payslips, withdraw, pending y claim-for, con montos en tokens convertidos por texto con los decimals() del token; trae el ERC-20 de prueba con permit que su scripts/devnet.sh despliega y permite localmente; y su scripts/e2e.sh corre el ciclo entero. Verificado de punta a punta en el devnet público.

Motor de políticas de pago. SYNSEMA/vela-policy-engine es un motor de políticas de pago, política adentro y LLM afuera: un agente (cualquier proceso con una clave de proponente: un worker en la plataforma Synsema, una laptop) propone pagos; el enclave aplica la política del dueño (quién propone, a quién se paga y hasta cuánto, el límite automático por pago, la asignación otorgada) y o bien paga al instante por un contrato trigger (TreasuryTrigger.sol, uno por app, ERC-20 o ETH) o retiene la propuesta con todas las razones para el approve / reject del dueño; la respuesta del trigger liquida el pago o lo devuelve. El worker autónomo del agente (agent.syn) convierte las facturas de inbox/ en propuestas (JSON tal cual; texto libre con un LLM configurado) y no tiene fondos: lo peor que puede hacer un bug o una inyección de prompt es una propuesta que la política frena. El protocolo del cliente es un módulo (client/vela_lib.syn) que el worker y la CLI usan con use. Verificado de punta a punta en el devnet público, ciclo del trigger incluido.

Dark pool. SYNSEMA/vela-dark-pool es un dark pool para operaciones en bloque, mecánicamente una subasta de sobre cerrado por lotes: una parte vendedora ofrece un bloque de un token a cambio de otro; quienes pujan depositan el token de pago y mandan sus pujas cifradas al enclave: nadie, ni la parte vendedora, ve una puja antes del cierre; el matching corre adentro (precio uniforme o pay-as-bid, pujas ordenadas por multiplicación cruzada de pares (cantidad, total), enteros exactos, con una división larga propia porque / pasa por float); ganadores y perdedores se liquidan desde su escrow como pull-payments, cada quien conoce sólo su resultado y el precio de cierre, las pujas perdedoras no se revelan nunca, y la cadena ve recibos opened y cleared sin nadie adentro. Tres partes, una subasta, dos pujas cifradas, retiros verificados en cadena: scripts/e2e.sh, verificado en el devnet público.

Cada kit es una app web. La entrada de la receta en los cuatro es una consola (web.syn, kind = web): la consola de nómina (fondear, dar de alta personas, corridas, los recibos y retiros de cada persona), la consola del motor de políticas (desplegar con un trigger creado por un contrato fábrica en el stack, política, beneficiarios, las propuestas del agente, aprobar o rechazar, recibos), la consola del dark pool (la mesa del vendedor y una mesa por comprador) y la mesa de trabajo de transferencias privadas (desplegar tu app.syn, registrar, depositar, cualquier payload cifrado al Executor, tus eventos descifrados, usuarios por el facilitador, reportes). Cada acción es un request al enclave; el estado de la consola vive en el volumen del proyecto. Desplegada desde synsema.com, el entorno del proyecto se aprovisiona desde el devnet público al crearlo ([provision] env_url en syn.toml: un token propio, las direcciones, las claves); en local, synsema serve web.syn con las mismas líneas en .env. Las consolas custodian las claves de las cuentas que crean (una persona a la que un portal le guarda las claves, un postor de demostración, un agente de demostración): la biblioteca firma con cualquier clave que tenga (send_tx_as, submit_as) o envía por ella a través del facilitador (submit_for_with); una cuenta con billetera propia usa el cliente de línea de comandos. Los cuatro verificados de punta a punta en el devnet público desde el navegador.

Dónde encaja: un guest, no un artefacto nuevo§

Vela no carga un comando WASI (sin _start, sin argv) ni provee los imports synsema_host del artefacto embebible: su Executor instancia un módulo una vez y llama a sus propios exports — allocate, deallocate, load_module, deploy, deposit, process_request, trusted_request — con punteros a la memoria del módulo. Por eso el motor queda como está, y un adaptador fino en packages/guests/vela/ mapea esos exports sobre el ABI genérico synsema_call, con tu programa embebido: un .wasm es exactamente una app, y el SHA-256 que Vela verifica on-chain cubre el intérprete y tu lógica juntos.

Ese es el eje host de la regla de dos ejes. En el eje cliente, lo que Vela necesitaba y era genérico — primitivas WebCrypto (ecdh_, hkdf_sha256, aes_gcm_), --deterministic, steps() — entró al lenguaje con nombres genéricos. El nombre de ninguna empresa está en el motor.

Tu programa → un módulo (sin compilador)§

El módulo es el intérprete más tu programa. Cada release de Synsema publica el intérprete como guest de Vela — synsema-vela-guest.wasm, un asset de la release — con un slot de app adentro: un bloque de datos de tamaño fijo con cabecera. embed.syn (en el kit como scripts/embed.syn, en el repositorio como packages/guests/vela/tools/embed.syn) encuentra el slot en el archivo y lo sobreescribe con tu .syn. El resultado es un módulo que es exactamente tu app, y el SHA-256 que Vela verifica en cadena cubre intérprete y programa juntos. Programas de hasta 512 KB; el embed tarda un segundo.

synsema test app/app.syn                                   # the app, natively — the same code runs in the enclave
sh scripts/build.sh                                        # downloads the release's guest once, embeds app/app.syn → build/app.wasm (+ .sha256)
synsema run scripts/embed.syn -- synsema-vela-guest.wasm app/app.syn build/app.wasm   # the same, by hand
node scripts/smoke.mjs build/app.wasm                      # optional: Node 20 or 24+ (not 22, see below) — imports, exports, load_module, deploy, determinism

El guest siempre apunta a wasm32-wasip1: el linker de Vela define WASI y nada más, así que print se convierte en el log del Executor (INF …). El límite de subida de Vela es 50 MB; el guest de la release trae el ledger de ejemplo, así que se puede desplegar tal como viene.

El adaptador en sí — Rust, sólo si cambiás el adaptador (packages/guests/vela): rustup target add wasm32-wasip1 y, desde la raíz del repo en Linux o WSL, bash packages/guests/vela/tools/build.sh mi_guest.wasm (compila y pone los stubs de los imports WASI; mi_guest.wasm es el módulo que se sondea, se llena y se despliega; SYNSEMA_VELA_APP=/ruta/a/app.syn bash …/build.sh mi_guest.wasm llena el slot al compilar), cargo test --target <tu host triple> para sus tests unitarios, node tests/vela_guest.probe.mjs <el .wasm> y cd tests/wasmtime-go && go run . <el .wasm> para las dos sondas (el WASI de Node, y wasmtime-go v1.0.0 — el runtime exacto del Executor).

Reconstruir el hash publicado (v0.6.48+). build.sh es la misma receta que corren el CI y la release, así que un checkout del tag da el synsema-vela-guest.wasm de la release byte por byte desde cualquier carpeta y con cualquier CARGO_HOME, en Linux o WSL: la comprobación que una contraparte corre contra el wasmSha256 que guarda la cadena. Remapea las rutas de las fuentes que Rust deja en el módulo (las ubicaciones de los panics) y compila el guest desde el workspace de la raíz del repo; un cargo build a mano dentro de packages/guests/vela no hace ninguna de las dos y da otro hash. En Windows nativo no se reproduce (las rutas dentro del módulo quedan con \), y el script se niega a correr ahí (Git Bash, MSYS, Cygwin), igual que con RUSTFLAGS puesto. Cada release lo comprueba en dos runners, uno en otra carpeta y con otro CARGO_HOME; la comprobación avisa, no rompe la release. El motor lee su versión al compilar, así que compilá el tag con SYNSEMA_VERSION igual a ese tag (SYNSEMA_VERSION=v0.6.49 bash packages/guests/vela/tools/build.sh out.wasm): sin eso el hash es otro. v0.6.50: 9ba6f31970c59db9e51f31d888abdbd30885e7932e5469fa7b7d80760ccc9ee5.

Corré la sonda de Node con Node 20 o Node 24+, no con 22: Node 22.x se cae de forma intermitente dentro de V8 (un segmentation fault por la carrera del tier-up concurrente) al ejecutar este módulo — reproducido con 22.23.2 en Linux, una corrida de cada tres; node --no-wasm-dynamic-tiering … lo evita. El bug es de ese host, no del guest: wasmtime-go v1.0.0, el runtime del Executor, no se ve afectado, y CI corre las dos sondas.

El contrato de tu .syn§

El adaptador llama una task por punto de entrada de Vela; cada una recibe un mapa y devuelve un mapa. Las tasks que no definís caen como se indica.

Export de Velataskctx que recibe la taskmapa que devuelve
deploy(appId, params)deploy(ctx){app_id, kind: "deploy", params} — params es el JSON del constructor ya decodificado (nothing si está vacío){state, fuel?} — state obligatorio
load_module(appId)load_module(ctx) → cae en deploy con params: nothing{app_id, kind: "load_module", params: nothing}{state, fuel?} — el Executor lo llama sólo para calentar su cache de módulos tras un reinicio y descarta el estado; si el deploy de respaldo falla (necesitaba params) el adaptador responde un estado vacío con un aviso, en vez de un error que dejaría la app sin poder cargarse
deposit(appId, sender, token, value, state)deposit(ctx){app_id, kind: "deposit", sender, token, value, value_hex, state} — direcciones 0x + 40 hex, minúsculas; value como texto decimal exacto, value_hex como 0x…; state decodificado de JSON (texto si no es JSON){state?, events?, app_events?, fuel?, error?}
process_request(…, requestType = 1)process(ctx){app_id, kind: "process", request_type: 1, sender, payload, payload_hex, state} — payload decodificado de JSON, o texto; payload_hex los bytes crudos como 0x…{state?, events?, app_events?, withdrawals?, fuel?, error?}
process_request(…, requestType = 2) (deanonimización)deanonymize(ctx) → cae en processigual, kind: "deanonymize", request_type: 2{report, state?, fuel?} — report obligatorio (Vela rechaza un resultado tipo 2 sin él; en cualquier otro tipo un reporte se descarta con aviso)
trusted_request(appId, payload, state) (TRUSTPROCESS desde un trigger contract)trusted(ctx) → cae en process{app_id, kind: "trusted", request_type: 4, sender: nothing, payload, payload_hex, state} — el payload es lo que devolvió getTrustProcessPayload del trigger: bytes ABI, en claro; leé payload_hex y hacele abi_decodecomo process, y sin app_events (un app event vuelve a disparar el trigger; uno vacío termina el ciclo)

Dos tipos de request nunca llegan al programa: AssociateKey (3, un usuario registrando su clave P-521 y su seed — lo resuelve el Executor) y un PROCESS con payload vacío (el Executor devuelve el estado intacto sin llamar al módulo; un depósito en ese request sí corre deposit). Un depósito y un process del mismo request on-chain corren uno tras otro, el segundo sobre el estado que devolvió el primero.

Las formas dentro del mapa devuelto§

orden de claves que armó tu programa (determinista). Omitido (o nothing) en deposit/process: el estado queda como vino, byte a byte.

user tiene que haber registrado una clave P-521** (novaw registeruser, o el register del cliente): un evento para una dirección sin registrar hace que el Executor tire el request entero (CodePubKeyNotRegistered, código 9) — no emitas a un destinatario que no podés garantizar. Un subtype ausente son 32 bytes en cero (para un usuario con seed el Executor lo reemplaza por un HMAC de la seed igual).

a un trigger contract durante stateUpdate.

("execute_requested") que queda alineada a la izquierda y rellenada con ceros — la convención subtypeToBytes32 del starter kit, contra la que compara un trigger contract.

primera que aparece: <campo>_hex ("0x…", bytes exactos — lo que un contrato decodifica con abi.decode; armalos con abi_encode y decode(b, "hex")), <campo>_base64, o <campo> (un texto tal cual, un mapa o lista como JSON compacto). Un valor bytes de Synsema dentro de data saldría como texto base64 en JSON — usá data_hex cuando los bytes importan.

(ETH). Pull-payment: el receptor después llama claim(token, payee) en el ProcessorEndpoint. En una app con trigger, to es el trigger contract y el app event correspondiente lleva la llamada.

256 bits; también se acepta un entero JSON o un texto 0x…. El adaptador los emite como el Uint256 hex de Vela.

aplican). Un error de runtime del programa hace lo mismo, con el mensaje del motor. Nunca un trap.

determinista de sentencias ejecutadas. Vela cobra fuel × EXECUTOR_FUEL_PRICE_PER_UNIT (al menos MIN_FEE_PER_REQUEST, 10 wei en el starter kit) contra el maxFeeValue del request y hace fallar el request si no alcanza — con steps() un handler son típicamente unos cientos de unidades y novaw reserva 100 wei por defecto, así que declará una constante como la app de referencia (5/20/35/50) o decile a tus usuarios que reserven más.

print dentro del programa va al log del Executor; no es un canal de datos. El resultado viaja sólo por el mapa devuelto.

Determinismo, por construcción§

El programa corre bajo el techo stdout,declassify=app (desde v0.6.46; declassify se explica abajo): sin now(), sin random()/token(), sin red, sin archivos, sin LLM. Vela firma el state root, así que las mismas entradas tienen que producir los mismos bytes — el techo lo vuelve una propiedad del runtime y no de la disciplina (la app Go de referencia estampa time.Now() en cada transacción; acá esa línea no puede compilar). Los mapas conservan el orden de inserción y el JSON se emite en ese orden, así que los bytes del estado son los mismos entre corridas e instancias (verificado: cinco procesos, hashes idénticos). Todo lo puro está disponible: tipos, JSON, decimal (exacto, precisión arbitraria), bytes_to_int/int_to_bytes (enteros exactos de 256 bits), keccak256, sha256, abi_encode/abi_decode, match, try/recover, y bloques test que corren nativos.

Dos cosas que saber sobre números. Un entero de cualquier tamaño sobrevive al estado: se escribe como número JSON pelado y json_decode lo vuelve a leer exacto (engine v0.6.29+; antes, un número pelado por encima de 2⁵³ volvía como float). Un decimal no: también se escribe como número JSON pelado, y vuelve como float — guardá los montos decimales como texto en el estado (text(d)) y convertí al usarlos. Y el hex Uint256 de Vela quita los ceros a la izquierda (una quantity, "0x1f18"): int(h) lo lee y hex(n) lo escribe (hex(0) es "0x0"), mientras que bytes(h, "hex") es para data y quiere una cantidad par de dígitos. examples/payment_app.syn todavía trae sus propios hex_to_int/int_to_hex, escritos antes de esos builtins; hex_to_int además lee el "0x" vacío de Vela como 0 — int(h, fallback = 0) hace lo mismo.

Qué puede publicar la app: declassify bajo el techo del guest (motor v0.6.46+, v0.6.49+)§

Adentro del guest las etiquetas están siempre encendidas y app es el único principal: ctx["state"], ctx["payload"] y todo lo que se calcula a partir de ellos es privado de app. Lo que sale en claro hacia la cadena — el código en error, un app event, un retiro — sale sólo por el declassify(valor, "motivo") de la propia app. Desde v0.6.46 declassify es una capability que acota el techo del host, así que el guest corre toda app bajo stdout,declassify=app, y no se puede publicar nada más que los datos de la propia app. Desde v0.6.49 la app además lo declara, como declara net: una línea en su preámbulo, require declassify("app").

Lo que devuelve la appLo que recibe el request
declassify(ctx["state"]["n"], "public counter") en un app eventel evento, en claro
un valor privado de app, sin desclasificar, en un campo público (app_events, withdrawals, error){"error": "label_violation"}, no se aplica nada
declassify de un valor privado de otro principal (uno que el programa hizo con private(x, "other")){"error": "label_violation"}, no se aplica nada (antes de v0.6.47: runtime_error)
un error de runtime sobre un dato privado (decimal("abc") sobre un campo del payload), o un print bajo una rama privada{"error": "label_violation"}, no se aplica nada (antes de v0.6.47 el primero era runtime_error)
un error de runtime que no toca datos privados{"error": "runtime_error"}, con el mensaje en el log (ERR runtime_error: …)

El log nunca dice dónde cayó un corte de etiquetas (motor v0.6.47+). El log del Executor se escribe fuera del enclave, y un valor privado puede elegir cuál de N sitios falla, así que cada label_violation de arriba deja la misma única línea: ERR label_violation: the flow checker stopped the run; what failed and where stay in the enclave. Antes de v0.6.47 nombraba el camino (app_events[0].data is private to app …). Para ver qué falló, corré los tests de la app en nativo sin el techo (synsema test --labels app/app.syn): esa corrida es tuya, así que el error nombra el sitio y el arreglo; con el techo muestra el texto fijo, como en el enclave (Etiquetas § Qué muestra un corte).

Breaking en v0.6.49: una app escrita para v0.6.48 no tiene require declassify("app"), y en el guest de v0.6.49 cada uno de sus declassify es un label_violation, así que no se aplica ningún request. Agregá la línea (las tres apps de ejemplo y los kits ya la tienen). Para testear en nativo con las reglas exactas del guest, corré los tests con su techo:

synsema test --labels --cap-set stdout,declassify=app app.syn

(app.syn usa private(...) en sus tests, así que un synsema test app.syn sin --labels falla con private: labels are off; run with --labels or serve --attested.)

vela-ceiling.syn
-- The Vela guest runs every app under the ceiling `stdout,declassify=app`, with labels on.
-- These tests reproduce that ceiling natively:
--   synsema test --labels --cap-set stdout,declassify=app <this folder>
-- A declassify of any other principal is refused under that ceiling (a label_violation that
-- stops the run, so it cannot be a test here): see the Vela page.
-- Under a ceiling a program publishes only what it declares (engine v0.6.49+): without the
-- `require` below, the declassify is refused too, even of `app`.
intent: "doc example: what a Vela app may publish under the guest's ceiling"
require declassify("app")

task outcome(state, amount)
    -- the balance is the app's private data; the outcome code is published on purpose
    let balance be private(state["balance"], "app")
    let code be when balance >= amount then "ok" otherwise "insufficient_balance"
    give declassify(code, "the outcome code is public on-chain")

test "declassify of the app principal passes the ceiling"
    assert(outcome({"balance": 10}, 4) == "ok")
    assert(outcome({"balance": 1}, 4) == "insufficient_balance")

test "what is not declassified stays private"
    let balance be private(10, "app")
    assert(is_private(balance >= 4))

Lo que un observador sigue viendo§

El inventario honesto, para que nadie suponga más privacidad de la que hay. On-chain y público: cada request y su resultado, la raíz del estado cifrado, los ids de aplicación, los fondos bloqueados por app, los eventos cifrados por usuario y los app events que tu programa emite en claro. Se filtra igual, por construcción: la dirección del remitente, el monto bloqueado con un request, el timestamp y el hecho de que hubo un request. Los retiros y lo que ejecuta un trigger son llamadas on-chain: dirección y monto, públicos. Privado: el estado y los cuerpos de los requests, descifrados solo dentro del enclave, y los eventos por usuario: registrá un seed (los clientes lo hacen) para que los 50 subtipos derivados por HMAC no se puedan vincular entre usuarios. El Manager puede censurar, demorar o reordenar un request; no puede leerlo ni falsificar un resultado.

Dos cosas sobre el módulo. El Executor lo carga desde el host, así que el operador puede leerlo, y un guest de Synsema lleva el fuente del programa en su slot, no solo un binario: toda regla y parámetro va en el estado cifrado, nunca en el programa. Y el build es reproducible: scripts/build.sh baja el asset de la release (verificado contra su .sha256) y embed.syn escribe el programa en el slot de forma determinista, así que cualquiera puede reconstruir build/app.wasm con el mismo asset y el mismo programa y comparar el wasmSha256 que verificó la cadena con el que obtuvo.

La política de salida: esconder la FORMA (engine v0.6.24+)§

Las etiquetas mantienen el dato adentro del enclave — dentro de un guest están siempre encendidas, así que todo Etiquetas aplica sin bandera. Lo que no esconden es la forma de lo que sale, y en una cadena pública la forma la lee cualquiera: cuántos eventos emitió un request, cuán largos son, si el root del estado se movió. deploy puede devolver una policy, y el adaptador la aplica en cada request posterior.

task deploy(ctx)
    give {"state": {"total": 0},
          "policy": {"reject": "private", "events_pad": 256, "events_min": 2}}
clavevalorqué cierra
reject"private" | "public" (default)sólo en un request process, un {"error": …} que devuelva tu app se convierte en un resultado exitoso — sin withdrawals, sin app events, el estado que la app ve sin cambios — más un evento cifrado al sender con {"rejected": <código>, "detail": …}. On-chain el request simplemente salió bien. Nunca para deposit (un depósito fallido tiene que fallar, o el contrato retiene fondos sin saldo), trusted ni deanonymize, y nunca para runtime_error/invariant_violation, que siguen públicos
events_padentero ≥ 1 (bytes)rellena todo evento privado cuyo data sea un objeto JSON con una clave "_" de espacios hasta el siguiente múltiplo de N, así el largo deja de ser un canal. Los clientes ignoran _
events_minentero ≥ 0eventos de relleno al sender hasta que el request lleve al menos K — una transferencia (2 eventos) y un retiro (1) dejan de distinguirse por cantidad. Necesita sender; un request trusted no tiene
state_padentero ≥ 0 (bytes), default 256 bajo reject: "private"rellena todo el estado hasta el siguiente múltiplo de N, así un request aceptado y uno rechazado devuelven estados del mismo tamaño

Dos cosas sobre el estado. _vela está reservada: el adaptador guarda ahí la política como última clave, tu app nunca la ve en ctx["state"], y se repone a la salida — así que el estado que devolviste no es byte a byte el que va a la cadena. Y _vela lleva un contador que sube en toda transición, porque sin él reject: "private" no escondía nada: un request rechazado devolvía el estado anterior byte a byte, así que "el root no se movió" era la señal del rechazo, y con el relleno apagado el largo del estado recuperó el monto exacto de una oferta sellada en 49 repeticiones.

Un límite, dicho. Bajo reject: "private" el motivo igual le llega al sender, cifrado por el Executor. Ese cifrado es de Vela, no de Synsema. Si no podés apoyarte en él, corré con reject: "public", o dejá el detalle afuera devolviendo {"error": código} sin error_detail.

Las apps de ejemplo§

ProgramaQué muestraTests
app.synUn ledger privado: saldos por cuenta y token, transferencias, retiros con un recibo ABI público (app_events + data_hex + un subtipo etiqueta), un reporte de deanonimización, una task trusted que decodifica (address,address,uint256) de payload_hex. La sonda de Node lo maneja.10
examples/payment_app.synLa Private Transfer app de Horizen (payment_app de vela-nova) en Synsema, compatible byte a byte con el wallet novaw: mismas instrucciones de payload, mismos cuerpos de eventos (balance en hex Uint256), params de deploy allowedTokens, reportes balances/tx_history, recibos keccak por invoice — novaw deployapp / registeruser / deposit / privatetransfer / withdraw / getprivatebalance / requestreport la manejan sin cambios. Sin timestamps (no hay reloj en el enclave): tx_history filtra por dirección.10
examples/trigger_app.synUn pool de ejecución para el ciclo del trigger (abajo): execute bloquea fondos, retira al trigger y emite execute_requested con abi.encode(bytes16,address,uint256,bytes); trusted decodifica (bytes16,uint256,uint8), acredita el resto, borra el lock y no emite app events.8

Las tres usan private(...) en sus tests: corrélos con synsema test --labels <archivo> (o con las reglas exactas del guest, --labels --cap-set stdout,declassify=app); sin --labels fallan con private: labels are off.

El cliente: el otro lado del enclave, en Synsema§

novaw sólo habla el payload de la payment app; cualquier otra app necesita su propio cliente. examples/client/vela_client.syn hace todo lo que un cliente puede hacer, nativo, con los builtins que llegaron en v0.6.20 — sin Go, sin TypeScript. Copiá .env.example a .env: VELA_RPC_URL, VELA_PROCESSOR, VELA_TEE_AUTHENTICATOR, VELA_AUTHORITY_URL, VELA_SUBGRAPH_URL, VELA_APP_ID, VELA_MAX_FEE (wei), VELA_SECP_KEY (firma transacciones, paga), VELA_P521_KEY / VELA_P521_PUB (de keys), VELA_USER_KEY (sólo para los comandos de facilitador).

synsema run vela_client.syn -- …Qué hace
keysun par P-521 nuevo, impreso para .env (ecdh_keypair + reveal)
addressla dirección que firma (VELA_SECP_KEY)
teela clave P-521 de comunicación del Executor, leída de TeeAuthenticator.getPubSecp521r1()
deploy <wasm> [params-json|-] [trigger]subida multipart a <authority>/deploy/upload (multipart_encode), luego submitDeployRequest(0, descriptor) o submitDeployRequestWithTrigger(0, descriptor, trigger); applicationId y requestId del log DeployRequestSubmitted; espera por el subgraph; imprime el VELA_APP_ID a configurar
registerAssociateKey: tu clave pública P-521 ‖ la seed de privacidad cifrada para el Executor (226 bytes); la seed es secp256k1_sign(keccak256("subtype-key-v1")), exactamente como novaw
deposit <monto> [token]ETH (wei) por submitRequest con payload vacío, o un ERC-20 allowlisted tras approve(processor, monto)
send '<json>' [wei]PROCESS con el payload cifrado para el Executor; un depósito de ETH opcional viaja en el mismo request
report '<json>', report-download <id>DEANONYMIZATION (quien llama tiene que ser una autoridad permitida), luego el reporte traído del authority service — GET /nonce, una firma EIP-191 de chainId(8) ‖ appId(8) ‖ reportId(32) ‖ nonce(32), POST /getreport — y descifrado
events [n], app-events [n]tus eventos, filtrados por tus 50 subtipos HMAC y descifrados; los eventos públicos de la app, con la etiqueta decodificada cuando el subtipo es una
status <requestId>la fila requestCompleteds / deployRequestCompleteds del subgraph
user, register-for, send-for '<json>' [monto token], events-for [n]facilitador / meta-transacciones (submitRequestFor): el usuario (VELA_USER_KEY, sin ETH) firma un RequestAuthorization EIP-712 (dominio Vela / versión "0" / chainId / el endpoint, nonce = getFacilitatorNonce(user)) y, para depositar un ERC-20, un Permit EIP-2612 bajo el dominio del token; VELA_SECP_KEY manda la transacción y paga gas y fee. Sólo ASSOCIATEKEY y PROCESS; el depósito sólo puede ser un ERC-20, nunca ETH

Cada request espera RequestCompleted por el subgraph e informa el código y mensaje de error cuando status ≠ 0. Las partes puras tienen tests (synsema test vela_client.syn): el ida y vuelta del cifrado, seed y subtipos, parseo de logs, calldata, los digests EIP-712.

Los formatos de cable, para que nadie tenga que releer Go§

clave = HKDF-SHA256(X compartida de ECDH P-521, sin salt, sin info, 32 bytes) entre la clave P-521 del usuario y la clave de comunicación del Executor (133 bytes, sin comprimir). Los reportes se cifran para la clave registrada de quien los pide. En Synsema: ecdh_shared_secret → hkdf_sha256 → aes_gcm_encrypt.

bytes (133 solos también se aceptan). La seed es una firma secp256k1 de 65 bytes; los subtipos de eventos del usuario son HMAC-SHA256(seed, byte(i)), i = 1..50 — el Executor estampa uno en cada evento, y un cliente filtra el subgraph por ese conjunto.

de DeployRequestSubmitted y su requestId la data del evento.

"sha256:…", "wasmSha256": "…", "constructorParams": {…}} tras POST /deploy/upload (campo multipart wasm`).

msg.value = maxFeeValue y el endpoint tira de los tokens (allowance, o el permit en submitRequestFor).

Trigger contracts: llamadas on-chain desde adentro del enclave§

Un trigger contract es la única autonomía que tiene una app hoy: sin cron, sin oráculo, sin red. El ciclo, según el doc de diseño del starter kit y verificado de punta a punta:

1. Un usuario manda process (acá {"command": "execute", "execute": {target, value, data}}). La app bloquea el monto, devuelve un withdrawal al trigger contract y un app event cuyo data_hex es abi.encode(bytes16 lockId, address target, uint256 value, bytes data) con el subtipo etiqueta execute_requested. 2. Durante stateUpdate el ProcessorEndpoint reclama el ETH retirado hacia el trigger, llama execute(appEventData) — la llamada corre desde la dirección del pool — luego withdraw() devuelve lo que sobró, y getTrustProcessPayload(...) devuelve abi.encode(bytes16 lockId, uint256 remain, uint8 outcome). Un payload no vacío encola un TRUSTPROCESS (cola prioritaria, sin sender, sin fee). 3. El Executor llama trusted_request: la app decodifica payload_hex, acredita remain al dueño del lock, borra el lock, emite un execution_outcome cifrado al dueño — y ningún app event, que es lo que termina el ciclo (la guarda events.length == 0 del trigger devuelve "").

examples/trigger/src/PoolTrigger.sol es el contrato compañero (extiende el AbstractTrigger de Vela; build.sh clona HorizenOfficial/vela v0.2.0 — esos contratos son BSL y no se vendorizan — y lo despliega con forge). La app se despliega con {"triggerContract": "0x…"} en los params del constructor y la misma dirección en submitDeployRequestWithTrigger (vela_client.syn -- deploy pool.wasm '{"triggerContract":"0x…"}' 0x…). Medido: el target recibió exactamente 0,01 ETH durante stateUpdate, y el TRUSTPROCESS volvió unos 25 s después.

ERC-20 y el facilitador§

Los tokens tienen que estar en el TokenAllowlist (addAllowedToken, rol ADMIN) y, para una app, en sus allowedTokens al desplegar. examples/erc20/src/TestToken.sol es un ERC-20 mínimo con permit EIP-2612; build.sh lo despliega y lo da de alta. Desde el cliente: deploy payment.wasm '{"allowedTokens":["0x…"]}', deposit 1000000 0x… (approve + pull), después transferencias con "tokenAddress" en el payload; los eventos traen tokenAddress y la custodia del endpoint guarda los tokens.

El patrón facilitador es un flujo del lado cliente, no una limitación de la plataforma: submitRequestFor deja que otro pague. El usuario sólo firma typed data; un usuario con 0 ETH registró su clave (register-for) y depositó 2 TST más una transferencia en un solo request (send-for … 2000000 0x…), con el facilitador pagando gas y 85 wei de fees; los eventos del usuario se descifran con su propia seed (events-for). Un detalle con el que te vas a cruzar: ECDSA.recover de OpenZeppelin y permit quieren v = 27/28, mientras que secp256k1_sign devuelve el recovery id 0/1 — evm_signature(sig) convierte (engine v0.6.29+; antes, sumarle 27 al último byte a mano).

El devnet público§

devnet.synsema.app corre el starter kit v0.2.0 de Horizen (Anvil, los contratos de Vela, el subgraph, el Executor como TEE emulado, el Manager, el Authority Service) detrás de HTTPS, para cualquiera que quiera desplegar una app Synsema en Vela sin nueve contenedores en su máquina. Un token propio está a un comando; el cliente escribe las líneas en client/.env:

synsema run vela_client.syn -- devnet            # or: curl -X POST https://devnet.synsema.app/token
VELA_RPC_URL=https://devnet.synsema.app/<your token>/rpc
VELA_AUTHORITY_URL=https://devnet.synsema.app/<your token>/authority
VELA_SUBGRAPH_URL=https://devnet.synsema.app/<your token>/subgraph/subgraphs/name/hcce
VELA_PROCESSOR=0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
VELA_TEE_AUTHENTICATOR=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
VELA_TOKEN_ALLOWLIST=0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
VELA_DEFAULT_AUTHORITY=0x5FbDB2315678afecb367f032d93F642f64180aa3
VELA_TEST_TOKEN=0x976fcd02f7C4773dd89C309fBF55D5923B4c98a1
VELA_TRIGGER_FACTORY=0xC9a43158891282A2B1475592D5719c001986Aaec
VELA_TOKEN=0x976fcd02f7C4773dd89C309fBF55D5923B4c98a1
VELA_ADMIN_URL=https://devnet.synsema.app/<your token>
VELA_SECP_KEY=…   VELA_P521_KEY=…   VELA_P521_PUB=…   (your account and a fresh P-521 pair: everything a client needs)

Es un devnet: las claves son conocidas, no hay attestation, se resetea cada tanto (las direcciones de los contratos vuelven iguales; los ids de app, saldos y claves registradas no), y nada de lo que pongas ahí es privado frente a quien administra la máquina: un lugar para iterar, no para guardar valor. El token viene con una cuenta propia —10 000 ETH, DEPLOYER_ROLE en el ProcessorEndpoint y 100 000 TST— así que tus deploys nunca chocan con los nonces de nadie (VELA_SECP_KEY), y con una mesa de admin (VELA_ADMIN_URL) que firma las dos acciones de admin que necesitás: dar de alta un ERC-20 (vela_client.syn -- allow-token <dirección>, o POST $VELA_ADMIN_URL/allow-token {"address"}) y habilitar un auditor para una app que desplegó tu cuenta (vela_client.syn -- allow-authority <appId> <dirección>, o POST $VELA_ADMIN_URL/allow-authority {"app_id", "address"}). Ninguna clave admin sale de la máquina: las diez cuentas incorporadas de la cadena (las de Anvil, de claves conocidas) son de la máquina, y una transacción firmada por una de ellas se rechaza en la puerta. Un ERC-20 de prueba con permit (TST, 6 decimales) ya está en la allowlist: VELA_TEST_TOKEN. El RPC sirve los métodos estándar eth_/net_/web3_ de lectura y envío por HTTP (sin suscripciones WebSocket: se consulta); los controles propios de la cadena (los métodos RPC anvil_ y evm_ del nodo — no los builtins de Synsema —, las cuentas desbloqueadas) quedan en la máquina, así que nadie puede rebobinar la cadena para los demás. La respuesta trae también wallet_conf, un wallet.conf listo para el novaw de Horizen. https://devnet.synsema.app/ muestra qué está haciendo el stack —servicios, aplicaciones, requests recientes, desde cuándo corre— y /status lo mismo en JSON. El token es un nombre en la ruta que deja afuera a los escáneres y permite contar el uso por persona, no un secreto que valga algo. Diez tokens por hora por dirección, salvo para el control de la plataforma Synsema, que pide uno por cada proyecto que crea desde una receta. El ProcessorEndpoint del kit se despliega con lugar para diez aplicaciones; el devnet lo sube a 100 000 (devnet/admin.syn en el repositorio de la plataforma), así que un revert MaxNumOfApplicationsExceeded es un stack en su valor por defecto, no un error de tu deploy.

El stack local (starter kit v0.2.0) — una receta que funciona§

compose up -d en dockerfiles/ (nueve imágenes, alrededor de 1,5 GB). La cadena queda publicada en localhost:8545, el authority service en :8081, el subgraph en :8000; la red interna es dockerfiles_pes_network. El deployer escribe las direcciones en el volumen deploy-data: ProcessorEndpoint 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9, TeeAuthenticator 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0, TokenAllowlist 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9, DefaultAuthority 0x5FbDB2315678afecb367f032d93F642f64180aa3`. La cuenta #0 de Anvil es deployer y admin.

el admin llama DefaultAuthority.addAllowedAuthority(appId, caller) (cast está dentro del contenedor vela-skit-chain). Multi-app funciona: cada deploy recibe su propio applicationId.

mount — copialo primero al filesystem de un contenedor: docker run --rm --network dockerfiles_pes_network --entrypoint sh -v <carpeta wallet>:/wallet -w /wallet postgres:14 -c 'cp /wallet/novaw-linux /usr/local/bin/novaw && chmod +x /usr/local/bin/novaw && novaw <cmd>', con MSYS_NO_PATHCONV=1 en Git Bash y un wallet.conf apuntando a 10.10.40.30:8545, 10.10.40.40:8081, vela-skit-subgraph-node:8000.

contenedor sobre la red por defecto de Docker con RPC_URL=http://host.docker.internal:8545 (el contenedor de la cadena en marcha no tiene DNS para bajar solc ni de GitHub).

en unos 25 s. Límites del Executor: wasmtime-go v1.0.0, un request por bloque, timeout de comunicación de 30 s, sin medición de fuel (el fuel es autodeclarado), memoria por debajo de 2 GB.

Tropiezos al escribir guests y clientes§

clave blanda: anda, pero evitala también). Después de un . sirve cualquier palabra (tx.to, ev.from, ev.type). Hay literales hex (0xab, 0b101), e index_of también toma texto (index_of(s, pedazo) → la posición o nothing). Los mapas y las listas son valores (engine v0.6.29+): una task que hace set dentro del estado que recibió cambia su propia copia, no la del que llama — devolvé el estado nuevo con give (en engines anteriores mutaba el mapa del que llama, y eso mordía a los tests que reutilizan un estado).

una clave hex del .env es as_secret(bytes(env("K"), "hex"), "K") (un secret() de texto son los caracteres hex, no la clave). hmac(data, key) devuelve el MAC como bytes (hex(mac) para mostrarlo; el viejo hmac_sha256 devolvía texto hex), y todavía lee un bytes plano de data o de clave como su texto impreso — envolvé ambos en as_secret: hmac(as_secret(msg, "m"), as_secret(seed, "s")). La clave privada de ecdh_keypair lleva la etiqueta ecdh_keypair.private (require reveal("ecdh_keypair.private") para imprimirla una vez).

respuesta tiene status, ok, body, json, headers. multipart_encode(partes) → {body, content_type}. require file.read("*") para una ruta dada por línea de comandos dentro del directorio de trabajo (desde el engine v0.6.45 un se queda en una carpeta: "*" son solo los archivos del directorio de trabajo; cualquier ruta del disco es require file.read sin scope); args() no necesita capability; sleep necesita time; secp256k1_sign necesita sign("<etiqueta del secret>").

correr — pasá --target <host triple>.

Agregar otro host§

Copiá packages/guests/vela/, renombrá el crate, y leé el contrato del host desde su código fuente: qué exports llama, sus firmas, cómo escribe las entradas y lee los resultados, qué imports provee, qué prohíbe. Escribilo arriba de src/lib.rs con el archivo y la versión de donde salió. Mantené la forma — decodificar entradas → un mapa ctx → run_app(task, fallback, ctx) → codificar el mapa de la task al resultado exacto del host — elegí wasm32-wasip1 cuando el host enlaza WASI y wasm32-unknown-unknown sólo si provee los imports synsema_host, tratá el techo como el contrato de determinismo, escribí la sonda, cableá CI y documentá el contrato del .syn en el README del guest. Si al ABI genérico le falta algo, agregá una operación synsema_call documentada y útil para todos los hosts — nunca un export especial para uno.