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
52 changes: 52 additions & 0 deletions django-backend/soroscan/ingest/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
Team,
TeamMembership,
TrackedContract,
TransactionCost,
WebhookDeadLetter,
WebhookDeliveryLog,
WebhookSubscription,
Expand Down Expand Up @@ -1418,3 +1419,54 @@ def test_dedup_view(self, request, contract_id):
json.dumps({"dedup_hash": dedup_hash, "material": material}),
content_type="application/json",
)


# ---------------------------------------------------------------------------
# Transaction Cost Analysis (Issue #804)
# ---------------------------------------------------------------------------


@admin.register(TransactionCost)
class TransactionCostAdmin(admin.ModelAdmin):
list_display = [
"tx_hash_short",
"contract",
"function_name",
"total_fee_stroops",
"is_outlier",
"ledger_sequence",
"created_at",
]
list_filter = ["is_outlier", "function_name", "created_at"]
search_fields = [
"tx_hash",
"contract__contract_id",
"contract__name",
"function_name",
]
readonly_fields = [
"tx_hash",
"contract",
"function_name",
"ledger_sequence",
"total_fee_stroops",
"cpu_instructions_used",
"memory_bytes_used",
"network_bytes_used",
"is_outlier",
"created_at",
]

def tx_hash_short(self, obj):
return obj.tx_hash[:16] + "..."
tx_hash_short.short_description = "TX Hash"
tx_hash_short.admin_order_field = "tx_hash"

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False
73 changes: 73 additions & 0 deletions django-backend/soroscan/ingest/migrations/0042_apiusagelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("ingest", "0041_eventdeduplicationconfig"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="APIUsageLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("method", models.CharField(max_length=12)),
("endpoint", models.CharField(db_index=True, max_length=255)),
("path", models.CharField(max_length=512)),
("status_code", models.PositiveSmallIntegerField(db_index=True)),
("request_bytes", models.PositiveIntegerField(default=0)),
("response_bytes", models.PositiveIntegerField(default=0)),
("error_type", models.CharField(blank=True, db_index=True, max_length=64)),
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
(
"api_key",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="usage_logs",
to="ingest.apikey",
),
),
(
"organization",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="api_usage_logs",
to="ingest.organization",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="api_usage_logs",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-timestamp"],
"indexes": [
models.Index(fields=["organization", "timestamp"], name="ingest_apiu_organiz_f4df26_idx"),
models.Index(fields=["organization", "endpoint", "timestamp"], name="ingest_apiu_organiz_7f7db3_idx"),
models.Index(fields=["organization", "error_type", "timestamp"], name="ingest_apiu_organiz_76c671_idx"),
],
},
),
]
65 changes: 65 additions & 0 deletions django-backend/soroscan/ingest/migrations/0043_transactioncost.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("ingest", "0042_apiusagelog"),
]

operations = [
migrations.CreateModel(
name="TransactionCost",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("tx_hash", models.CharField(max_length=64, unique=True)),
(
"function_name",
models.CharField(blank=True, db_index=True, max_length=128),
),
("ledger_sequence", models.PositiveIntegerField()),
("total_fee_stroops", models.BigIntegerField()),
("cpu_instructions_used", models.BigIntegerField(default=0)),
("memory_bytes_used", models.BigIntegerField(default=0)),
("network_bytes_used", models.BigIntegerField(default=0)),
(
"is_outlier",
models.BooleanField(db_index=True, default=False),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"contract",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="transaction_costs",
to="ingest.trackedcontract",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["contract", "-created_at"],
name="ingest_tran_contrac_6a6677_idx",
),
models.Index(
fields=["function_name"],
name="ingest_tran_functio_3be0ea_idx",
),
models.Index(
fields=["contract", "function_name", "created_at"],
name="ingest_tran_contrac_0f10c6_idx",
),
],
},
),
]
103 changes: 68 additions & 35 deletions django-backend/soroscan/ingest/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,51 @@ def __str__(self):
)


class APIUsageLog(models.Model):
"""Per-request API usage facts used for organization analytics."""

organization = models.ForeignKey(
Organization,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="api_usage_logs",
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="api_usage_logs",
)
api_key = models.ForeignKey(
"APIKey",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="usage_logs",
)
method = models.CharField(max_length=12)
endpoint = models.CharField(max_length=255, db_index=True)
path = models.CharField(max_length=512)
status_code = models.PositiveSmallIntegerField(db_index=True)
request_bytes = models.PositiveIntegerField(default=0)
response_bytes = models.PositiveIntegerField(default=0)
error_type = models.CharField(max_length=64, blank=True, db_index=True)
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)

class Meta:
ordering = ["-timestamp"]
indexes = [
models.Index(fields=["organization", "timestamp"]),
models.Index(fields=["organization", "endpoint", "timestamp"]),
models.Index(fields=["organization", "error_type", "timestamp"]),
]

def __str__(self):
return f"{self.method} {self.endpoint} -> {self.status_code}"


class Team(models.Model):
"""
Multi-tenant organization: groups users and shared tracked contracts.
Expand Down Expand Up @@ -2190,46 +2235,34 @@ def __str__(self):
return f"ABI v{self.version_number} for {self.contract.contract_id[:8]}... (ledger {self.valid_from_ledger}–{self.valid_to_ledger or '∞'})"


class BlacklistedContract(models.Model):
class TransactionCost(models.Model):
"""
Contracts whose events must not be indexed.

Any contract_id present in this table is silently skipped by the
ingestion loop, regardless of whether it also exists in
TrackedContract. A log entry is written each time a skip occurs
so operators can audit the decision.
Tracks Soroban transaction resource costs per contract interaction.
Enables cost analytics, outlier detection, and optimization suggestions.
"""

contract_id = models.CharField(
max_length=56,
unique=True,
db_index=True,
validators=[
RegexValidator(
regex=r"^C[A-Z2-7]{55}$",
message="Contract address must start with 'C' and be exactly 56 characters using valid Base32 characters (A-Z, 2-7).",
)
],
help_text="Stellar contract address to block from indexing (C...)",
)
reason = models.TextField(
blank=True,
help_text="Human-readable explanation of why this contract is blacklisted",
)
added_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="blacklisted_contracts",
help_text="User who added this entry",
)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
tx_hash = models.CharField(max_length=64, unique=True)
contract = models.ForeignKey(
TrackedContract,
on_delete=models.CASCADE,
related_name="transaction_costs",
)
function_name = models.CharField(max_length=128, blank=True, db_index=True)
ledger_sequence = models.PositiveIntegerField()
total_fee_stroops = models.BigIntegerField()
cpu_instructions_used = models.BigIntegerField(default=0)
memory_bytes_used = models.BigIntegerField(default=0)
network_bytes_used = models.BigIntegerField(default=0)
is_outlier = models.BooleanField(default=False, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
verbose_name = "Blacklisted Contract"
verbose_name_plural = "Blacklisted Contracts"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["contract", "-created_at"]),
models.Index(fields=["function_name"]),
models.Index(fields=["contract", "function_name", "created_at"]),
]

def __str__(self):
return f"Blacklisted({self.contract_id[:8]}...)"
return f"${self.total_fee_stroops} stroops | {self.function_name} @ ledger {self.ledger_sequence}"
32 changes: 32 additions & 0 deletions django-backend/soroscan/ingest/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Team,
TeamMembership,
TrackedContract,
TransactionCost,
WebhookSubscription,
)

Expand Down Expand Up @@ -551,3 +552,34 @@ class Meta:
"error_message",
]
read_only_fields = ["id", "verified_at"]


class TransactionCostSerializer(serializers.ModelSerializer):
contract_id = serializers.CharField(source="contract.contract_id", read_only=True)

class Meta:
model = TransactionCost
fields = [
"id",
"tx_hash",
"contract_id",
"function_name",
"ledger_sequence",
"total_fee_stroops",
"cpu_instructions_used",
"memory_bytes_used",
"network_bytes_used",
"is_outlier",
"created_at",
]
read_only_fields = fields


class CostAnalyticsQuerySerializer(serializers.Serializer):
contract_id = serializers.CharField(required=True)
groupby = serializers.ChoiceField(
choices=["function", "day"], default="function"
)
range = serializers.ChoiceField(
choices=["7d", "30d", "90d"], default="7d"
)
Loading
Loading