Skip to content
Merged
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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,36 @@
Satellite-verified productivity certificates + on-chain lien registry + AI credit scoring = instant, trustworthy agricultural lending.

> **Hackathon:** National Solana Hackathon by Decentrathon (April 2026, Kazakhstan)
> **Case:** Case 1 (RWA Tokenization) + Case 2 (AI + Blockchain)

> **Case:** Case 1 (RWA Tokenization)

> **Live demo:** [terra-ledger.com](https://terra-ledger.com)

> **Devnet programs:** [terra_token](https://explorer.solana.com/address/2eAqpJ7yjso7FDA4sDQLJQioNCRuoYSUeha2Y88NRRMX?cluster=devnet) | [lien_registry](https://explorer.solana.com/address/3qYHSTPeRLRDfWmtzEhiaHpT2kchgW8GqaYcwmDbKnq4?cluster=devnet)

---

## Screenshots

### Credit Profile Dashboard
NDVI chart, credit gauge, Agricultural Health Index radar, and lien history for a registered parcel.

![Dashboard](docs/screenshots/dashboard-credit-profile.png)

[//]: # (### AI Risk Assessment On-Chain (Solana Explorer))

[//]: # (The keeper bot writes AI credit scores directly on-chain via `update_risk_assessment`. Each transaction is verifiable on Solana Explorer.)

[//]: # ()
[//]: # (![Solana Explorer](docs/screenshots/solana-explorer-risk-assessment.png))

### Double-Pledge Prevention (Transaction Error)
Attempting to register a second lien on an already-encumbered parcel fails atomically on-chain with `ActiveLienExists` error.

![Double Pledge Error](docs/screenshots/solana-explorer-double-pledge.png)

---

## The Problem

Kazakhstan's agricultural sector produces 4.5% of GDP but receives only 2.3% of total bank lending, a structural credit gap of **1.2 trillion tenge (~$2.7B)**. Banks reject **67% of agricultural loan applications** because they cannot verify land collateral quality and cleanliness:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type ParcelHandlerSuite struct {
ctrl *gomock.Controller
parcelRepo *mock.MockParcelRepo
solana *mock.MockSolanaClient
geocoder *mock.MockGeocoder
handler *ParcelHandler

cadastral string
Expand All @@ -36,9 +37,10 @@ func (s *ParcelHandlerSuite) SetupTest() {
s.ctrl = gomock.NewController(s.T())
s.parcelRepo = mock.NewMockParcelRepo(s.ctrl)
s.solana = mock.NewMockSolanaClient(s.ctrl)
s.geocoder = mock.NewMockGeocoder(s.ctrl)

logger := zerolog.Nop()
s.handler = NewParcelHandler(s.parcelRepo, s.solana, nil, nil, nil, &logger)
s.handler = NewParcelHandler(s.parcelRepo, s.solana, nil, nil, s.geocoder, &logger)
s.cadastral = fmt.Sprintf("KZ-%s-%03d", gofakeit.LetterN(4), gofakeit.IntRange(1, 999))
}

Expand Down Expand Up @@ -71,6 +73,7 @@ func parcelRegisterCases() []parcelRegisterCase {
return b
},
setupMock: func(s *ParcelHandlerSuite) {
s.geocoder.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(51.16, 71.47).Times(1)
s.parcelRepo.EXPECT().
Create(gomock.Any(), matchCadastral(s.cadastral)).
Return(nil).
Expand All @@ -85,6 +88,7 @@ func parcelRegisterCases() []parcelRegisterCase {
return b
},
setupMock: func(s *ParcelHandlerSuite) {
s.geocoder.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(51.16, 71.47).Times(1)
s.parcelRepo.EXPECT().
Create(gomock.Any(), matchCadastral(s.cadastral)).
Return(entity.ErrAlreadyExists).
Expand Down
10 changes: 5 additions & 5 deletions backend/internal/adapter/repository/copernicus.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ const (

// Per-parcel seasonal NDVI values for realistic fallback (spring, summer, fall, winter).
var parcelSeasonalNDVI = map[string][4]float64{
"KZ11-0032-001": {0.76, 0.74, 0.78, 0.72},
"KZ11-0032-002": {0.68, 0.71, 0.65, 0.60},
"KZ11-0032-003": {0.82, 0.79, 0.81, 0.75},
"KZ11-0032-004": {0.55, 0.58, 0.52, 0.48},
"KZ11-0032-005": {0.73, 0.77, 0.70, 0.68},
"KZ11-0033-001": {0.76, 0.74, 0.78, 0.72},
"KZ11-0033-002": {0.68, 0.71, 0.65, 0.60},
"KZ11-0033-003": {0.82, 0.79, 0.81, 0.75},
"KZ11-0033-004": {0.55, 0.58, 0.52, 0.48},
"KZ11-0033-005": {0.73, 0.77, 0.70, 0.68},
}

func fallbackNDVI(cadastral string) float64 {
Expand Down
10 changes: 5 additions & 5 deletions backend/internal/adapter/repository/copernicus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ func TestFallbackNDVI_PerParcel(t *testing.T) {
cadastral string
wantDiff bool
}{
{"known_parcel_001", "KZ11-0032-001", true},
{"known_parcel_004", "KZ11-0032-004", true},
{"known_parcel_001", "KZ11-0033-001", true},
{"known_parcel_004", "KZ11-0033-004", true},
{"unknown_parcel", "KZ99-9999-999", false},
}

Expand All @@ -31,7 +31,7 @@ func TestFallbackNDVI_PerParcel(t *testing.T) {
assert.Greater(t, ndvi, 0.0)
assert.LessOrEqual(t, ndvi, 1.0)
if tc.wantDiff {
other := fallbackNDVI("KZ11-0032-003")
other := fallbackNDVI("KZ11-0033-003")
assert.NotEqual(t, ndvi, other, "different parcels should have different NDVI")
}
})
Expand Down Expand Up @@ -86,7 +86,7 @@ func TestCopernicusClient_FetchNDVI_NoCredentials(t *testing.T) {
logger := zerolog.Nop()
c := NewCopernicusClient("", "", &logger)

ndvi, err := c.FetchNDVI(context.Background(), "KZ11-0032-001", 51.13, 69.41, "2026-03-01", "2026-04-01")
ndvi, err := c.FetchNDVI(context.Background(), "KZ11-0033-001", 51.13, 69.41, "2026-03-01", "2026-04-01")
require.NoError(t, err)
assert.Greater(t, ndvi, 0.0)
}
Expand All @@ -101,7 +101,7 @@ func TestCopernicusClient_FetchNDVI_TokenFails(t *testing.T) {
c := NewCopernicusClient("id", "secret", &logger)
c.httpClient = ts.Client()

ndvi, err := c.FetchNDVI(context.Background(), "KZ11-0032-003", 51.14, 69.40, "2026-03-01", "2026-04-01")
ndvi, err := c.FetchNDVI(context.Background(), "KZ11-0033-003", 51.14, 69.40, "2026-03-01", "2026-04-01")
require.NoError(t, err)
assert.Greater(t, ndvi, 0.0, "should use fallback on token failure")
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
DELETE FROM parcels WHERE cadastral_number IN ('KZ11-0032-001', 'KZ11-0032-002', 'KZ11-0032-003');
DELETE FROM parcels WHERE cadastral_number IN ('KZ11-0033-001', 'KZ11-0033-002', 'KZ11-0033-003');
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
INSERT INTO parcels (cadastral_number, owner_wallet, area_ha, land_class, kyc_verified, oblast, rayon, holder_name, holder_iin_hash, egiss_snapshot) VALUES
('KZ11-0032-001', 'Gk7vSsbuMQ4YXEqTNdS8MjbKjPfruyVaFSfgBhAPo3rN', 150.00, 2, TRUE, 'Akmola', 'Shortandy', 'Askar Omarov', '8a3b1f...mock', '{"source":"egiss_mock","verified":true}'),
('KZ11-0032-002', 'F4J6gPtHvQuR2e8mLKwAs3Y7NxZrE9fDcXbW5uTpV1hS', 220.50, 3, TRUE, 'Akmola', 'Shortandy', 'Bolat Tulegenov', '9c4d2e...mock', '{"source":"egiss_mock","verified":true}'),
('KZ11-0032-003', 'H9mNkR3wYqLdT7vXpC5sE2jA8fBuGx4ZoK6iWrM1hVnQ', 85.00, 1, TRUE, 'Akmola', 'Shortandy', 'Dana Serikova', 'ab5e3f...mock', '{"source":"egiss_mock","verified":true}')
('KZ11-0033-001', 'Gk7vSsbuMQ4YXEqTNdS8MjbKjPfruyVaFSfgBhAPo3rN', 150.00, 2, TRUE, 'Akmola', 'Shortandy', 'Askar Omarov', '8a3b1f...mock', '{"source":"egiss_mock","verified":true}'),
('KZ11-0033-002', 'F4J6gPtHvQuR2e8mLKwAs3Y7NxZrE9fDcXbW5uTpV1hS', 220.50, 3, TRUE, 'Akmola', 'Shortandy', 'Bolat Tulegenov', '9c4d2e...mock', '{"source":"egiss_mock","verified":true}'),
('KZ11-0033-003', 'H9mNkR3wYqLdT7vXpC5sE2jA8fBuGx4ZoK6iWrM1hVnQ', 85.00, 1, TRUE, 'Akmola', 'Shortandy', 'Dana Serikova', 'ab5e3f...mock', '{"source":"egiss_mock","verified":true}')
ON CONFLICT (cadastral_number) DO NOTHING;
Original file line number Diff line number Diff line change
@@ -1 +1 @@
DELETE FROM credit_scores WHERE cadastral_number IN ('KZ11-0032-001', 'KZ11-0032-002', 'KZ11-0032-003');
DELETE FROM credit_scores WHERE cadastral_number IN ('KZ11-0033-001', 'KZ11-0033-002', 'KZ11-0033-003');
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
INSERT INTO credit_scores (parcel_id, cadastral_number, ai_score, recommended_ltv, collateral_grade, estimated_value_tenge, model_version, explanation, risk_factors)
SELECT id, cadastral_number,
CASE cadastral_number
WHEN 'KZ11-0032-001' THEN 75
WHEN 'KZ11-0032-002' THEN 65
WHEN 'KZ11-0032-003' THEN 95
WHEN 'KZ11-0033-001' THEN 75
WHEN 'KZ11-0033-002' THEN 65
WHEN 'KZ11-0033-003' THEN 95
END,
CASE cadastral_number
WHEN 'KZ11-0032-001' THEN 0.500
WHEN 'KZ11-0032-002' THEN 0.500
WHEN 'KZ11-0032-003' THEN 0.700
WHEN 'KZ11-0033-001' THEN 0.500
WHEN 'KZ11-0033-002' THEN 0.500
WHEN 'KZ11-0033-003' THEN 0.700
END,
CASE cadastral_number
WHEN 'KZ11-0032-001' THEN 'B'
WHEN 'KZ11-0032-002' THEN 'B'
WHEN 'KZ11-0032-003' THEN 'A'
WHEN 'KZ11-0033-001' THEN 'B'
WHEN 'KZ11-0033-002' THEN 'B'
WHEN 'KZ11-0033-003' THEN 'A'
END,
CASE cadastral_number
WHEN 'KZ11-0032-001' THEN 75000000
WHEN 'KZ11-0032-002' THEN 110250000
WHEN 'KZ11-0032-003' THEN 42500000
WHEN 'KZ11-0033-001' THEN 75000000
WHEN 'KZ11-0033-002' THEN 110250000
WHEN 'KZ11-0033-003' THEN 42500000
END,
'fallback-v1',
CASE cadastral_number
WHEN 'KZ11-0032-001' THEN 'Good productivity parcel in Akmola region. No active liens, land class 2.'
WHEN 'KZ11-0032-002' THEN 'Average productivity parcel in Akmola region. No active liens, land class 3.'
WHEN 'KZ11-0032-003' THEN 'High productivity parcel in Akmola region. No active liens, top land class.'
WHEN 'KZ11-0033-001' THEN 'Good productivity parcel in Akmola region. No active liens, land class 2.'
WHEN 'KZ11-0033-002' THEN 'Average productivity parcel in Akmola region. No active liens, land class 3.'
WHEN 'KZ11-0033-003' THEN 'High productivity parcel in Akmola region. No active liens, top land class.'
END,
'[]'::jsonb
FROM parcels
WHERE cadastral_number IN ('KZ11-0032-001', 'KZ11-0032-002', 'KZ11-0032-003')
WHERE cadastral_number IN ('KZ11-0033-001', 'KZ11-0033-002', 'KZ11-0033-003')
ON CONFLICT (cadastral_number) DO NOTHING;
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
ALTER TABLE parcels ADD COLUMN IF NOT EXISTS latitude NUMERIC(9,6);
ALTER TABLE parcels ADD COLUMN IF NOT EXISTS longitude NUMERIC(9,6);

UPDATE parcels SET latitude = 51.1605, longitude = 71.4704 WHERE cadastral_number = 'KZ11-0032-001';
UPDATE parcels SET latitude = 51.1805, longitude = 71.5104 WHERE cadastral_number = 'KZ11-0032-002';
UPDATE parcels SET latitude = 51.1405, longitude = 71.4304 WHERE cadastral_number = 'KZ11-0032-003';
UPDATE parcels SET latitude = 51.2005, longitude = 71.5504 WHERE cadastral_number = 'KZ11-0032-004';
UPDATE parcels SET latitude = 51.1200, longitude = 71.3900 WHERE cadastral_number = 'KZ11-0032-005';
UPDATE parcels SET latitude = 51.1605, longitude = 71.4704 WHERE cadastral_number = 'KZ11-0033-001';
UPDATE parcels SET latitude = 51.1805, longitude = 71.5104 WHERE cadastral_number = 'KZ11-0033-002';
UPDATE parcels SET latitude = 51.1405, longitude = 71.4304 WHERE cadastral_number = 'KZ11-0033-003';
UPDATE parcels SET latitude = 51.2005, longitude = 71.5504 WHERE cadastral_number = 'KZ11-0033-004';
UPDATE parcels SET latitude = 51.1200, longitude = 71.3900 WHERE cadastral_number = 'KZ11-0033-005';
Original file line number Diff line number Diff line change
@@ -1 +1 @@
DELETE FROM certificates WHERE cadastral_number IN ('KZ11-0032-001', 'KZ11-0032-002', 'KZ11-0032-003');
DELETE FROM certificates WHERE cadastral_number IN ('KZ11-0033-001', 'KZ11-0033-002', 'KZ11-0033-003');
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,23 @@ INSERT INTO certificates (parcel_id, cadastral_number, season, ndvi_score, ndwi_
SELECT p.id, p.cadastral_number, s.season, s.ndvi, s.ndwi, s.evi, s.lai, s.cloud_free, s.samples, s.crop, s.yield_t, s.obs, s.obs
FROM parcels p
JOIN (VALUES
-- KZ11-0032-001: strong parcel, improving trend
('KZ11-0032-001', '2025-Q2', 0.72, -0.08, 0.45, 3.2, 89.5, 12, 'wheat', 2.8, '2025-04-15'::timestamptz),
('KZ11-0032-001', '2025-Q3', 0.76, -0.05, 0.48, 3.5, 92.1, 15, 'wheat', 3.1, '2025-07-15'::timestamptz),
('KZ11-0032-001', '2025-Q4', 0.74, -0.12, 0.44, 3.1, 85.3, 11, 'wheat', 2.9, '2025-10-15'::timestamptz),
('KZ11-0032-001', '2026-Q1', 0.78, -0.03, 0.50, 3.8, 91.0, 14, 'wheat', 3.3, '2026-01-15'::timestamptz),
-- KZ11-0033-001: strong parcel, improving trend
('KZ11-0033-001', '2025-Q2', 0.72, -0.08, 0.45, 3.2, 89.5, 12, 'wheat', 2.8, '2025-04-15'::timestamptz),
('KZ11-0033-001', '2025-Q3', 0.76, -0.05, 0.48, 3.5, 92.1, 15, 'wheat', 3.1, '2025-07-15'::timestamptz),
('KZ11-0033-001', '2025-Q4', 0.74, -0.12, 0.44, 3.1, 85.3, 11, 'wheat', 2.9, '2025-10-15'::timestamptz),
('KZ11-0033-001', '2026-Q1', 0.78, -0.03, 0.50, 3.8, 91.0, 14, 'wheat', 3.3, '2026-01-15'::timestamptz),

-- KZ11-0032-002: moderate parcel, stable
('KZ11-0032-002', '2025-Q2', 0.65, -0.18, 0.38, 2.5, 82.0, 10, 'barley', 2.2, '2025-04-15'::timestamptz),
('KZ11-0032-002', '2025-Q3', 0.68, -0.15, 0.40, 2.7, 88.4, 13, 'barley', 2.4, '2025-07-15'::timestamptz),
('KZ11-0032-002', '2025-Q4', 0.63, -0.22, 0.36, 2.3, 79.1, 9, 'barley', 2.1, '2025-10-15'::timestamptz),
('KZ11-0032-002', '2026-Q1', 0.66, -0.16, 0.39, 2.6, 84.7, 11, 'barley', 2.3, '2026-01-15'::timestamptz),
-- KZ11-0033-002: moderate parcel, stable
('KZ11-0033-002', '2025-Q2', 0.65, -0.18, 0.38, 2.5, 82.0, 10, 'barley', 2.2, '2025-04-15'::timestamptz),
('KZ11-0033-002', '2025-Q3', 0.68, -0.15, 0.40, 2.7, 88.4, 13, 'barley', 2.4, '2025-07-15'::timestamptz),
('KZ11-0033-002', '2025-Q4', 0.63, -0.22, 0.36, 2.3, 79.1, 9, 'barley', 2.1, '2025-10-15'::timestamptz),
('KZ11-0033-002', '2026-Q1', 0.66, -0.16, 0.39, 2.6, 84.7, 11, 'barley', 2.3, '2026-01-15'::timestamptz),

-- KZ11-0032-003: excellent parcel, high productivity
('KZ11-0032-003', '2025-Q2', 0.80, 0.02, 0.55, 4.2, 95.0, 18, 'wheat', 3.8, '2025-04-15'::timestamptz),
('KZ11-0032-003', '2025-Q3', 0.82, 0.05, 0.58, 4.5, 96.2, 20, 'wheat', 4.0, '2025-07-15'::timestamptz),
('KZ11-0032-003', '2025-Q4', 0.79, -0.01, 0.53, 4.0, 93.1, 16, 'wheat', 3.7, '2025-10-15'::timestamptz),
('KZ11-0032-003', '2026-Q1', 0.83, 0.04, 0.57, 4.6, 94.8, 19, 'wheat', 4.1, '2026-01-15'::timestamptz)
-- KZ11-0033-003: excellent parcel, high productivity
('KZ11-0033-003', '2025-Q2', 0.80, 0.02, 0.55, 4.2, 95.0, 18, 'wheat', 3.8, '2025-04-15'::timestamptz),
('KZ11-0033-003', '2025-Q3', 0.82, 0.05, 0.58, 4.5, 96.2, 20, 'wheat', 4.0, '2025-07-15'::timestamptz),
('KZ11-0033-003', '2025-Q4', 0.79, -0.01, 0.53, 4.0, 93.1, 16, 'wheat', 3.7, '2025-10-15'::timestamptz),
('KZ11-0033-003', '2026-Q1', 0.83, 0.04, 0.57, 4.6, 94.8, 19, 'wheat', 4.1, '2026-01-15'::timestamptz)
) AS s(cadastral, season, ndvi, ndwi, evi, lai, cloud_free, samples, crop, yield_t, obs)
ON p.cadastral_number = s.cadastral
ON CONFLICT ON CONSTRAINT uq_cert_cadastral_observed DO NOTHING;
33 changes: 33 additions & 0 deletions backend/internal/usecase/repository/mock/mock_repository.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions contracts/Anchor.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ skip-lint = false
[programs.localnet]
terra_token = "2eAqpJ7yjso7FDA4sDQLJQioNCRuoYSUeha2Y88NRRMX"
lien_registry = "3qYHSTPeRLRDfWmtzEhiaHpT2kchgW8GqaYcwmDbKnq4"
transfer_hook = "CpvRLN1XUqpjPw1uHAQcPHQjgbvSF7jnaftwEghta964"

[programs.devnet]
terra_token = "2eAqpJ7yjso7FDA4sDQLJQioNCRuoYSUeha2Y88NRRMX"
lien_registry = "3qYHSTPeRLRDfWmtzEhiaHpT2kchgW8GqaYcwmDbKnq4"
transfer_hook = "CpvRLN1XUqpjPw1uHAQcPHQjgbvSF7jnaftwEghta964"

[registry]
url = "https://api.apr.dev"
Expand Down
2 changes: 2 additions & 0 deletions contracts/programs/terra_token/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ pub enum TerraTokenError {
InvalidAIScore,
#[msg("Invalid collateral grade (must be 0-3)")]
InvalidCollateralGrade,
#[msg("Invalid LTV (must be 0-10000 basis points)")]
InvalidLTV,
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub fn handler(
) -> Result<()> {
require!(ai_score <= 100, TerraTokenError::InvalidAIScore);
require!(collateral_grade <= 3, TerraTokenError::InvalidCollateralGrade);
require!(recommended_ltv <= 10000, TerraTokenError::InvalidLTV);

let parcel = &mut ctx.accounts.parcel_config;
parcel.ai_score = ai_score;
Expand Down
Loading
Loading