Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ jobs:
- name: Run clippy
run: cargo clippy -- -D warnings -A clippy::uninlined_format_args

typescript_sdk:
name: TypeScript SDK
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: sdk/typescript
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: sdk/typescript/package-lock.json
- run: npm ci
- run: npm test

e2e_raw:
name: E2E Raw Provider
runs-on: ubuntu-latest
Expand Down
23 changes: 19 additions & 4 deletions crates/cli/src/commands/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use rrelayer_core::{
common_types::EvmAddress,
relayer::RelayerId,
transaction::types::{
Transaction, TransactionData, TransactionId, TransactionSpeed, TransactionValue,
Transaction, TransactionData, TransactionHash, TransactionId, TransactionSpeed,
TransactionValue,
},
};
use std::io::{self, Write};
Expand Down Expand Up @@ -218,7 +219,7 @@ async fn handle_withdraw(

println!("ERC-20 token withdrawal transaction sent..");
println!("Transaction id: {}", tx.id);
println!("Transaction hash: {}", tx.hash);
println!("Transaction hash: {}", format_transaction_hash(tx.hash));
}
None => {
let tx = client
Expand All @@ -238,7 +239,7 @@ async fn handle_withdraw(

println!("ETH withdrawal transaction sent..");
println!("Transaction id: {}", tx.id);
println!("Transaction hash: {}", tx.hash);
println!("Transaction hash: {}", format_transaction_hash(tx.hash));
}
}

Expand Down Expand Up @@ -369,11 +370,25 @@ async fn handle_send(

println!("Transaction sent..");
println!("Transaction id: {}", tx.id);
println!("Transaction hash: {}", tx.hash);
println!("Transaction hash: {}", format_transaction_hash(tx.hash));

Ok(())
}

fn format_transaction_hash(hash: Option<TransactionHash>) -> String {
hash.map_or_else(|| "<pending>".to_string(), |hash| hash.to_string())
}

#[cfg(test)]
mod tests {
use super::format_transaction_hash;

#[test]
fn accepted_submission_without_hash_is_printed_as_pending() {
assert_eq!(format_transaction_hash(None), "<pending>");
}
}

async fn handle_fund(
relayer_id: &RelayerId,
amount: &str,
Expand Down
45 changes: 45 additions & 0 deletions crates/core/src/transaction/api/send_random_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,48 @@ async fn select_random_relayer(
))
})
}

#[cfg(test)]
mod tests {
use super::*;
use crate::transaction::types::{TransactionHash, TransactionId};
use axum::{body::to_bytes, http::StatusCode, response::IntoResponse};
use serde_json::json;
use std::str::FromStr;

#[tokio::test]
async fn random_submission_returns_http_200_with_explicit_null_hash_when_pending() {
let id = TransactionId::from_str("11111111-1111-4111-8111-111111111111").unwrap();
let response = Json(SendTransactionResult { id, hash: None }).into_response();

assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
json!({
"id": "11111111-1111-4111-8111-111111111111",
"hash": null
})
);
}

#[tokio::test]
async fn random_submission_returns_http_200_with_known_hash() {
let id = TransactionId::from_str("11111111-1111-4111-8111-111111111111").unwrap();
let hash = TransactionHash::from_str(
"0x2222222222222222222222222222222222222222222222222222222222222222",
)
.unwrap();
let response = Json(SendTransactionResult { id, hash: Some(hash) }).into_response();

assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
json!({
"id": "11111111-1111-4111-8111-111111111111",
"hash": "0x2222222222222222222222222222222222222222222222222222222222222222"
})
);
}
}
57 changes: 50 additions & 7 deletions crates/core/src/transaction/api/send_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ impl FromStr for RelayTransactionRequest {
#[derive(Debug, Serialize, Deserialize)]
pub struct SendTransactionResult {
pub id: TransactionId,
pub hash: TransactionHash,
pub hash: Option<TransactionHash>,
}

/// API endpoint to send a new transaction through a relayer.
Expand Down Expand Up @@ -127,16 +127,59 @@ pub async fn send_transaction(
.add_transaction(&relayer.id, &transaction_to_send)
.await?;

let result = SendTransactionResult {
id: transaction.id,
hash: transaction.known_transaction_hash.ok_or(internal_server_error(Some(
"should always have a known transaction hash".to_string(),
)))?,
};
let result =
SendTransactionResult { id: transaction.id, hash: transaction.known_transaction_hash };

if let Some(reservation) = rate_limit_reservation {
reservation.commit();
}

Ok(result)
}

#[cfg(test)]
mod tests {
use super::*;
use axum::{body::to_bytes, http::StatusCode, response::IntoResponse};
use serde_json::json;

fn transaction_id() -> TransactionId {
TransactionId::from_str("11111111-1111-4111-8111-111111111111").unwrap()
}

#[tokio::test]
async fn direct_submission_returns_http_200_with_explicit_null_hash_when_pending() {
let response =
Json(SendTransactionResult { id: transaction_id(), hash: None }).into_response();

assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
json!({
"id": "11111111-1111-4111-8111-111111111111",
"hash": null
})
);
}

#[tokio::test]
async fn direct_submission_returns_http_200_with_known_hash() {
let hash = TransactionHash::from_str(
"0x2222222222222222222222222222222222222222222222222222222222222222",
)
.unwrap();
let response =
Json(SendTransactionResult { id: transaction_id(), hash: Some(hash) }).into_response();

assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
json!({
"id": "11111111-1111-4111-8111-111111111111",
"hash": "0x2222222222222222222222222222222222222222222222222222222222222222"
})
);
}
}
10 changes: 6 additions & 4 deletions crates/e2e-tests/src/tests/transactions/status/confirmed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@ impl TestRunner {
let hash = status.hash.unwrap();
info!("Transaction hash: {:?}", hash);
info!("Expected hash: {:?}", send_result.hash);
if hash != send_result.hash {
return Err(anyhow::anyhow!(
"Confirmed transaction should match the sent transaction hash"
));
if let Some(expected_hash) = send_result.hash {
if hash != expected_hash {
return Err(anyhow::anyhow!(
"Confirmed transaction should match the sent transaction hash"
));
}
}
if status.receipt.is_none() {
return Err(anyhow::anyhow!("Confirmed transaction should have receipt"));
Expand Down
10 changes: 6 additions & 4 deletions crates/e2e-tests/src/tests/transactions/status/inmempool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ impl TestRunner {
let hash = status.hash.unwrap();
info!("Transaction hash: {:?}", hash);
info!("Expected hash: {:?}", send_result.hash);
if hash != send_result.hash {
return Err(anyhow::anyhow!(
"InMempool transaction should match the sent transaction hash"
));
if let Some(expected_hash) = send_result.hash {
if hash != expected_hash {
return Err(anyhow::anyhow!(
"InMempool transaction should match the sent transaction hash"
));
}
}

if status.receipt.is_some() {
Expand Down
10 changes: 6 additions & 4 deletions crates/e2e-tests/src/tests/transactions/status/mined.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ impl TestRunner {
let hash = status.hash.unwrap();
info!("Transaction hash: {:?}", hash);
info!("Expected hash: {:?}", send_result.hash);
if hash != send_result.hash {
return Err(anyhow::anyhow!(
"Mined transaction should match the sent transaction hash"
));
if let Some(expected_hash) = send_result.hash {
if hash != expected_hash {
return Err(anyhow::anyhow!(
"Mined transaction should match the sent transaction hash"
));
}
}

if status.receipt.is_none() {
Expand Down
2 changes: 2 additions & 0 deletions documentation/rrelayer/docs/pages/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

### Breaking changes

- transaction submission responses keep `hash` present but allow `null` after durable acceptance; poll by `id`

---

## Releases
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,13 @@ Just so you understand the properties, the properties you get back when you send
```ts
export interface TransactionSent {
id: string;
hash: `0x${string}`;
hash: `0x${string}` | null;
}
```

`null` is a successful, durably accepted submission whose broadcast hash is not available yet.
Poll the transaction by `id`; do not resubmit it.

### Sending Native Transaction

You can send native funds using the below:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,13 @@ Just so you understand the properties, the properties you get back when you send
#[derive(Debug, Serialize, Deserialize)]
pub struct SendTransactionResult {
pub id: TransactionId,
pub hash: TransactionHash,
pub hash: Option<TransactionHash>,
}
```

`None` is a successful, durably accepted submission whose broadcast hash is not available yet.
Poll the transaction by `id`; do not resubmit it.

### Sending Native Transaction

You can send native funds using the below:
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"test": "npm run build && node --test test/*.test.js",
"prepublishOnly": "npm run build",
"format": "prettier --write \"src/**/*.{ts,js,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,js,json}\"",
Expand Down
17 changes: 17 additions & 0 deletions sdk/typescript/src/api/transaction/types.contract.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { TransactionSent } from './types';

type Assert<T extends true> = T;
type Equal<Left, Right> =
(<Value>() => Value extends Left ? 1 : 2) extends <
Value,
>() => Value extends Right ? 1 : 2
? true
: false;

export type PendingTransactionSentContract = Assert<
{ id: string; hash: null } extends TransactionSent ? true : false
>;

export type TransactionSentHashContract = Assert<
Equal<TransactionSent['hash'], `0x${string}` | null>
>;
3 changes: 2 additions & 1 deletion sdk/typescript/src/api/transaction/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@ export interface TransactionToSend {

export interface TransactionSent {
id: string;
hash: `0x${string}`;
/** Null means the transaction was durably accepted and is awaiting broadcast. */
hash: `0x${string}` | null;
}
65 changes: 65 additions & 0 deletions sdk/typescript/src/clients/relayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,71 @@ export class RelayerClient {
this._apiBaseConfig
);
},
/**
* Wait for a durably accepted transaction to receive its broadcast hash.
* A null hash in the send response is accepted/pending, not a rejection.
*/
waitForTransactionHashById: async (
transactionId: string,
tryEveryMs: number = 100,
maxAttempts: number = 1200
): Promise<`0x${string}`> => {
if (!Number.isInteger(tryEveryMs) || tryEveryMs <= 0) {
throw new Error('tryEveryMs must be a positive integer');
}
if (!Number.isInteger(maxAttempts) || maxAttempts <= 0) {
throw new Error('maxAttempts must be a positive integer');
}

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const result = await this.transaction.get(transactionId);
if (!result) {
throw new Error('Transaction not found');
}
if (result.txHash) {
return result.txHash;
}

switch (result.status.toUpperCase()) {
case TransactionStatus.PENDING:
case TransactionStatus.INMEMPOOL:
if (attempt === maxAttempts) {
throw new Error(
`Timed out waiting for transaction ${transactionId} hash after ${maxAttempts} attempts`
);
}
await new Promise((resolve) => setTimeout(resolve, tryEveryMs));
break;
case TransactionStatus.MINED:
case TransactionStatus.CONFIRMED:
throw new Error(
'Transaction reached a mined state without a hash'
);
case TransactionStatus.FAILED:
throw new Error('Transaction failed before receiving a hash');
case TransactionStatus.EXPIRED:
throw new Error('Transaction expired before receiving a hash');
case TransactionStatus.CANCELLED:
throw new Error(
'Transaction was cancelled before receiving a hash'
);
case TransactionStatus.REPLACED:
throw new Error(
'Transaction was replaced before receiving a hash'
);
case TransactionStatus.DROPPED:
throw new Error(
'Transaction was dropped before receiving a hash'
);
default:
throw new Error(`Unknown transaction status ${result.status}`);
}
}

throw new Error(
`Timed out waiting for transaction ${transactionId} hash after ${maxAttempts} attempts`
);
},
waitForTransactionReceiptById: async (
transactionId: string,
tryEveryMs: number = 100
Expand Down
Loading