El caso
Caso en el repositorio:
challenges/01-out-of-nowhere· Tipo: análisis · 25 puntos
El enunciado es corto: estás por salir de la oficina y notas una transferencia de 1.5 millones de dólares hacia uno de los proveedores de liquidez. ¿De dónde salió?
La respuesta se escribe en challenges/01-out-of-nowhere/answer.txt con el formato origin_tx = 0x.... El archivo challenge.json precisa qué se busca: el hash de la transacción en la cadena de origen que puso en marcha el retiro.
Esas dos palabras son la primera pista. Si hay una "cadena de origen", la transferencia que vemos en Ethereum no empezó ahí: es el último paso de un movimiento entre dos blockchains. La respuesta está en otra cadena.
En la parte 0 dije que para este caso bastaban un explorador de bloques y cast. Me quedé corto: también vamos a consultar la API pública de un explorador con curl y a filtrar sus respuestas con jq. Ambos quedaron instalados en el comando 1 de la parte 0.
Paso 1: qué hizo la transacción en Ethereum
Todos los comandos se ejecutan desde la carpeta del repositorio. set -a exporta las variables del .env, de modo que cast encuentra el RPC sin tener que pasarle --rpc-url cada vez.
```sh
>>> INICIO 1: cargar el .env y guardar el hash de la transacción
cd ~/dev/Alpha-Challenge-2026 && set -a && source .env && set +a export TX=0xe7b8d46c3f3e5f727cb42c9dfe7fc36855ab5092cf160e4c8812a2a27a84350b
<<< FIN 1
```
```sh
>>> INICIO 2: datos básicos de la transacción
cast tx $TX
<<< FIN 2
```
``` $ cast tx $TX
blockHash 0xe067d64fad496d2eb689b1ca3616dbde22d9b13233a9d03a592fc1904164564e blockNumber 24334977 from 0xEc5f2EFa1A13c81179dDb0f0d4385e99E275994b transactionIndex 73 effectiveGasPrice 279000000 hash 0xe7b8d46c3f3e5f727cb42c9dfe7fc36855ab5092cf160e4c8812a2a27a84350b type 2 chainId 1 nonce 385 gasLimit 194222 maxFeePerGas 279000000 maxPriorityFeePerGas 279000000 to 0xBBbD1BbB4f9b936C3604906D7592A644071dE884 value 0 accessList [] input 0x14b824e0000000000000000000000000000000000159fa4cd496a40b6531521bb9138a06000000000000000000000000ec5f2efa1a13c81179ddb0f0d4385e99e275994b000000000000000000000000000000000000000000000000000552e0b832280053544b5a000000000000000000000000000000000000000000000000000000004554480000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000412ad7ca3f724f88c5780e32ac72d3831a6fc2462aa8384723093b83180b14860303eb3281cfa6fdfc6a1bcc8c27e01e5c55e6e46ac885849152b1620af86779511c00000000000000000000000000000000000000000000000000000000000000 r 0xd66afe8d8d50abe640182cd1d9edfd3f22bea0df5d7f6194d65d7d889e7e8e73 s 0x5a9c7ba3d3edf3451bdaf7346ba8f1351b817e7e38c31700c07e790a958efee3 yParity 1 ```
Tres datos saltan a la vista:
valuees 0: no se movió ETH. El dinero es un token.toes un contrato,0xBBbD...E884. Quien envía la transacción le pide a ese contrato que haga algo.inputes la llamada codificada. Sus primeros 4 bytes,0x14b824e0, son el selector: el identificador de la función que se llamó.
Para ver qué pasó por dentro, cast run vuelve a ejecutar la transacción sobre el estado de su bloque y muestra el árbol de llamadas internas, con los nombres de las funciones cuando los reconoce:
```sh
>>> INICIO 3: traza completa de la transacción
cast run $TX
<<< FIN 3
```
``` $ cast run $TX Executing previous transactions from the block. Traces: [104285] 0xBBbD1BbB4f9b936C3604906D7592A644071dE884::unlock(1796419105728715166915089084934490630 [1.796e36], 0xEc5f2EFa1A13c81179dDb0f0d4385e99E275994b, 1498500000000000 [1.498e15], 0x53544b5a, 0x45544800, 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000, 0x2ad7ca3f724f88c5780e32ac72d3831a6fc2462aa8384723093b83180b14860303eb3281cfa6fdfc6a1bcc8c27e01e5c55e6e46ac885849152b1620af86779511c) ├─ [35507] 0x93746538D4519C809827205Bd1C2c7a0E15bd74b::createUnlock(1796419105728715166915089084934490630 [1.796e36], 0xEc5f2EFa1A13c81179dDb0f0d4385e99E275994b, 1498500000000000 [1.498e15], 0x53544b5a, 0x45544800, 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000, 0x2ad7ca3f724f88c5780e32ac72d3831a6fc2462aa8384723093b83180b14860303eb3281cfa6fdfc6a1bcc8c27e01e5c55e6e46ac885849152b1620af86779511c) │ ├─ [3000] PRECOMPILES::ecrecover(0x69cd4f2b222a1fbefad4d6ec73165d24987bd4c1370ba9d67996cdfa420876dd, 28, 19378407622701552415244818103418063327335059275069218414848448294913052018179, 1772496192991520027470730236118079441204136939384097764405112824324111694161) [staticcall] │ │ └─ ← [Return] 0xD513b05905CDD1D5595B384A97EbEa15D1e79F97 │ └─ ← [Stop] ├─ [40652] 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48::transfer(0xEc5f2EFa1A13c81179dDb0f0d4385e99E275994b, 1498500000000 [1.498e12]) │ ├─ [33363] 0x43506849D7C04F9138D1A2050bbF3A0c054402dd::transfer(0xEc5f2EFa1A13c81179dDb0f0d4385e99E275994b, 1498500000000 [1.498e12]) [delegatecall] │ │ ├─ emit Transfer(from: 0xBBbD1BbB4f9b936C3604906D7592A644071dE884, to: 0xEc5f2EFa1A13c81179dDb0f0d4385e99E275994b, amount: 1498500000000 [1.498e12]) │ │ └─ ← [Return] true │ └─ ← [Return] true ├─ emit Received(param0: 0xEc5f2EFa1A13c81179dDb0f0d4385e99E275994b, param1: 0x000000000159fA4CD496a40B6531521Bb9138A06, param2: 917551056842671309452305380979543736893630245704 [9.175e47], param3: 1498500000000 [1.498e12], param4: 0x53544b5a) └─ ← [Stop]
Transaction successfully executed. Gas used: 128389 ```
La traza se lee de arriba hacia abajo, y cada nivel de sangría es una llamada dentro de la anterior:
- Se llama a la función
unlockdel contrato0xBBbD...E884con siete parámetros. - Ese contrato le pide a otro,
0x9374...d74b, que valide el desbloqueo (createUnlock). La validación usaecrecover, una función nativa de Ethereum que, a partir de una firma, recupera la dirección que la firmó:0xD513...9F97. - Con la firma validada, el contrato transfiere 1,498,500,000,000 unidades de USDC (
0xA0b8...eB48) a0xEc5f...994b. - Al final emite un evento
Receivedcon los datos del movimiento.
Una función llamada unlock que solo entrega fondos si alguien autorizado firmó es la firma típica de un puente (bridge): un sistema que bloquea activos en una cadena y los libera en otra.
Paso 2: identificar el puente
La dirección 0xBBbD1BbB4f9b936C3604906D7592A644071dE884 aparece en la documentación de Allbridge como el contrato de Allbridge Classic en Ethereum. La documentación de sus contratos describe el unlock con estos parámetros: lockId, recipient, amount, lockSource, tokenSource y tokenSourceAddress, más la firma del validador. La versión documentada es la del puente multifirma, que recibe dos firmas; el contrato de este caso recibe una sola, así que su firma completa sería unlock(uint128,address,uint256,bytes4,bytes4,bytes32,bytes).
Antes de confiar en esa firma, conviene verificarla. El selector de una función son los primeros 4 bytes del hash keccak256 de su firma; si coincide con el inicio del input, la firma es la correcta:
```sh
>>> INICIO 4: verificar el selector de unlock
cast sig "unlock(uint128,address,uint256,bytes4,bytes4,bytes32,bytes)"
<<< FIN 4
```
$ cast sig "unlock(uint128,address,uint256,bytes4,bytes4,bytes32,bytes)"
0x14b824e0
Coincide con el inicio del input. Ahora sabemos qué significa cada parámetro de la traza.
Paso 3: leer los parámetros
lockSource y tokenSource son identificadores de cadena de 4 bytes en texto. cast los convierte:
```sh
>>> INICIO 5: decodificar los identificadores de cadena
cast to-ascii 0x53544b5a cast to-ascii 0x45544800
<<< FIN 5
```
$ cast to-ascii 0x53544b5a
STKZ
$ cast to-ascii 0x45544800
ETH
lockSource = STKZ es la cadena donde se bloquearon los fondos. tokenSource = ETH, junto con tokenSourceAddress, que es la dirección de USDC en Ethereum rellenada con ceros, dice que el token es USDC originario de Ethereum. Es decir: alguien tenía en la cadena STKZ una versión envuelta de USDC, la devolvió al puente y recibió USDC nativo en Ethereum.
Los montos aparecen con dos precisiones distintas. Allbridge maneja internamente 9 decimales; USDC usa 6:
```sh
>>> INICIO 6: convertir los montos a unidades legibles
cast format-units 1498500000000000 9 cast format-units 1498500000000 6
<<< FIN 6
```
$ cast format-units 1498500000000000 9
1498500
$ cast format-units 1498500000000 6
1498500
Son 1,498,500 USDC en ambos casos. Contra los 1.5 millones del enunciado faltan exactamente 1,500, el 0.1%. Eso es consistente con una comisión del puente.
lockId es el dato que conecta las dos cadenas. Según la documentación de Allbridge, es un valor aleatorio de 16 bytes que se genera al bloquear los fondos en la cadena de origen, y cuyo primer byte indica la versión del puente. La traza lo muestra en decimal; en hexadecimal es:
```sh
>>> INICIO 7: pasar el lockId a hexadecimal
cast to-hex 1796419105728715166915089084934490630
<<< FIN 7
```
$ cast to-hex 1796419105728715166915089084934490630
0x159fa4cd496a40b6531521bb9138a06
cast omite el cero inicial. Con sus 16 bytes completos, el valor es 0x0159fa4cd496a40b6531521bb9138a06; el 01 del principio es la versión del puente. Ese es el identificador que hay que buscar del otro lado.
Dos observaciones más antes de cambiar de cadena:
- El puente no verifica criptográficamente lo que pasó en la otra cadena. Confía en la firma de su validador (
0xD513...9F97). Si esa llave se comprometiera, el puente liberaría fondos sin respaldo. fromy el destinatario son la misma dirección,0xEc5f...994b, el proveedor de liquidez del enunciado. Fue él quien envió la transacción para cobrar.
Paso 4: qué cadena es STKZ
Allbridge Classic conectaba unas veinte cadenas, y STKZ no se explica sola. La pista está en el token: según el blog de Allbridge, su USDC envuelto desde Ethereum (aeUSDC) se volvió la principal stablecoin de Stacks, una red de contratos inteligentes que liquida sobre Bitcoin. Eso cuadra con tokenSource = ETH y USDC.
Una pista no es una confirmación. La misma documentación lista el contrato del puente en Stacks, SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.bridge. Los contratos de Stacks están escritos en un lenguaje llamado Clarity y su código fuente es público, así que se puede consultar con la API de Hiro, el explorador principal de Stacks:
```sh
>>> INICIO 8: buscar el identificador STKZ en el código del puente de Stacks
curl -s "https://api.hiro.so/v2/contracts/source/SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K/bridge" \ | jq -r '.source' | grep -n -i "53544b5a\|stkz" || echo "STKZ no aparece en el contrato"
<<< FIN 8
```
49:(define-constant THIS-CHAIN 0x53544b5a) ;; "STKZ"
El propio contrato declara STKZ como el identificador de su cadena. Los fondos se bloquearon en Stacks.
Paso 5: buscar el lockId en Stacks
Toca encontrar, entre las transacciones del puente de Stacks, la llamada lock con nuestro lockId. Hay que tener en cuenta que el unlock en Ethereum ocurrió el 28 de enero de 2026 a las 17:56:59 UTC, así que el lock tiene que ser anterior.
La API de Hiro permite listar las transacciones de un contrato en páginas de 50. Mi primera búsqueda recorría todas las páginas y filtraba las llamadas lock cuyo argumento lock-id fuera el nuestro. Además, por cada página imprimía la fecha de su transacción más antigua para ver el avance:
```sh
>>> INICIO 9: primera búsqueda del lockId (falla en silencio, ver abajo)
CONTRACT=SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.bridge LOCKID=0x0159fa4cd496a40b6531521bb9138a06 for offset in $(seq 0 50 5050); do PAGE=$(curl -s "https://api.hiro.so/extended/v2/addresses/$CONTRACT/transactions?limit=50&offset=$offset") echo "$PAGE" | jq -r --arg id "$LOCKID" '.results[].tx | select(.tx_type=="contract_call" and .contract_call.function_name=="lock") | select(any(.contract_call.function_args[]; .name=="lock-id" and .repr==$id)) | "ENCONTRADO (.tx_id)"' echo "offset $offset | más antigua: $(echo "$PAGE" | jq -r '.results[-1].tx.block_time_iso // "sin resultados"')" >&2 [ "$(echo "$PAGE" | jq '.results | length')" = "0" ] && break sleep 1 done
<<< FIN 9
```
offset 0 | más antigua: 2026-08-16T21:26:08.000Z
offset 50 | más antigua: 2026-06-04T16:39:22.000Z
...
offset 350 | más antigua: 2026-01-30T06:42:01.000Z
offset 400 | más antigua: 2026-01-22T16:55:01.000Z
...
offset 5000 | más antigua: 2024-02-19T16:44:10.000Z
offset 5050 | más antigua: sin resultados
Recorrió las 5,026 transacciones del contrato, desde febrero de 2024, y no encontró nada.
Hasta aquí, la conclusión natural era que el lock no estaba en este contrato. Descarté también el puente legacy de Stacks que aparece en la documentación: solo tiene 48 transacciones, todas de 2023. Pero un filtro que no encuentra nada no prueba que el dato no exista. Puede ser el filtro el que está mal. Para comprobarlo, revisé las dos páginas que cubren el 28 de enero (offsets 350 y 400, según el registro de avance) de dos formas: listando lo que el filtro ve en cada transacción y buscando el lockId como texto plano en la respuesta cruda.
```sh
>>> INICIO 10: ver qué recibe el filtro y buscar el lockId en el texto crudo
CONTRACT=SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.bridge for offset in 350 400; do PAGE=$(curl -s "https://api.hiro.so/extended/v2/addresses/$CONTRACT/transactions?limit=50&offset=$offset") echo "$PAGE" | jq -r '.results[].tx | "(.block_time_iso) (.tx_id[0:12]) (.contract_call.function_name // "-") lock-id=([.contract_call.function_args[]? | select(.name=="lock-id") | .repr] | first // "-")"' echo "$PAGE" | grep -o "0159fa4cd496a40b6531521bb9138a06" | head -1 | sed "s/^/ENCONTRADO-CRUDO offset $offset: /" sleep 1 done
<<< FIN 10
```
2026-02-05T15:24:00.000Z 0xa7295c88b3 lock lock-id=-
2026-02-05T13:17:01.000Z 0x4879367985 unlock lock-id=-
2026-02-05T12:55:40.000Z 0xfa5e149d7c unlock lock-id=-
...
2026-01-22T18:04:02.000Z 0x6d3c4c9e65 lock lock-id=-
2026-01-22T16:55:01.000Z 0x95b041118f lock lock-id=-
ENCONTRADO-CRUDO offset 400: 0159fa4cd496a40b6531521bb9138a06
Dos hallazgos:
- Ninguna transacción muestra su
lock-id, ni siquiera las llamadaslock. El endpointv2de Hiro no incluye los argumentos de las llamadas en sus listados; solo el contrato y el nombre de la función. El filtro del comando 9 recorría una lista que no existe, devolvía "falso" en cada transacción y nunca reportó un error. - El
lockIdsí está en la página de offset 400, que cubre del 22 al 28 de enero de 2026. La búsqueda de texto plano lo encontró porque no depende de la estructura de la respuesta.
La lección vale para cualquier investigación on-chain: antes de confiar en un filtro que no encuentra nada, pruébalo contra un caso que sabes que existe, o compáralo con una búsqueda más tonta pero más robusta.
Lo que sigue revela la respuesta.
Solución (spoiler)
Para aislar la transacción, se filtra la página de offset 400 por cualquier campo que contenga el lockId:
```sh
>>> INICIO 11: identificar la transacción que contiene el lockId
CONTRACT=SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.bridge curl -s "https://api.hiro.so/extended/v2/addresses/$CONTRACT/transactions?limit=50&offset=400" \ | jq --arg id "0159fa4cd496a40b6531521bb9138a06" '.results[].tx | select(tojson | contains($id)) | {tx_id, block_time_iso, tx_status, sender_address, llamada: "(.contract_call.contract_id).(.contract_call.function_name)"}'
<<< FIN 11
```
{
"tx_id": "0x36f2d5c245d08de980d0d23e4bd23b088312ce9e4b9845b4fd71930f52aab8fc",
"block_time_iso": "2026-01-28T17:41:06.000Z",
"tx_status": "success",
"sender_address": "SP388WPTVQMET2Z7M3ANQ6VRR8AATBF8VDPH0RRF9",
"llamada": "SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.bridge.lock"
}
El endpoint v1 de transacciones individuales sí incluye los argumentos, así que ahí se confirma el detalle:
```sh
>>> INICIO 12: detalle de la transacción con sus argumentos
STX_TX=0x36f2d5c245d08de980d0d23e4bd23b088312ce9e4b9845b4fd71930f52aab8fc curl -s "https://api.hiro.so/extended/v1/tx/$STX_TX" \ | jq '{tx_id, block_time_iso, tx_status, sender_address, args: [.contract_call.function_args[] | {name, repr}], post_conditions: [.post_conditions[] | {amount, asset: .asset.asset_name}]}'
<<< FIN 12
```
{
"tx_id": "0x36f2d5c245d08de980d0d23e4bd23b088312ce9e4b9845b4fd71930f52aab8fc",
"block_time_iso": "2026-01-28T17:41:06.000Z",
"tx_status": "success",
"sender_address": "SP388WPTVQMET2Z7M3ANQ6VRR8AATBF8VDPH0RRF9",
"args": [
{ "name": "lock-id", "repr": "0x0159fa4cd496a40b6531521bb9138a06" },
{ "name": "trait-address", "repr": "'SP3Y2ZSH8P7D50B0VBTSX11S7XSG24M1VB9YFQA4K.token-aeusdc" },
{ "name": "amount", "repr": "u1500000000000" },
{ "name": "recipient", "repr": "0xec5f2efa1a13c81179ddb0f0d4385e99e275994b000000000000000000000000" },
{ "name": "destination", "repr": "0x45544800" }
],
"post_conditions": [
{ "amount": "1500000000000", "asset": "aeUSDC" }
]
}
Cada dato del lock en Stacks coincide con el unlock en Ethereum:
| Dato | Lock en Stacks | Unlock en Ethereum |
| :---- | :---- | :---- |
| lockId | 0x0159fa4c...8a06 | 0x0159fa4c...8a06 |
| Cadenas | destination = ETH | lockSource = STKZ (Stacks) |
| Destinatario | 0xec5f2efa...994b + ceros de relleno | 0xEc5f2EFa...994b |
| Monto | 1,500,000 aeUSDC | 1,498,500 USDC (menos 0.1%) |
| Hora (UTC) | 28-ene-2026 17:41:06 | 28-ene-2026 17:56:59 |
El retiro empezó en Stacks y terminó en Ethereum 16 minutos después. La dirección que bloqueó los fondos en Stacks (SP388W...RRF9) no es la misma que los cobró en Ethereum: el proveedor de liquidez solo aparece como destinatario.
```sh
>>> INICIO 13: escribir la respuesta en la línea 2 de answer.txt y calificarla
sed -i '2s/.*/origin_tx = 0x36f2d5c245d08de980d0d23e4bd23b088312ce9e4b9845b4fd71930f52aab8fc/' challenges/01-out-of-nowhere/answer.txt python3 alpha.py check 01
<<< FIN 13
```
``` $ python3 alpha.py check 01 01-out-of-nowhere [analysis, tier 1] origin_tx correct 25/25 points
total: 25/25 points ```
Dónde ayudó la IA y dónde no
Usé un asistente de IA durante todo el caso. Ayudó a reconocer el patrón de un puente en la traza, a ubicar la documentación de Allbridge y a escribir los filtros de jq, que son lo más tedioso de este tipo de investigación.
También se equivocó. El filtro del comando 9 lo propuso la IA, y su suposición sobre la estructura de la respuesta de la API era falsa. Lo detectamos porque no aceptamos el "no encontrado" como respuesta y lo contrastamos con una búsqueda de texto plano. Esa verificación es justo el tipo de criterio que esta serie quiere enseñar: la IA acelera, pero tenemos que usar nuestro criterio.
Lo que aprendimos
- Una transferencia grande en Ethereum puede ser solo el último paso de un movimiento que empezó en otra cadena. Si el contrato que la envía tiene una función tipo
unlock, hay un puente detrás. cast runmuestra lo que una transacción hizo por dentro, ycast sigpermite verificar la firma de una función contra su selector.- Los puentes conectan las dos cadenas con un identificador compartido (aquí, el
lockId). Encontrarlo en ambos lados es lo que prueba el origen. - Las APIs de los exploradores cambian entre versiones. Antes de confiar en un filtro, hay que ver la respuesta cruda.
Siguiente entrega
En la parte 2 resolvemos Smart Money: atribuir direcciones a fondos de capital de riesgo a partir de sus movimientos en un fundraise.