diff --git a/README.md b/README.md index d222eeb..6e2b74b 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/backend/internal/adapter/controller/http/parcel_handler_test.go b/backend/internal/adapter/controller/http/parcel_handler_test.go index f61287f..6ac7910 100644 --- a/backend/internal/adapter/controller/http/parcel_handler_test.go +++ b/backend/internal/adapter/controller/http/parcel_handler_test.go @@ -23,6 +23,7 @@ type ParcelHandlerSuite struct { ctrl *gomock.Controller parcelRepo *mock.MockParcelRepo solana *mock.MockSolanaClient + geocoder *mock.MockGeocoder handler *ParcelHandler cadastral string @@ -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)) } @@ -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). @@ -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). diff --git a/backend/internal/adapter/repository/copernicus.go b/backend/internal/adapter/repository/copernicus.go index a479368..43ecbf7 100644 --- a/backend/internal/adapter/repository/copernicus.go +++ b/backend/internal/adapter/repository/copernicus.go @@ -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 { diff --git a/backend/internal/adapter/repository/copernicus_test.go b/backend/internal/adapter/repository/copernicus_test.go index 9175da1..91f1c3c 100644 --- a/backend/internal/adapter/repository/copernicus_test.go +++ b/backend/internal/adapter/repository/copernicus_test.go @@ -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}, } @@ -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") } }) @@ -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) } @@ -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") } diff --git a/backend/internal/infrastructure/migration/migrations/010_seed_demo_parcels.down.sql b/backend/internal/infrastructure/migration/migrations/010_seed_demo_parcels.down.sql index fd45f3b..d1dba53 100644 --- a/backend/internal/infrastructure/migration/migrations/010_seed_demo_parcels.down.sql +++ b/backend/internal/infrastructure/migration/migrations/010_seed_demo_parcels.down.sql @@ -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'); diff --git a/backend/internal/infrastructure/migration/migrations/010_seed_demo_parcels.up.sql b/backend/internal/infrastructure/migration/migrations/010_seed_demo_parcels.up.sql index 2fcde78..507ea2f 100644 --- a/backend/internal/infrastructure/migration/migrations/010_seed_demo_parcels.up.sql +++ b/backend/internal/infrastructure/migration/migrations/010_seed_demo_parcels.up.sql @@ -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; diff --git a/backend/internal/infrastructure/migration/migrations/011_seed_demo_scores.down.sql b/backend/internal/infrastructure/migration/migrations/011_seed_demo_scores.down.sql index b21338d..eb3d59c 100644 --- a/backend/internal/infrastructure/migration/migrations/011_seed_demo_scores.down.sql +++ b/backend/internal/infrastructure/migration/migrations/011_seed_demo_scores.down.sql @@ -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'); diff --git a/backend/internal/infrastructure/migration/migrations/011_seed_demo_scores.up.sql b/backend/internal/infrastructure/migration/migrations/011_seed_demo_scores.up.sql index 182eeef..5a9c389 100644 --- a/backend/internal/infrastructure/migration/migrations/011_seed_demo_scores.up.sql +++ b/backend/internal/infrastructure/migration/migrations/011_seed_demo_scores.up.sql @@ -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; diff --git a/backend/internal/infrastructure/migration/migrations/014_add_parcel_coordinates.up.sql b/backend/internal/infrastructure/migration/migrations/014_add_parcel_coordinates.up.sql index 24f767a..e6a1603 100644 --- a/backend/internal/infrastructure/migration/migrations/014_add_parcel_coordinates.up.sql +++ b/backend/internal/infrastructure/migration/migrations/014_add_parcel_coordinates.up.sql @@ -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'; diff --git a/backend/internal/infrastructure/migration/migrations/016_seed_demo_certificates.down.sql b/backend/internal/infrastructure/migration/migrations/016_seed_demo_certificates.down.sql index d167fc6..9214a85 100644 --- a/backend/internal/infrastructure/migration/migrations/016_seed_demo_certificates.down.sql +++ b/backend/internal/infrastructure/migration/migrations/016_seed_demo_certificates.down.sql @@ -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'); diff --git a/backend/internal/infrastructure/migration/migrations/016_seed_demo_certificates.up.sql b/backend/internal/infrastructure/migration/migrations/016_seed_demo_certificates.up.sql index 2638660..7a99060 100644 --- a/backend/internal/infrastructure/migration/migrations/016_seed_demo_certificates.up.sql +++ b/backend/internal/infrastructure/migration/migrations/016_seed_demo_certificates.up.sql @@ -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; diff --git a/backend/internal/usecase/repository/mock/mock_repository.go b/backend/internal/usecase/repository/mock/mock_repository.go index c23c535..2f500ae 100644 --- a/backend/internal/usecase/repository/mock/mock_repository.go +++ b/backend/internal/usecase/repository/mock/mock_repository.go @@ -782,3 +782,36 @@ func (mr *MockConsentRepoMockRecorder) Revoke(ctx, walletAddress any) *gomock.Ca mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Revoke", reflect.TypeOf((*MockConsentRepo)(nil).Revoke), ctx, walletAddress) } + +// MockGeocoder is a mock of Geocoder interface. +type MockGeocoder struct { + ctrl *gomock.Controller + recorder *MockGeocoderMockRecorder +} + +type MockGeocoderMockRecorder struct { + mock *MockGeocoder +} + +func NewMockGeocoder(ctrl *gomock.Controller) *MockGeocoder { + mock := &MockGeocoder{ctrl: ctrl} + mock.recorder = &MockGeocoderMockRecorder{mock} + return mock +} + +func (m *MockGeocoder) EXPECT() *MockGeocoderMockRecorder { + return m.recorder +} + +func (m *MockGeocoder) Resolve(cadastral string, oblast string) (float64, float64) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", cadastral, oblast) + ret0, _ := ret[0].(float64) + ret1, _ := ret[1].(float64) + return ret0, ret1 +} + +func (mr *MockGeocoderMockRecorder) Resolve(cadastral, oblast any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockGeocoder)(nil).Resolve), cadastral, oblast) +} diff --git a/contracts/Anchor.toml b/contracts/Anchor.toml index 2bac0d2..80260af 100644 --- a/contracts/Anchor.toml +++ b/contracts/Anchor.toml @@ -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" diff --git a/contracts/programs/terra_token/src/errors.rs b/contracts/programs/terra_token/src/errors.rs index cc19e51..03b6f8c 100644 --- a/contracts/programs/terra_token/src/errors.rs +++ b/contracts/programs/terra_token/src/errors.rs @@ -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, } diff --git a/contracts/programs/terra_token/src/instructions/update_risk_assessment.rs b/contracts/programs/terra_token/src/instructions/update_risk_assessment.rs index 157c795..524236c 100644 --- a/contracts/programs/terra_token/src/instructions/update_risk_assessment.rs +++ b/contracts/programs/terra_token/src/instructions/update_risk_assessment.rs @@ -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; diff --git a/contracts/scripts/seed.ts b/contracts/scripts/seed.ts index d193c27..4d31632 100644 --- a/contracts/scripts/seed.ts +++ b/contracts/scripts/seed.ts @@ -13,6 +13,8 @@ import { TerraToken } from "../target/types/terra_token"; import { LienRegistry } from "../target/types/lien_registry"; const { PublicKey, SystemProgram, Keypair } = anchor.web3; +const TOKEN_2022_PROGRAM_ID = new PublicKey("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"); +const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); // --------------------------------------------------------------------------- // PDA helpers (mirrors tests/helpers/setup.ts) @@ -62,7 +64,7 @@ interface ParcelSeed { const PARCELS: ParcelSeed[] = [ { - cadastral: "KZ11-0032-001", + cadastral: "KZ11-0033-001", areaHa: 4530, landClass: 2, certificates: [ @@ -72,7 +74,7 @@ const PARCELS: ParcelSeed[] = [ ], }, { - cadastral: "KZ11-0032-002", + cadastral: "KZ11-0033-002", areaHa: 2100, landClass: 1, certificates: [ @@ -81,7 +83,7 @@ const PARCELS: ParcelSeed[] = [ ], }, { - cadastral: "KZ11-0032-003", + cadastral: "KZ11-0033-003", areaHa: 8750, landClass: 3, certificates: [ @@ -91,7 +93,7 @@ const PARCELS: ParcelSeed[] = [ ], }, { - cadastral: "KZ11-0032-004", + cadastral: "KZ11-0033-004", areaHa: 1200, landClass: 5, certificates: [ @@ -100,7 +102,7 @@ const PARCELS: ParcelSeed[] = [ ], }, { - cadastral: "KZ11-0032-005", + cadastral: "KZ11-0033-005", areaHa: 6300, landClass: 2, certificates: [ @@ -201,17 +203,36 @@ async function main() { for (const cert of parcel.certificates) { try { + const certMint = Keypair.generate(); + + // Derive ATA for the farmer (owner) for this certificate mint + const [tokenAccount] = PublicKey.findProgramAddressSync( + [ + farmer.publicKey.toBuffer(), + TOKEN_2022_PROGRAM_ID.toBuffer(), + certMint.publicKey.toBuffer(), + ], + ASSOCIATED_TOKEN_PROGRAM_ID, + ); + await terraToken.methods - .mintCertificate(parcel.cadastral, cert.season, cert.ndviScore) + .mintCertificate(parcel.cadastral, cert.season, cert.ndviScore, "winter_wheat") .accountsStrict({ parcelConfig: parcelPda, + certificateMint: certMint.publicKey, + tokenAccount, + owner: farmer.publicKey, mintAuthority: farmer.publicKey, + tokenProgram: TOKEN_2022_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, }) + .signers([certMint]) .rpc(); const ndviDecimal = (cert.ndviScore / 1000).toFixed(3); console.log( - ` [OK] ${parcel.cadastral} / ${cert.season} ndvi=${ndviDecimal}`, + ` [OK] ${parcel.cadastral} / ${cert.season} ndvi=${ndviDecimal} mint=${certMint.publicKey.toBase58().slice(0, 8)}...`, ); } catch (err: any) { console.error( @@ -225,7 +246,7 @@ async function main() { // ------------------------------------------------------------------------- // 3. Register lien on parcel 001 // ------------------------------------------------------------------------- - console.log("--- Registering lien on KZ11-0032-001 ---"); + console.log("--- Registering lien on KZ11-0033-001 ---"); const lienCadastral = PARCELS[0].cadastral; const [parcelPda001] = getParcelPda(terraToken, lienCadastral); const [encumbrancePda] = getEncumbrancePda( @@ -251,6 +272,7 @@ async function main() { parcelConfig: parcelPda001, lender: lender.publicKey, systemProgram: SystemProgram.programId, + terraTokenProgram: terraToken.programId, }) .signers([lender]) .rpc(); diff --git a/contracts/tests/lien_registry.test.ts b/contracts/tests/lien_registry.test.ts index 79311f7..2195a9b 100644 --- a/contracts/tests/lien_registry.test.ts +++ b/contracts/tests/lien_registry.test.ts @@ -131,7 +131,7 @@ describe("lien_registry", () => { parcelConfig: parcelPda, lender: env.lender2.publicKey, systemProgram: SystemProgram.programId, - + terraTokenProgram: env.terraToken.programId, }) .signers([env.lender2]) .rpc(); diff --git a/contracts/tests/transfer_hook.test.ts b/contracts/tests/transfer_hook.test.ts new file mode 100644 index 0000000..1a02b28 --- /dev/null +++ b/contracts/tests/transfer_hook.test.ts @@ -0,0 +1,103 @@ +import * as anchor from "@anchor-lang/core"; +import { Program } from "@anchor-lang/core"; +import { expect } from "chai"; +import { TransferHook } from "../target/types/transfer_hook"; +import { + createTestEnv, + getParcelPda, + TestEnv, + TEST_AREA_HA, + TEST_LAND_CLASS, + TEST_EGISS_HASH, +} from "./helpers/setup"; + +const { Keypair, SystemProgram } = anchor.web3; + +const TEST_CADASTRAL_HOOK = "KZ11-0032-HK1"; +const TEST_CADASTRAL_DORMANT = "KZ11-0032-HK2"; + +describe("transfer_hook", () => { + let env: TestEnv; + let transferHook: Program; + + before(async () => { + env = await createTestEnv(); + + transferHook = new Program( + require("../target/idl/transfer_hook.json"), + env.provider, + ); + + // Register a healthy parcel (risk_flag=0, dormant_seasons=0) + const [parcelPda] = getParcelPda(env.terraToken, TEST_CADASTRAL_HOOK); + await env.terraToken.methods + .registerParcel(TEST_CADASTRAL_HOOK, TEST_AREA_HA, TEST_LAND_CLASS, TEST_EGISS_HASH) + .accounts({ parcelConfig: parcelPda, owner: env.farmer.publicKey, systemProgram: SystemProgram.programId }) + .signers([env.farmer]) + .rpc(); + + // Register a parcel that will become dormant + const [dormantPda] = getParcelPda(env.terraToken, TEST_CADASTRAL_DORMANT); + await env.terraToken.methods + .registerParcel(TEST_CADASTRAL_DORMANT, TEST_AREA_HA, TEST_LAND_CLASS, TEST_EGISS_HASH) + .accounts({ parcelConfig: dormantPda, owner: env.farmer.publicKey, systemProgram: SystemProgram.programId }) + .signers([env.farmer]) + .rpc(); + }); + + describe("execute", () => { + it("succeeds for healthy parcel (risk_flag=0, dormant_seasons=0)", async () => { + const [parcelPda] = getParcelPda(env.terraToken, TEST_CADASTRAL_HOOK); + const dummy = Keypair.generate(); + + // Should not throw + await transferHook.methods + .execute(new anchor.BN(1)) + .accountsStrict({ + source: dummy.publicKey, + mint: dummy.publicKey, + destination: dummy.publicKey, + authority: dummy.publicKey, + parcelConfig: parcelPda, + }) + .rpc(); + }); + + it("rejects when dormant_seasons > 2", async () => { + const [dormantPda] = getParcelPda(env.terraToken, TEST_CADASTRAL_DORMANT); + + // Run seasonal_check 3 times with different keepers to avoid tx dedup. + // Parcel has 0 ndvi submissions, so each check increments dormant_seasons. + const keepers = [env.keeper, env.lender1, env.lender2]; + for (let i = 0; i < 3; i++) { + await env.terraToken.methods + .seasonalCheck(TEST_CADASTRAL_DORMANT) + .accounts({ parcelConfig: dormantPda, keeper: keepers[i].publicKey, lienIndex: null }) + .signers([keepers[i]]) + .rpc(); + } + + // Verify dormant_seasons = 3 + const parcel = await env.terraToken.account.parcelConfig.fetch(dormantPda); + expect(parcel.dormantSeasons).to.equal(3); + + // transfer_hook::execute should fail + const dummy = Keypair.generate(); + try { + await transferHook.methods + .execute(new anchor.BN(1)) + .accountsStrict({ + source: dummy.publicKey, + mint: dummy.publicKey, + destination: dummy.publicKey, + authority: dummy.publicKey, + parcelConfig: dormantPda, + }) + .rpc(); + expect.fail("should have thrown CertificateExpired"); + } catch (err: any) { + expect(err.toString()).to.include("CertificateExpired"); + } + }); + }); +}); diff --git a/deployments/docker-compose.prod.yml b/deployments/docker-compose.prod.yml index f485404..ffa3415 100644 --- a/deployments/docker-compose.prod.yml +++ b/deployments/docker-compose.prod.yml @@ -31,10 +31,16 @@ services: COPERNICUS_CLIENT_ID: ${COPERNICUS_CLIENT_ID} COPERNICUS_CLIENT_SECRET: ${COPERNICUS_CLIENT_SECRET} KEEPER_INTERVAL: ${KEEPER_INTERVAL:-6h} + secrets: + - relay_keypair depends_on: postgres: condition: service_healthy restart: always +secrets: + relay_keypair: + file: ./relay_keypair.json + volumes: pgdata: diff --git a/docs/screenshots/dashboard-credit-profile.png b/docs/screenshots/dashboard-credit-profile.png new file mode 100644 index 0000000..e42e0b4 Binary files /dev/null and b/docs/screenshots/dashboard-credit-profile.png differ diff --git a/docs/screenshots/landing-page.png b/docs/screenshots/landing-page.png new file mode 100644 index 0000000..9006197 Binary files /dev/null and b/docs/screenshots/landing-page.png differ diff --git a/docs/screenshots/solana-explorer-double-pledge.png b/docs/screenshots/solana-explorer-double-pledge.png new file mode 100644 index 0000000..6c6c8cb Binary files /dev/null and b/docs/screenshots/solana-explorer-double-pledge.png differ diff --git a/e2e/demo-flow.spec.ts b/e2e/demo-flow.spec.ts index acba914..984b5a9 100644 --- a/e2e/demo-flow.spec.ts +++ b/e2e/demo-flow.spec.ts @@ -24,7 +24,7 @@ test.describe("TerraLedger Demo Flow", () => { // Search step should appear — fill cadastral number const searchInput = page.locator('input[placeholder*="cadastral" i], input[type="text"]').first(); await expect(searchInput).toBeVisible({ timeout: 5000 }); - await searchInput.fill("KZ11-0032-001"); + await searchInput.fill("KZ11-0033-001"); // Submit search (click the button or press enter) const searchButton = page.getByRole("button", { name: /search|find|next/i }); @@ -35,14 +35,14 @@ test.describe("TerraLedger Demo Flow", () => { } // Should see profile data — wait for it to load - await expect(page.getByText("KZ11-0032-001")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("KZ11-0033-001")).toBeVisible({ timeout: 10000 }); }); test("parcel detail page shows credit profile sections", async ({ page }) => { - await page.goto("/parcel/KZ11-0032-001"); + await page.goto("/parcel/KZ11-0033-001"); // Wait for data to load - await expect(page.getByText("Parcel KZ11-0032-001")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Parcel KZ11-0033-001")).toBeVisible({ timeout: 10000 }); // Parcel details section await expect(page.getByText("Parcel Details")).toBeVisible(); diff --git a/web/src/App.tsx b/web/src/App.tsx index a8233e9..5a537e5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { NotFound } from './pages/NotFound/NotFound' import { Skeleton } from './components/Skeleton/Skeleton' import { ToastProvider } from './components/Toast/Toast' +const Landing = lazy(() => import('./pages/Landing/Landing')) const WizardFlow = lazy(() => import('./pages/WizardFlow/WizardFlow')) const ParcelDetail = lazy(() => import('./pages/ParcelDetail/ParcelDetail')) @@ -19,6 +20,11 @@ function PageFallback() { const router = createBrowserRouter([ { + index: true, + element: }>, + }, + { + path: 'app', element: , children: [ { index: true, element: }> }, @@ -26,6 +32,7 @@ const router = createBrowserRouter([ { path: '*', element: }, ], }, + { path: '*', element: }, ]) export function App() { diff --git a/web/src/pages/FarmerPortal/FarmerPortal.tsx b/web/src/pages/FarmerPortal/FarmerPortal.tsx index 2bfead7..f8c9728 100644 --- a/web/src/pages/FarmerPortal/FarmerPortal.tsx +++ b/web/src/pages/FarmerPortal/FarmerPortal.tsx @@ -144,7 +144,7 @@ export default function FarmerPortal() {
update('cadastral_number', e.target.value)} /> diff --git a/web/src/pages/Landing/Landing.module.css b/web/src/pages/Landing/Landing.module.css new file mode 100644 index 0000000..06d30d6 --- /dev/null +++ b/web/src/pages/Landing/Landing.module.css @@ -0,0 +1,605 @@ +.page { + min-height: 100vh; + background: var(--color-background); + overflow-x: hidden; +} + +/* ── Header ── */ +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + height: var(--topbar-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--spacing-xl); + background: rgba(15, 15, 15, 0.85); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--color-border); +} + +.logo { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + font-family: var(--font-heading); + font-size: var(--font-h3); + font-weight: 700; + color: var(--color-primary); + text-decoration: none; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.logoIcon { + width: 24px; + height: 24px; + image-rendering: pixelated; +} + +.headerCta { + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 var(--spacing-md); + font-family: var(--font-mono); + font-size: var(--font-caption); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-background); + background: var(--color-primary); + border-radius: var(--radius-md); + text-decoration: none; + transition: background var(--transition); +} + +.headerCta:hover { + background: var(--color-primary-hover); +} + +/* ── Hero ── */ +.hero { + position: relative; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: calc(var(--topbar-height) + var(--spacing-3xl)) var(--spacing-xl) var(--spacing-3xl); +} + +.heroBackground { + position: absolute; + inset: 0; + background: + radial-gradient(ellipse at 50% 30%, rgba(37, 208, 171, 0.10) 0%, transparent 55%), + repeating-linear-gradient( + 0deg, + transparent, + transparent 63px, + rgba(37, 208, 171, 0.03) 63px, + rgba(37, 208, 171, 0.03) 64px + ), + repeating-linear-gradient( + 90deg, + transparent, + transparent 63px, + rgba(37, 208, 171, 0.03) 63px, + rgba(37, 208, 171, 0.03) 64px + ); + pointer-events: none; +} + +.heroContent { + position: relative; + z-index: 1; + max-width: 720px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-lg); + animation: heroFadeIn 600ms ease-out; +} + +@keyframes heroFadeIn { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +.eyebrow { + font-family: var(--font-mono); + font-size: var(--font-caption); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-primary); + background: var(--color-primary-bg); + padding: var(--spacing-xs) var(--spacing-md); + border-radius: var(--radius-full); +} + +.headline { + font-family: var(--font-heading); + font-size: 56px; + font-weight: 700; + line-height: 1.05; + text-transform: uppercase; + color: var(--color-text); + text-wrap: balance; +} + +.headlineAccent { + color: var(--color-primary); +} + +.subtitle { + font-size: var(--font-body); + color: var(--color-text-secondary); + line-height: 1.6; + max-width: 480px; + text-wrap: pretty; +} + +.heroCtas { + display: flex; + gap: var(--spacing-md); + flex-wrap: wrap; + justify-content: center; +} + +.btnPrimary { + display: inline-flex; + align-items: center; + height: 48px; + padding: 0 var(--spacing-xl); + font-family: var(--font-mono); + font-size: var(--font-body-sm); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-background); + background: var(--color-primary); + border-radius: var(--radius-md); + text-decoration: none; + transition: background var(--transition), box-shadow var(--transition); + animation: ctaPulse 2.5s ease-in-out infinite; +} + +.btnPrimary:hover { + background: var(--color-primary-hover); +} + +@keyframes ctaPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(37, 208, 171, 0.3); } + 50% { box-shadow: 0 0 0 8px rgba(37, 208, 171, 0); } +} + +.btnSecondary { + display: inline-flex; + align-items: center; + height: 48px; + padding: 0 var(--spacing-xl); + font-family: var(--font-mono); + font-size: var(--font-body-sm); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text); + background: var(--color-surface-hover); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + text-decoration: none; + transition: border-color var(--transition); +} + +.btnSecondary:hover { + border-color: var(--color-primary); +} + +/* ── Scroll Indicator ── */ +.scrollIndicator { + position: absolute; + bottom: var(--spacing-xl); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + opacity: 0.6; + transition: opacity 0.4s ease; +} + +.scrollMouse { + width: 24px; + height: 38px; + border: 2px solid var(--color-text-secondary); + border-radius: 12px; + position: relative; +} + +.scrollMouse::before { + content: ''; + position: absolute; + left: 50%; + top: 8px; + width: 3px; + height: 8px; + margin-left: -1.5px; + background: var(--color-primary); + border-radius: 2px; + animation: scrollWheel 2s ease-in-out infinite; +} + +.scrollLabel { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +@keyframes scrollWheel { + 0% { opacity: 1; transform: translateY(0); } + 40% { opacity: 1; } + 100% { opacity: 0; transform: translateY(12px); } +} + +/* ── Scroll Animations ── */ +.animatable { + opacity: 0; + transform: translateY(16px); + transition: opacity 500ms ease-out, transform 500ms ease-out; +} + +.visible { + opacity: 1; + transform: translateY(0); +} + +/* Stagger pipeline cards */ +.visible .featureCard:nth-child(1) { transition-delay: 0ms; } +.visible .featureCard:nth-child(2) { transition-delay: 150ms; } +.visible .featureCard:nth-child(3) { transition-delay: 300ms; } + +.featureCard { + opacity: 0; + transform: translateY(12px); + transition: opacity 400ms ease-out, transform 400ms ease-out, border-color var(--transition); +} + +.visible .featureCard { + opacity: 1; + transform: translateY(0); +} + +/* ── Section Heading ── */ +.sectionHeading { + font-family: var(--font-heading); + font-size: var(--font-h2); + font-weight: 700; + color: var(--color-text); + text-align: center; + text-transform: uppercase; + margin-bottom: var(--spacing-xl); +} + +/* ── Stats ── */ +.stats { + padding: var(--spacing-2xl) var(--spacing-xl); +} + +.statsInner { + max-width: 960px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-lg); +} + +.statCard { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--spacing-xl); + text-align: center; +} + +.statValue { + font-family: var(--font-mono); + font-size: 40px; + font-weight: 700; + color: var(--color-primary); + line-height: 1.2; + margin-bottom: var(--spacing-sm); +} + +.statLabel { + font-family: var(--font-mono); + font-size: var(--font-caption); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-secondary); +} + +/* ── Pipeline ── */ +.pipeline { + padding: var(--spacing-3xl) var(--spacing-xl); +} + +.pipelineInner { + max-width: 960px; + margin: 0 auto; +} + +.pipelineGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-lg); + position: relative; +} + +.pipelineGrid::before { + content: ''; + position: absolute; + top: 24px; + left: calc(33.33% / 2); + right: calc(33.33% / 2); + height: 2px; + background: repeating-linear-gradient( + 90deg, + var(--color-border) 0, + var(--color-border) 6px, + transparent 6px, + transparent 10px + ); +} + +/* Traveling dot on the connecting line */ +.pipelineGrid::after { + content: ''; + position: absolute; + top: 21px; + left: calc(33.33% / 2); + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-primary); + box-shadow: 0 0 8px rgba(37, 208, 171, 0.6); + opacity: 0; +} + +.visible .pipelineGrid::after { + opacity: 1; + animation: travelDot 3s ease-in-out 600ms infinite; +} + +@keyframes travelDot { + 0% { left: calc(33.33% / 2); opacity: 0; } + 5% { opacity: 1; } + 45% { opacity: 1; } + 50% { left: calc(100% - 33.33% / 2 - 8px); opacity: 0; } + 100% { left: calc(100% - 33.33% / 2 - 8px); opacity: 0; } +} + +.featureCard { + position: relative; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--spacing-xl); + padding-top: calc(var(--spacing-xl) + 20px); + transition: border-color var(--transition); +} + +.featureCard:hover { + border-color: var(--color-border-hover); +} + +.stepBadge { + position: absolute; + top: calc(-1 * var(--spacing-md)); + left: var(--spacing-xl); + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-primary-bg); + border: 2px solid var(--color-primary); + border-radius: var(--radius-full); + font-family: var(--font-mono); + font-size: var(--font-body-sm); + font-weight: 700; + color: var(--color-primary); + box-shadow: 0 0 0 0 rgba(37, 208, 171, 0); + transition: box-shadow 400ms ease-out; +} + +/* Sequential badge glow: 01 → 02 → 03 */ +.visible .featureCard:nth-child(1) .stepBadge { + animation: badgeGlow 3s ease-in-out 600ms infinite; +} + +.visible .featureCard:nth-child(2) .stepBadge { + animation: badgeGlow 3s ease-in-out 1.6s infinite; +} + +.visible .featureCard:nth-child(3) .stepBadge { + animation: badgeGlow 3s ease-in-out 2.6s infinite; +} + +@keyframes badgeGlow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(37, 208, 171, 0); } + 15% { box-shadow: 0 0 12px 4px rgba(37, 208, 171, 0.3); } + 30% { box-shadow: 0 0 0 0 rgba(37, 208, 171, 0); } +} + +.featureTitle { + font-family: var(--font-heading); + font-size: var(--font-h3); + font-weight: 600; + color: var(--color-text); + margin-bottom: var(--spacing-sm); +} + +.featureDesc { + font-size: var(--font-body-sm); + color: var(--color-text-secondary); + line-height: 1.6; +} + +/* ── Roles ── */ +.roles { + padding: var(--spacing-2xl) var(--spacing-xl) var(--spacing-3xl); +} + +.rolesInner { + max-width: 720px; + margin: 0 auto; +} + +.roleGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-lg); +} + +.roleCard { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + padding: var(--spacing-xl); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + text-decoration: none; + transition: border-color var(--transition), transform var(--transition); +} + +.roleCard:hover { + transform: translateY(-2px); +} + +.roleCardFarmer:hover { + border-color: #22c55e; +} + +.roleCardFarmer:hover .roleArrow { + color: #22c55e; +} + +.roleCardLender:hover { + border-color: #3b82f6; +} + +.roleCardLender:hover .roleArrow { + color: #3b82f6; +} + +.roleTitle { + font-family: var(--font-heading); + font-size: var(--font-h3); + font-weight: 600; + color: var(--color-text); +} + +.roleDesc { + font-size: var(--font-body-sm); + color: var(--color-text-secondary); + line-height: 1.5; +} + +.roleArrow { + font-size: var(--font-h2); + color: var(--color-primary); + margin-top: auto; + transition: transform var(--transition); +} + +.roleCard:hover .roleArrow { + transform: translateX(4px); +} + +/* ── Links ── */ +.links { + padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl); +} + +.linksInner { + max-width: 960px; + margin: 0 auto; + display: flex; + justify-content: center; + gap: var(--spacing-md); + flex-wrap: wrap; +} + +.linkPill { + display: inline-flex; + align-items: center; + min-height: 44px; + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + font-family: var(--font-mono); + font-size: var(--font-caption); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + text-decoration: none; + transition: border-color var(--transition), color var(--transition); +} + +.linkPill:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* ── Footer ── */ +.footer { + border-top: 1px solid var(--color-border); + padding: var(--spacing-xl); + text-align: center; + font-size: var(--font-caption); + color: var(--color-text-muted); +} + +/* ── Responsive ── */ +@media (max-width: 1024px) { + .headline { font-size: 44px; } + .statValue { font-size: 32px; } +} + +@media (max-width: 768px) { + .header { padding: 0 var(--spacing-md); } + .headline { font-size: 32px; } + .hero { + min-height: auto; + padding-top: calc(var(--topbar-height) + var(--spacing-2xl)); + padding-bottom: var(--spacing-2xl); + } + .statsInner { grid-template-columns: 1fr; } + .pipelineGrid { + grid-template-columns: 1fr; + } + .pipelineGrid::before { display: none; } + .pipelineGrid::after { display: none; } + .roleGrid { grid-template-columns: 1fr; } + .statValue { font-size: 28px; } + .scrollIndicator { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .heroContent { animation: none; } + .btnPrimary { animation: none; } + .scrollMouse::before { animation: none; } + .animatable { opacity: 1; transform: none; transition: none; } + .visible .pipelineGrid::after { animation: none; opacity: 0; } + .visible .featureCard .stepBadge { animation: none; } +} diff --git a/web/src/pages/Landing/Landing.tsx b/web/src/pages/Landing/Landing.tsx new file mode 100644 index 0000000..f74625a --- /dev/null +++ b/web/src/pages/Landing/Landing.tsx @@ -0,0 +1,233 @@ +import { useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import leafIcon from '../../../favicon.svg' +import styles from './Landing.module.css' + +const GITHUB_URL = 'https://github.com/code7unner/terra-ledger' +const TERRA_TOKEN_EXPLORER = + 'https://explorer.solana.com/address/2eAqpJ7yjso7FDA4sDQLJQioNCRuoYSUeha2Y88NRRMX?cluster=devnet' +const LIEN_REGISTRY_EXPLORER = + 'https://explorer.solana.com/address/3qYHSTPeRLRDfWmtzEhiaHpT2kchgW8GqaYcwmDbKnq4?cluster=devnet' + +function useCountUp(target: number, duration: number, decimals: number, triggerRef: React.RefObject) { + const [value, setValue] = useState(0) + const triggered = useRef(false) + + useEffect(() => { + const el = triggerRef.current + if (!el) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !triggered.current) { + triggered.current = true + const start = performance.now() + const step = (now: number) => { + const t = Math.min((now - start) / duration, 1) + const ease = 1 - Math.pow(1 - t, 3) + setValue(Math.round(target * ease * Math.pow(10, decimals)) / Math.pow(10, decimals)) + if (t < 1) requestAnimationFrame(step) + } + requestAnimationFrame(step) + } + }, + { threshold: 0.1 }, + ) + observer.observe(el) + return () => observer.disconnect() + }, [target, duration, decimals, triggerRef]) + + return value +} + +export default function Landing() { + const [scrolled, setScrolled] = useState(false) + const statsRef = useRef(null) + + const creditGap = useCountUp(2.7, 1500, 1, statsRef) + const rejection = useCountUp(67, 1500, 0, statsRef) + const verifyDays = useCountUp(21, 1500, 0, statsRef) + + useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 80) + window.addEventListener('scroll', onScroll, { passive: true }) + return () => window.removeEventListener('scroll', onScroll) + }, []) + + useEffect(() => { + const els = document.querySelectorAll('[data-animate]') + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add(styles.visible) + } + }) + }, + { threshold: 0.1 }, + ) + els.forEach((el) => observer.observe(el)) + return () => observer.disconnect() + }, []) + + return ( +
+ {/* Header */} +
+ + + TerraLedger + + + Launch App + +
+ + {/* Hero */} +
+
+
+ Built on Solana / Devnet +

+ // Agricultural Credit + Intelligence +

+

+ Satellite-verified productivity certificates, AI credit scoring, and on-chain + lien registry for Kazakhstan's 18 million hectares of underserved farmland. +

+
+ + Launch App + + + GitHub + +
+
+
+
+ scroll +
+
+ + {/* Stats */} +
+
+
+
${creditGap.toFixed(1)}B
+
Agricultural Credit Gap
+
+
+
{Math.round(rejection)}%
+
Loan Rejection Rate
+
+
+
{Math.round(verifyDays)}-Day
+
Manual Verification
+
+
+
+ + {/* Pipeline */} +
+
+

How It Works

+
+
+ 01 +

Satellite Verification

+

+ Real Sentinel-2 data from Copernicus. NDVI, NDWI, EVI indices with cloud + masking over a 12-month time series per parcel. +

+
+
+ 02 +

AI Credit Scoring

+

+ Claude API analyzes satellite indices and land metadata. Outputs a 0-100 + score, collateral grade, recommended LTV, and risk factors. +

+
+
+ 03 +

On-Chain Registry

+

+ AI score written to Solana PDA. Non-transferable NDVI certificates via + Token-2022. Atomic double-pledge prevention through CPI. +

+
+
+
+
+ + {/* Roles */} +
+
+

Get Started

+
+ +

I'm a Farmer

+

+ Register your land parcel and get a satellite-verified credit score in + minutes +

+ + + +

I'm a Lender

+

+ Search verified parcels and assess credit risk with on-chain transparency +

+ + +
+
+
+ + {/* Links */} +
+ +
+ + {/* Footer */} +
+ Built for Decentrathon 5 — National Solana Hackathon Kazakhstan +
+
+ ) +} diff --git a/web/src/pages/LenderDashboard/LenderDashboard.tsx b/web/src/pages/LenderDashboard/LenderDashboard.tsx index 127dd59..69a868d 100644 --- a/web/src/pages/LenderDashboard/LenderDashboard.tsx +++ b/web/src/pages/LenderDashboard/LenderDashboard.tsx @@ -13,7 +13,7 @@ export function LenderDashboard() { const handleSearch = (e: React.FormEvent) => { e.preventDefault() if (cadastral.trim()) { - navigate(`/parcel/${encodeURIComponent(cadastral.trim())}`) + navigate(`/app/parcel/${encodeURIComponent(cadastral.trim())}`) } } diff --git a/web/src/pages/LienManagement/LienManagement.tsx b/web/src/pages/LienManagement/LienManagement.tsx index 9e44569..73f6267 100644 --- a/web/src/pages/LienManagement/LienManagement.tsx +++ b/web/src/pages/LienManagement/LienManagement.tsx @@ -172,7 +172,7 @@ export default function LienManagement() {
setCadastral(e.target.value)} /> diff --git a/web/src/pages/MapView/MapView.tsx b/web/src/pages/MapView/MapView.tsx index efbf232..6b28a5f 100644 --- a/web/src/pages/MapView/MapView.tsx +++ b/web/src/pages/MapView/MapView.tsx @@ -44,7 +44,7 @@ function ParcelMarker({ parcel }: { parcel: MapParcel }) { {parcel.oblast && <>Oblast: {parcel.oblast}
} View details diff --git a/web/src/pages/WizardFlow/WizardFlow.tsx b/web/src/pages/WizardFlow/WizardFlow.tsx index 81edfd1..c11c65b 100644 --- a/web/src/pages/WizardFlow/WizardFlow.tsx +++ b/web/src/pages/WizardFlow/WizardFlow.tsx @@ -1,4 +1,4 @@ -import { useReducer, useCallback } from 'react' +import { useReducer, useCallback, useEffect } from 'react' import { useSearchParams } from 'react-router-dom' import { Stepper } from '../../components/Stepper/Stepper' import { LandingStep } from './steps/LandingStep' @@ -40,6 +40,7 @@ type Action = | { type: 'BACK' } | { type: 'UPDATE_DATA'; partial: Partial } | { type: 'UPDATE_AND_NEXT'; partial: Partial } + | { type: 'GO_TO_STEP'; step: number } | { type: 'RESTART' } const initialData: WizardData = { @@ -72,6 +73,8 @@ function reducer(state: State, action: Action): State { return { ...state, data: { ...state.data, ...action.partial } } case 'UPDATE_AND_NEXT': return { ...state, data: { ...state.data, ...action.partial }, step: state.step + 1 } + case 'GO_TO_STEP': + return { ...state, step: action.step } case 'RESTART': return initialState } @@ -88,8 +91,16 @@ export default function WizardFlow() { const back = useCallback(() => dispatch({ type: 'BACK' }), []) const updateData = useCallback((partial: Partial) => dispatch({ type: 'UPDATE_DATA', partial }), []) const selectPath = useCallback((p: WizardPath) => dispatch({ type: 'SELECT_PATH', path: p }), []) + const goToStep = useCallback((s: number) => dispatch({ type: 'GO_TO_STEP', step: s }), []) const restart = useCallback(() => dispatch({ type: 'RESTART' }), []) + useEffect(() => { + const roleParam = searchParams.get('role') + if (roleParam === 'farmer' || roleParam === 'lender') { + dispatch({ type: 'SELECT_PATH', path: roleParam }) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + if (path === 'none') { return (
@@ -108,7 +119,7 @@ export default function WizardFlow() { {path === 'farmer' ? ( <> {step === 0 && } - {step === 1 && } + {step === 1 && goToStep(4)} />} {step === 2 && } {step === 3 && } {step === 4 && } diff --git a/web/src/pages/WizardFlow/steps/LandingStep.tsx b/web/src/pages/WizardFlow/steps/LandingStep.tsx index 9cde9b2..d454e69 100644 --- a/web/src/pages/WizardFlow/steps/LandingStep.tsx +++ b/web/src/pages/WizardFlow/steps/LandingStep.tsx @@ -46,7 +46,7 @@ function ParcelMarker({ parcel }: { parcel: MapParcel }) { Area: {parcel.area_ha} ha
Land class: {parcel.land_class}
{parcel.oblast && <>Oblast: {parcel.oblast}
} - + View details
diff --git a/web/src/pages/WizardFlow/steps/RegisterParcelStep.tsx b/web/src/pages/WizardFlow/steps/RegisterParcelStep.tsx index 53d56ed..613a8ec 100644 --- a/web/src/pages/WizardFlow/steps/RegisterParcelStep.tsx +++ b/web/src/pages/WizardFlow/steps/RegisterParcelStep.tsx @@ -16,6 +16,7 @@ interface Props { onUpdate: (partial: Partial) => void onNext: () => void onBack: () => void + onSkipToSummary?: () => void } const OBLAST_OPTIONS = [ @@ -44,7 +45,7 @@ const LAND_CLASS_OPTIONS = [ { value: 5, label: '5 - Lowest (desert, unused)' }, ] -export function RegisterParcelStep({ data, isDemo, onUpdate, onNext, onBack }: Props) { +export function RegisterParcelStep({ data, isDemo, onUpdate, onNext, onBack, onSkipToSummary }: Props) { const { signAndSend, connected, walletAddress: extensionWallet, txStatus } = useSignAndSend() const { registerParcel } = useParcel() const { toast } = useToast() @@ -60,7 +61,7 @@ export function RegisterParcelStep({ data, isDemo, onUpdate, onNext, onBack }: P useEffect(() => { if (isDemo && !cadastral) { - setCadastral('KZ11-0032-001') + setCadastral('KZ11-0033-001') setAreaHa(150) setLandClass(2) setOblast('Akmola') @@ -85,16 +86,42 @@ export function RegisterParcelStep({ data, isDemo, onUpdate, onNext, onBack }: P }) try { + let alreadyOnChain = false + if (connected && walletAddress) { - const egissHash = new Uint8Array(32) - const ix = await buildRegisterParcelInstruction( - walletAddress as Address, - cadastral, - areaHa, - landClass, - egissHash, - ) - await signAndSend([ix]) + try { + const egissHash = new Uint8Array(32) + const ix = await buildRegisterParcelInstruction( + walletAddress as Address, + cadastral, + areaHa, + landClass, + egissHash, + ) + await signAndSend([ix]) + } catch (txErr: unknown) { + const msg = txErr instanceof Error ? txErr.message : String(txErr) + const isAlreadyRegistered = + msg.includes('already in use') || + msg.includes('custom program error: 0x0') || + msg.includes('code 0') || + msg.includes('Code: 0') + if (!isAlreadyRegistered) { + throw txErr + } + alreadyOnChain = true + } + } + + if (alreadyOnChain) { + toast('Parcel already registered on-chain. Loading profile...', 'success') + setSubmitting(false) + if (onSkipToSummary) { + onSkipToSummary() + } else { + onNext() + } + return } await registerParcel({ @@ -134,7 +161,7 @@ export function RegisterParcelStep({ data, isDemo, onUpdate, onNext, onBack }: P
setCadastral(e.target.value)} /> diff --git a/web/src/pages/WizardFlow/steps/SearchParcelStep.tsx b/web/src/pages/WizardFlow/steps/SearchParcelStep.tsx index 9d091da..961c141 100644 --- a/web/src/pages/WizardFlow/steps/SearchParcelStep.tsx +++ b/web/src/pages/WizardFlow/steps/SearchParcelStep.tsx @@ -12,7 +12,7 @@ interface Props { onNext: () => void } -const DEMO_PARCELS = ['KZ11-0032-001', 'KZ11-0032-002', 'KZ11-0032-003'] +const DEMO_PARCELS = ['KZ11-0033-001', 'KZ11-0033-002', 'KZ11-0033-003'] export function SearchParcelStep({ onUpdate, onNext }: Props) { const [query, setQuery] = useState('') @@ -52,7 +52,7 @@ export function SearchParcelStep({ onUpdate, onNext }: Props) {
setQuery(e.target.value)} onKeyDown={handleKeyDown} diff --git a/web/src/pages/WizardFlow/steps/SummaryStep.tsx b/web/src/pages/WizardFlow/steps/SummaryStep.tsx index 4b44212..3206b84 100644 --- a/web/src/pages/WizardFlow/steps/SummaryStep.tsx +++ b/web/src/pages/WizardFlow/steps/SummaryStep.tsx @@ -30,7 +30,7 @@ export function SummaryStep({ cadastral, onRestart, showLienButton, onRegisterLi }, [cadastral, fetchProfile, fetchLiens]) const handleShare = async () => { - const url = `${window.location.origin}/parcel/${encodeURIComponent(cadastral)}` + const url = `${window.location.origin}/app/parcel/${encodeURIComponent(cadastral)}` try { await navigator.clipboard.writeText(url) setCopied(true) diff --git a/web/src/solana/transaction.ts b/web/src/solana/transaction.ts index 1f7ecc7..e90c31d 100644 --- a/web/src/solana/transaction.ts +++ b/web/src/solana/transaction.ts @@ -43,11 +43,18 @@ function parseTransactionError(err: unknown): string { } } - // 3. Match "custom program error: 0xNNNN" + // 3. Match "custom program error: 0xNNNN" with known codes const hexMatch = raw.match(/custom program error: 0x([0-9a-fA-F]+)/) if (hexMatch) { const code = parseInt(hexMatch[1], 16) - return `Program error (code ${code})` + const knownCodes: Record = { + 6000: 'Active lien already exists on this parcel (double-pledge prevented)', + 6001: 'Parcel is not KYC verified', + 6002: 'Only the lender can release this encumbrance', + 6003: 'Encumbrance is not active', + 6004: 'Lien amount must be greater than zero', + } + return knownCodes[code] ?? `Program error (code ${code})` } // 4. Common wallet/network errors