• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

marscoin / martianrepublic / 23826884096

01 Apr 2026 01:05AM UTC coverage: 11.348% (-0.01%) from 11.36%
23826884096

push

github

Martian Congress
fix: UTXO endpoint now accepts addresses[] — removes xpub requirement

The marsUtxoMulti endpoint was still requiring xpub param and calling
Pebas. Now accepts addresses[] array, uses BlockchainRpc::getUtxosForTx()
for direct marscoind scantxoutset UTXO selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

0 of 14 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

664 of 5851 relevant lines covered (11.35%)

1.61 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

8.07
/app/Http/Controllers/Wallet/ApiController.php
1
<?php
2

3
namespace App\Http\Controllers\Wallet;
4

5
use App\Http\Controllers\Controller;
6
use App\Includes\AppHelper;
7
use App\Includes\GovernanceTiers;
8
use App\Includes\LegislationRepo;
9
use App\Models\Citizen;
10
use App\Models\CivicWallet;
11
use App\Models\Feed;
12
use App\Models\HDWallet;
13
use App\Models\Posts;
14
use App\Models\Profile;
15
use App\Models\Proposals;
16
use App\Models\Publication;
17
use App\Models\Threads;
18
use App\Models\User;
19
use Illuminate\Http\Request;
20
use Illuminate\Support\Facades\Auth;
21
use Illuminate\Support\Facades\Http;
22
use Illuminate\Support\Facades\Log;
23
use Illuminate\Support\Facades\Process;
24

25
class ApiController extends Controller
26
{
27
    /**
28
     * Setup the layout used by the controller.
29
     *
30
     * @return void
31
     */
32
    public function __construct()
7✔
33
    {
34
        $this->middleware('auth');
7✔
35
    }
36

37
    /**
38
     * Internal
39
     *
40
     * @ignore
41
     *
42
     * @hideFromAPIDocumentation
43
     */
44
    public function permapinpic(Request $request)
×
45
    {
46
        $uid = Auth::user()->id;
×
47
        $hash = '';
×
48
        $dataPic = $request->input('picture');
×
49
        $type = $request->input('type');
×
50
        $public_address = $request->input('address');
×
51

52
        // --- SECURITY: Sanitize the public_address to prevent directory traversal ---
53
        $safeAddress = AppHelper::sanitizePathSegment($public_address);
×
54
        if ($safeAddress === null) {
×
55
            return response()->json(['error' => 'Invalid address format.'], 400);
×
56
        }
57

58
        // --- SECURITY: Validate the base64 image data (extension, MIME, size, PHP code) ---
59
        $validation = AppHelper::validateBase64Image($dataPic);
×
60
        if (! $validation['valid']) {
×
61
            Log::warning('permapinpic upload rejected: '.$validation['error'].' (user: '.$uid.')');
×
62

63
            return response()->json(['error' => $validation['error']], 422);
×
64
        }
65

66
        $safeExtension = $validation['extension'];
×
67
        $decodedData = $validation['data'];
×
68

69
        $file_path = './assets/citizen/'.$safeAddress.'/';
×
70
        if (! file_exists($file_path)) {
×
71
            mkdir($file_path, 0755, true);
×
72
        }
73

74
        // --- SECURITY: Write .htaccess to prevent PHP execution in upload dir ---
75
        AppHelper::writeUploadHtaccess($file_path);
×
76

77
        // Use validated extension, not user-supplied one
78
        $file_path = './assets/citizen/'.$safeAddress.'/profile_pic.'.$safeExtension;
×
79

80
        file_put_contents($file_path, $decodedData);
×
81
        $hash = AppHelper::upload($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true');
×
82

83
        $citcache = Citizen::where('userid', '=', $uid)->first();
×
84
        if (is_null($citcache)) {
×
85
            $citcache = new Citizen;
×
86
        }
87
        $citcache->userid = $uid;
×
88
        $citcache->avatar_link = config('blockchain.ipfs.gateway_url').$hash;
×
89
        $citcache->save();
×
90

91
        return response()->json(['Hash' => $hash], 200);
×
92
    }
93

94
    /**
95
     * Internal
96
     *
97
     * @ignore
98
     *
99
     * @hideFromAPIDocumentation
100
     */
101
    public function permapinvideo(Request $request)
×
102
    {
103
        $uid = Auth::user()->id;
×
104
        $hash = '';
×
105
        $dataPic = $request->input('file');
×
106
        $type = $request->input('type');
×
107
        $public_address = $request->input('address');
×
108

109
        // --- SECURITY: Sanitize the public_address to prevent directory traversal ---
110
        $safeAddress = AppHelper::sanitizePathSegment($public_address);
×
111
        if ($safeAddress === null) {
×
112
            return response()->json(['error' => 'Invalid address format.'], 400);
×
113
        }
114

115
        if ($request->hasFile('file')) {
×
116
            // --- SECURITY: Validate the uploaded file (extension, MIME, size, PHP code) ---
117
            $uploadedFile = $request->file('file');
×
118
            $validation = AppHelper::validateUploadedFile($uploadedFile, [
×
119
                'webm' => ['video/webm', 'audio/webm'],
×
120
            ]);
×
121
            if (! $validation['valid']) {
×
122
                Log::warning('permapinvideo upload rejected: '.$validation['error'].' (user: '.$uid.')');
×
123

124
                return response()->json(['error' => $validation['error']], 422);
×
125
            }
126

127
            $file_path = './assets/citizen/'.$safeAddress.'/';
×
128
            if (! file_exists($file_path)) {
×
129
                mkdir($file_path, 0755, true);
×
130
            }
131

132
            // --- SECURITY: Write .htaccess to prevent PHP execution in upload dir ---
133
            AppHelper::writeUploadHtaccess($file_path);
×
134

135
            $file_path = './assets/citizen/'.$safeAddress.'/';
×
136
            $request->file('file')->move($file_path, 'profile_video.webm');
×
137
            $file_path = $file_path.'profile_video.webm';
×
138
            $hash = AppHelper::upload($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true');
×
139

140
            $citcache = Citizen::where('userid', '=', $uid)->first();
×
141
            if (is_null($citcache)) {
×
142
                $citcache = new Citizen;
×
143
            }
144
            $citcache->userid = $uid;
×
145
            $citcache->liveness_link = config('blockchain.ipfs.gateway_url').$hash;
×
146
            $citcache->save();
×
147

148
            return response()->json(['Hash' => $hash], 200);
×
149
        }
150

151
        return response()->json(['error' => 'No file uploaded.'], 400);
×
152
    }
153

154
    /**
155
     * Internal
156
     *
157
     * @hideFromAPIDocumentation
158
     */
159
    public function permapinlog(Request $request)
×
160
    {
161
        $public_address = $request->input('address');
×
162
        $title = $request->input('title');
×
163
        $entry = $request->input('entry');
×
164
        $uid = Auth::user()->id;
×
165

166
        // --- SECURITY: Sanitize the public_address to prevent directory traversal ---
167
        $safeAddress = AppHelper::sanitizePathSegment($public_address);
×
168
        if ($safeAddress === null) {
×
169
            return response()->json(['error' => 'Invalid address format.'], 400);
×
170
        }
171

172
        // --- SECURITY: Sanitize title for use in path (md5 hash is safe, but validate title exists) ---
173
        if (empty($title)) {
×
174
            return response()->json(['error' => 'Title is required.'], 400);
×
175
        }
176

177
        $file_path = './assets/citizen/'.$safeAddress.'/logbook/'.md5($title);
×
178

179
        if (! file_exists($file_path)) {
×
180
            mkdir($file_path, 0755, true); // More secure permissions
×
181
        }
182

183
        // --- SECURITY: Write .htaccess to prevent PHP execution in upload dir ---
184
        AppHelper::writeUploadHtaccess($file_path);
×
185

186
        // --- SECURITY: Check content for PHP code ---
187
        $logContent = $title."\n\n".$entry;
×
188
        if (AppHelper::containsPhpCode($logContent)) {
×
189
            Log::warning('permapinlog rejected: content contains PHP code (user: '.$uid.')');
×
190

191
            return response()->json(['error' => 'Content contains potentially dangerous code.'], 422);
×
192
        }
193

194
        // --- SECURITY: Check content size (max 5MB) ---
195
        if (strlen($logContent) > 5242880) {
×
196
            return response()->json(['error' => 'Content exceeds maximum size of 5MB.'], 422);
×
197
        }
198

199
        $file = $file_path.'/log.markdown';
×
200
        file_put_contents($file, $logContent);
×
201

202
        $files = $request->file('filenames');
×
203
        if ($files && is_array($files)) {
×
204
            foreach ($files as $f) {
×
205
                // --- SECURITY: Validate each uploaded file ---
206
                $validation = AppHelper::validateUploadedFile($f);
×
207
                if (! $validation['valid']) {
×
208
                    Log::warning('permapinlog file rejected: '.$validation['error'].' (user: '.$uid.', file: '.$f->getClientOriginalName().')');
×
209

210
                    // Skip invalid files but continue processing valid ones
211
                    continue;
×
212
                }
213

214
                $name = $f->hashName(); // Generates a unique, random name...
×
215

216
                // --- SECURITY: Ensure the generated filename does not have a dangerous extension ---
217
                $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
×
218
                if (AppHelper::isExtensionBlocked($ext)) {
×
219
                    Log::warning('permapinlog blocked dangerous file extension: '.$ext.' (user: '.$uid.')');
×
220

221
                    continue;
×
222
                }
223

224
                $f->move($file_path, $name);
×
225
            }
226
        }
227

228
        try {
229
            $hash = AppHelper::uploadFolder($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true&recursive=true&wrap-with-directory=true&quieter'); // Example: use a config value or env variable
×
230
            AppHelper::insertPublicationCache($uid, $file_path, $hash, $title);
×
231
        } catch (\Exception $e) {
×
232
            // Handle error; possibly log it and return a user-friendly message
233
            return response()->json(['error' => $e->getMessage()], 500);
×
234
        }
235

236
        return response()->json(['Hash' => $hash, 'Path' => $file_path], 200)
×
237
            ->header('Content-Type', 'application/json;');
×
238
    }
239

240
    public function removepinlog(Request $request)
×
241
    {
242
        $cid = $request->input('cid'); // The CID to unpin
×
243

244
        if (! $cid) {
×
245
            return response()->json(['error' => 'CID is required'], 400);
×
246
        }
247

248
        // Validate CID format to prevent injection into the IPFS API URL
249
        if (! AppHelper::isValidCID($cid)) {
×
250
            return response()->json(['error' => 'Invalid CID format'], 400);
×
251
        }
252

253
        // Verify the publication belongs to the current user
254
        $uid = Auth::user()->id;
×
255
        $publication = Publication::where('ipfs_hash', $cid)->first();
×
256
        if ($publication && $publication->userid != $uid) {
×
257
            return response()->json(['error' => 'Unauthorized: you can only remove your own publications.'], 403);
×
258
        }
259

260
        $ipfsApiUrl = config('blockchain.ipfs.api_url').'/api/v0/pin/rm?arg='.urlencode($cid).'&recursive=true';
×
261
        Log::debug($ipfsApiUrl);
×
262
        try {
263
            // Initialize cURL session
264
            $ch = curl_init();
×
265
            curl_setopt($ch, CURLOPT_URL, $ipfsApiUrl);
×
266
            curl_setopt($ch, CURLOPT_VERBOSE, true);
×
267
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
×
268
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); // Ensure this is POST
×
269

270
            $response = curl_exec($ch);
×
271
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
×
272

273
            Log::debug($response);
×
274

275
            if ($httpCode != 200) {
×
276
                // Error handling, IPFS might return error messages as JSON
277
                $errorMsg = "Failed to unpin CID. HTTP status code: $httpCode";
×
278
                if ($responseJson = json_decode($response)) {
×
279
                    if (! empty($responseJson->Message)) {
×
280
                        $errorMsg = $responseJson->Message;
×
281
                    }
282
                }
283
                throw new \Exception($errorMsg);
×
284
            }
285

286
            $publicationDeleted = Publication::where('ipfs_hash', $cid)->delete();
×
287
            if (! $publicationDeleted) {
×
288
                throw new \Exception('Failed to delete publication from the database.');
×
289
            }
290

291
        } catch (\Exception $e) {
×
292
            // Handle error; possibly log it and return a user-friendly message
293
            return response()->json(['error' => $e->getMessage()], 500);
×
294
        }
295

296
        // Close cURL session
297
        curl_close($ch);
×
298

299
        // Respond to the client
300
        return response()->json(['message' => "Successfully unpinned CID: $cid"], 200)
×
301
            ->header('Content-Type', 'application/json;');
×
302
    }
303

304
    /**
305
     * Handles JSON storage and pinning to a distributed file system.
306
     *
307
     * @hideFromAPIDocumentation
308
     */
309
    public function permapinjson(Request $request)
×
310
    {
311
        $public_address = $request->input('address');
×
312
        $type = $request->input('type');
×
313
        $json = $request->input('payload');
×
314

315
        // --- SECURITY: Sanitize the public_address to prevent directory traversal ---
316
        $safeAddress = AppHelper::sanitizePathSegment($public_address);
×
317
        if ($safeAddress === null) {
×
318
            return response()->json(['error' => 'Invalid address format.'], 400);
×
319
        }
320

321
        // --- SECURITY: Sanitize the type parameter to prevent path traversal ---
322
        $safeType = AppHelper::sanitizePathSegment($type);
×
323
        if ($safeType === null) {
×
324
            return response()->json(['error' => 'Invalid type format.'], 400);
×
325
        }
326

327
        // --- SECURITY: Check file size (max 5MB) ---
328
        if (strlen($json) > 5242880) {
×
329
            return response()->json(['error' => 'Payload exceeds maximum size of 5MB.'], 422);
×
330
        }
331

332
        // --- SECURITY: Reject payloads containing PHP code ---
333
        if (AppHelper::containsPhpCode($json)) {
×
334
            Log::warning('permapinjson rejected: payload contains PHP code');
×
335

336
            return response()->json(['error' => 'Payload contains potentially dangerous content.'], 422);
×
337
        }
338

339
        // --- SECURITY: Validate that payload is valid JSON ---
340
        $decodedJson = json_decode($json);
×
341
        if ($json !== '' && json_last_error() !== JSON_ERROR_NONE) {
×
342
            return response()->json(['error' => 'Invalid JSON payload.'], 422);
×
343
        }
344

345
        $projectRoot = config('app.project_root', base_path());
×
346
        $base_path = $projectRoot.'/assets/citizen/'.$safeAddress;
×
347

348
        // Check and create the directory if it doesn't exist
349
        Log::info($base_path);
×
350
        clearstatcache();
×
351
        if (! is_dir($base_path)) {
×
352
            Log::info('Trying to create directory: '.$base_path);
×
353
            if (! mkdir($base_path, 0755, true)) {
×
354
                Log::error('Failed to create directory: '.$base_path);
×
355

356
                return response()->json(['error' => 'Failed to create directory. Check permissions.'], 500);
×
357
            }
358
            Log::info('Directory created: '.$base_path);
×
359
        }
360

361
        // Check if the directory is writable, regardless of whether it was just created or already existed
362
        if (! is_writable($base_path)) {
×
363
            Log::error('Directory not writable: '.$base_path);
×
364

365
            return response()->json(['error' => 'Directory is not writable. Check permissions.'], 500);
×
366
        }
367

368
        // --- SECURITY: Write .htaccess to prevent PHP execution in upload dir ---
369
        AppHelper::writeUploadHtaccess($base_path);
×
370

371
        $file_path = $base_path.'/'.$safeType.'.json';
×
372

373
        // Attempt to write the JSON data to the file
374
        if (file_put_contents($file_path, $json) === false) {
×
375
            return response()->json(['error' => 'Failed to write to file.'], 500);
×
376
        }
377

378
        try {
379
            Log::info('PermaJson: '.$file_path);
×
380

381
            // Check if the type contains the word 'log'
382
            if (strpos($safeType, 'log') !== false) {
×
383
                // The type contains 'log', use uploadFolder
384
                $apiResponse = AppHelper::uploadFolder($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true&recursive=true&wrap-with-directory=true&quieter');
×
385
            } else {
386
                // The type does not contain 'log', use upload
387
                $apiResponse = AppHelper::upload($file_path, config('blockchain.ipfs.api_url').'/api/v0/add?pin=true');
×
388
            }
389

390
            if (is_string($apiResponse)) {
×
391
                $formattedResponse = ['Hash' => $apiResponse];
×
392
            } else {
393
                Log::error('Upload error: Formatting');
×
394

395
                return response()->json(['error' => 'formatting error'], 500);
×
396
            }
397

398
            return response()->json($formattedResponse, 200)->header('Content-Type', 'application/json;');
×
399
        } catch (\Exception $e) {
×
400
            // Handle exceptions during the upload and pinning process
401
            Log::error('Upload error: '.$e->getMessage());
×
402

403
            return response()->json(['error' => $e->getMessage()], 500);
×
404
        }
405
    }
406

407
    /**
408
     * Internal
409
     *
410
     * @hideFromAPIDocumentation
411
     */
412
    public function setfeed(Request $request)
2✔
413
    {
414
        $uid = Auth::user()->id;
2✔
415
        $txid = $request->input('txid');
2✔
416
        $action_tag = $request->input('type');
2✔
417
        $public_address = $request->input('address');
2✔
418
        $embedded_link = $request->input('embedded_link');
2✔
419
        $message = $request->input('message');
2✔
420

421
        AppHelper::insertBlockchainCache($public_address, $uid, $action_tag, $message, $embedded_link, $txid);
2✔
422

423
        $profile = Profile::where('userid', '=', $uid)->first();
2✔
424
        if ($profile) {
2✔
425
            $profile->general_public = 1;
2✔
426
            // Set has_application when GP application is submitted
427
            if ($action_tag === 'GP') {
2✔
428
                $profile->has_application = 1;
2✔
429
            }
430
            $profile->save();
2✔
431
        }
432

433
        return response()->json(['Hash' => $txid], 200);
2✔
434
    }
435

436
    /**
437
     * Internal
438
     *
439
     * @hideFromAPIDocumentation
440
     */
441
    public function getBalance($address)
×
442
    {
443
        $balance = AppHelper::getMarscoinBalance($address);
×
444

445
        return response()->json(['balance' => $balance], 200);
×
446
    }
447

448
    /**
449
     * Internal
450
     *
451
     * @hideFromAPIDocumentation
452
     */
453
    public function dismissAlert(Request $request)
×
454
    {
455
        $alertType = $request->input('alertType');
×
456

457
        // Only allow known alert types to prevent session key injection
458
        $allowedAlerts = ['wallet_alert', 'citizen_alert', 'onboarding_alert', 'backup_alert', 'endorsement_alert'];
×
459
        if (! in_array($alertType, $allowedAlerts, true)) {
×
460
            return response()->json(['error' => 'Invalid alert type.'], 400);
×
461
        }
462

463
        session()->put($alertType, true);
×
464

465
        return response()->json(['success' => true]);
×
466
    }
467

468
    /**
469
     * Internal
470
     *
471
     * @hideFromAPIDocumentation
472
     */
473
    public function getPrice(Request $request)
×
474
    {
475
        $price = AppHelper::getMarscoinPrice();
×
476

477
        return response()->json(['mars_price' => $price], 200);
×
478
    }
479

480
    /**
481
     * Internal
482
     *
483
     * @hideFromAPIDocumentation
484
     */
485
    public function getTransactions(Request $request)
×
486
    {
487
        $address = $request->input('address');
×
488

489
        // Validate Marscoin address to prevent URL injection
490
        if (! $address || ! AppHelper::isValidMarscoinAddress($address)) {
×
491
            return response()->json(['error' => 'Invalid Marscoin address.'], 400);
×
492
        }
493

494
        $json = AppHelper::file_get_contents_curl(config('blockchain.explorer.fallback_url').'/api/txs/?address='.urlencode($address));
×
495

496
        return response($json)->header('Content-Type', 'application/json');
×
497
    }
498

499
    /**
500
     * Internal
501
     *
502
     * @hideFromAPIDocumentation
503
     */
504
    public function setfullname(Request $request)
×
505
    {
506
        $uid = Auth::user()->id;
×
507
        $firstname = $request->input('firstname');
×
508
        $lastname = $request->input('lastname');
×
509
        if (! $firstname || ! $lastname) {
×
510
            return response()->json(['error' => 'First and last name are required.'], 400);
×
511
        }
512

513
        $fullname = $firstname.' '.$lastname;
×
514

515
        $citcache = Citizen::where('userid', '=', $uid)->first();
×
516
        if (is_null($citcache)) {
×
517
            $citcache = new Citizen;
×
518
        }
519

520
        $citcache->userid = $uid;
×
521
        $citcache->firstname = $firstname;
×
522
        $citcache->lastname = $lastname;
×
523
        $citcache->save();
×
524

525
        $user = User::where('id', '=', $uid)->first();
×
526
        if ($user) {
×
527
            $user->fullname = $fullname;
×
528
            $user->save();
×
529
        }
530

531
        return response()->json(['success' => true]);
×
532
    }
533

534
    /**
535
     * @hideFromAPIDocumentation
536
     */
537
    public function cacheonboarding(Request $request)
×
538
    {
539
        $uid = Auth::user()->id;
×
540
        $shortbio = $request->input('shortbio');
×
541
        $displayname = $request->input('displayname');
×
542
        $publicaddress = $request->input('publicaddress');
×
543

544
        $citcache = Citizen::where('userid', '=', $uid)->first();
×
545
        if (is_null($citcache)) {
×
546
            $citcache = new Citizen;
×
547
        }
548

549
        $citcache->userid = $uid;
×
550
        $citcache->shortbio = $shortbio;
×
551
        $citcache->displayname = $displayname;
×
552
        $citcache->public_address = $publicaddress;
×
553
        $citcache->save();
×
554

555
        return response()->json(['success' => true]);
×
556
    }
557

558
    public function rejectApplication(Request $request)
×
559
    {
560
        $user = Auth::user();
×
561
        $profile = Profile::where('userid', $user->id)->first();
×
562
        $reporter = Citizen::where('userid', '=', $user->id)->first();
×
563

564
        $rejectionReasons = [
×
565
            'avatar_link' => 'Missing Personal Image',
×
566
            'liveness_link' => 'Incomplete Video',
×
567
            'duplicate' => 'Duplicate Entry',
×
568
        ];
×
569

570
        if (! $profile || ! $profile->citizen) {
×
571
            return response()->json(['error' => 'Unauthorized access.'], 403);
×
572
        }
573

574
        $applicantUserId = $request->input('applicantUserId');
×
575
        $fieldToUpdate = $request->input('field');
×
576

577
        // Validate the field to update
578
        if (! in_array($fieldToUpdate, ['avatar_link', 'liveness_link'])) {
×
579
            return response()->json(['error' => 'Invalid field specified.'], 400);
×
580
        }
581

582
        // Update the citizen table, setting the specified field to NULL for the applicant
583
        Citizen::where('userid', $applicantUserId)->update([$fieldToUpdate => null]);
×
584

585
        $applicant = Citizen::where('userid', '=', $applicantUserId)->first();
×
586
        $applicantAddress = $applicant ? $applicant->public_address : 'Unknown';
×
587

588
        $content = "The application of {$applicantAddress} has been rejected due to ".$rejectionReasons[$fieldToUpdate].'.';
×
589

590
        $fullname = $reporter ? ($reporter->firstname.' '.$reporter->lastname) : $user->fullname;
×
591
        Posts::create([
×
592
            'thread_id' => 27,
×
593
            'author_id' => $user->id,
×
594
            'content' => $content,
×
595
            'authorName' => $fullname,
×
596
            'created_at' => now(),
×
597
            'updated_at' => now(),
×
598
        ]);
×
599

600
        return response()->json(['success' => 'Application has been rejected and recorded.']);
×
601
    }
602

603
    /**
604
     * @hideFromAPIDocumentation
605
     */
606
    /**
607
     * Proxy for price.marscoin.org - avoids Cloudflare CSP
608
     */
609
    public function marsPrice()
×
610
    {
611
        try {
612
            $response = Http::timeout(5)->get(config('blockchain.price.marscoin_url'));
×
613
            if ($response->successful()) {
×
614
                return response($response->body())->header('Content-Type', 'application/json');
×
615
            }
616
        } catch (\Exception $e) {
×
617
        }
618

619
        return response()->json(['error' => 'Price unavailable'], 500);
×
620
    }
621

622
    public function marsUtxoMulti(Request $request)
×
623
    {
624
        $receiver = $request->input('receiver_address');
×
625
        $amount = $request->input('amount');
×
NEW
626
        $addresses = $request->input('addresses', []);
×
NEW
627
        $xpub = $request->input('xpub');
×
628

NEW
629
        if (! $receiver || ! $amount) {
×
NEW
630
            return response()->json(['error' => 'Missing receiver_address or amount'], 400);
×
631
        }
632

633
        try {
634
            // Direct RPC if address provided
NEW
635
            if (! empty($addresses)) {
×
NEW
636
                $rpc = new \App\Services\BlockchainRpc;
×
NEW
637
                $result = $rpc->getUtxosForTx($addresses[0], (float) $amount);
×
NEW
638
                return response()->json($result);
×
639
            }
640

641
            // Fallback: Pebas with xpub
NEW
642
            if ($xpub) {
×
NEW
643
                $response = Http::timeout(15)->get(config('blockchain.pebas.url') . '/api/mars/utxo-multi?xpub=' . urlencode($xpub) . '&receiver_address=' . urlencode($receiver) . '&amount=' . urlencode($amount));
×
NEW
644
                if ($response->successful()) {
×
NEW
645
                    return response($response->body())->header('Content-Type', 'application/json');
×
646
                }
647
            }
648

NEW
649
            return response()->json(['error' => 'No sender address or xpub'], 400);
×
UNCOV
650
        } catch (\Exception $e) {
×
NEW
651
            return response()->json(['error' => 'UTXO selection failed: ' . $e->getMessage()], 500);
×
652
        }
653
    }
654

655
    public function marsTxHistory(Request $request)
×
656
    {
657
        $address = $request->input('address');
×
658
        if (! $address || ! AppHelper::isValidMarscoinAddress($address)) {
×
659
            return response()->json(['error' => 'Invalid address'], 400);
×
660
        }
661

662
        // Try Pebas first (has full Electrum history)
663
        try {
664
            $response = Http::timeout(5)
×
665
                ->get(config('blockchain.pebas.url').'/api/mars/txhistory/', ['address' => $address]);
×
666
            if ($response->successful()) {
×
667
                $data = $response->json();
×
668
                if (! isset($data['error'])) {
×
669
                    return response()->json($data);
×
670
                }
671
            }
672
        } catch (\Exception $e) {
×
673
        }
674

675
        // Fallback: marscoind scantxoutset + getrawtransaction (txindex=1)
676
        try {
677
            $cli = config('blockchain.rpc.cli_path');
×
678
            $dataDir = config('blockchain.rpc.data_dir');
×
679
            $descriptors = json_encode(['addr('.$address.')']);
×
680

681
            $result = Process::timeout(30)->run([
×
682
                $cli, '-datadir='.$dataDir, 'scantxoutset', 'start', $descriptors,
×
683
            ]);
×
684

685
            if (! $result->successful()) {
×
686
                return response()->json(['error' => 'Transaction history unavailable'], 500);
×
687
            }
688

689
            $scan = json_decode($result->output(), true);
×
690
            $utxos = $scan['unspents'] ?? [];
×
691
            $transactions = [];
×
692

693
            foreach (array_slice($utxos, 0, 50) as $utxo) {
×
694
                try {
695
                    $txResult = Process::timeout(10)->run([
×
696
                        $cli, '-datadir='.$dataDir, 'getrawtransaction', $utxo['txid'], '1',
×
697
                    ]);
×
698
                    if ($txResult->successful()) {
×
699
                        $tx = json_decode($txResult->output(), true);
×
700
                        $transactions[] = [
×
701
                            'txid' => $utxo['txid'],
×
702
                            'amount' => $utxo['amount'],
×
703
                            'confirmations' => $utxo['confirmations'] ?? 0,
×
704
                            'height' => $utxo['height'] ?? null,
×
705
                            'time' => $tx['time'] ?? null,
×
706
                            'blocktime' => $tx['blocktime'] ?? null,
×
707
                        ];
×
708
                    }
709
                } catch (\Exception $e) {
×
710
                    continue;
×
711
                }
712
            }
713

714
            return response()->json([
×
715
                'address' => $address,
×
716
                'balance' => $scan['total_amount'] ?? 0,
×
717
                'transactions' => $transactions,
×
718
                'source' => 'marscoind',
×
719
            ]);
×
720
        } catch (\Exception $e) {
×
721
            return response()->json(['error' => 'Transaction history unavailable: '.$e->getMessage()], 500);
×
722
        }
723
    }
724

725
    public function closewallet(Request $request)
3✔
726
    {
727
        $uid = Auth::user()->id;
3✔
728
        $profile = Profile::where('userid', '=', $uid)->first();
3✔
729
        if ($profile) {
3✔
730
            $profile->wallet_open = 0;
3✔
731
            $profile->save();
3✔
732
        }
733

734
        return response()->json(['success' => true]);
3✔
735
    }
736

737
    /**
738
     * @hideFromAPIDocumentation
739
     */
740
    /**
741
     * Called after HD discovery finds an address matching the user's civic wallet.
742
     * Links the HD session to the civic wallet so civic-only features are accessible.
743
     */
744
    /**
745
     * Proxy for pebas HD discovery - avoids CSP issues with Cloudflare
746
     */
747
    public function discoverAddresses(Request $request)
×
748
    {
749
        $xpub = $request->input('xpub');
×
750
        $gapLimit = $request->input('gap_limit', 20);
×
751

752
        if (! $xpub) {
×
753
            return response()->json(['error' => 'xpub required'], 400);
×
754
        }
755

756
        try {
757
            $response = Http::timeout(30)
×
758
                ->get(config('blockchain.pebas.url').'/api/mars/discover', [
×
759
                    'xpub' => $xpub,
×
760
                    'gap_limit' => $gapLimit,
×
761
                ]);
×
762

763
            return response()->json($response->json());
×
764
        } catch (\Exception $e) {
×
765
            return response()->json(['error' => 'Discovery failed: '.$e->getMessage()], 500);
×
766
        }
767
    }
768

769
    public function linkCivicWallet(Request $request)
2✔
770
    {
771
        $uid = Auth::user()->id;
2✔
772
        $address = $request->input('address');
2✔
773

774
        if (! $address || ! AppHelper::isValidMarscoinAddress($address)) {
2✔
775
            return response()->json(['error' => 'Invalid address'], 400);
×
776
        }
777

778
        $civicWallet = CivicWallet::where('user_id', $uid)
2✔
779
            ->where('public_addr', $address)
2✔
780
            ->first();
2✔
781

782
        if (! $civicWallet) {
2✔
783
            return response()->json(['error' => 'No matching civic wallet found'], 404);
1✔
784
        }
785

786
        $profile = Profile::where('userid', $uid)->first();
1✔
787
        if ($profile) {
1✔
788
            $profile->civic_wallet_open = $civicWallet->id;
1✔
789
            $profile->save();
1✔
790
            Log::info("Civic wallet linked for user {$uid}: {$address}");
1✔
791
        }
792

793
        return response()->json([
1✔
794
            'success' => true,
1✔
795
            'civic_wallet_id' => $civicWallet->id,
1✔
796
            'address' => $address,
1✔
797
        ]);
1✔
798
    }
799

800
    public function renameWallet(Request $request)
×
801
    {
802
        $request->validate([
×
803
            'hdwallet_id' => 'required',
×
804
            'new_name' => 'required|string|max:500',
×
805
        ]);
×
806

807
        $wallet = HDWallet::where('id', $request->hdwallet_id)
×
808
            ->where('user_id', Auth::id())
×
809
            ->firstOrFail();
×
810
        $wallet->wallet_type = $request->new_name;
×
811
        $wallet->save();
×
812

813
        return response()->json(['success' => 'Wallet renamed successfully']);
×
814
    }
815

816
    /**
817
     * @hideFromAPIDocumentation
818
     */
819
    public function setendorsed(Request $request)
×
820
    {
821
        $endorserId = Auth::user()->id;
×
822
        $targetUserId = $request->input('id');
×
823

824
        // Prevent self-endorsement
825
        if ((int) $endorserId === (int) $targetUserId) {
×
826
            return response()->json(['error' => 'Cannot endorse yourself.'], 400);
×
827
        }
828

829
        // Only citizens can endorse
830
        $endorserProfile = Profile::where('userid', '=', $endorserId)->first();
×
831
        if (! $endorserProfile || ! $endorserProfile->citizen) {
×
832
            return response()->json(['error' => 'Only citizens can endorse.'], 403);
×
833
        }
834

835
        $targetProfile = Profile::where('userid', '=', $targetUserId)->first();
×
836
        if (! $targetProfile) {
×
837
            return response()->json(['error' => 'User not found.'], 404);
×
838
        }
839

840
        // Cannot endorse someone who is already a citizen
841
        if ($targetProfile->citizen) {
×
842
            return response()->json(['error' => 'This user is already a citizen.'], 400);
×
843
        }
844

845
        // Check for duplicate endorsement (endorser already endorsed this target)
846
        $targetCitizen = Citizen::where('userid', '=', $targetUserId)->first();
×
847
        $targetAddress = $targetCitizen ? $targetCitizen->public_address : null;
×
848
        if (! $targetAddress) {
×
849
            $targetWallet = CivicWallet::where('user_id', '=', $targetUserId)->first();
×
850
            $targetAddress = $targetWallet ? $targetWallet->public_addr : null;
×
851
        }
852

853
        if ($targetAddress) {
×
854
            $alreadyEndorsed = Feed::where('userid', '=', $endorserId)
×
855
                ->where('tag', '=', 'ED')
×
856
                ->where('message', '=', $targetAddress)
×
857
                ->exists();
×
858
            if ($alreadyEndorsed) {
×
859
                return response()->json(['error' => 'You have already endorsed this person.'], 400);
×
860
            }
861
        }
862

863
        // Enforce endorsement limit: 1 endorsement per 10 citizens, max 5
864
        $citizenCount = Profile::where('citizen', '=', 1)->count();
×
865
        $endorsementAllowance = min(5, max(1, (int) floor($citizenCount / 10)));
×
866
        $endorsementsGiven = Feed::where('userid', '=', $endorserId)
×
867
            ->where('tag', '=', 'ED')
×
868
            ->count();
×
869
        if ($endorsementsGiven >= $endorsementAllowance) {
×
870
            return response()->json([
×
871
                'error' => "You have reached your endorsement limit ({$endorsementAllowance}). Each citizen may give 1 endorsement per 10 citizens in the republic (max 5).",
×
872
            ], 400);
×
873
        }
874

875
        // Increment endorsement count
876
        $targetProfile->endorse_cnt = ($targetProfile->endorse_cnt ?? 0) + 1;
×
877
        $targetProfile->save();
×
878

879
        // Check if target now meets the threshold for auto-upgrade to citizen
880
        // Bootstrap: with 0 citizens, threshold is 0 (first pioneer auto-qualifies)
881
        // Then: 1 per 10 citizens (rounded up), capped at 5
882
        $endorsementThreshold = $citizenCount === 0 ? 0 : min(5, max(1, (int) ceil($citizenCount * 0.1)));
×
883
        if ($targetProfile->endorse_cnt >= $endorsementThreshold) {
×
884
            $targetProfile->citizen = 1;
×
885
            $targetProfile->save();
×
886

887
            Log::info("Auto-upgrade to citizen: user {$targetUserId} reached {$targetProfile->endorse_cnt} endorsements (threshold: {$endorsementThreshold})");
×
888
        }
889

890
        return response()->json([
×
891
            'success' => true,
×
892
            'endorse_cnt' => $targetProfile->endorse_cnt,
×
893
            'threshold' => $endorsementThreshold,
×
894
            'promoted' => (bool) $targetProfile->citizen,
×
895
        ]);
×
896
    }
897

898
    /**
899
     * @hideFromAPIDocumentation
900
     */
901
    public function cacheproposal(Request $request)
×
902
    {
903
        $uid = Auth::user()->id;
×
904
        $txid = $request->input('txid');
×
905
        $public_address = $request->input('address');
×
906
        $embedded_link = $request->input('embedded_link');
×
907
        $json = $request->input('message');
×
908
        $data = json_decode($json);
×
909

910
        if (! $data || ! isset($data->data)) {
×
911
            return response()->json(['error' => 'Invalid message payload.'], 400);
×
912
        }
913

914
        $citcache = Citizen::where('userid', '=', $uid)->first();
×
915

916
        if (! AppHelper::isValidCID($embedded_link ?? '')) {
×
917
            return response()->json(['error' => 'Invalid IPFS hash'], 400);
×
918
        }
919

920
        $category = $data->data->category ?? '';
×
921
        $tier = GovernanceTiers::categoryToTier($category);
×
922
        $tierConfig = GovernanceTiers::get($tier);
×
923

924
        $proposal = new Proposals;
×
925
        $proposal->user_id = $uid;
×
926
        $proposal->title = $data->data->title ?? '';
×
927
        $proposal->description = $data->data->description ?? '';
×
928
        $proposal->category = $category;
×
929
        $proposal->tier = $tier;
×
930
        $proposal->author = Auth::user()->fullname;
×
931
        $proposal->ipfs_hash = $embedded_link;
×
932
        $proposal->participation = $data->data->participation ?? $tierConfig['quorum_percent'];
×
933
        $proposal->threshold = $data->data->threshold ?? $tierConfig['threshold'];
×
934
        $proposal->duration = $data->data->duration ?? $tierConfig['duration_sols'];
×
935
        $proposal->expiration = $data->data->expiration ?? $tierConfig['sunset_sols'];
×
936
        $proposal->txid = $txid;
×
937
        $proposal->public_address = $public_address;
×
938
        $proposal->status = 'screening';
×
939

940
        // Calculate lifecycle timestamps
941
        $timestamps = GovernanceTiers::calculateTimestamps($tier);
×
942
        $proposal->screening_ends_at = $timestamps['screening_ends_at'];
×
943
        $proposal->voting_ends_at = $timestamps['voting_ends_at'];
×
944
        $proposal->timelock_ends_at = $timestamps['timelock_ends_at'];
×
945
        $proposal->sunset_at = $timestamps['sunset_at'];
×
946

947
        $proposal->save();
×
948

949
        // Commit to LegislationRepo
950
        try {
951
            $repo = new LegislationRepo;
×
952
            $gitHash = $repo->submitProposal(
×
953
                $proposal->id,
×
954
                $proposal->title,
×
955
                $proposal->description,
×
956
                $proposal->author,
×
957
                $tier,
×
958
                [
×
959
                    'participation' => $proposal->participation.'%',
×
960
                    'threshold' => $proposal->threshold.'%',
×
961
                    'duration' => $proposal->duration.' sols',
×
962
                    'expiration' => $proposal->expiration > 0 ? $proposal->expiration.' sols' : 'never',
×
963
                    'txid' => $txid,
×
964
                ]
×
965
            );
×
966
            $proposal->git_hash = $gitHash;
×
967
            $proposal->save();
×
968
        } catch (\Exception $e) {
×
969
            Log::warning('LegislationRepo commit failed: '.$e->getMessage());
×
970
        }
971
        $prop_id = $proposal->id;
×
972

973
        $authorName = $citcache ? ($citcache->firstname.' '.$citcache->lastname) : Auth::user()->fullname;
×
974

975
        $post = new Posts;
×
976
        $post->thread_id = 2;
×
977
        $post->author_id = $uid;
×
978
        $post->content = $proposal->description;
×
979
        $post->authorName = $authorName;
×
980
        $post->save();
×
981

982
        $post_id = $post->id;
×
983

984
        $threads = new Threads;
×
985
        $threads->category_id = 2;
×
986
        $threads->author_id = $uid;
×
987
        $threads->title = $data->data->title ?? '';
×
988
        $threads->first_post_id = $post_id;
×
989
        $threads->proposal_id = $prop_id;
×
990
        $threads->save();
×
991

992
        $thd_id = $threads->id;
×
993

994
        Proposals::where('id', $prop_id)->update(['discussion' => $thd_id]);
×
995

996
        return response()->json(['Proposal' => $prop_id, 'Discussion' => $thd_id], 200);
×
997
    }
998

999
    /**
1000
     * Broadcast a signed raw transaction via local marscoind.
1001
     * No Pebas/Electrum dependency.
1002
     */
1003
    public function broadcastTx(Request $request)
×
1004
    {
1005
        $rawTx = $request->input('rawtx');
×
1006
        if (! $rawTx) {
×
1007
            return response()->json(['error' => 'rawtx required'], 400);
×
1008
        }
1009

1010
        $rpc = new \App\Services\BlockchainRpc;
×
1011
        $txid = $rpc->broadcast($rawTx);
×
1012

1013
        if ($txid) {
×
1014
            return response()->json(['txid' => $txid]);
×
1015
        }
1016

1017
        return response()->json(['error' => 'Broadcast failed'], 500);
×
1018
    }
1019
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc